diff --git a/.github/workflows/_go-checks.yaml b/.github/workflows/_go-checks.yaml index e532afda..bf36f251 100644 --- a/.github/workflows/_go-checks.yaml +++ b/.github/workflows/_go-checks.yaml @@ -8,11 +8,6 @@ on: required: false default: ubuntu-latest type: string - run-powersync-replay: - description: Run PowerSync replay correctness check - required: false - default: true - type: boolean jobs: lint: @@ -83,9 +78,6 @@ jobs: # Regenerate API client from committed schema snapshot. (cd internal/boundary/graphql/gen && go run github.com/Khan/genqlient) - # Regenerate SQLite reflection/sqlc artifacts from embedded schema snapshot. - go generate ./internal/sqlite - if [ -n "$(git status --porcelain)" ]; then echo "Generated artifacts are out of date. Run generation locally and commit results." git status --short @@ -93,27 +85,6 @@ jobs: exit 1 fi - powersync-replay: - runs-on: ${{ inputs.runner }} - timeout-minutes: 20 - permissions: - contents: read - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Setup Hermit - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1 - with: - cache: "true" - - - name: Run PowerSync replay correctness test - if: ${{ inputs.run-powersync-replay }} - run: task test:correctness:powersync-replay - - - name: Skip PowerSync replay correctness test - if: ${{ !inputs.run-powersync-replay }} - run: echo "PowerSync replay correctness test disabled for this workflow call" - tagged-compile: runs-on: ${{ inputs.runner }} timeout-minutes: 15 @@ -137,7 +108,6 @@ jobs: - unit - integration - gen-check - - powersync-replay - tagged-compile runs-on: ubuntu-latest timeout-minutes: 1 diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml index 1b3cd34d..b783afa8 100644 --- a/.github/workflows/nightly.yaml +++ b/.github/workflows/nightly.yaml @@ -12,8 +12,6 @@ concurrency: jobs: checks: uses: ./.github/workflows/_go-checks.yaml - with: - run-powersync-replay: true secrets: inherit integration-live: diff --git a/.github/workflows/workflow-lint.yaml b/.github/workflows/workflow-lint.yaml index a9b3ae8b..0405a2dc 100644 --- a/.github/workflows/workflow-lint.yaml +++ b/.github/workflows/workflow-lint.yaml @@ -20,6 +20,6 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Run actionlint - uses: reviewdog/action-actionlint@f45e6423f07e6ea6fce879bbdb0d74407c1fbf55 # v1 + uses: reviewdog/action-actionlint@e0207a28405ecad11953ba625a95d92f7889572a # v1 with: fail_level: any diff --git a/README.md b/README.md index faf71788..3132e17f 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,20 @@ # Tero CLI -Improve your observability data quality from the terminal. +The terminal interface to the [Tero](https://usetero.com) control plane. Connect a +Datadog account and explore the issues, waste, and posture Tero finds in your +observability data — interactively in your terminal, or as JSON for scripting. *Built by the creators of [Vector.dev](https://vector.dev).* -## What is this? +Tero analyzes what your logs mean semantically — patterns, quality, and value — +and surfaces the waste that doesn't help you during incidents (typically 40%+ of +log volume). The CLI is read-only: it connects to your existing observability +platform over an API and shows you what Tero found. It deploys no agents, +collectors, or pipelines. -Tero helps you find and fix waste in your observability data. - -Connect your Datadog account (read-only) and Tero will: -- Understand what your logs mean semantically - patterns, quality, value -- Identify waste - typically 40%+ of volume that doesn't help during incidents -- Help you remove it with informed actions - only what won't hurt you - -**If someone sent you here:** Your team lead or SRE found waste in one of your services. The CLI will show you exactly what patterns are wasteful and why, then help you fix them. Takes 10 minutes. - -**If you're evaluating Tero:** This is how you interact with the platform. Install it, connect your Datadog account, see what we find. Takes 5 minutes. - -## Quick Start +--- -**Install:** +## Install ```bash # Quick install (macOS and Linux) @@ -36,154 +31,293 @@ scoop install tero docker pull usetero/tero ``` -**Run:** +Verify the install: ```bash -tero +tero --version ``` -On first run, `tero` will: -1. Ask you to authenticate (or create an account) -2. Walk you through connecting your Datadog account (read-only API key) -3. Analyze your data and show you what it found +--- + +The rest of this README follows the [Diátaxis](https://diataxis.fr) structure: -After that, just run `tero` anytime to explore waste, check status, or take action. +- **[Getting started](#getting-started)** — a guided first run (start here). +- **[How-to guides](#how-to-guides)** — recipes for specific tasks. +- **[Reference](#reference)** — commands, flags, keys, and configuration. +- **[Concepts](#concepts)** — how Tero works and why. -## What does it do? +--- -`tero` is an interactive chat interface. Ask questions, get answers about your observability data. +## Getting started -The CLI doesn't just identify waste—it teaches you what makes observability data valuable. Each recommendation explains why something is or isn't useful during incidents, helping your team get better at instrumentation over time. +A first run takes about five minutes. You'll authenticate, connect a Datadog +account, and land in the issue explorer. -**Common workflows:** +### 1. Launch the app +```bash +tero ``` -"How much waste do I have?" -→ Shows total waste across your account, broken down by service -"What's wrong with checkout-api?" -→ Shows specific waste patterns in that service -→ Explains what each pattern is and why it's waste -→ Lets you take action +`tero` with no arguments opens the interactive terminal UI and walks you through +onboarding. -"Show me the database_connection_debug logs" -→ Displays actual log samples -→ Explains the pattern and cost impact +### 2. Authenticate -"Block those logs" -→ Creates exclusion rule in Datadog -→ Confirms savings -``` +On first run you'll be prompted to log in (or create an account). This opens a +browser-based device login. Once you're authenticated, the CLI remembers you — +you won't be asked again until you log out. -**Example session:** +### 3. Connect a Datadog account +If the selected account has no Datadog connection yet, onboarding walks you +through it: + +1. **Pick your Datadog region** — US1, US3, US5, EU1, AP1, or US1-FED. +2. **Enter your Datadog API key** — *Datadog → Organization Settings → API Keys.* +3. **Enter your Datadog Application key** — *Datadog → Organization Settings → + Application Keys.* + +Tero validates the keys, registers the account, and begins analyzing your data. +Access is **read-only**: Tero only reads telemetry metadata. + +### 4. Explore your issues + +After onboarding you land in the **issue explorer** — a read-only list of the +active issues Tero found, highest priority first. Use: + +- `↑` / `↓` (or `k` / `j`) to move through issues +- `r` to refresh +- `ctrl+d` to open the status drawer (Issues, Checks, Services, Log events, Edge) +- `/` to open the command palette (refresh, switch org/account, theme, quit) +- `ctrl+c` to quit + +### 5. Or just ask the CLI directly + +You don't need the UI to see your account. The same data is available as plain +commands: + +```bash +tero status # account health, services, log events, cost, open issues +tero issues # active issues +tero services # enabled services +tero checks # product checks and posture ``` -$ tero -Welcome back, Ben. +That's it. Run `tero` anytime to explore, or use the commands above in scripts. -I analyzed your Datadog account. Found $89K/year in waste across 12 services. +--- -Services with the most waste: - 1. checkout-api: $50K/year - 2. user-auth: $24K/year - 3. payment-processor: $15K/year +## How-to guides -Want to start with checkout-api? +### Authenticate, or check who you are -> yes +```bash +tero auth login # browser device login +tero auth status # show the current user and org +tero auth logout # clear stored credentials +``` -Found 24 waste patterns in checkout-api. Here are the top 3: +### Connect (or reconnect) a Datadog account -┌────────────────────────────┬──────────┬──────────────┐ -│ Event │ Volume │ Cost Impact │ -├────────────────────────────┼──────────┼──────────────┤ -│ middleware_execution_trace │ 2M/hr │ $26K/year │ -│ database_query_start │ 800K/hr │ $11K/year │ -│ cache_operation_success │ 1M/hr │ $13K/year │ -└────────────────────────────┴──────────┴──────────────┘ +Connecting Datadog happens interactively inside `tero`. It runs automatically +during onboarding when the active account has no Datadog connection. To connect +a *different* account, run `tero`, press `/`, and choose **Switch Account** — +onboarding re-runs for the account you select. -> what is middleware_execution_trace? +> There is no headless `tero datadog connect` command yet; connection is +> interactive only. -This log tracks execution through the middleware chain. Template: +### See your account status at a glance - 2024-01-15 10:23:45.123 INFO middleware executing name=cors path=/api/checkout request_id=req_x7k2m - 2024-01-15 10:23:45.125 INFO middleware executing name=auth path=/api/checkout request_id=req_x7k2m - 2024-01-15 10:23:45.127 INFO middleware executing name=rate_limit path=/api/checkout request_id=req_x7k2m - 2024-01-15 10:23:45.129 INFO middleware executing name=request_logger path=/api/checkout request_id=req_x7k2m +```bash +tero status +``` -Logs 6-8 times per request as it moves through the middleware chain. At 250K requests/hour, that's 2M logs/hour. +``` +Account Status +Health OK +Ready for use true +Services 3 active / 17 total +Log events 197 (197 analyzed) +Volume 4.2k/hr +Cost $74/yr +Open issues 1 (0 high, 1 medium, 0 low) +``` -Likely added during development to debug middleware ordering. When middleware fails, you get actual error logs. Success logs don't help during incidents. +### List your active issues -Safe to drop. +```bash +tero issues +``` -> block it +``` +PRIORITY ID SERVICE TITLE +medium ISS-4 accounting Order receipt logs include full customer shipping addresses +``` -Created exclusion rule in Datadog. This will save $26K/year immediately. +### Inspect services, checks, and edge instances -This log is emitted from src/middleware/logger.ts - want me to help you remove it from the code so it doesn't come back? +```bash +tero services # enabled services with health, volume, and cost +tero checks # product checks with open findings, active issues, and cost +tero edge # registered edge instances +``` -> yes +### Get machine-readable output for scripting -[Scanning your local repository for the logging statement...] -Found in src/middleware/logger.ts:45 +Every surface command supports `-o json` (default is `table`): -[Opens your editor with the change ready to review] +```bash +tero issues -o json +tero status -o json | jq '.open_issues' +tero services -o json | jq -r '.[] | select(.health != "OK") | .name' +``` -Done. Blocked in Datadog and removed from code. Want to see the next waste pattern? +```json +[ + { + "id": "019eaa9e-3242-7ba8-92b1-1b034a4b532d", + "display_id": "ISS-4", + "priority": "medium", + "service": "accounting", + "title": "Order receipt logs include full customer shipping addresses" + } +] ``` -## Safety +### Switch organization or account -**Read-only access:** Tero only reads data from Datadog. We never write or modify anything without your explicit confirmation. +Inside `tero`, press `/` to open the command palette and choose **Switch +Organization** or **Switch Account**. To switch org from a script: -**No pipeline required:** We're not a data pipeline or routing tool. Tero connects via API to your existing observability platforms - no new infrastructure to deploy or manage. +```bash +tero auth switch +``` -**No infrastructure changes:** No agents to install. No collector configs to update. No deployment required. Just a read-only API connection. +### Start over -**Opt-in actions:** When you choose to block waste, we configure your existing tools (Datadog exclusion rules, code changes, etc.). Everything is reversible. +```bash +tero reset # clear stored preferences and authentication for this environment +``` -## What This Isn't +--- -**Not a cost-cutting tool.** Tero helps you improve observability quality. Reduced costs are a side effect of better data. +## Reference + +### Commands + +| Command | Description | +|---------|-------------| +| `tero` | Launch the interactive UI (onboarding → issue explorer). | +| `tero status` | Account health, service/event counts, cost, and open-issue summary. | +| `tero issues` | Active issues (priority, ID, service, title). | +| `tero checks` | Product checks with findings, active issues, and cost. | +| `tero services` | Enabled services with health, volume, and cost. | +| `tero edge` | Edge instances registered for the account. | +| `tero auth login` | Authenticate via browser device login. | +| `tero auth status` | Show the current user and organization. | +| `tero auth logout` | Clear stored credentials. | +| `tero auth switch [org]` | Switch the active organization. | +| `tero auth token` | Print the current access token. | +| `tero reset` | Clear preferences and authentication for the current environment. | + +### Global flags + +| Flag | Description | +|------|-------------| +| `-o, --output ` | Output format for surface commands. Default `table`. | +| `--endpoint ` | Override the control-plane endpoint. | +| `-d, --debug` | Enable debug logging. | +| `-v, --version` | Print the CLI version. | +| `-h, --help` | Help for any command. | + +### Interactive UI keys + +| Key | Action | +|-----|--------| +| `↑` / `↓` (or `k` / `j`) | Navigate issues / drawer rows. | +| `r` | Refresh the issue list. | +| `ctrl+d` | Toggle the status drawer (Issues, Checks, Services, Log events, Edge). | +| `tab` | Next tab in the drawer. | +| `esc` | Close the drawer. | +| `/` | Open the command palette. | +| `ctrl+c` | Quit. | + +### Environment variables + +| Variable | Description | +|----------|-------------| +| `TERO_ENV` | Environment to target: `prd` (default), `dev`, or `local`. | +| `TERO_API_ENDPOINT` | Override the control-plane GraphQL endpoint. | +| `TERO_DEBUG` | Set to `1` or `true` to enable debug logging. | + +### Files + +Credentials and preferences live under `~/.tero/environments//`. Logs are +written to `~/.tero/environments//tero.log`. Run `tero internal inspect +paths` to print the resolved locations. -**Not a pipeline.** We don't route, sample, or transform your data in flight. We analyze what you have and help you improve it at the source. +--- + +## Concepts -**Not automatic.** We never drop data without your explicit approval. You're in control of every action. +### How it works -## Common Questions +The CLI is a thin presentation layer over the Tero **control plane**. It holds no +local database and runs no sync engine: every command reads (or writes) directly +to the control plane over GraphQL. Intelligence — the semantic analysis of your +logs, the issues, the cost modeling — lives server-side. The CLI's job is to +authenticate you, connect your data source, and show you the results. -**What Datadog permissions does Tero need?** +### What Tero finds -Read-only access to start. Tero will request specific write permissions (like creating exclusion rules) only when you choose to take action. See [setup guide](https://tero.com/docs/setup) for details. +Tero builds a semantic catalog of your log events — what each pattern *means*, +how much it costs, and whether it helps during an incident. From that it surfaces +**issues** (things worth your attention, like leaking PII or high-cost noise) and +runs **checks** across cost and compliance domains. The CLI lets you browse all +of this per account. -**What data does Tero collect?** +### Safety -We analyze metadata about your telemetry (log event names, volumes, services, costs) to build our semantic catalog. We don't store your actual log content. See [Privacy Policy](https://tero.com/privacy). +- **Read-only by default.** Tero reads telemetry metadata to build its catalog. + It does not store your raw log content. +- **No infrastructure.** No agents, collectors, or pipeline configs — just a + read-only API connection to your existing platform. +- **Opt-in everything.** Connecting Datadog requires keys you provide; nothing is + configured without your action. -**Does this work with other observability tools?** +### What this isn't -Datadog only right now. CloudWatch, Splunk, and others coming soon. +- **Not a pipeline.** Tero doesn't route, sample, or transform data in flight. It + analyzes what you already have and helps you improve it at the source. +- **Not a cost tool.** Reduced spend is a side effect of better data, not the + goal. Tero explains *why* a pattern is or isn't valuable. -**More questions?** +### Supported sources -See our [full documentation](https://tero.com/docs) or [contact us](https://tero.com/contact). +Datadog today. CloudWatch, Splunk, and others are on the roadmap. + +--- ## Resources -- **[Documentation](https://tero.com/docs)** - Full platform docs and guides -- **[GitHub Issues](https://github.com/usetero/cli/issues)** - Bug reports and feature requests -- **[Contact Us](https://tero.com/contact)** - Questions or feedback -- **[Contributing](CONTRIBUTING.md)** - Developer documentation for working on the CLI +- **[Documentation](https://usetero.com/docs)** — full platform docs and guides +- **[GitHub Issues](https://github.com/usetero/cli/issues)** — bug reports and feature requests +- **[Contact us](https://usetero.com/contact)** — questions or feedback +- **[Contributing](CONTRIBUTING.md)** — developer documentation for working on the CLI ## About -Tero is from the creators of [Vector.dev](https://vector.dev) (acquired by Datadog). We've spent a decade inside enterprise observability systems and seen this problem from every angle - as engineers, founders, and inside major vendors. - -We built Tero because observability data quality is broken and nobody's fixing it. Not the vendors (they profit from waste), not the pipelines (they can't understand semantic meaning), and not the cost tools (they show you bills, not solutions). +Tero is from the creators of [Vector.dev](https://vector.dev) (acquired by +Datadog). We've spent a decade inside enterprise observability systems and seen +this problem from every angle — as engineers, founders, and inside major vendors. -Tero is different. We understand what your data means, identify what's wrong, and help you fix it - the right way. +We built Tero because observability data quality is broken and nobody's fixing +it: not the vendors (they profit from waste), not the pipelines (they can't +understand semantic meaning), and not the cost tools (they show you bills, not +solutions). Tero understands what your data means, identifies what's wrong, and +helps you fix it at the source. --- diff --git a/Taskfile.yml b/Taskfile.yml index 41f179d3..7c144181 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -39,10 +39,9 @@ tasks: # =========================================================================== generate: - desc: Generate all code (GraphQL client + SQLite schema) + desc: Generate all code (GraphQL client) cmds: - task: generate:client - - task: generate:schema generate:client: desc: Generate GraphQL client (requires control plane at localhost:18081) @@ -51,13 +50,6 @@ tasks: - npx get-graphql-schema http://localhost:18081/graphql > schema.graphql - go run github.com/Khan/genqlient - generate:schema: - desc: Generate PowerSync and SQLite schema (uses local control plane) - cmds: - - go generate ./internal/powersync - - go generate ./internal/sqlite - - sed '/^-- Auto-generated/d; /^-- Run .task generate/d' ../control-plane/internal/powersync/schema.sql > internal/chat/tools/query_schema.sql - # =========================================================================== # Build # =========================================================================== @@ -183,24 +175,11 @@ tasks: cmds: - './scripts/with-env.sh gotestsum --format short-verbose -- -v -tags=correctness -run "^TestCorrectness_" ./...' - test:correctness:powersync-replay: - desc: Run deterministic PowerSync fixture replay correctness test - cmds: - - | - if [ -n "{{.FIXTURE}}" ]; then - TERO_POWERSYNC_FIXTURE_PATH="{{.FIXTURE}}" ./scripts/with-env.sh gotestsum --format short-verbose -- \ - -v -tags=correctness -run "^TestCorrectness_PowerSync_ControllerFixtureReplayDeterministic$" ./internal/powersync/extension - else - ./scripts/with-env.sh gotestsum --format short-verbose -- \ - -v -tags=correctness -run "^TestCorrectness_PowerSync_ControllerFixtureReplayDeterministic$" ./internal/powersync/extension - fi - test:all: - desc: Run CI test suite (unit + hermetic integration + correctness replay) + desc: Run CI test suite (unit + hermetic integration) cmds: - task: test - task: test:integration - - task: test:correctness:powersync-replay test:all:full: desc: Run full test suite including live integration checks @@ -243,34 +222,6 @@ tasks: cmds: - "./scripts/with-env.sh go run ./cmd/tero auth switch {{.CLI_ARGS}}" - internal:powersync:capture: - desc: Capture raw PowerSync stream to NDJSON (TERO_ENV=dev|prd) - vars: - OUTPUT: '{{.OUTPUT | default "fixtures/powersync/capture.ndjson"}}' - DURATION: '{{.DURATION | default "90s"}}' - MAX_BYTES: '{{.MAX_BYTES | default "26214400"}}' - cmds: - - | - CMD="./scripts/with-env.sh go run ./cmd/tero internal powersync capture --output {{.OUTPUT}} --duration {{.DURATION}} --max-bytes {{.MAX_BYTES}}" - if [ -n "{{.ACCOUNT_ID}}" ]; then - CMD="$CMD --account-id {{.ACCOUNT_ID}}" - fi - eval "$CMD" - - internal:powersync:sanitize-fixture: - desc: Sanitize raw PowerSync fixture to commit-safe NDJSON - cmds: - - | - if [ -z "{{.INPUT}}" ] || [ -z "{{.OUTPUT}}" ]; then - echo "INPUT and OUTPUT are required" - exit 1 - fi - CMD="./scripts/with-env.sh go run ./cmd/tero internal powersync sanitize-fixture --input {{.INPUT}} --output {{.OUTPUT}}" - if [ -n "{{.MAX_LINES}}" ]; then - CMD="$CMD --max-lines {{.MAX_LINES}}" - fi - eval "$CMD" - # =========================================================================== # Admin (internal tooling, not shipped) # =========================================================================== @@ -285,15 +236,6 @@ tasks: cmds: - "./scripts/with-env.sh go run ./cmd/admin leave-org {{.CLI_ARGS}}" - # =========================================================================== - # Dependencies - # =========================================================================== - - update:powersync: - desc: Update PowerSync SQLite extension to latest release - cmds: - - ./scripts/update-powersync.sh {{.CLI_ARGS}} - # =========================================================================== # Cleanup # =========================================================================== diff --git a/bin/.ripgrep-15.1.0.pkg b/bin/.ripgrep-15.1.0.pkg new file mode 120000 index 00000000..383f4511 --- /dev/null +++ b/bin/.ripgrep-15.1.0.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/rg b/bin/rg new file mode 120000 index 00000000..fa2775bd --- /dev/null +++ b/bin/rg @@ -0,0 +1 @@ +.ripgrep-15.1.0.pkg \ No newline at end of file diff --git a/cmd/admin/main.go b/cmd/admin/main.go index 28077224..0f5cdb76 100644 --- a/cmd/admin/main.go +++ b/cmd/admin/main.go @@ -97,7 +97,7 @@ func setup(cmd *cobra.Command) (*workosadmin.Client, string, error) { cliConfig := config.LoadCLIConfig() tokenStore := keyring.New(cliConfig.Environment()) - workosClient := workos.NewClient(cliConfig.WorkOSClientID, cliConfig.APIEndpoint, cliConfig.PowerSyncEndpoint, cliConfig.ChatEndpoint) + workosClient := workos.NewClient(cliConfig.WorkOSClientID, cliConfig.APIEndpoint, cliConfig.PowerSyncEndpoint) authService := auth.NewService(workosClient, tokenStore, scope) userID, err := authService.GetUserID(cmd.Context()) diff --git a/docs/domains/statusbar.md b/docs/domains/statusbar.md index be060f06..01a221e3 100644 --- a/docs/domains/statusbar.md +++ b/docs/domains/statusbar.md @@ -12,8 +12,11 @@ confusing stale state. ## What the status bar owns -The status bar owns presentation state and interaction for five tabs: -waste, quality, compliance, services, and sync. +The status bar owns presentation state and interaction for product-surface tabs +that mirror the webapp navigation: + +- Control Plane: policies, issues, checks. +- Data Plane: services, log events, edge instances. It does not own business truth. Facts still come from synced SQLite state and sync runtime signals. @@ -32,9 +35,10 @@ Think of the status bar as a shell plus tab plugins. interaction. - Shared poll lifecycle (`tabpoll/`): typed `PollMsg` -> async fetch -> typed `DataMsg` cycle. -- Shared policy-tab behavior (`policytab/`): - reusable base for waste/quality/compliance polling, change detection, and - list/detail cursor lifecycle. +- Product surfaces (`surfaces/`): + non-interactive summary tabs (policies, issues, checks, log events, edge + instances) that poll a snapshot, gate on "has data", and render compact + + drawer views. - Shared list/detail mechanics (`listdetail/`): keyboard navigation and detail-view enter/exit semantics. @@ -50,14 +54,15 @@ into tab packages. `internal/domain` ownership. 4. Drawer interactions are tab-owned; the shell routes keys but does not micromanage tab internals. -5. Shared polling/list-detail behavior belongs in `tabpoll`, `policytab`, and - `listdetail`, not duplicated per tab. +5. Shared polling/list-detail behavior belongs in `tabpoll` and `listdetail`, + not duplicated per tab. ## Data and ownership boundaries Status bar tabs read from local runtime state: -- policy/service counts from SQLite query surfaces, +- policy, issue, check, service, and log-event counts from SQLite query + surfaces, - sync health from the syncer integration model. They should not call remote APIs directly from tab update/render paths. @@ -65,20 +70,20 @@ Onboarding handles API-first bootstrap; status bar is runtime projection UI. ## Why the current split exists -Waste, quality, and compliance look similar because they solve the same shape of -problem: poll summary + categories, render compact signal, then offer a -list/detail drawer for category inspection. +Some product surfaces look similar because they solve the same shape of problem: +poll summary state, render compact signals where useful, then offer a drawer +summary for inspection. -The shared `policytab.Base` exists to remove boilerplate that was previously +The shared `surfaces.Model` exists to remove boilerplate that was previously easy to drift: - poll lifecycle bookkeeping, - "has data" gating, -- cursor clamp and state-change checks, -- standard list/detail navigation wiring. +- snapshot change detection, +- standard compact/drawer rendering. -The remaining logic in each tab should be domain-specific rendering and query -selection only. +The remaining logic in each surface should be domain-specific query selection +and snapshot shaping only (the per-surface `fetch...` functions). ## Naming contract @@ -89,7 +94,7 @@ To keep tabs consistent, use these names: - rendering helpers: `render...` for tab-local view composition. When two tabs need the same lifecycle behavior, move it to `tabpoll`, -`policytab`, or `viewkit` instead of introducing one-off names in each tab. +`surfaces`, or `viewkit` instead of introducing one-off names in each tab. ## Practical change checklist diff --git a/docs/plans/drop-powersync.md b/docs/plans/drop-powersync.md new file mode 100644 index 00000000..5efa8267 --- /dev/null +++ b/docs/plans/drop-powersync.md @@ -0,0 +1,270 @@ +# Plan: Drop PowerSync from the CLI + +Status: **proposal / scoping** (no code yet). Target: dedicated branch, separate +from `clay-mirror-new-ui-surfaces`. + +## Why + +The control plane has moved off PowerSync. Migration +`20260424084500_remove_status_cache_tables.sql` (control-plane, 2026-04-24) +drops `datadog_account_statuses_cache`, `service_statuses_cache`, +`log_event_statuses_cache`, and `issue_statuses_cache` — described in the +migration itself as *"the former PowerSync materialization tables"* — and routes +status reads through canonical Postgres views instead. + +The CLI's synced schema (`internal/sqlite/schema.sql`, +`internal/powersync/extension/schema.json`) was last regenerated **2026-02-25** +and is still built entirely around those dropped `*_cache` tables. So the CLI is +syncing a schema the control plane no longer maintains. PowerSync is effectively +dead weight for reads today. + +This dovetails with the original task (adopt the latest control-plane APIs): +the new first-class entities — `issues`, `checks`, `edgeInstances` — are exposed +over **GraphQL**, not PowerSync. Moving the CLI's reads to direct GraphQL both +removes the stale sync engine and picks up the new APIs in one motion. + +Aligns with `CLAUDE.md`: *"CLI is presentation only,"* local data is +*"queried (CLI)"*. The traditional CLI commands already query GraphQL directly; +this brings the TUI in line. + +## What PowerSync actually does in the CLI today + +The local `sqlite.DB` **is** a PowerSync-extension-loaded SQLite connection +(`internal/sqlite/database.go` loads `sqlite3_powersync_init`). PowerSync plays +three roles through that connection: + +1. **Local store / schema** — the synced tables and views are PowerSync-managed. +2. **Download (read path)** — replicates control plane → local SQLite. The + status bar, onboarding, and chat history all read these tables. +3. **Upload outbox (write path)** — local writes through PowerSync views are + captured into the `ps_crud` queue. The `internal/upload` uploader drains + `ps_crud` and translates each entry into a **GraphQL mutation** + (`conversationHandler`→`graphql.Conversations`, `messageHandler`→message + mutations, `policyHandler`→`graphql.Policies`, `serviceHandler`→ + `graphql.Services`). + +Key insight: the upload **transport is already GraphQL**. PowerSync is only the +local outbox queue in front of it. The read path, by contrast, depends wholly on +PowerSync replication. + +## The one decision that shapes everything: local store or stateless? + +Because `sqlite.DB` is the PowerSync extension, removing PowerSync forces a +choice about the local store: + +- **Option A — Stateless (direct GraphQL, no local DB).** TUI reads issue + GraphQL queries on demand; chat renders from in-memory state and persists via + GraphQL mutations. Delete the local SQLite store entirely. Simplest end state, + best alignment with "CLI is presentation only," but changes chat's optimistic + persistence model and gives up offline/local cache. +- **Option B — Plain SQLite cache + custom outbox.** Keep a local SQLite (no + PowerSync extension) as a read cache and write outbox; populate it from + GraphQL queries and drain a hand-rolled outbox to GraphQL mutations. Preserves + offline behavior but re-implements a meaningful slice of PowerSync. + +**Decision: Option A (confirmed 2026-06-08).** The control plane is the source +of truth, the CLI is presentation, and the GraphQL client + mutations already +exist. Resolved gating questions: + +- **Offline: not required.** No local cache needed; safe to delete the local DB. +- **Chat history: available over GraphQL.** Dropping local SQLite loses no + history — conversations/messages are served by the control plane. +- **Edge instances: in scope.** Wire `edgeInstances` GraphQL in phase 1 + alongside issues/checks (no longer a deferred stub). + +Optimistic chat UI is preserved with in-memory state reconciled on mutation +success/failure. Option B is off the table. + +The rest of this plan assumes **Option A**. + +## Schema bump findings (2026-06-08, regenerated mirror) + +Regenerating `gen/schema.graphql` against the live control plane (it was ~2 +months stale) surfaced breaking changes — the current operations no longer +validate: + +| Existing CLI op | Status in current control-plane schema | +|---|---| +| `createConversation` / `updateConversation` / `deleteConversation` | **removed** — chat is not a control-plane GraphQL concern | +| `createMessage` | **removed** | +| `approveLogEventPolicy` / `dismissLogEventPolicy` | **removed** → policy lifecycle moved to the Issue model (`ignoreIssue`, `createLogEventPolicy`) | +| `updateService` | renamed → `setServiceEnabled` | +| `workspaces` (query) | **removed** | + +Consequence: the **current uploader is already broken** against the live +control plane (pushes `createConversation`/`createMessage` mutations that no +longer exist). Rebuilding the data layer is not optional cleanup. + +**Workspaces removed from the control plane (found 2026-06-08).** The +`workspaces` query *and the `Workspace` type itself* are gone from the schema — +the concept was removed, not renamed. The CLI threads workspace context through +~39 non-test files: a whole onboarding workspace-selection step +(`onboarding/workspaces/*`), onboarding gates/transitions, chat +(`input_flow`, `model`), the status bar org/workspace display, and app wiring. +Removing it is a user-facing flow change, not a mechanical edit — **needs a +product decision on the post-workspace onboarding/UX** before code. Tracked +separately from the core PowerSync removal. + +**Chat is ephemeral (decided 2026-06-08).** Conversation/message history is no +longer persisted across sessions — in-memory during a session is enough. So: +delete all chat persistence (conversation/message GraphQL ops, uploader +handlers, sqlite conversations/messages tables and query surfaces); chat state +lives in-memory and streams via `internal/boundary/chat`. This removes the last +thing that needed a persistent local store and locks **Option A**. + +## Operation-file migration status (task #1, in progress) + +Reconciling `queries/*.graphql` against the refreshed schema (control plane up +locally). Done so far: + +- `services.graphql`: `updateService(input:{enabled})` → `setServiceEnabled(id, + enabled)`. Operation names kept (`EnableService`/`DisableService`) so the + generated method signatures don't churn. +- `accounts.graphql`: `CreateAccountInput` → `AccountCreateInput`. +- `organizations.graphql`: `CreateOrganizationInput` → `OrganizationCreateInput`; + removed the now-gone `workspace { … }` from the bootstrap result. +- `datadog_accounts.graphql`: `CreateDatadogAccountWithCredentialsInput` → + `DatadogAccountCreateInput`. +- Deleted: `conversations.graphql`, `messages.graphql` (chat ephemeral), + `log_event_policies.graphql` (approve/dismiss → Issue model), + `workspaces.graphql` (workspaces removed). + +**Blocker — the read model is deeply restructured, not renamed.** +`DatadogAccountStatus` went from ~33 flat metric fields to a nested model: +`health: StatusHealth`, `readiness: StatusReadiness`, +`coverage: DatadogAccountStatusCoverage`, `current: DatadogAccountCurrentStatus`, +`preview/effective: StatusScenario`. So `GetDatadogAccountStatus` must be +re-shaped to the nested model, and its ~8 consumers (onboarding datadog +discovery, status-bar surfaces/services, chat update handlers, the sqlite status +layer, domain types) re-mapped. This is tasks #2/#4 work fused into the regen — +genqlient won't emit `generated.go` until every operation validates, so the +client regen lands together with the read-model remap, not before it. + +Net: task #1 is not a mechanical regen; it is the front edge of the full +read-model migration. Sequence the read-model remap (datadog status + the new +issues/checks/edge queries) as the unit that unblocks the regen. + +## Consumer inventory (18 non-test importers) → replacement + +| Area | Files | Uses PowerSync for | Replacement under Option A | +|---|---|---|---| +| Lifecycle wiring | `internal/cmd/root.go`, `internal/cmd/internal_powersync.go` | creates `Syncer`, wires uploader | delete syncer/uploader wiring; keep GraphQL services | +| App shell | `internal/app/app.go` | holds `syncer`, injects into statusbar/onboarding | remove `syncer` field + injections | +| Status bar | `internal/app/statusbar/statusbar.go`, `syncstatus/*` | sync dot + sync-error toasts | remove `syncstatus` entirely (no sync to show) | +| Sync events | `internal/app/events/sync.go` | `SyncStateChanged` etc. | remove | +| Onboarding | `internal/app/onboarding/onboarding.go`, `onboarding/sync/{model,update,view}.go` | "waiting for first sync" gate (`IsReady`) | replace with an initial GraphQL fetch / readiness check, or drop the gate | +| Uploader | `internal/upload/*` (uploader, handlers) | drains `ps_crud` → GraphQL | replace queue-drain with **synchronous GraphQL mutations** at write sites; keep handler→mutation mapping logic | +| Local DB | `internal/sqlite/*` | PowerSync-backed SQLite + generated read/write surfaces | reads → GraphQL queries; remove write surfaces + extension load | +| Schema gen | `internal/sqlite/generate/main.go`, `internal/powersync/extension/generate/main.go` | reflect PowerSync schema | delete | +| Boundary | `internal/boundary/powersync/*`, `internal/powersync/**` | the whole engine | delete | +| Test helpers | `powersynctest`, `messagelisttest` | mock syncer | delete / simplify | + +## Read-path migration (CP → GraphQL queries) + +Map each synced read surface to a GraphQL query. Existing services +(`internal/boundary/graphql/*_service.go`) cover some; the rich status/summary +reads need **new** operations against the control plane's current schema. + +| Today (synced SQLite) | GraphQL replacement | Exists? | +|---|---|---| +| `Conversations()`, `Messages()` | `conversation_service.go`, `message_service.go` | ✅ mostly | +| `Services()` | `service_service.go` | ✅ | +| `LogEvents()`, `LogEventPolicies()` | `policy_service.go` (extend) | ⚠️ partial | +| `DatadogAccountStatuses().GetSummary()` | `account` query (canonical view) | ❌ new op | +| `ServiceStatuses()`, `LogEventStatuses()` | `services`/`logEvents` status fields | ❌ new op | +| `LogEventPolicyCategoryStatuses()` (the "Checks" surface) | `checks` query (`Check`, `CheckConnection`) | ❌ new op | +| *(new)* Issues surface | `issues` query (`Issue`, `IssueConnection`) | ❌ new op | +| *(new)* Edge instances surface | `edgeInstances` query | ❌ new op | + +Action: regenerate the genqlient schema mirror +(`internal/boundary/graphql/gen/schema.graphql`) against the current control +plane — it is missing `Issue`, `Check`, `IssueConnection` — then add the queries +above. The status-bar `surfaces.go` `fetch*` functions get re-pointed from +`db.*` to the GraphQL services (with the CLAUDE.md caveat that tab update/render +paths stay non-blocking — fetch in `tea.Cmd`, which `tabpoll` already does). + +## Write-path migration (ps_crud outbox → synchronous mutations) + +Today: `m.db.Conversations().Create()` / `Messages().Create*()` write through +PowerSync views → captured in `ps_crud` → uploader → GraphQL mutation. + +Under Option A: write sites call the GraphQL mutation directly (in a `tea.Cmd`), +keep optimistic in-memory rendering, and reconcile on success/failure. The +handler→mutation logic in `internal/upload/*_handler.go` is reusable — it just +moves from "queue consumer" to "called inline." Write sites to convert: + +- `internal/app/chat/input_flow.go` (conversation + user message create) +- `internal/app/chat/usecase/{tool_loop,assistant_persistence}.go` (assistant / + tool-result messages) +- `internal/app/chattools/setserviceenabled.go` (service patch) +- policy approve/dismiss flows (`policyHandler` equivalent) + +Risk: lose the automatic retry/queue durability PowerSync provided. Mitigate +with explicit retry + error toasts (the uploader already had a retry policy we +can lift). + +## Deletion inventory (once reads/writes are migrated) + +- `internal/powersync/**` (engine, syncer, stream capture, crud queue) +- `internal/boundary/powersync/**` (admin API client) +- `internal/upload/**` (replaced by inline mutations) +- `internal/app/statusbar/syncstatus/**`, `internal/app/events/sync.go` +- `internal/app/onboarding/sync/**` (or repurpose as a generic loading gate) +- PowerSync-specific SQLite: extension load in `database.go`, `schema.sql`, + `internal/powersync/extension/**`, `internal/sqlite/generate/**` +- `*_cache` read surfaces in `internal/sqlite/` and their `gen/` SQL +- Config: `PowerSyncEndpoint`, `POWERSYNC_API_TOKEN`, embedded extension binary + +## Sequencing (phased, each phase shippable) + +1. **Schema refresh + new queries.** Regenerate genqlient against current CP; add + `issues`/`checks`/`account-summary`/`edgeInstances` queries + domain types. + No deletions yet. (This is also the original "adopt new APIs" work.) +2. **Reads → GraphQL.** Re-point `surfaces.go` + status bar + onboarding reads to + the new GraphQL services. PowerSync still running but reads no longer depend + on synced tables. Verify parity. +3. **Writes → mutations.** Convert chat/policy/service write sites to inline + GraphQL mutations with optimistic UI + retry. Stop relying on `ps_crud`. +4. **Remove the engine.** Delete `internal/powersync`, `internal/upload`, + `boundary/powersync`, syncstatus, sync onboarding, PowerSync SQLite substrate, + config, embedded extension. Drop the local DB (or swap to plain SQLite if we + reverse the Option A decision). +5. **Cleanup.** Docs (`architecture/data-flow.md`, `domains/statusbar.md`), + `CLAUDE.md` code-location table, dead test helpers. + +## Principle: aggregation is always server-side + +**Always read aggregates from the control-plane GraphQL APIs; the CLI must not +compute them client-side.** Confirmed 2026-06-08, and the control plane supports +this. This follows `CLAUDE.md` ("intelligence lives in the control plane", +"CLI is presentation only") and removes drift between CLI and webapp numbers. + +Concrete implication for the current mirror work: `surfaces.go` today computes +several aggregates locally and these must be replaced by server-provided fields +in phase 2 — + +- `fetchIssues`: `high = PolicyPendingCriticalCount + PolicyPendingHighCount` + → read a server `high`/severity rollup. +- `fetchChecks`: `len(waste)+len(quality)+len(compliance)`, `categorySummary` + pending sums, `pendingCategorySignal` high + cost sums → read `checks` + aggregate fields. +- `fetchLogEvents`: coverage percentage `analyzed/total` → read server coverage. +- `fetchPolicies`: total fallback summing pending/approved/dismissed → read the + server total. + +## Open questions + +Resolved (2026-06-08): offline **not required**; chat history **available over +GraphQL**; edge instances **in scope** for phase 1; **aggregation is always +server-side** (see principle above) — so phase 2 is a straight re-point to +server aggregates, no client-side aggregation. + +None remaining that block starting phase 1. + +## Testing + +- Phase 2/3: snapshot/parity tests comparing GraphQL-sourced view state against + the previous synced-SQLite values where both exist. +- Keep behavior-first tests (`operations/testing.md`); mock the GraphQL services, + not a DB. +- Manual `/verify` of chat compose/persist + status bar after each phase. diff --git a/go.mod b/go.mod index d7c50a92..67e03496 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/usetero/cli -go 1.25.0 +go 1.25.11 require ( charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66 @@ -14,14 +14,13 @@ require ( github.com/google/uuid v1.6.0 github.com/hashicorp/go-retryablehttp v0.7.8 github.com/lucasb-eyer/go-colorful v1.3.0 - github.com/mattn/go-sqlite3 v1.14.34 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/rivo/uniseg v0.4.7 github.com/sahilm/fuzzy v0.1.1 github.com/spf13/cobra v1.10.2 github.com/workos/workos-go/v6 v6.4.0 github.com/zalando/go-keyring v0.2.6 - golang.org/x/mod v0.33.0 + golang.org/x/mod v0.36.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -57,11 +56,11 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.16 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect - golang.org/x/net v0.50.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect - golang.org/x/tools v0.41.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect + golang.org/x/tools v0.45.0 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 137a3d09..e1c57e7a 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,6 @@ charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971 h1:xZFcNsJMiI charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971/go.mod h1:i61Y3FmdbcBNSKa+pKB3DaE4uVQmBLMs/xlvRyHcXAE= github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs= github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU= -github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= -github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= @@ -112,8 +110,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= -github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -164,19 +160,19 @@ github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH8 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/app/app.go b/internal/app/app.go index ba25e5b2..34a343b6 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -8,27 +8,21 @@ import ( tea "charm.land/bubbletea/v2" - "github.com/usetero/cli/internal/app/chat" - "github.com/usetero/cli/internal/app/chat/usecase" - chattools "github.com/usetero/cli/internal/app/chattools" appevents "github.com/usetero/cli/internal/app/events" + "github.com/usetero/cli/internal/app/explorer" "github.com/usetero/cli/internal/app/keybar" "github.com/usetero/cli/internal/app/onboarding" "github.com/usetero/cli/internal/app/palette" "github.com/usetero/cli/internal/app/statusbar" "github.com/usetero/cli/internal/app/toast" "github.com/usetero/cli/internal/auth" - chatboundary "github.com/usetero/cli/internal/boundary/chat" graphql "github.com/usetero/cli/internal/boundary/graphql" "github.com/usetero/cli/internal/config" "github.com/usetero/cli/internal/domain" "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/powersync" "github.com/usetero/cli/internal/preferences" - "github.com/usetero/cli/internal/sqlite" "github.com/usetero/cli/internal/styles" "github.com/usetero/cli/internal/update" - "github.com/usetero/cli/internal/upload" ) // state represents the app state. @@ -36,7 +30,7 @@ type state int const ( stateOnboarding state = iota - stateChat + stateExplorer ) // Layout constants. @@ -59,9 +53,7 @@ type Model struct { // Dependencies cfg *config.CLIConfig - storage sqlite.Storage authService auth.Auth - syncer powersync.Syncer services graphql.ServiceSet userPrefs preferences.UserPreferences orgPrefs preferences.OrgPreferences @@ -69,21 +61,15 @@ type Model struct { // Runtime (created after account selection / onboarding) sessionCancel context.CancelFunc sessionCtx context.Context - db sqlite.DB - uploader upload.Uploader - chatClient chatboundary.Client - runtimeDeps usecase.RuntimeDeps - toolRegistry *chattools.Registry user *auth.User account domain.Account - workspace domain.Workspace // Components statusBar *statusbar.Model toast *toast.Model keyBar *keybar.Model onboarding *onboarding.Model - chat *chat.Model + explorer *explorer.Model quitDlg *quitDialog palette *palette.Model state state @@ -106,8 +92,6 @@ func New( authService auth.Auth, userPrefs preferences.UserPreferences, orgPrefs preferences.OrgPreferences, - storage sqlite.Storage, - syncer powersync.Syncer, scope log.Scope, ) *Model { if ctx == nil { @@ -125,12 +109,6 @@ func New( if orgPrefs == nil { panic("orgPrefs is nil") } - if storage == nil { - panic("storage is nil") - } - if syncer == nil { - panic("syncer is nil") - } scope = scope.Child("app") @@ -140,16 +118,14 @@ func New( scope: scope, version: version, cfg: cfg, - storage: storage, authService: authService, - syncer: syncer, services: services, userPrefs: userPrefs, orgPrefs: orgPrefs, - statusBar: statusbar.New(theme, scope, syncer, cfg.APIEndpoint, cfg.Env), + statusBar: statusbar.New(theme, scope, cfg.APIEndpoint, cfg.Env), toast: toast.New(theme), keyBar: keybar.New(theme, scope), - onboarding: onboarding.New(ctx, theme, services, userPrefs, orgPrefs, authService, syncer, scope), + onboarding: onboarding.New(ctx, theme, services, userPrefs, orgPrefs, authService, scope), state: stateOnboarding, } } @@ -201,22 +177,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd, handled := m.handleOnboardingMessage(msg); handled { return m, cmd } - m.handleStreamCompleted(msg) return m, m.updateChildren(msg) } -// newChat creates a fresh chat model with current dependencies. -func (m *Model) newChat() *chat.Model { - return chat.New( - m.user, - m.account, - m.workspace, - m.theme, - m.db, - m.runtimeDeps, - m.toolRegistry, - m.scope, - ) +// newExplorer creates a fresh issue explorer scoped to the active account. +func (m *Model) newExplorer() *explorer.Model { + return explorer.New(m.services, m.theme, m.scope) } // openPalette creates and opens the command palette. diff --git a/internal/app/app_layout_view.go b/internal/app/app_layout_view.go index 94f36b36..539296d2 100644 --- a/internal/app/app_layout_view.go +++ b/internal/app/app_layout_view.go @@ -36,11 +36,11 @@ func (m *Model) updateLayout() { if m.onboarding != nil { m.onboarding.SetSize(contentWidth, pageHeight) } - case stateChat: - if m.chat != nil { - m.chat.SetSize(contentWidth, pageHeight) + case stateExplorer: + if m.explorer != nil { + m.explorer.SetSize(contentWidth, pageHeight) // Chat page origin: toast + statusbar + gap (no top padding) - m.chat.SetOrigin(horizontalPadding, toastHeight+statusBarHeight+gapAfterStatusBar) + m.explorer.SetOrigin(horizontalPadding, toastHeight+statusBarHeight+gapAfterStatusBar) } } } @@ -67,9 +67,9 @@ func (m *Model) updateKeyBar() { if m.onboarding != nil { bindings = m.onboarding.ShortHelp() } - case stateChat: - if m.chat != nil { - bindings = m.chat.ShortHelp() + case stateExplorer: + if m.explorer != nil { + bindings = m.explorer.ShortHelp() } } } @@ -138,9 +138,9 @@ func (m *Model) currentPageView() string { if m.onboarding != nil { return m.onboarding.View() } - case stateChat: - if m.chat != nil { - return m.chat.View() + case stateExplorer: + if m.explorer != nil { + return m.explorer.View() } } return "" diff --git a/internal/app/app_onboarding_controls.go b/internal/app/app_onboarding_controls.go index 5b291f79..c88f2279 100644 --- a/internal/app/app_onboarding_controls.go +++ b/internal/app/app_onboarding_controls.go @@ -3,14 +3,12 @@ package app import ( tea "charm.land/bubbletea/v2" - "github.com/usetero/cli/internal/app/chat/usecase" appevents "github.com/usetero/cli/internal/app/events" "github.com/usetero/cli/internal/app/onboarding" "github.com/usetero/cli/internal/app/statusbar" "github.com/usetero/cli/internal/config" "github.com/usetero/cli/internal/domain" "github.com/usetero/cli/internal/preferences" - "github.com/usetero/cli/internal/sqlite" ) // activateOrg sets the active org, reloads org prefs/storage for the new @@ -25,7 +23,6 @@ func (m *Model) activateOrg(orgID domain.OrganizationID, msg tea.Msg) tea.Cmd { m.scope.Error("failed to reload config for org", "error", err) } else { m.orgPrefs = preferences.NewOrgService(cfg, m.scope) - m.storage = sqlite.NewStorageService(cfg) if m.onboarding != nil { m.onboarding.SetOrgPreferences(m.orgPrefs) } @@ -62,8 +59,8 @@ func (m *Model) switchOrganization() tea.Cmd { return m.restartOnboarding() } -// switchAccount clears account preference (cascades to workspace) and -// re-enters onboarding. The saved org auto-selects, then prompts for account. +// switchAccount clears the account preference and re-enters onboarding. The +// saved org auto-selects, then prompts for account. func (m *Model) switchAccount() tea.Cmd { m.scope.Info("switching account") _ = m.orgPrefs.ClearDefaultAccountID() @@ -76,18 +73,13 @@ func (m *Model) switchAccount() tea.Cmd { func (m *Model) restartOnboarding() tea.Cmd { m.shutdown() - m.db = nil - m.uploader = nil - m.chatClient = nil - m.runtimeDeps = usecase.RuntimeDeps{} - m.toolRegistry = nil - m.chat = nil + m.explorer = nil m.services = m.services.WithAccountID("") // clear stale account scope - m.statusBar = statusbar.New(m.theme, m.scope, m.syncer, m.cfg.APIEndpoint, m.cfg.Env) + m.statusBar = statusbar.New(m.theme, m.scope, m.cfg.APIEndpoint, m.cfg.Env) m.windowTitle = "" - m.onboarding = onboarding.New(m.ctx, m.theme, m.services, m.userPrefs, m.orgPrefs, m.authService, m.syncer, m.scope) + m.onboarding = onboarding.New(m.ctx, m.theme, m.services, m.userPrefs, m.orgPrefs, m.authService, m.scope) m.state = stateOnboarding m.updateLayout() diff --git a/internal/app/chat/AGENTS.md b/internal/app/chat/AGENTS.md deleted file mode 100644 index 10f8fa89..00000000 --- a/internal/app/chat/AGENTS.md +++ /dev/null @@ -1,25 +0,0 @@ -# App Chat - -Bubble Tea orchestration for chat rounds, turns, and message list behavior. - -## Rules - -1. Scope stream/tool events by turn ID before applying updates. -2. Keep reducers pure and handlers side-effectful. -3. Message list projection math must be shared between viewport and rendering. -4. User-cancel semantics must stay non-error and non-persisted for committed assistant output. - -## Structure Expectations - -1. `messagelist/update.go` should remain a router. -2. Input-family handlers live in dedicated files (`update_key`, `update_mouse`, `update_lifecycle`). -3. Transition logic belongs in reducer files. - -## Testing Requirements - -For behavior changes in this tree: - -1. Add/adjust reducer unit tests. -2. Add/adjust behavior tests in `messagelist/behavior_test.go` for user-visible outcomes. -3. Run `go test ./internal/chat ./internal/app/chat/... -count=1`. - diff --git a/internal/app/chat/chat.go b/internal/app/chat/chat.go deleted file mode 100644 index 5c2cd9a8..00000000 --- a/internal/app/chat/chat.go +++ /dev/null @@ -1 +0,0 @@ -package chat diff --git a/internal/app/chat/chat_test.go b/internal/app/chat/chat_test.go deleted file mode 100644 index 699a4556..00000000 --- a/internal/app/chat/chat_test.go +++ /dev/null @@ -1,606 +0,0 @@ -package chat - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "sync" - "testing" - - tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/usecase" - appevents "github.com/usetero/cli/internal/app/events" - "github.com/usetero/cli/internal/boundary/chat" - "github.com/usetero/cli/internal/boundary/chat/chattest" - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" - domaintools "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/teatest" -) - -// newTestChat creates a chat model with a real DB and a mock streaming client. -func newTestChat(t *testing.T, client chat.Client) *Model { - t.Helper() - theme := styles.NewTheme(true) - scope := logtest.NewScope(t) - db := dbtest.OpenTestDB(t) - runtimeDeps := usecase.NewRuntimeDeps(db, client) - - m := New(nil, domain.Account{ID: "acct-1"}, domain.Workspace{ID: "ws-1"}, theme, db, runtimeDeps, nil, scope) - m.SetSize(80, 40) - return m -} - -// blockingClient returns a mock client whose stream calls onMessage once then -// blocks until cancelled. Suitable for testing cancel mid-stream. -func blockingClient() *chattest.MockClient { - return &chattest.MockClient{ - StreamFunc: func(ctx context.Context, _ chat.Request, onMessage func(*domain.Message)) (*corechat.StreamResult, error) { - onMessage(&domain.Message{ - ID: "asst-1", - Content: []domain.Block{{Index: 0, Type: domain.BlockTypeText, Text: &domain.TextBlock{Content: "hello"}}}, - }) - <-ctx.Done() - return nil, ctx.Err() - }, - } -} - -// completingClient returns a mock client that immediately completes with a -// text response. -func completingClient() *chattest.MockClient { - return &chattest.MockClient{ - StreamFunc: func(_ context.Context, _ chat.Request, onMessage func(*domain.Message)) (*corechat.StreamResult, error) { - onMessage(&domain.Message{ - ID: "asst-1", - Model: "test-model", - StopReason: "end_turn", - Content: []domain.Block{{Index: 0, Type: domain.BlockTypeText, Text: &domain.TextBlock{Content: "hello"}}}, - }) - return &corechat.StreamResult{}, nil - }, - } -} - -// failingClient returns a mock client that returns an error immediately. -func failingClient() *chattest.MockClient { - return &chattest.MockClient{ - StreamFunc: func(_ context.Context, _ chat.Request, _ func(*domain.Message)) (*corechat.StreamResult, error) { - return nil, errors.New("connection failed") - }, - } -} - -func abortedClient(reason string) *chattest.MockClient { - return &chattest.MockClient{ - StreamSnapshotsFunc: func(_ context.Context, _ chat.Request, onSnapshot func(corechat.StreamSnapshot)) (*corechat.StreamResult, error) { - msg := &domain.Message{ - ID: "asst-1", - Model: "test-model", - Content: []domain.Block{{Index: 0, Type: domain.BlockTypeText, Text: &domain.TextBlock{Content: "partial"}}}, - } - onSnapshot(corechat.StreamSnapshot{ - ConversationID: "conv-1", - TurnID: "turn-1", - Seq: 1, - Status: corechat.StreamStatusAborted, - AbortReason: reason, - Done: true, - Message: msg, - }) - return &corechat.StreamResult{Message: msg}, nil - }, - } -} - -func recordingCompletingClient(requests *[]chat.Request) *chattest.MockClient { - var mu sync.Mutex - call := 0 - - return &chattest.MockClient{ - StreamSnapshotsFunc: func(_ context.Context, req chat.Request, onSnapshot func(corechat.StreamSnapshot)) (*corechat.StreamResult, error) { - mu.Lock() - *requests = append(*requests, req) - call++ - asstID := domain.MessageID(fmt.Sprintf("asst-%d", call)) - mu.Unlock() - - msg := &domain.Message{ - ID: asstID, - Model: "test-model", - StopReason: "end_turn", - Content: []domain.Block{{Index: 0, Type: domain.BlockTypeText, Text: &domain.TextBlock{Content: "hello"}}}, - } - - if onSnapshot != nil { - onSnapshot(corechat.StreamSnapshot{ - ConversationID: "conv-1", - TurnID: "turn-1", - Seq: 1, - Status: corechat.StreamStatusStreaming, - Message: msg, - }) - onSnapshot(corechat.StreamSnapshot{ - ConversationID: "conv-1", - TurnID: "turn-1", - Seq: 2, - Status: corechat.StreamStatusCompleted, - Done: true, - Message: msg, - }) - } - - return &corechat.StreamResult{Message: msg}, nil - }, - } -} - -// submitAndDrain sends a UserSubmittedInput and drains the cmd loop. -func submitAndDrain(m *Model, text string, maxSteps int) { - cmd := m.Update(msgs.UserSubmittedInput{Text: text}) - teatest.DrainCmds(m.Update, cmd, maxSteps) -} - -func submitToolResultsAndDrain(m *Model, results []domaintools.Result, maxSteps int) { - cmd := m.Update(msgs.UserSubmittedInput{ToolResults: results}) - teatest.DrainCmds(m.Update, cmd, maxSteps) -} - -func listMessages(t *testing.T, m *Model) []domain.Message { - t.Helper() - messages, err := m.db.Messages().List(context.Background(), m.conversationID) - if err != nil { - t.Fatalf("failed to list messages: %v", err) - } - return messages -} - -func TestCancelActiveRound(t *testing.T) { - t.Parallel() - - t.Run("cleans up orphaned user message from DB", func(t *testing.T) { - t.Parallel() - m := newTestChat(t, blockingClient()) - - // Submit triggers conversation creation + user message persistence. - submitAndDrain(m, "hello", 20) - - // User message should be in the DB. - messages := listMessages(t, m) - if len(messages) == 0 { - t.Fatal("expected user message in DB after submit") - } - - // Cancel the active round (simulates ESC). - cancelled, cmd := m.CancelActiveRound() - if !cancelled { - t.Fatal("expected active round to be cancelled") - } - teatest.DrainCmds(m.Update, cmd, 20) - - // The orphaned user message should be cleaned up. - messages = listMessages(t, m) - if len(messages) != 0 { - t.Errorf("expected 0 messages after cancel, got %d (roles: %v)", len(messages), messageRoles(messages)) - } - }) - - t.Run("next submit after cancel produces valid history", func(t *testing.T) { - t.Parallel() - m := newTestChat(t, completingClient()) - - // First submit + cancel. - submitAndDrain(m, "first", 20) - cancelled, cmd := m.CancelActiveRound() - if cancelled { - teatest.DrainCmds(m.Update, cmd, 20) - } - - // Second submit — should complete normally. - submitAndDrain(m, "second", 50) - - messages := listMessages(t, m) - if err := validateAlternation(messages); err != nil { - t.Errorf("invalid message history after cancel + resubmit: %v (roles: %v)", err, messageRoles(messages)) - } - }) -} - -func TestRequestHistoryUsesInMemorySessionNotDBRead(t *testing.T) { - t.Parallel() - - var requests []chat.Request - m := newTestChat(t, recordingCompletingClient(&requests)) - - submitAndDrain(m, "first", 50) - - // Simulate durability drift: assistant row missing in SQLite. - stored := listMessages(t, m) - for _, msg := range stored { - if msg.Role == domain.RoleAssistant { - if err := m.db.Messages().Delete(context.Background(), msg.ID); err != nil { - t.Fatalf("delete assistant: %v", err) - } - } - } - - submitAndDrain(m, "second", 70) - - if len(requests) < 2 { - t.Fatalf("requests = %d, want at least 2", len(requests)) - } - second := requests[1].Messages - if len(second) < 3 { - t.Fatalf("second request message count = %d, want >= 3", len(second)) - } - if second[0].Role != domain.RoleUser { - t.Fatalf("second[0].role = %q, want user", second[0].Role) - } - if second[1].Role != domain.RoleAssistant { - t.Fatalf("second[1].role = %q, want assistant (session history)", second[1].Role) - } - if second[len(second)-1].Role != domain.RoleUser { - t.Fatalf("last role = %q, want user", second[len(second)-1].Role) - } -} - -func TestToolResultFollowupRequestContainsAssistantAndToolResult(t *testing.T) { - t.Parallel() - - var requests []chat.Request - m := newTestChat(t, recordingCompletingClient(&requests)) - - // Turn 1: user prompt -> assistant response. - submitAndDrain(m, "run a query", 50) - - // Turn 2: user tool_result follow-up should include prior assistant + tool_result. - submitToolResultsAndDrain(m, []domaintools.Result{{ - ToolUseID: "toolu_1", - Content: map[string]any{ - "rows": []map[string]any{ - {"service_id": "svc-1", "weekly_volume": 12345}, - }, - }, - }}, 60) - - if len(requests) < 2 { - t.Fatalf("requests = %d, want >= 2", len(requests)) - } - second := requests[1].Messages - if len(second) != 3 { - t.Fatalf("second request message count = %d, want 3", len(second)) - } - if second[0].Role != domain.RoleUser { - t.Fatalf("second[0].role = %q, want user", second[0].Role) - } - if second[1].Role != domain.RoleAssistant { - t.Fatalf("second[1].role = %q, want assistant", second[1].Role) - } - if second[2].Role != domain.RoleUser { - t.Fatalf("second[2].role = %q, want user(tool_result)", second[2].Role) - } - if len(second[2].Content) != 1 || second[2].Content[0].Type != domain.BlockTypeToolResult { - t.Fatalf("second[2] content = %#v, want single tool_result block", second[2].Content) - } - if got := second[2].Content[0].ToolResult.ToolUseID; got != "toolu_1" { - t.Fatalf("tool_use_id = %q, want %q", got, "toolu_1") - } -} - -func TestToolResultFollowupKeepsAssistantWhenStreamMessageIDMissing(t *testing.T) { - t.Parallel() - - var requests []chat.Request - var mu sync.Mutex - call := 0 - client := &chattest.MockClient{ - StreamSnapshotsFunc: func(_ context.Context, req chat.Request, onSnapshot func(corechat.StreamSnapshot)) (*corechat.StreamResult, error) { - mu.Lock() - requests = append(requests, req) - call++ - n := call - mu.Unlock() - - if n == 1 { - msg := &domain.Message{ - // Intentionally empty ID: mirrors stream payloads that do not carry message IDs. - ID: "", - Model: "test-model", - StopReason: "tool_use", - Content: []domain.Block{{ - Index: 0, - Type: domain.BlockTypeToolUse, - ToolUse: &domain.ToolUse{ - ID: "toolu_1", - Name: "query", - Input: json.RawMessage(`{"sql":"select 1"}`), - InputComplete: true, - }, - }}, - } - onSnapshot(corechat.StreamSnapshot{ - ConversationID: req.ConversationID, - TurnID: "turn-1", - Seq: 1, - Status: corechat.StreamStatusToolUse, - Done: true, - Message: msg, - }) - return &corechat.StreamResult{Message: msg}, nil - } - - msg := &domain.Message{ - ID: "asst-2", - Model: "test-model", - StopReason: "end_turn", - Content: []domain.Block{{Index: 0, Type: domain.BlockTypeText, Text: &domain.TextBlock{Content: "done"}}}, - } - onSnapshot(corechat.StreamSnapshot{ - ConversationID: req.ConversationID, - TurnID: "turn-2", - Seq: 1, - Status: corechat.StreamStatusCompleted, - Done: true, - Message: msg, - }) - return &corechat.StreamResult{Message: msg}, nil - }, - } - - m := newTestChat(t, client) - submitAndDrain(m, "run a query", 60) - submitToolResultsAndDrain(m, []domaintools.Result{{ - ToolUseID: "toolu_1", - Content: map[string]any{ - "rows": []map[string]any{{"service_id": "svc-1"}}, - }, - }}, 60) - - mu.Lock() - defer mu.Unlock() - if len(requests) < 2 { - t.Fatalf("requests = %d, want >= 2", len(requests)) - } - second := requests[1].Messages - if len(second) != 3 { - t.Fatalf("second request message count = %d, want 3", len(second)) - } - if second[1].Role != domain.RoleAssistant { - t.Fatalf("second[1].role = %q, want assistant", second[1].Role) - } - if len(second[1].Content) != 1 || second[1].Content[0].Type != domain.BlockTypeToolUse { - t.Fatalf("second[1].content = %#v, want single tool_use block", second[1].Content) - } - if got := second[1].Content[0].ToolUse.ID; got != "toolu_1" { - t.Fatalf("assistant tool_use.id = %q, want toolu_1", got) - } -} - -func TestInternalToolLoopKeepsTopLevelSessionAligned(t *testing.T) { - t.Parallel() - - var requests []chat.Request - var mu sync.Mutex - call := 0 - client := &chattest.MockClient{ - StreamSnapshotsFunc: func(_ context.Context, req chat.Request, onSnapshot func(corechat.StreamSnapshot)) (*corechat.StreamResult, error) { - mu.Lock() - requests = append(requests, req) - call++ - n := call - mu.Unlock() - - if n == 1 { - msg := &domain.Message{ - ID: "asst-1", - Model: "test-model", - StopReason: "tool_use", - Content: []domain.Block{{ - Index: 0, - Type: domain.BlockTypeToolUse, - ToolUse: &domain.ToolUse{ - ID: "toolu_1", - Name: "query", - Input: json.RawMessage(`{"sql":"select 1"}`), - InputComplete: true, - }, - }}, - } - onSnapshot(corechat.StreamSnapshot{ - ConversationID: req.ConversationID, - TurnID: "turn-1", - Seq: 1, - Status: corechat.StreamStatusToolUse, - Done: true, - Message: msg, - }) - return &corechat.StreamResult{Message: msg}, nil - } - - msg := &domain.Message{ - ID: domain.MessageID(fmt.Sprintf("asst-%d", n)), - Model: "test-model", - StopReason: "end_turn", - Content: []domain.Block{{Index: 0, Type: domain.BlockTypeText, Text: &domain.TextBlock{Content: "done"}}}, - } - onSnapshot(corechat.StreamSnapshot{ - ConversationID: req.ConversationID, - TurnID: fmt.Sprintf("turn-%d", n), - Seq: 1, - Status: corechat.StreamStatusCompleted, - Done: true, - Message: msg, - }) - return &corechat.StreamResult{Message: msg}, nil - }, - } - - m := newTestChat(t, client) - submitAndDrain(m, "run a query", 80) - - stored := listMessages(t, m) - if len(stored) == 0 { - t.Fatal("expected first user message") - } - firstTurnID := stored[0].ID - - cmd := m.Update(msgs.ToolCompleted{ - TurnID: firstTurnID, - ToolUseID: "toolu_1", - Result: domaintools.Result{ - ToolUseID: "toolu_1", - Content: map[string]any{ - "rows": []map[string]any{{"service_id": "svc-1"}}, - }, - }, - }) - teatest.DrainCmds(m.Update, cmd, 120) - - submitAndDrain(m, "what are my top disabled services?", 120) - - mu.Lock() - defer mu.Unlock() - if len(requests) < 3 { - t.Fatalf("requests = %d, want >= 3", len(requests)) - } - third := requests[2].Messages - if len(third) < 5 { - t.Fatalf("third request message count = %d, want >= 5", len(third)) - } - if third[1].Role != domain.RoleAssistant || third[1].StopReason != "tool_use" { - t.Fatalf("third[1] = role=%q stop_reason=%q, want assistant tool_use", third[1].Role, third[1].StopReason) - } - if third[2].Role != domain.RoleUser || len(third[2].Content) == 0 || third[2].Content[0].Type != domain.BlockTypeToolResult { - t.Fatalf("third[2] = %#v, want user tool_result message", third[2]) - } - if got := third[2].Content[0].ToolResult.ToolUseID; got != "toolu_1" { - t.Fatalf("third[2] tool_use_id = %q, want toolu_1", got) - } -} - -func TestStreamFailed(t *testing.T) { - t.Parallel() - - t.Run("cleans up orphaned user message from DB", func(t *testing.T) { - t.Parallel() - m := newTestChat(t, failingClient()) - - submitAndDrain(m, "hello", 20) - - messages := listMessages(t, m) - if len(messages) != 0 { - t.Errorf("expected 0 messages after stream failure, got %d (roles: %v)", len(messages), messageRoles(messages)) - } - }) - - t.Run("maps protocol errors to user-friendly toast", func(t *testing.T) { - t.Parallel() - m := newTestChat(t, completingClient()) - cmd := m.Update(msgs.StreamFailed{TurnID: "turn-1", Err: errors.New("protocol error: unknown event type")}) - errMsg, ok := extractErrorToast(cmd) - if !ok { - t.Fatal("expected error toast command") - } - if errMsg.Message != "The chat service returned an unexpected stream format. Please retry." { - t.Fatalf("toast message = %q", errMsg.Message) - } - }) -} - -func TestStreamAborted(t *testing.T) { - t.Parallel() - - t.Run("non-user abort persists partial assistant message", func(t *testing.T) { - t.Parallel() - m := newTestChat(t, abortedClient("context_canceled")) - - submitAndDrain(m, "hello", 40) - - messages := listMessages(t, m) - if len(messages) != 2 { - t.Fatalf("expected 2 messages, got %d (roles: %v)", len(messages), messageRoles(messages)) - } - if messages[0].Role != domain.RoleUser { - t.Fatalf("message 0 role = %s, want user", messages[0].Role) - } - if messages[1].Role != domain.RoleAssistant { - t.Fatalf("message 1 role = %s, want assistant", messages[1].Role) - } - if messages[1].StopReason != "aborted" { - t.Fatalf("assistant stop_reason = %q, want %q", messages[1].StopReason, "aborted") - } - }) - - t.Run("user_cancelled abort does not persist assistant message", func(t *testing.T) { - t.Parallel() - m := newTestChat(t, abortedClient("user_cancelled")) - - submitAndDrain(m, "hello", 40) - - messages := listMessages(t, m) - if len(messages) != 1 { - t.Fatalf("expected 1 message, got %d (roles: %v)", len(messages), messageRoles(messages)) - } - if messages[0].Role != domain.RoleUser { - t.Fatalf("message role = %s, want user", messages[0].Role) - } - }) -} - -// validateAlternation checks that messages strictly alternate roles, -// starting with user. Returns an error describing the violation if any. -func validateAlternation(messages []domain.Message) error { - if len(messages) == 0 { - return nil - } - if messages[0].Role != domain.RoleUser { - return errors.New("first message must be user") - } - for i := 1; i < len(messages); i++ { - if messages[i].Role == messages[i-1].Role { - return errors.New("consecutive messages with same role: " + string(messages[i].Role)) - } - } - return nil -} - -// messageRoles returns a slice of role strings for debugging. -func messageRoles(messages []domain.Message) []string { - roles := make([]string, len(messages)) - for i, m := range messages { - roles[i] = string(m.Role) - } - return roles -} - -func extractErrorToast(cmd tea.Cmd) (appevents.ErrorToastPublished, bool) { - if cmd == nil { - return appevents.ErrorToastPublished{}, false - } - msg := cmd() - if msg == nil { - return appevents.ErrorToastPublished{}, false - } - if e, ok := msg.(appevents.ErrorToastPublished); ok { - return e, true - } - batch, ok := msg.(tea.BatchMsg) - if !ok { - return appevents.ErrorToastPublished{}, false - } - for _, sub := range batch { - if sub == nil { - continue - } - subMsg := sub() - if e, ok := subMsg.(appevents.ErrorToastPublished); ok { - return e, true - } - } - return appevents.ErrorToastPublished{}, false -} diff --git a/internal/app/chat/empty_state_poll_test.go b/internal/app/chat/empty_state_poll_test.go deleted file mode 100644 index 4fd1dbb2..00000000 --- a/internal/app/chat/empty_state_poll_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package chat - -import ( - "context" - "testing" - - "github.com/usetero/cli/internal/app/chat/usecase" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/sqlite/sqlitetest" - "github.com/usetero/cli/internal/styles" -) - -type stubDatadogAccountStatuses struct { - getSummary func(ctx context.Context) (domain.AccountSummary, error) -} - -func (s stubDatadogAccountStatuses) GetSummary(ctx context.Context) (domain.AccountSummary, error) { - return s.getSummary(ctx) -} - -func TestEmptyStatePollDoesNotMutateSynchronously(t *testing.T) { - mockDB := sqlitetest.NewMockDB() - mockDB.DatadogAccountStatusesImpl = stubDatadogAccountStatuses{ - getSummary: func(context.Context) (domain.AccountSummary, error) { - return domain.AccountSummary{ServiceCount: 7}, nil - }, - } - - m := New( - nil, - domain.Account{ID: "acct-1"}, - domain.Workspace{ID: "ws-1"}, - styles.NewTheme(true), - mockDB, - usecase.RuntimeDeps{}, - nil, - logtest.NewScope(t), - ) - - cmd := m.Update(emptyStatePollTickMsg{}) - if cmd == nil { - t.Fatalf("expected poll update to return command") - } - if m.policySummary != nil { - t.Fatalf("expected summary to remain unset until async result message") - } -} - -func TestEmptyStateSummaryMessageUpdatesState(t *testing.T) { - mockDB := sqlitetest.NewMockDB() - mockDB.DatadogAccountStatusesImpl = stubDatadogAccountStatuses{ - getSummary: func(context.Context) (domain.AccountSummary, error) { - return domain.AccountSummary{ServiceCount: 5, ActiveServices: 3}, nil - }, - } - - m := New( - nil, - domain.Account{ID: "acct-1"}, - domain.Workspace{ID: "ws-1"}, - styles.NewTheme(true), - mockDB, - usecase.RuntimeDeps{}, - nil, - logtest.NewScope(t), - ) - - msg := m.fetchEmptyStateSummary()() - if _, ok := msg.(emptyStateSummaryLoadedMsg); !ok { - t.Fatalf("expected emptyStateSummaryLoadedMsg, got %T", msg) - } - - m.Update(msg) - if m.policySummary == nil { - t.Fatalf("expected summary after handling async summary message") - } - if m.policySummary.ServiceCount != 5 { - t.Fatalf("unexpected service count: got %d want %d", m.policySummary.ServiceCount, 5) - } -} diff --git a/internal/app/chat/events/input.go b/internal/app/chat/events/input.go deleted file mode 100644 index 0da6dd6d..00000000 --- a/internal/app/chat/events/input.go +++ /dev/null @@ -1,9 +0,0 @@ -package events - -import domaintools "github.com/usetero/cli/internal/domain/tools" - -// UserSubmittedInput is fired when user input is ready (text from input bar or tool results). -type UserSubmittedInput struct { - Text string - ToolResults []domaintools.Result -} diff --git a/internal/app/chat/events/stream.go b/internal/app/chat/events/stream.go deleted file mode 100644 index c5cc0a6e..00000000 --- a/internal/app/chat/events/stream.go +++ /dev/null @@ -1,48 +0,0 @@ -package events - -import ( - "github.com/usetero/cli/internal/domain" - domaintools "github.com/usetero/cli/internal/domain/tools" -) - -// TurnStarted is fired when a new turn begins within a round. -type TurnStarted struct { - UserMessageID domain.MessageID - ConversationID domain.ConversationID -} - -// AssistantContentUpdated is fired as the assistant message streams in. -type AssistantContentUpdated struct { - TurnID domain.MessageID // user message ID that started this turn - Message domain.Message -} - -// StreamCompleted is fired when the assistant stream finishes. -type StreamCompleted struct { - TurnID domain.MessageID // user message ID that started this turn - Message domain.Message - Title string // AI-generated conversation title, if provided - ContextWindow int // model's max token capacity - InputTokens int // tokens consumed by input this turn - OutputTokens int // tokens generated by output this turn -} - -// StreamFailed is fired when the assistant stream encounters an error. -type StreamFailed struct { - TurnID domain.MessageID // user message ID that started this turn - Err error -} - -// ToolResultsReady is fired by a turn when all tool results are collected. -// This is internal to round - it triggers the next turn in the tool loop. -type ToolResultsReady struct { - TurnID domain.MessageID // identifies which turn completed - Results []domaintools.Result // collected tool results -} - -// ToolResultMessagePersisted is fired when the round persists an internal -// user tool_result message during a tool loop. The top-level chat session uses -// this to keep in-memory request history aligned with round-local state. -type ToolResultMessagePersisted struct { - Message domain.Message -} diff --git a/internal/app/chat/events/tools.go b/internal/app/chat/events/tools.go deleted file mode 100644 index 57d3c153..00000000 --- a/internal/app/chat/events/tools.go +++ /dev/null @@ -1,24 +0,0 @@ -package events - -import ( - "github.com/usetero/cli/internal/domain" - domaintools "github.com/usetero/cli/internal/domain/tools" -) - -// ToolCompleted is fired when any tool finishes executing. -type ToolCompleted struct { - TurnID domain.MessageID - ToolUseID string - Result domaintools.Result - Error error -} - -// ResultOrError returns the tool result, wrapping Error when present. -func (m ToolCompleted) ResultOrError() domaintools.Result { - r := m.Result - r.ToolUseID = m.ToolUseID - if m.Error != nil { - r.Error = &domaintools.ErrorResult{Message: m.Error.Error()} - } - return r -} diff --git a/internal/app/chat/input_flow.go b/internal/app/chat/input_flow.go deleted file mode 100644 index c28eec97..00000000 --- a/internal/app/chat/input_flow.go +++ /dev/null @@ -1,131 +0,0 @@ -package chat - -import ( - tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - appevents "github.com/usetero/cli/internal/app/events" - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/sqlite" -) - -// handleUserInput creates conversation if needed, then persists the user message. -func (m *Model) handleUserInput(input msgs.UserSubmittedInput) tea.Cmd { - if len(input.Text) > 0 { - m.scope.Info("user submitted text", "text_length", len(input.Text)) - } else { - m.scope.Info("user submitted tool results", "count", len(input.ToolResults)) - } - - // Cancel any in-flight round before starting a new one. - _, cancelCmd := m.CancelActiveRound() - - // If no conversation yet, create one first (only for text input). - if m.conversationID == "" { - return tea.Batch(cancelCmd, m.createConversation(input)) - } - - return tea.Batch(cancelCmd, m.persistUserMessage(input)) -} - -// createConversation creates a new conversation. -func (m *Model) createConversation(input msgs.UserSubmittedInput) tea.Cmd { - return func() tea.Msg { - ctx, cancel := sqlite.WithTimeout(m.runtimeDeps.EffectContext, dbOpTimeout) - defer cancel() - - convID, err := m.db.Conversations().Create( - ctx, - m.account.ID, - m.workspace.ID, - ) - if err != nil { - m.scope.Error("failed to create conversation", "error", err) - return appevents.ErrorToastPublished{Message: "Failed to create conversation", Err: err} - } - - return conversationCreated{ - conversationID: convID, - input: input, - } - } -} - -// conversationCreated is fired after conversation is created. -type conversationCreated struct { - conversationID domain.ConversationID - input msgs.UserSubmittedInput -} - -// persistUserMessage saves the user message and updates in-memory request history. -func (m *Model) persistUserMessage(input msgs.UserSubmittedInput) tea.Cmd { - return func() tea.Msg { - ctx, cancel := sqlite.WithTimeout(m.runtimeDeps.EffectContext, dbOpTimeout) - defer cancel() - - var msgID domain.MessageID - var err error - var domainResults []domain.ToolResult - - if len(input.ToolResults) > 0 { - // Convert typed results to domain format at the boundary. - domainResults = make([]domain.ToolResult, len(input.ToolResults)) - for i, r := range input.ToolResults { - domainResults[i] = domain.ToolResult{ - ToolUseID: r.ToolUseID, - IsError: r.IsError(), - Content: r.ToMap(), - } - if r.Error != nil { - domainResults[i].Error = r.Error.Message - } - } - msgID, err = m.db.Messages().CreateToolResultMessage(ctx, m.account.ID, m.conversationID, domainResults) - } else { - msgID, err = m.db.Messages().CreateUserMessage(ctx, m.account.ID, m.conversationID, input.Text) - } - if err != nil { - m.scope.Error("failed to create user message", "error", err) - return appevents.ErrorToastPublished{Message: "Failed to save message", Err: err} - } - - if m.session == nil { - m.session = corechat.NewSession(m.conversationID, nil) - } - if len(domainResults) > 0 { - m.session.AppendUserToolResultsMessage(msgID, domainResults) - } else { - m.session.AppendUserTextMessage(msgID, input.Text) - } - messages := m.session.Messages() - - return userMessagePersisted{ - conversationID: m.conversationID, - messageID: msgID, - input: input, - messages: messages, - } - } -} - -// userMessagePersisted is fired after user message is saved to database. -type userMessagePersisted struct { - conversationID domain.ConversationID - messageID domain.MessageID - input msgs.UserSubmittedInput - messages []domain.Message -} - -// handlePersistedMessage starts the turn after the user message is persisted. -func (m *Model) handlePersistedMessage(msg userMessagePersisted) tea.Cmd { - m.scope.Info("turn started", "conversation_id", msg.conversationID, "user_message_id", msg.messageID) - - return m.messageList.StartTurn( - msg.conversationID, - m.account.ID, - msg.messageID, - msg.input, - msg.messages, - nil, - ) -} diff --git a/internal/app/chat/inputbar/inputbar.go b/internal/app/chat/inputbar/inputbar.go deleted file mode 100644 index e468b9fa..00000000 --- a/internal/app/chat/inputbar/inputbar.go +++ /dev/null @@ -1,262 +0,0 @@ -package inputbar - -import ( - "fmt" - "math/rand/v2" - "strings" - - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/textarea" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - - msgs "github.com/usetero/cli/internal/app/chat/events" - appevents "github.com/usetero/cli/internal/app/events" - "github.com/usetero/cli/internal/auth" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/cursor" - "github.com/usetero/cli/internal/tea/keymap" -) - -const ( - textareaHeight = 3 // visible input lines - - // Layout: border(1) + innerPadL(2) + text + innerPadR(2) - borderWidth = 1 - innerPadX = 2 - innerPadY = 1 - chrome = borderWidth + innerPadX*2 - - inputBarHeight = textareaHeight + innerPadY*2 // textarea + top/bottom inner padding -) - -// Model handles user input via a textarea. -type Model struct { - theme styles.Theme - textarea textarea.Model - width int - scope log.Scope - activeTurn domain.MessageID - pendingText string // saved input text, restored on stream failure - placeholder string // rendered outside textarea to avoid bg issues -} - -// placeholder returns a random placeholder for the session. -func placeholder(user *auth.User) string { - name := "" - if user != nil && user.FirstName != "" { - name = user.FirstName - } - - pool := []string{ - "What should we get into?", - "What's on your mind?", - } - if name != "" { - pool = append(pool, - "Ready when you are, "+name, - "Let's get to work, "+name, - ) - } - return pool[rand.IntN(len(pool))] -} - -// New creates a new input bar. -func New(user *auth.User, theme styles.Theme, scope log.Scope) *Model { - scope = scope.Child("inputbar") - - // Input bar uses elevated background to match user message blocks. - elevated := theme.WithBg(theme.BgElevated) - - ta := textarea.New() - ta.ShowLineNumbers = false - ta.SetHeight(textareaHeight) - ta.CharLimit = -1 - ta.SetVirtualCursor(false) - ta.Focus() - - base := lipgloss.NewStyle().Foreground(elevated.Text).Background(elevated.Bg) - ta.SetStyles(textarea.Styles{ - Focused: textarea.StyleState{ - Base: base, - Text: base, - Prompt: base, - }, - Blurred: textarea.StyleState{ - Base: base.Foreground(elevated.TextMuted), - Text: base.Foreground(elevated.TextMuted), - Prompt: base.Foreground(elevated.TextMuted), - }, - Cursor: textarea.CursorStyle{ - Color: elevated.Accent, - Shape: tea.CursorBar, - Blink: true, - }, - }) - - ta.SetPromptFunc(0, func(_ textarea.PromptInfo) string { - return "" - }) - - return &Model{ - theme: elevated, - textarea: ta, - scope: scope, - placeholder: placeholder(user), - } -} - -// Init initializes the input bar. -func (m *Model) Init() tea.Cmd { - return tea.Batch( - m.textarea.Focus(), - textarea.Blink, - ) -} - -// Update handles messages. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - switch msg := msg.(type) { - case tea.KeyPressMsg: - // DEBUG: log all keys - m.scope.Debug("key received", "key", msg.String()) - - // "/" on empty input opens the command palette - if key.Matches(msg, keymap.Palette) && m.textarea.Value() == "" { - return func() tea.Msg { return appevents.PaletteOpenRequested{} } - } - - // Newline - consume, don't forward to textarea - if key.Matches(msg, keymap.Newline) { - m.textarea.InsertRune('\n') - return nil - } - // Enter to submit - consume, don't forward to textarea - if key.Matches(msg, keymap.Send) { - text := strings.TrimSpace(m.textarea.Value()) - if text != "" { - m.pendingText = text - m.textarea.Reset() - return func() tea.Msg { return msgs.UserSubmittedInput{Text: text} } - } - return nil - } - - case msgs.StreamFailed: - if msg.TurnID != m.activeTurn { - return nil - } - if m.pendingText != "" { - m.textarea.SetValue(m.pendingText) - m.pendingText = "" - } - return nil - - case msgs.TurnStarted: - m.activeTurn = msg.UserMessageID - return nil - - case msgs.StreamCompleted: - if msg.TurnID != m.activeTurn { - return nil - } - m.pendingText = "" - return nil - } - - // Forward to textarea - var cmd tea.Cmd - m.textarea, cmd = m.textarea.Update(msg) - return cmd -} - -// View renders the input bar styled like a user message block: -// grey background, green left border, matching padding. -func (m *Model) View() string { - if m.width == 0 { - return "" - } - - var view string - if m.textarea.Value() == "" { - // Render placeholder ourselves so background is correct. - view = lipgloss.NewStyle(). - Foreground(m.theme.TextMuted). - Background(m.theme.Bg). - Render(m.placeholder) - } else { - view = m.textarea.View() - - // The textarea emits SGR resets (\033[0m) that kill our background. - // Re-establish the theme background after every reset. - r, g, b, _ := m.theme.Bg.RGBA() - bgSeq := fmt.Sprintf("\033[48;2;%d;%d;%dm", r>>8, g>>8, b>>8) - view = strings.ReplaceAll(view, "\033[0m", "\033[0m"+bgSeq) - } - - // Insert cursor marker - cur := m.textarea.Cursor() - if cur != nil { - view = cursor.Insert(view, cur.X, cur.Y) - } - - // Pad textarea output to exactly textareaHeight lines - lines := strings.Split(view, "\n") - for len(lines) < textareaHeight { - lines = append(lines, "") - } - view = strings.Join(lines[:textareaHeight], "\n") - - // Inner box: elevated bg, padding, matches user message block styling - contentWidth := m.width - borderWidth - inner := lipgloss.NewStyle(). - Background(m.theme.Bg). - Foreground(m.theme.Text). - Padding(innerPadY, innerPadX). - Width(contentWidth). - Render(view) - - // Border: accent left border, matches renderBlock for user messages - bordered := lipgloss.NewStyle(). - Width(contentWidth + borderWidth). - BorderLeft(true). - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(m.theme.Accent). - Render(inner) - - return bordered -} - -// SetWidth sets the width. -func (m *Model) SetWidth(width int) { - m.width = width - // Textarea gets the width inside all chrome layers - m.textarea.SetWidth(width - chrome) -} - -// Height returns the height of the input bar. -func (m *Model) Height() int { - return inputBarHeight -} - -// Focus returns a command to focus the textarea. -func (m *Model) Focus() tea.Cmd { - return m.textarea.Focus() -} - -// Blur removes focus from the textarea. -func (m *Model) Blur() { - m.textarea.Blur() -} - -// Focused returns whether the textarea is focused. -func (m *Model) Focused() bool { - return m.textarea.Focused() -} - -// ShortHelp returns the key bindings for the short help view. -func (m *Model) ShortHelp() []key.Binding { - return []key.Binding{keymap.Send, keymap.Newline, keymap.Palette} -} diff --git a/internal/app/chat/inputbar/inputbar_test.go b/internal/app/chat/inputbar/inputbar_test.go deleted file mode 100644 index 73a9adf4..00000000 --- a/internal/app/chat/inputbar/inputbar_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package inputbar - -import ( - "strings" - "testing" - - tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - appevents "github.com/usetero/cli/internal/app/events" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/styles" -) - -func newTestInputBar(t *testing.T) *Model { - t.Helper() - m := New(nil, styles.NewTheme(true), logtest.NewScope(t)) - m.SetWidth(80) - return m -} - -func TestUpdate_Submit(t *testing.T) { - t.Parallel() - m := newTestInputBar(t) - m.textarea.SetValue(" hello ") - - cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) - if cmd == nil { - t.Fatal("expected submit cmd") - } - msg := cmd() - input, ok := msg.(msgs.UserSubmittedInput) - if !ok { - t.Fatalf("expected UserSubmittedInput, got %T", msg) - } - if input.Text != "hello" { - t.Fatalf("submitted text = %q, want hello", input.Text) - } - if m.textarea.Value() != "" { - t.Fatalf("textarea should reset, got %q", m.textarea.Value()) - } -} - -func TestUpdate_Newline(t *testing.T) { - t.Parallel() - m := newTestInputBar(t) - m.textarea.SetValue("a") - - cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModShift}) - if cmd != nil { - t.Fatal("newline should not emit cmd") - } - if !strings.Contains(m.textarea.Value(), "\n") { - t.Fatalf("expected newline in textarea, got %q", m.textarea.Value()) - } -} - -func TestUpdate_Palette(t *testing.T) { - t.Parallel() - m := newTestInputBar(t) - - cmd := m.Update(tea.KeyPressMsg{Text: "/"}) - if cmd == nil { - t.Fatal("expected palette open cmd") - } - if _, ok := cmd().(appevents.PaletteOpenRequested); !ok { - t.Fatalf("expected appevents.PaletteOpenRequested, got %T", cmd()) - } -} - -func TestUpdate_PendingTextRestore(t *testing.T) { - t.Parallel() - m := newTestInputBar(t) - m.pendingText = "restored text" - - m.Update(msgs.StreamFailed{Err: nil}) - if m.textarea.Value() != "restored text" { - t.Fatalf("textarea = %q, want restored text", m.textarea.Value()) - } - - m.Update(msgs.StreamCompleted{}) - if m.pendingText != "" { - t.Fatalf("pendingText = %q, want empty", m.pendingText) - } -} diff --git a/internal/app/chat/layout.go b/internal/app/chat/layout.go deleted file mode 100644 index a8aefcfc..00000000 --- a/internal/app/chat/layout.go +++ /dev/null @@ -1,109 +0,0 @@ -package chat - -import ( - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/sqlite" -) - -// SetSize updates the dimensions. This is a flexible component. -func (m *Model) SetSize(width, height int) { - m.width = width - m.height = height - m.updateLayout() -} - -// SetOrigin sets the terminal-absolute position of this component's top-left corner. -func (m *Model) SetOrigin(x, y int) { - m.originX = x - m.originY = y - m.updateLayout() -} - -// updateLayout calculates sizes for children based on current dimensions. -func (m *Model) updateLayout() { - // Input bar is fixed height. - m.inputBar.SetWidth(m.width) - inputBarHeight := m.inputBar.Height() - - // MessageList is flexible - gets remaining space (minus 1 for spacer between list and input bar). - spacer := 0 - if m.hasMessages() { - spacer = 1 - } - messageListHeight := m.height - inputBarHeight - spacer - if messageListHeight < 0 { - messageListHeight = 0 - } - m.messageList.SetSize(m.width, messageListHeight) - m.messageList.SetOrigin(m.originX, m.originY) -} - -// ShortHelp returns the key bindings for the short help view. -func (m *Model) ShortHelp() []key.Binding { - if m.focus == focusMessages { - return []key.Binding{scrollUp, focusInputBar} - } - if m.hasMessages() { - return append(m.inputBar.ShortHelp(), focusChat) - } - return m.inputBar.ShortHelp() -} - -// ConversationID returns the current conversation ID. -func (m *Model) ConversationID() domain.ConversationID { - return m.conversationID -} - -// CancelActiveRound cancels the active round if one exists. -// Returns true if a round was cancelled and a command for async cleanup. -func (m *Model) CancelActiveRound() (bool, tea.Cmd) { - if !m.messageList.HasActiveRound() { - return false, nil - } - - last := m.messageList.LastRound() - m.messageList.CancelActiveRound() - - var cleanupCmd tea.Cmd - if last != nil { - ids := last.LastTurnMessageIDs() - if m.session != nil { - m.session.RemoveMessagesByID(ids) - } - cleanupCmd = m.cleanupOrphanedMessages(ids) - - // Turn 1: remove round entirely (no assistant content to show). - if !last.HasAssistantContent() { - m.messageList.RemoveLastRound() - } - } - - return true, cleanupCmd -} - -// hasMessages returns true if there are messages to display. -func (m *Model) hasMessages() bool { - return m.messageList.Len() > 0 -} - -type orphanedMessagesCleanupCompleted struct { - ids []domain.MessageID - err error -} - -func (m *Model) cleanupOrphanedMessages(ids []domain.MessageID) tea.Cmd { - if len(ids) == 0 || m.runtimeDeps.OrphanCleaner == nil { - return nil - } - cleaner := m.runtimeDeps.OrphanCleaner - return func() tea.Msg { - ctx, cancel := sqlite.WithTimeout(m.runtimeDeps.EffectContext, dbOpTimeout) - defer cancel() - return orphanedMessagesCleanupCompleted{ - ids: ids, - err: cleaner.CleanupMessages(ctx, ids), - } - } -} diff --git a/internal/app/chat/messagelist/behavior_test.go b/internal/app/chat/messagelist/behavior_test.go deleted file mode 100644 index 483de31f..00000000 --- a/internal/app/chat/messagelist/behavior_test.go +++ /dev/null @@ -1,342 +0,0 @@ -package messagelist - -import ( - "encoding/json" - "fmt" - "strings" - "testing" - - tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/app/chat/messagelist/round" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/action" - chattools "github.com/usetero/cli/internal/app/chattools" - chat "github.com/usetero/cli/internal/boundary/chat" - "github.com/usetero/cli/internal/domain" - domaintools "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/tea/teatest" -) - -func addCompletedRound(t *testing.T, m *Model, turnID domain.MessageID, text string) { - t.Helper() - - m.StartTurn("conv-1", "acct-1", turnID, msgs.UserSubmittedInput{Text: "prompt " + string(turnID)}, nil, nil) - m.Update(msgs.StreamCompleted{ - TurnID: turnID, - Message: domain.Message{ - ID: "asst-" + turnID, - StopReason: "end_turn", - Content: []domain.Block{ - {Index: 0, Type: domain.BlockTypeText, Text: &domain.TextBlock{Content: text}}, - }, - }, - }) -} - -func seedHistoryWithActiveRound(t *testing.T, height int) *Model { - t.Helper() - - m := newStreamingMessageList(t) - m.SetSize(80, height) - - for i := range 6 { - id := domain.MessageID(fmt.Sprintf("user-%d", i+1)) - addCompletedRound(t, m, id, fmt.Sprintf("history %d", i+1)) - } - - m.StartTurn("conv-1", "acct-1", "user-live", msgs.UserSubmittedInput{Text: "live"}, nil, nil) - return m -} - -type toggleSpyBlock struct { - text string - toggleCnt int -} - -func (b *toggleSpyBlock) View() string { return b.text } -func (b *toggleSpyBlock) Height() int { return 1 } -func (b *toggleSpyBlock) Update(tea.Msg) tea.Cmd { return nil } -func (b *toggleSpyBlock) SetWidth(int) {} -func (b *toggleSpyBlock) SetFocused(bool) {} -func (b *toggleSpyBlock) Focused() bool { return false } -func (b *toggleSpyBlock) Kind() block.Kind { return block.KindAssistantText } -func (b *toggleSpyBlock) Toggle(int) { b.toggleCnt++ } - -func TestBehavior_CancelledRoundIgnoresStaleAssistantUpdates(t *testing.T) { - t.Parallel() - - m := newStreamingMessageList(t) - - m.StartTurn("conv-1", "acct-1", "user-1", msgs.UserSubmittedInput{Text: "first"}, nil, nil) - m.CancelActiveRound() - m.StartTurn("conv-1", "acct-1", "user-2", msgs.UserSubmittedInput{Text: "second"}, nil, nil) - - m.Update(msgs.AssistantContentUpdated{ - TurnID: "user-1", - Message: domain.Message{ - ID: "asst-stale", - Content: []domain.Block{ - {Index: 0, Type: domain.BlockTypeText, Text: &domain.TextBlock{Content: "stale text"}}, - }, - }, - }) - - if len(m.rounds) != 2 { - t.Fatalf("round count=%d, want 2", len(m.rounds)) - } - if m.rounds[0].State() != round.StateCancelled { - t.Fatalf("round[0] state=%v, want cancelled", m.rounds[0].State()) - } - if !m.rounds[1].IsActive() { - t.Fatalf("round[1] should still be active") - } - if strings.Contains(m.View(), "stale text") { - t.Fatalf("stale update should not be rendered in view") - } -} - -func TestBehavior_StreamUpdateScrollPolicy(t *testing.T) { - t.Parallel() - - t.Run("at bottom sticks to bottom on assistant updates", func(t *testing.T) { - t.Parallel() - m := seedHistoryWithActiveRound(t, 8) - m.vp.ScrollToBottom() - - m.Update(msgs.AssistantContentUpdated{ - TurnID: "user-live", - Message: domain.Message{ - ID: "asst-live", - Content: []domain.Block{ - {Index: 0, Type: domain.BlockTypeText, Text: &domain.TextBlock{Content: "live update"}}, - }, - }, - }) - - if !m.vp.AtBottom() { - t.Fatalf("expected to remain at bottom after update") - } - }) - - t.Run("scrolled up does not get yanked to bottom", func(t *testing.T) { - t.Parallel() - m := seedHistoryWithActiveRound(t, 8) - m.vp.ScrollToBottom() - m.vp.ScrollBy(-4) - m.vp.UpdateFocusFromScroll() - - if m.vp.AtBottom() { - t.Fatalf("precondition failed: expected scrolled-up viewport") - } - beforeIdx, beforeLine := m.vp.Offset() - - m.Update(msgs.AssistantContentUpdated{ - TurnID: "user-live", - Message: domain.Message{ - ID: "asst-live", - Content: []domain.Block{ - {Index: 0, Type: domain.BlockTypeText, Text: &domain.TextBlock{Content: "live update"}}, - }, - }, - }) - - if m.vp.AtBottom() { - t.Fatalf("viewport should stay scrolled up after update") - } - afterIdx, afterLine := m.vp.Offset() - if beforeIdx != afterIdx || beforeLine != afterLine { - t.Fatalf("expected offset stability while scrolled up: before=(%d,%d) after=(%d,%d)", beforeIdx, beforeLine, afterIdx, afterLine) - } - }) -} - -func TestBehavior_MouseReleaseActionPolicy(t *testing.T) { - t.Parallel() - - newToggleModel := func(t *testing.T) (*Model, *toggleSpyBlock, *toggleSpyBlock) { - t.Helper() - m := newStreamingMessageList(t) - m.SetSize(80, 8) - m.SetOrigin(0, 0) - addCompletedRound(t, m, "seed-round", "seed") - - b0 := &toggleSpyBlock{text: "alpha"} - b1 := &toggleSpyBlock{text: "beta"} - m.blocks = []blockEntry{ - {block: b0, roundIndex: 0}, - {block: b1, roundIndex: 0}, - } - // 2 blocks with 1-line heights and 1-line gap between them: - // block 0 at y=0, gap at y=1, block 1 at y=2. - m.vp.SetItems([]int{1, 1}, []int{0, 1}) - m.vp.SetTrailingHeight(0) - m.vp.ScrollToTop() - return m, b0, b1 - } - - t.Run("plain click triggers toggle", func(t *testing.T) { - t.Parallel() - m, b0, b1 := newToggleModel(t) - - m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: 2, Y: 0}) - m.Update(tea.MouseReleaseMsg{Button: tea.MouseLeft, X: 2, Y: 0}) - - if b0.toggleCnt != 1 { - t.Fatalf("expected first block toggle once, got %d", b0.toggleCnt) - } - if b1.toggleCnt != 0 { - t.Fatalf("expected second block untouched, got %d", b1.toggleCnt) - } - }) - - t.Run("drag selection across blocks does not toggle", func(t *testing.T) { - t.Parallel() - m, b0, b1 := newToggleModel(t) - - m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: 2, Y: 0}) - m.Update(tea.MouseMotionMsg{Button: tea.MouseLeft, X: 2, Y: 2}) - cmd := m.Update(tea.MouseReleaseMsg{Button: tea.MouseLeft, X: 2, Y: 2}) - - if cmd == nil { - t.Fatalf("expected copy command on drag highlight release") - } - if b0.toggleCnt != 0 || b1.toggleCnt != 0 { - t.Fatalf("expected no toggles on drag copy, got b0=%d b1=%d", b0.toggleCnt, b1.toggleCnt) - } - }) -} - -func TestBehavior_StaleToolCompletedIgnored(t *testing.T) { - t.Parallel() - - m := newStreamingMessageList(t) - m.StartTurn("conv-1", "acct-1", "user-1", msgs.UserSubmittedInput{Text: "first"}, nil, nil) - m.CancelActiveRound() - m.StartTurn("conv-1", "acct-1", "user-2", msgs.UserSubmittedInput{Text: "second"}, nil, nil) - - if len(m.rounds) != 2 { - t.Fatalf("round count=%d, want 2", len(m.rounds)) - } - if m.rounds[0].State() != round.StateCancelled { - t.Fatalf("round[0] state=%v, want cancelled", m.rounds[0].State()) - } - if !m.rounds[1].IsActive() { - t.Fatalf("round[1] should be active before stale tool completion") - } - - cmd := m.Update(msgs.ToolCompleted{ - TurnID: "user-1", - ToolUseID: "tool-old", - Result: domaintools.Result{ToolUseID: "tool-old"}, - }) - - if cmd != nil { - t.Fatalf("expected stale ToolCompleted to be ignored, got non-nil cmd") - } - if !m.rounds[1].IsActive() { - t.Fatalf("round[1] should remain active after stale tool completion") - } -} - -func TestBehavior_ToolResultsStayBoundToOriginalBlock(t *testing.T) { - t.Parallel() - - type toolInput struct { - Name string `json:"name"` - } - - actionTool := chattools.NewActionTool( - chat.Tool{Name: "set_service_enabled"}, - func(input json.RawMessage) (domaintools.Result, error) { - var in toolInput - if err := json.Unmarshal(input, &in); err != nil { - return domaintools.Result{}, err - } - return domaintools.Result{ - Content: map[string]any{ - "name": in.Name, - }, - }, nil - }, - action.Config{ - DisplayName: func(_ json.RawMessage) string { return "Enable Service" }, - Status: func(_ json.RawMessage) string { return "Enabling" }, - Result: func(result domaintools.Result) string { - name, _ := result.Content["name"].(string) - return name + " enabled" - }, - }, - ) - registry := chattools.NewRegistry(nil, nil, map[string]chattools.ActionTool{ - "set_service_enabled": actionTool, - }) - - m := newStreamingMessageList(t) - m.toolRegistry = registry - - m.StartTurn("conv-1", "acct-1", "user-1", msgs.UserSubmittedInput{Text: "enable"}, nil, nil) - - cmd1 := m.Update(msgs.AssistantContentUpdated{ - TurnID: "user-1", - Message: domain.Message{ - ID: "asst-1", - Content: []domain.Block{ - { - Index: 0, - Type: domain.BlockTypeToolUse, - ToolUse: &domain.ToolUse{ - ID: "tool-1", - Name: "set_service_enabled", - Input: json.RawMessage(`{"name":"alpha"}`), - InputComplete: true, - }, - }, - }, - }, - }) - teatest.DrainCmds(m.Update, cmd1, 64) - - viewAfterFirst := m.View() - if !strings.Contains(viewAfterFirst, "alpha enabled") { - t.Fatalf("expected first result in view, got:\n%s", viewAfterFirst) - } - - cmd2 := m.Update(msgs.AssistantContentUpdated{ - TurnID: "user-1", - Message: domain.Message{ - ID: "asst-1", - Content: []domain.Block{ - { - Index: 0, - Type: domain.BlockTypeToolUse, - ToolUse: &domain.ToolUse{ - ID: "tool-1", - Name: "set_service_enabled", - Input: json.RawMessage(`{"name":"alpha"}`), - InputComplete: true, - }, - }, - { - Index: 1, - Type: domain.BlockTypeToolUse, - ToolUse: &domain.ToolUse{ - ID: "tool-2", - Name: "set_service_enabled", - Input: json.RawMessage(`{"name":"beta"}`), - InputComplete: true, - }, - }, - }, - }, - }) - teatest.DrainCmds(m.Update, cmd2, 128) - - viewAfterSecond := m.View() - if strings.Count(viewAfterSecond, "alpha enabled") != 1 { - t.Fatalf("expected alpha result to remain exactly once, got:\n%s", viewAfterSecond) - } - if strings.Count(viewAfterSecond, "beta enabled") != 1 { - t.Fatalf("expected beta result exactly once, got:\n%s", viewAfterSecond) - } -} diff --git a/internal/app/chat/messagelist/block/block.go b/internal/app/chat/messagelist/block/block.go deleted file mode 100644 index d5e0b8ca..00000000 --- a/internal/app/chat/messagelist/block/block.go +++ /dev/null @@ -1,60 +0,0 @@ -// Package block defines the shared interface for all visual blocks -// in the message list viewport. -package block - -import tea "charm.land/bubbletea/v2" - -// BorderWidth is the width of the left border on blocks. -// The messagelist applies a left border; blocks handle their own internal padding. -const BorderWidth = 1 - -// PaddingX is the horizontal padding inside each block. -const PaddingX = 2 - -// PaddingY is the vertical padding inside elevated blocks (text, tools, user). -const PaddingY = 1 - -// Kind identifies the type of a block for layout and styling decisions. -type Kind int - -const ( - KindUser Kind = iota - KindAssistantText - KindThinking - KindTool - KindThinkingAnimation -) - -// Block is the interface for all visual atoms in the message list. -// Each block is an independently focusable, renderable unit. -// Width is set via SetWidth before View is called. -type Block interface { - // View renders the block at the width set by SetWidth. - View() string - - // Height returns the number of lines this block renders. - Height() int - - // Update handles messages. - Update(tea.Msg) tea.Cmd - - // SetWidth sets the available width for rendering. - SetWidth(int) - - // SetFocused sets whether this block is the focused block in the viewport. - SetFocused(bool) - - // Focused returns whether this block is currently focused. - Focused() bool - - // Kind returns the block type. - Kind() Kind -} - -// Toggleable is an optional interface for blocks that can be toggled -// (e.g. expand/collapse thinking blocks, show/hide tool body). -// y is the click position relative to the block's top edge. -// Blocks should only toggle when the click is on the header line. -type Toggleable interface { - Toggle(y int) -} diff --git a/internal/app/chat/messagelist/interaction.go b/internal/app/chat/messagelist/interaction.go deleted file mode 100644 index 47e62e6a..00000000 --- a/internal/app/chat/messagelist/interaction.go +++ /dev/null @@ -1,41 +0,0 @@ -package messagelist - -import ( - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" -) - -type keyDecision struct { - handle bool - focusDelta int - scrollDelta int -} - -func reduceKeyPress(msg tea.KeyPressMsg, focused bool) keyDecision { - if !focused { - return keyDecision{} - } - switch { - case key.Matches(msg, focusPrevKey): - return keyDecision{handle: true, focusDelta: -1} - case key.Matches(msg, focusNextKey): - return keyDecision{handle: true, focusDelta: 1} - case key.Matches(msg, scrollUpKey): - return keyDecision{handle: true, scrollDelta: -1} - case key.Matches(msg, scrollDownKey): - return keyDecision{handle: true, scrollDelta: 1} - default: - return keyDecision{} - } -} - -func reduceMouseWheel(button tea.MouseButton) int { - switch button { - case tea.MouseWheelUp: - return -5 - case tea.MouseWheelDown: - return 5 - default: - return 0 - } -} diff --git a/internal/app/chat/messagelist/interaction_test.go b/internal/app/chat/messagelist/interaction_test.go deleted file mode 100644 index 8d5dbcd0..00000000 --- a/internal/app/chat/messagelist/interaction_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package messagelist - -import ( - "testing" - - tea "charm.land/bubbletea/v2" -) - -func TestReduceKeyPress(t *testing.T) { - t.Parallel() - - t.Run("ignored when not focused", func(t *testing.T) { - t.Parallel() - d := reduceKeyPress(tea.KeyPressMsg{Code: tea.KeyUp}, false) - if d.handle || d.focusDelta != 0 || d.scrollDelta != 0 { - t.Fatalf("unexpected decision: %+v", d) - } - }) - - t.Run("focus prev", func(t *testing.T) { - t.Parallel() - d := reduceKeyPress(tea.KeyPressMsg{Code: tea.KeyUp, Mod: tea.ModShift}, true) - if !d.handle || d.focusDelta != -1 || d.scrollDelta != 0 { - t.Fatalf("unexpected decision: %+v", d) - } - }) - - t.Run("focus next", func(t *testing.T) { - t.Parallel() - d := reduceKeyPress(tea.KeyPressMsg{Code: tea.KeyDown, Mod: tea.ModShift}, true) - if !d.handle || d.focusDelta != 1 || d.scrollDelta != 0 { - t.Fatalf("unexpected decision: %+v", d) - } - }) - - t.Run("scroll up", func(t *testing.T) { - t.Parallel() - d := reduceKeyPress(tea.KeyPressMsg{Code: tea.KeyUp}, true) - if !d.handle || d.focusDelta != 0 || d.scrollDelta != -1 { - t.Fatalf("unexpected decision: %+v", d) - } - }) - - t.Run("scroll down", func(t *testing.T) { - t.Parallel() - d := reduceKeyPress(tea.KeyPressMsg{Code: tea.KeyDown}, true) - if !d.handle || d.focusDelta != 0 || d.scrollDelta != 1 { - t.Fatalf("unexpected decision: %+v", d) - } - }) -} - -func TestReduceMouseWheel(t *testing.T) { - t.Parallel() - - if got := reduceMouseWheel(tea.MouseWheelUp); got != -5 { - t.Fatalf("MouseWheelUp=%d, want -5", got) - } - if got := reduceMouseWheel(tea.MouseWheelDown); got != 5 { - t.Fatalf("MouseWheelDown=%d, want 5", got) - } - if got := reduceMouseWheel(tea.MouseLeft); got != 0 { - t.Fatalf("MouseLeft=%d, want 0", got) - } -} diff --git a/internal/app/chat/messagelist/messagelist.go b/internal/app/chat/messagelist/messagelist.go deleted file mode 100644 index 74e3c4a7..00000000 --- a/internal/app/chat/messagelist/messagelist.go +++ /dev/null @@ -1,164 +0,0 @@ -package messagelist - -import ( - "charm.land/bubbles/v2/key" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/app/chat/messagelist/round" - "github.com/usetero/cli/internal/app/chat/usecase" - "github.com/usetero/cli/internal/app/chattools" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/viewport" -) - -var ( - scrollUpKey = key.NewBinding(key.WithKeys("up")) - scrollDownKey = key.NewBinding(key.WithKeys("down")) - focusPrevKey = key.NewBinding(key.WithKeys("shift+up")) - focusNextKey = key.NewBinding(key.WithKeys("shift+down")) -) - -// Layout constants. -const ( - // outerBorderWidth is the left border on the entire message list (thick accent bar). - outerBorderWidth = 1 - - // blockGap is the number of blank lines between blocks within a round. - blockGap = 1 - - // roundGap is the number of blank lines between rounds. - roundGap = 2 - - // gapBeforeDivider is blank lines before the round completion divider. - gapBeforeDivider = 1 - - // dividerHeight is the number of lines the divider itself occupies. - dividerHeight = 1 -) - -// blockEntry pairs a block with its round metadata for layout decisions. -type blockEntry struct { - block block.Block - roundIndex int -} - -// Model displays the conversation history and manages rounds. -// Scroll, focus, and hit-test math is delegated to a viewport.Model. -// -// Methods are split across files by responsibility: -// - messagelist.go struct, constructor, accessors, layout helpers -// - update.go message dispatch -// - render.go View, block rendering, gaps, dividers -// - mouse.go click routing, text selection -// - rounds.go round lifecycle, block collection -type Model struct { - theme styles.Theme - scope log.Scope - - // Data hierarchy (owns the blocks, routes updates) - rounds []*round.Model - - // Flat block list for viewport rendering (rebuilt from rounds) - blocks []blockEntry - layout layoutProjection - - // Viewport: scroll, focus, hit testing (pure math) - vp viewport.Model - - // Viewport dimensions - width int - height int - - // Screen origin (top-left corner of this component in terminal coordinates). - // Set by the parent layout so mouse clicks can be translated. - originX int - originY int - - // Whether this component has keyboard focus (different from block focus) - focused bool - - // Mouse selection state - mouseDown bool - mouseDownBlock int // block index where mouse was pressed (-1 = none) - mouseDownX int // X within block content - mouseDownY int // Y within block (line offset) - mouseDragBlock int // current block during drag (-1 = none) - mouseDragX int // current X within block content - mouseDragY int // current Y within block - - // Dependencies - runtimeDeps usecase.RuntimeDeps - toolRegistry *tools.Registry -} - -// New creates a new message list. -func New( - theme styles.Theme, - runtimeDeps usecase.RuntimeDeps, - toolRegistry *tools.Registry, - scope log.Scope, -) *Model { - scope = scope.Child("messagelist") - - return &Model{ - theme: theme, - scope: scope, - vp: viewport.New(), - mouseDownBlock: -1, - mouseDragBlock: -1, - runtimeDeps: runtimeDeps, - toolRegistry: toolRegistry, - } -} - -// --- Size and focus --- - -// SetSize sets the dimensions. -func (m *Model) SetSize(width, height int) { - atBottom := m.vp.AtBottom() - m.width = width - m.height = height - m.vp.SetHeight(height) - m.updateRoundWidths() - if atBottom { - m.vp.ScrollToBottom() - } -} - -// SetOrigin sets the terminal-absolute position of this component's top-left corner. -// Called by the parent during layout so mouse coordinates can be translated. -func (m *Model) SetOrigin(x, y int) { - m.originX = x - m.originY = y -} - -// SetFocused sets focus state. -func (m *Model) SetFocused(focused bool) { - m.focused = focused -} - -// Focused returns whether the message list is focused. -func (m *Model) Focused() bool { - return m.focused -} - -// Len returns the number of rounds. -func (m *Model) Len() int { - return len(m.rounds) -} - -// --- Layout helpers --- - -// contentWidth returns the width available for block content. -func (m *Model) contentWidth() int { - return m.width - outerBorderWidth -} - -// blockHeight returns the line count of block at idx without rendering. -func (m *Model) blockHeight(idx int) int { - h := m.blocks[idx].block.Height() - if h < 1 { - return 1 - } - return h -} diff --git a/internal/app/chat/messagelist/messagelisttest/messagelist.go b/internal/app/chat/messagelist/messagelisttest/messagelist.go deleted file mode 100644 index ed3ce436..00000000 --- a/internal/app/chat/messagelist/messagelisttest/messagelist.go +++ /dev/null @@ -1,29 +0,0 @@ -// Package messagelisttest provides test helpers for the messagelist package. -package messagelisttest - -import ( - "testing" - - "github.com/usetero/cli/internal/app/chat/messagelist" - "github.com/usetero/cli/internal/app/chat/usecase" - "github.com/usetero/cli/internal/boundary/chat/chattest" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/styles" -) - -// New creates a messagelist.Model wired with test dependencies. -// Uses a real SQLite database, mock chat client, real theme, and test logger. -func New(t *testing.T, width, height int) *messagelist.Model { - t.Helper() - - theme := styles.NewTheme(true) - db := dbtest.OpenTestDB(t) - client := &chattest.MockClient{} - scope := logtest.NewScope(t) - runtimeDeps := usecase.NewRuntimeDeps(db, client) - - m := messagelist.New(theme, runtimeDeps, nil, scope) - m.SetSize(width, height) - return m -} diff --git a/internal/app/chat/messagelist/mouse.go b/internal/app/chat/messagelist/mouse.go deleted file mode 100644 index e0241536..00000000 --- a/internal/app/chat/messagelist/mouse.go +++ /dev/null @@ -1,137 +0,0 @@ -package messagelist - -import ( - "image" - "strings" - - "charm.land/lipgloss/v2" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/tea/highlight" -) - -func (m *Model) selectionState() selectionState { - return selectionState{ - mouseDown: m.mouseDown, - mouseDownBlock: m.mouseDownBlock, - mouseDownX: m.mouseDownX, - mouseDownY: m.mouseDownY, - mouseDragBlock: m.mouseDragBlock, - mouseDragX: m.mouseDragX, - mouseDragY: m.mouseDragY, - } -} - -func (m *Model) setSelectionState(state selectionState) { - m.mouseDown = state.mouseDown - m.mouseDownBlock = state.mouseDownBlock - m.mouseDownX = state.mouseDownX - m.mouseDownY = state.mouseDownY - m.mouseDragBlock = state.mouseDragBlock - m.mouseDragX = state.mouseDragX - m.mouseDragY = state.mouseDragY -} - -// --- Click handling --- - -// handleBlockClick handles a click on a specific block. -// If the block implements Toggleable, it toggles and we rebuild -// the viewport items (heights may have changed). -func (m *Model) handleBlockClick(idx, y int) { - if idx < 0 || idx >= len(m.blocks) { - return - } - if t, ok := m.blocks[idx].block.(block.Toggleable); ok { - t.Toggle(y) - m.syncViewportItems() - } -} - -// --- Highlight / selection --- - -// hasHighlight returns whether there is a current text selection. -func (m *Model) hasHighlight() bool { - state := m.selectionState() - if state.mouseDownBlock < 0 || state.mouseDragBlock < 0 { - return false - } - return state.mouseDownBlock != state.mouseDragBlock || - state.mouseDownY != state.mouseDragY || - state.mouseDownX != state.mouseDragX -} - -// getHighlightRange returns the normalized selection range (start <= end). -func (m *Model) getHighlightRange() (startBlock, startLine, startCol, endBlock, endLine, endCol int) { - if m.mouseDownBlock < 0 { - return -1, -1, -1, -1, -1, -1 - } - - draggingForward := m.mouseDragBlock > m.mouseDownBlock || - (m.mouseDragBlock == m.mouseDownBlock && m.mouseDragY > m.mouseDownY) || - (m.mouseDragBlock == m.mouseDownBlock && m.mouseDragY == m.mouseDownY && m.mouseDragX >= m.mouseDownX) - - if draggingForward { - return m.mouseDownBlock, m.mouseDownY, m.mouseDownX, - m.mouseDragBlock, m.mouseDragY, m.mouseDragX - } - return m.mouseDragBlock, m.mouseDragY, m.mouseDragX, - m.mouseDownBlock, m.mouseDownY, m.mouseDownX -} - -// blockHighlightRange returns the highlight coordinates for a single block, -// given its index and the overall selection range. Returns (-1,-1,-1,-1) if -// the block is not in the selection. -func blockHighlightRange(idx, startBlock, startLine, startCol, endBlock, endLine, endCol int) (sLine, sCol, eLine, eCol int) { - if idx < startBlock || idx > endBlock { - return -1, -1, -1, -1 - } - if idx == startBlock && idx == endBlock { - return startLine, startCol, endLine, endCol - } - if idx == startBlock { - return startLine, startCol, -1, -1 // to end of block - } - if idx == endBlock { - return 0, 0, endLine, endCol - } - return 0, 0, -1, -1 // fully highlighted -} - -// extractHighlight returns the plain text of the current selection. -func (m *Model) extractHighlight() string { - startBlock, startLine, startCol, endBlock, endLine, endCol := m.getHighlightRange() - if startBlock < 0 { - return "" - } - - var sb strings.Builder - for i := startBlock; i <= endBlock && i < len(m.blocks); i++ { - sLine, sCol, eLine, eCol := blockHighlightRange(i, startBlock, startLine, startCol, endBlock, endLine, endCol) - if sLine < 0 { - continue - } - - // Extract from the block's own View() — the content before - // renderBlock wraps it with border/padding decorations. - content := m.blocks[i].block.View() - w := lipgloss.Width(content) - h := lipgloss.Height(content) - area := image.Rect(0, 0, w, h) - - text := highlight.Extract(content, area, sLine, sCol, eLine, eCol) - if text != "" { - if sb.Len() > 0 { - sb.WriteString("\n") - } - sb.WriteString(strings.TrimRight(text, "\n")) - } - } - - return sb.String() -} - -// clearSelection resets mouse selection state. -func (m *Model) clearSelection() { - m.mouseDown = false - m.mouseDownBlock = -1 - m.mouseDragBlock = -1 -} diff --git a/internal/app/chat/messagelist/mouse_target.go b/internal/app/chat/messagelist/mouse_target.go deleted file mode 100644 index 6c4dc9fa..00000000 --- a/internal/app/chat/messagelist/mouse_target.go +++ /dev/null @@ -1,27 +0,0 @@ -package messagelist - -type mouseTarget struct { - viewX int - viewY int - blockIdx int - blockY int - hit bool -} - -func projectMouseToView(msgX, msgY, originX, originY int) (viewX, viewY int) { - viewX = msgX - originX - outerBorderWidth - viewY = msgY - originY - return viewX, viewY -} - -func resolveMouseTarget(msgX, msgY, originX, originY int, itemAtY func(int) (int, int)) mouseTarget { - viewX, viewY := projectMouseToView(msgX, msgY, originX, originY) - blockIdx, blockY := itemAtY(viewY) - return mouseTarget{ - viewX: viewX, - viewY: viewY, - blockIdx: blockIdx, - blockY: blockY, - hit: blockIdx >= 0, - } -} diff --git a/internal/app/chat/messagelist/mouse_target_test.go b/internal/app/chat/messagelist/mouse_target_test.go deleted file mode 100644 index 48cdf2bd..00000000 --- a/internal/app/chat/messagelist/mouse_target_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package messagelist - -import "testing" - -func TestProjectMouseToView(t *testing.T) { - t.Parallel() - - viewX, viewY := projectMouseToView(25, 11, 10, 3) - if viewX != 14 || viewY != 8 { - t.Fatalf("got (%d,%d), want (14,8)", viewX, viewY) - } -} - -func TestResolveMouseTarget(t *testing.T) { - t.Parallel() - - t.Run("hit", func(t *testing.T) { - t.Parallel() - got := resolveMouseTarget(20, 9, 5, 2, func(viewY int) (int, int) { - if viewY == 7 { - return 3, 4 - } - return -1, -1 - }) - if !got.hit || got.blockIdx != 3 || got.blockY != 4 { - t.Fatalf("unexpected target: %+v", got) - } - if got.viewX != 14 || got.viewY != 7 { - t.Fatalf("unexpected view coords: %+v", got) - } - }) - - t.Run("miss", func(t *testing.T) { - t.Parallel() - got := resolveMouseTarget(20, 9, 5, 2, func(int) (int, int) { - return -1, -1 - }) - if got.hit { - t.Fatalf("expected miss: %+v", got) - } - if got.blockIdx != -1 || got.blockY != -1 { - t.Fatalf("unexpected block coords: %+v", got) - } - }) -} diff --git a/internal/app/chat/messagelist/mouse_test.go b/internal/app/chat/messagelist/mouse_test.go deleted file mode 100644 index b35b9b4d..00000000 --- a/internal/app/chat/messagelist/mouse_test.go +++ /dev/null @@ -1,183 +0,0 @@ -package messagelist - -import ( - "strings" - "testing" - - tea "charm.land/bubbletea/v2" - "github.com/usetero/cli/internal/app/chat/messagelist/block" -) - -// stubBlock is a minimal block.Block for testing selection math. -type stubBlock struct { - text string - kind block.Kind - width int - focused bool -} - -func newStubBlock(text string, kind block.Kind) *stubBlock { - return &stubBlock{text: text, kind: kind} -} - -func (b *stubBlock) View() string { return b.text } -func (b *stubBlock) Height() int { return len(strings.Split(b.text, "\n")) } -func (b *stubBlock) Update(tea.Msg) tea.Cmd { return nil } -func (b *stubBlock) SetWidth(w int) { b.width = w } -func (b *stubBlock) SetFocused(f bool) { b.focused = f } -func (b *stubBlock) Focused() bool { return b.focused } -func (b *stubBlock) Kind() block.Kind { return b.kind } - -func TestGetHighlightRange(t *testing.T) { - t.Parallel() - - t.Run("no selection", func(t *testing.T) { - t.Parallel() - m := &Model{mouseDownBlock: -1} - sb, _, _, eb, _, _ := m.getHighlightRange() - if sb != -1 || eb != -1 { - t.Errorf("expected (-1, -1), got (%d, %d)", sb, eb) - } - }) - - t.Run("forward drag", func(t *testing.T) { - t.Parallel() - m := &Model{ - mouseDownBlock: 0, mouseDownY: 1, mouseDownX: 5, - mouseDragBlock: 2, mouseDragY: 3, mouseDragX: 10, - } - sb, sl, sc, eb, el, ec := m.getHighlightRange() - if sb != 0 || sl != 1 || sc != 5 || eb != 2 || el != 3 || ec != 10 { - t.Errorf("forward: got (%d,%d,%d) -> (%d,%d,%d)", sb, sl, sc, eb, el, ec) - } - }) - - t.Run("backward drag normalizes", func(t *testing.T) { - t.Parallel() - m := &Model{ - mouseDownBlock: 2, mouseDownY: 3, mouseDownX: 10, - mouseDragBlock: 0, mouseDragY: 1, mouseDragX: 5, - } - sb, sl, sc, eb, el, ec := m.getHighlightRange() - if sb != 0 || sl != 1 || sc != 5 || eb != 2 || el != 3 || ec != 10 { - t.Errorf("backward: got (%d,%d,%d) -> (%d,%d,%d)", sb, sl, sc, eb, el, ec) - } - }) - - t.Run("same block backward drag normalizes by line", func(t *testing.T) { - t.Parallel() - m := &Model{ - mouseDownBlock: 1, mouseDownY: 5, mouseDownX: 3, - mouseDragBlock: 1, mouseDragY: 2, mouseDragX: 8, - } - sb, sl, sc, eb, el, ec := m.getHighlightRange() - if sb != 1 || sl != 2 || sc != 8 || eb != 1 || el != 5 || ec != 3 { - t.Errorf("same-block-backward: got (%d,%d,%d) -> (%d,%d,%d)", sb, sl, sc, eb, el, ec) - } - }) -} - -func TestBlockHighlightRange(t *testing.T) { - t.Parallel() - - // Selection from block 1 (line 2, col 5) to block 3 (line 4, col 10) - startBlock, startLine, startCol := 1, 2, 5 - endBlock, endLine, endCol := 3, 4, 10 - - t.Run("before selection", func(t *testing.T) { - t.Parallel() - sl, sc, el, ec := blockHighlightRange(0, startBlock, startLine, startCol, endBlock, endLine, endCol) - if sl != -1 || sc != -1 || el != -1 || ec != -1 { - t.Errorf("expected not selected, got (%d,%d,%d,%d)", sl, sc, el, ec) - } - }) - - t.Run("start block", func(t *testing.T) { - t.Parallel() - sl, sc, el, ec := blockHighlightRange(1, startBlock, startLine, startCol, endBlock, endLine, endCol) - if sl != 2 || sc != 5 || el != -1 || ec != -1 { - t.Errorf("start: got (%d,%d,%d,%d)", sl, sc, el, ec) - } - }) - - t.Run("middle block", func(t *testing.T) { - t.Parallel() - sl, sc, el, ec := blockHighlightRange(2, startBlock, startLine, startCol, endBlock, endLine, endCol) - if sl != 0 || sc != 0 || el != -1 || ec != -1 { - t.Errorf("middle: got (%d,%d,%d,%d)", sl, sc, el, ec) - } - }) - - t.Run("end block", func(t *testing.T) { - t.Parallel() - sl, sc, el, ec := blockHighlightRange(3, startBlock, startLine, startCol, endBlock, endLine, endCol) - if sl != 0 || sc != 0 || el != 4 || ec != 10 { - t.Errorf("end: got (%d,%d,%d,%d)", sl, sc, el, ec) - } - }) - - t.Run("after selection", func(t *testing.T) { - t.Parallel() - sl, sc, el, ec := blockHighlightRange(4, startBlock, startLine, startCol, endBlock, endLine, endCol) - if sl != -1 || sc != -1 || el != -1 || ec != -1 { - t.Errorf("expected not selected, got (%d,%d,%d,%d)", sl, sc, el, ec) - } - }) - - t.Run("single block selection", func(t *testing.T) { - t.Parallel() - sl, sc, el, ec := blockHighlightRange(2, 2, 1, 3, 2, 5, 8) - if sl != 1 || sc != 3 || el != 5 || ec != 8 { - t.Errorf("single: got (%d,%d,%d,%d)", sl, sc, el, ec) - } - }) -} - -func TestExtractHighlight(t *testing.T) { - t.Parallel() - - t.Run("extracts content without border", func(t *testing.T) { - t.Parallel() - m := &Model{ - mouseDownBlock: 0, mouseDownY: 0, mouseDownX: 0, - mouseDragBlock: 0, mouseDragY: 0, mouseDragX: 5, - } - m.blocks = []blockEntry{ - {block: newStubBlock("hello world", block.KindAssistantText)}, - } - - text := m.extractHighlight() - if !strings.Contains(text, "hello") { - t.Errorf("expected extracted text to contain 'hello', got %q", text) - } - // The key invariant: no border character should appear - if strings.Contains(text, "│") { - t.Errorf("extracted text should not contain border character, got %q", text) - } - }) - - t.Run("no selection returns empty", func(t *testing.T) { - t.Parallel() - m := &Model{mouseDownBlock: -1} - if text := m.extractHighlight(); text != "" { - t.Errorf("expected empty, got %q", text) - } - }) - - t.Run("multi-block extraction", func(t *testing.T) { - t.Parallel() - m := &Model{ - mouseDownBlock: 0, mouseDownY: 0, mouseDownX: 0, - mouseDragBlock: 1, mouseDragY: 0, mouseDragX: 3, - } - m.blocks = []blockEntry{ - {block: newStubBlock("first", block.KindAssistantText)}, - {block: newStubBlock("second", block.KindAssistantText)}, - } - - text := m.extractHighlight() - if !strings.Contains(text, "first") || !strings.Contains(text, "sec") { - t.Errorf("expected both blocks in extraction, got %q", text) - } - }) -} diff --git a/internal/app/chat/messagelist/projection.go b/internal/app/chat/messagelist/projection.go deleted file mode 100644 index a2093c4e..00000000 --- a/internal/app/chat/messagelist/projection.go +++ /dev/null @@ -1,81 +0,0 @@ -package messagelist - -type projectedItem struct { - roundIndex int - height int -} - -type projectedGap struct { - height int - dividerRound int -} - -type layoutProjection struct { - heights []int - gaps []projectedGap - trailingDividerRound int -} - -func projectItems(entries []blockEntry, blockHeight func(int) int) []projectedItem { - items := make([]projectedItem, 0, len(entries)) - for i, e := range entries { - items = append(items, projectedItem{ - roundIndex: e.roundIndex, - height: blockHeight(i), - }) - } - return items -} - -func projectLayout(items []projectedItem, roundActive func(int) bool) layoutProjection { - n := len(items) - p := layoutProjection{ - heights: make([]int, n), - gaps: make([]projectedGap, n), - trailingDividerRound: -1, - } - for i := range p.gaps { - p.gaps[i].dividerRound = -1 - } - for i := range items { - p.heights[i] = items[i].height - if i == 0 { - continue - } - prev := items[i-1] - curr := items[i] - if prev.roundIndex == curr.roundIndex { - p.gaps[i].height = blockGap - continue - } - g := roundGap - if !roundActive(prev.roundIndex) { - g += gapBeforeDivider + dividerHeight - p.gaps[i].dividerRound = prev.roundIndex - } - p.gaps[i].height = g - } - if n == 0 { - return p - } - last := items[n-1] - if !roundActive(last.roundIndex) { - p.trailingDividerRound = last.roundIndex - } - return p -} - -func (p layoutProjection) gapHeights() []int { - g := make([]int, len(p.gaps)) - for i := range p.gaps { - g[i] = p.gaps[i].height - } - return g -} - -func (p layoutProjection) trailingHeight() int { - if p.trailingDividerRound < 0 { - return 0 - } - return gapBeforeDivider + dividerHeight -} diff --git a/internal/app/chat/messagelist/projection_test.go b/internal/app/chat/messagelist/projection_test.go deleted file mode 100644 index 9305503f..00000000 --- a/internal/app/chat/messagelist/projection_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package messagelist - -import "testing" - -func TestProjectLayout(t *testing.T) { - t.Parallel() - - t.Run("empty", func(t *testing.T) { - t.Parallel() - p := projectLayout(nil, func(int) bool { return true }) - if len(p.heights) != 0 || len(p.gaps) != 0 || p.trailingDividerRound >= 0 { - t.Fatalf("unexpected layout: %+v", p) - } - }) - - t.Run("same round uses block gap", func(t *testing.T) { - t.Parallel() - items := []projectedItem{ - {roundIndex: 0, height: 3}, - {roundIndex: 0, height: 4}, - } - p := projectLayout(items, func(int) bool { return true }) - if p.heights[0] != 3 || p.heights[1] != 4 { - t.Fatalf("unexpected heights: %v", p.heights) - } - if p.gaps[1].height != blockGap { - t.Fatalf("gap[1]=%d, want %d", p.gaps[1].height, blockGap) - } - if p.trailingDividerRound >= 0 { - t.Fatalf("trailingDividerRound=%d, want none", p.trailingDividerRound) - } - }) - - t.Run("round boundary with completed previous includes divider gap", func(t *testing.T) { - t.Parallel() - items := []projectedItem{ - {roundIndex: 0, height: 2}, - {roundIndex: 1, height: 2}, - } - p := projectLayout(items, func(round int) bool { - return round == 1 // round 0 completed, round 1 active - }) - want := roundGap + gapBeforeDivider + dividerHeight - if p.gaps[1].height != want { - t.Fatalf("gap[1]=%d, want %d", p.gaps[1].height, want) - } - if p.gaps[1].dividerRound != 0 { - t.Fatalf("dividerRound=%d, want 0", p.gaps[1].dividerRound) - } - if p.trailingDividerRound >= 0 { - t.Fatalf("trailingDividerRound=%d, want none", p.trailingDividerRound) - } - }) - - t.Run("trailing divider for completed last round", func(t *testing.T) { - t.Parallel() - items := []projectedItem{{roundIndex: 0, height: 2}} - p := projectLayout(items, func(int) bool { return false }) - if p.trailingDividerRound != 0 { - t.Fatalf("trailingDividerRound=%d, want 0", p.trailingDividerRound) - } - }) -} diff --git a/internal/app/chat/messagelist/reducer.go b/internal/app/chat/messagelist/reducer.go deleted file mode 100644 index 62e25a6a..00000000 --- a/internal/app/chat/messagelist/reducer.go +++ /dev/null @@ -1,38 +0,0 @@ -package messagelist - -import ( - tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" -) - -type lifecycleDecision struct { - handle bool - forwardRounds bool - rebuild bool - clearSelection bool - scrollToBottom bool - focusLastAtBottom bool -} - -func reduceLifecycle(msg tea.Msg, wasAtBottom bool) lifecycleDecision { - switch msg.(type) { - case msgs.TurnStarted: - return lifecycleDecision{ - handle: true, - rebuild: true, - clearSelection: true, - scrollToBottom: true, - focusLastAtBottom: true, - } - case msgs.AssistantContentUpdated, msgs.StreamCompleted, msgs.StreamFailed: - return lifecycleDecision{ - handle: true, - forwardRounds: true, - rebuild: true, - scrollToBottom: wasAtBottom, - focusLastAtBottom: wasAtBottom, - } - default: - return lifecycleDecision{} - } -} diff --git a/internal/app/chat/messagelist/reducer_test.go b/internal/app/chat/messagelist/reducer_test.go deleted file mode 100644 index e853f618..00000000 --- a/internal/app/chat/messagelist/reducer_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package messagelist - -import ( - "testing" - - msgs "github.com/usetero/cli/internal/app/chat/events" -) - -func TestReduceLifecycle(t *testing.T) { - t.Parallel() - - t.Run("turn started always rebuilds and scrolls", func(t *testing.T) { - t.Parallel() - d := reduceLifecycle(msgs.TurnStarted{}, false) - if !d.handle || !d.rebuild || !d.clearSelection || !d.scrollToBottom || !d.focusLastAtBottom { - t.Fatalf("unexpected decision: %+v", d) - } - if d.forwardRounds { - t.Fatalf("forwardRounds = true, want false: %+v", d) - } - }) - - t.Run("assistant update forwards and preserves bottom stickiness", func(t *testing.T) { - t.Parallel() - dTop := reduceLifecycle(msgs.AssistantContentUpdated{}, false) - if !dTop.handle || !dTop.forwardRounds || !dTop.rebuild { - t.Fatalf("unexpected top decision: %+v", dTop) - } - if dTop.scrollToBottom || dTop.focusLastAtBottom { - t.Fatalf("top decision should not force bottom: %+v", dTop) - } - - dBottom := reduceLifecycle(msgs.StreamCompleted{}, true) - if !dBottom.scrollToBottom || !dBottom.focusLastAtBottom { - t.Fatalf("bottom decision should stick bottom: %+v", dBottom) - } - }) - - t.Run("unrelated messages are ignored", func(t *testing.T) { - t.Parallel() - d := reduceLifecycle(struct{}{}, true) - if d.handle { - t.Fatalf("expected no-op decision, got %+v", d) - } - }) -} diff --git a/internal/app/chat/messagelist/render.go b/internal/app/chat/messagelist/render.go deleted file mode 100644 index 50662b67..00000000 --- a/internal/app/chat/messagelist/render.go +++ /dev/null @@ -1,228 +0,0 @@ -package messagelist - -import ( - "fmt" - "image" - "strings" - "time" - - "charm.land/lipgloss/v2" - "github.com/charmbracelet/x/ansi" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/app/chat/messagelist/round" - "github.com/usetero/cli/internal/tea/highlight" -) - -// View renders the visible portion of the message list. -func (m *Model) View() string { - if m.width == 0 || m.height == 0 { - return "" - } - - if len(m.blocks) == 0 { - return m.emptyView() - } - - lines := m.renderVisible() - - // Pad to viewport height - for len(lines) < m.height { - lines = append(lines, "") - } - - output := strings.Join(lines, "\n") - return output -} - -// renderVisible renders only the blocks visible in the viewport. -func (m *Model) renderVisible() []string { - offsetIdx, offsetLine := m.vp.Offset() - focusIdx := m.vp.FocusIdx() - - lines := make([]string, 0, m.height) - reachedEnd := false - - // Highlight state - startBlock, startLine, startCol, endBlock, endLine, endCol := m.getHighlightRange() - hasHL := m.hasHighlight() - highlighter := highlight.WithColors(m.theme.SelectionBg, m.theme.SelectionFg) - - for idx := offsetIdx; idx < len(m.blocks); idx++ { - // Insert gap/divider before this block (except the first visible block) - if idx > offsetIdx { - lines = append(lines, m.gapLines(idx)...) - } - - // Set focus state before rendering so the block can use it internally - m.blocks[idx].block.SetFocused(m.focused && idx == focusIdx) - - // Render the block - rendered := m.renderBlock(m.blocks[idx]) - - // Apply text highlight if this block is in the selection range - if hasHL { - sLine, sCol, eLine, eCol := blockHighlightRange(idx, startBlock, startLine, startCol, endBlock, endLine, endCol) - if sLine >= 0 { - cw := m.contentWidth() - h := lipgloss.Height(rendered) - area := image.Rect(block.BorderWidth, 0, cw, h) - rendered = highlight.Apply(rendered, area, sLine, sCol, eLine, eCol, highlighter) - } - } - - blockLines := strings.Split(rendered, "\n") - - // For the first block, skip offsetLine lines (partial scroll into it) - if idx == offsetIdx && offsetLine > 0 { - if offsetLine < len(blockLines) { - blockLines = blockLines[offsetLine:] - } else { - blockLines = nil - } - } - - lines = append(lines, blockLines...) - - if idx == len(m.blocks)-1 { - reachedEnd = true - } - - if len(lines) >= m.height { - break - } - } - - // Add trailing divider for the last round if we rendered to the end - if reachedEnd && len(m.blocks) > 0 { - if m.layout.trailingDividerRound >= 0 { - for range gapBeforeDivider { - lines = append(lines, "") - } - lines = append(lines, m.divider(m.rounds[m.layout.trailingDividerRound])) - } - } - - // Trim to viewport height - if len(lines) > m.height { - lines = lines[:m.height] - } - - return lines -} - -// renderBlock renders a single block with appropriate width and padding. -func (m *Model) renderBlock(entry blockEntry) string { - b := entry.block - cw := m.contentWidth() - - // Determine border color and style based on block type and focus state. - borderColor := m.theme.Bg - borderStyle := lipgloss.NormalBorder() - if b.Kind() == block.KindUser { - // User messages: accent border, thicker when focused. - borderColor = m.theme.Accent - if b.Focused() { - borderColor = m.theme.Warning - borderStyle = lipgloss.ThickBorder() - } - } else if b.Focused() { - // Assistant blocks: invisible border, thick orange when focused. - borderColor = m.theme.Warning - borderStyle = lipgloss.ThickBorder() - } - - return lipgloss.NewStyle(). - Width(cw). - BorderLeft(true). - BorderStyle(borderStyle). - BorderForeground(borderColor). - Render(b.View()) -} - -// gapLines returns the renderable lines to insert before block at idx. -// The gap projection is the source of truth for measurement. -func (m *Model) gapLines(idx int) []string { - if idx <= 0 || idx >= len(m.layout.gaps) { - return nil - } - - g := m.layout.gaps[idx] - if g.height == 0 { - return nil - } - - // No divider in this gap, just blank lines. - if g.dividerRound < 0 { - return make([]string, g.height) - } - - // Round boundary gap with a divider for the completed previous round. - lines := make([]string, 0, g.height) - for range gapBeforeDivider { - lines = append(lines, "") - } - lines = append(lines, m.divider(m.rounds[g.dividerRound])) - for range roundGap { - lines = append(lines, "") - } - return lines -} - -// divider renders " ◇ Tero 4s ─────────" for a completed round, -// or " ◇ Cancelled 1.2s ─────────" for a cancelled round. -func (m *Model) divider(r *round.Model) string { - cw := m.contentWidth() - - colors := m.theme - border := lipgloss.NewStyle().Foreground(colors.Border).Background(colors.Bg) - - duration := r.Duration() - var durationStr string - if duration < time.Minute { - durationStr = fmt.Sprintf("%.1fs", duration.Seconds()) - } else { - durationStr = fmt.Sprintf("%.1fm", duration.Minutes()) - } - - var prefix string - var prefixStyle lipgloss.Style - switch r.State() { - case round.StateCancelled: - prefix = fmt.Sprintf("◇ Cancelled %s ", durationStr) - prefixStyle = lipgloss.NewStyle().Foreground(colors.Error).Background(colors.Bg) - case round.StateFailed: - base := fmt.Sprintf("◇ Error %s", durationStr) - errMsg := "" - if r.Err() != nil { - // Truncate error to fit: base + " — " + msg + " " + min 3 "─" - maxErr := cw - block.BorderWidth - lipgloss.Width(base) - len(" — ") - 1 - 3 - if maxErr > 0 { - errMsg = " — " + ansi.Truncate(r.Err().Error(), maxErr, "…") - } - } - prefix = base + errMsg + " " - prefixStyle = lipgloss.NewStyle().Foreground(colors.Error).Background(colors.Bg) - default: - prefix = fmt.Sprintf("◇ Tero %s ", durationStr) - prefixStyle = lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) - } - - indent := block.BorderWidth - prefixWidth := lipgloss.Width(prefix) - - lineWidth := cw - indent - prefixWidth - if lineWidth < 0 { - lineWidth = 0 - } - line := strings.Repeat("─", lineWidth) - - return strings.Repeat(" ", indent) + prefixStyle.Render(prefix) + border.Render(line) -} - -// emptyView renders an empty view padded to height. -func (m *Model) emptyView() string { - return lipgloss.NewStyle(). - Width(m.width). - Height(m.height). - Render("") -} diff --git a/internal/app/chat/messagelist/round/round.go b/internal/app/chat/messagelist/round/round.go deleted file mode 100644 index 25ba96aa..00000000 --- a/internal/app/chat/messagelist/round/round.go +++ /dev/null @@ -1,132 +0,0 @@ -package round - -import ( - "context" - "time" - - tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn" - "github.com/usetero/cli/internal/app/chat/usecase" - chattools "github.com/usetero/cli/internal/app/chattools" - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/components/thinking" -) - -// State represents the current state of a round. -type State int - -const ( - StateActive State = iota - StateAwaitingNextTurn // async DB work in progress before next turn - StateComplete - StateCancelled - StateFailed -) - -const dbOpTimeout = 2 * time.Second - -// IsActive returns true if the round is in-flight (active or awaiting next turn). -func (m *Model) IsActive() bool { - return m.state == StateActive || m.state == StateAwaitingNextTurn -} - -// Model represents a complete user→assistant exchange, potentially with multiple turns -// if tools are involved. A round starts with explicit user input and ends when the -// assistant stops (no more tool calls). -// It is a fixed-height component - height is determined by content. -type Model struct { - theme styles.Theme - scope log.Scope - - id domain.MessageID // first user message ID, identifies the round - conversationID domain.ConversationID - accountID domain.AccountID - - turns []*turn.Model - session *corechat.Session // authoritative in-memory history for active tool loop - thinking *thinking.Model - state State - lastErr error - width int - - startTime time.Time - endTime time.Time - - streamRunner usecase.StreamRunner - streamErrorMapper usecase.StreamErrorMapper - assistantPersister usecase.AssistantPersister - toolLoop usecase.ToolLoop - toolRegistry *chattools.Registry - effectCtx context.Context -} - -// New creates a new round from explicit user input. -func New( - theme styles.Theme, - conversationID domain.ConversationID, - accountID domain.AccountID, - userMessageID domain.MessageID, - input msgs.UserSubmittedInput, - width int, - runtimeDeps usecase.RuntimeDeps, - toolRegistry *chattools.Registry, - scope log.Scope, -) *Model { - scope = scope.Child("round") - streamRunner := runtimeDeps.StreamRunner - streamErrorMapper := runtimeDeps.StreamErrorMapper - assistantPersister := runtimeDeps.AssistantPersister - - // Create first turn with user's explicit input - firstTurn := turn.New( - theme, - conversationID, - accountID, - userMessageID, - input, - width, - streamRunner, - streamErrorMapper, - assistantPersister, - runtimeDeps.EffectContext, - toolRegistry, - scope, - ) - - return &Model{ - theme: theme, - scope: scope, - id: userMessageID, - conversationID: conversationID, - accountID: accountID, - turns: []*turn.Model{firstTurn}, - thinking: thinking.New(theme, thinking.Settings{Label: "Thinking"}), - state: StateActive, - width: width, - startTime: time.Now(), - streamRunner: runtimeDeps.StreamRunner, - streamErrorMapper: runtimeDeps.StreamErrorMapper, - assistantPersister: runtimeDeps.AssistantPersister, - toolLoop: runtimeDeps.ToolLoop, - toolRegistry: toolRegistry, - effectCtx: runtimeDeps.EffectContext, - } -} - -// Init starts the thinking animation. -func (m *Model) Init() tea.Cmd { - return m.thinking.Init() -} - -// StartStream begins streaming for the first turn. -func (m *Model) StartStream(messages []domain.Message, context []domain.ContextEntity) tea.Cmd { - if len(m.turns) == 0 { - return nil - } - m.session = corechat.NewSession(m.conversationID, messages) - return m.turns[0].StartStream(messages, context) -} diff --git a/internal/app/chat/messagelist/round/round_effects.go b/internal/app/chat/messagelist/round/round_effects.go deleted file mode 100644 index 40657a3a..00000000 --- a/internal/app/chat/messagelist/round/round_effects.go +++ /dev/null @@ -1,132 +0,0 @@ -package round - -import ( - "fmt" - "strings" - - tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn" - "github.com/usetero/cli/internal/app/chat/usecase" - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" - domaintools "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/sqlite" -) - -// startNextTurn persists tool results and creates the next turn using in-memory history. -func (m *Model) startNextTurn(results []domaintools.Result) tea.Cmd { - m.scope.Info("starting next turn", "result_count", len(results)) - for _, summary := range summarizeToolResults(results) { - m.scope.Debug("next turn tool result", "summary", summary) - } - - return func() tea.Msg { - ctx, cancel := sqlite.WithTimeout(m.effectCtx, dbOpTimeout) - defer cancel() - - prepared, err := m.toolLoop.PrepareNextTurn(ctx, usecase.PrepareNextTurnInput{ - AccountID: m.accountID, - ConversationID: m.conversationID, - Results: results, - Session: m.session, - }) - if err != nil { - // Durability failure should not block the active chat loop. - m.scope.Error("failed to create tool result message", "error", err) - } - if m.session == nil { - m.session = corechat.NewSession(m.conversationID, prepared.Messages) - } - messages := prepared.Messages - for _, summary := range summarizeHistory(messages) { - m.scope.Debug("next turn history", "summary", summary) - } - - return nextTurnReady{ - roundID: m.id, - messageID: prepared.MessageID, - results: results, - messages: messages, - toolResultMessage: prepared.ToolResultMessage, - } - } -} - -func summarizeToolResults(results []domaintools.Result) []string { - out := make([]string, 0, len(results)) - for _, r := range results { - rows := -1 - if rawRows, ok := r.Content["rows"]; ok { - if list, ok := rawRows.([]map[string]any); ok { - rows = len(list) - } else if listAny, ok := rawRows.([]any); ok { - rows = len(listAny) - } - } - if rows >= 0 { - out = append(out, fmt.Sprintf("tool_use_id=%s is_error=%t rows=%d", r.ToolUseID, r.IsError(), rows)) - continue - } - out = append(out, fmt.Sprintf("tool_use_id=%s is_error=%t", r.ToolUseID, r.IsError())) - } - return out -} - -func summarizeHistory(messages []domain.Message) []string { - out := make([]string, 0, len(messages)) - for _, msg := range messages { - blockKinds := make([]string, 0, len(msg.Content)) - for _, b := range msg.Content { - blockKinds = append(blockKinds, string(b.Type)) - } - out = append(out, fmt.Sprintf( - "id=%s role=%s stop_reason=%s blocks=%d kinds=%s", - msg.ID, - msg.Role, - msg.StopReason, - len(msg.Content), - strings.Join(blockKinds, ","), - )) - } - return out -} - -// nextTurnReady is an internal message to create the next turn after persistence. -type nextTurnReady struct { - roundID domain.MessageID - messageID domain.MessageID - results []domaintools.Result - messages []domain.Message - toolResultMessage domain.Message -} - -// handleNextTurnReady creates and starts the next turn. -func (m *Model) handleNextTurnReady(msg nextTurnReady) tea.Cmd { - // Create input with tool results (empty text) - input := msgs.UserSubmittedInput{ - ToolResults: msg.results, - } - - nextTurn := turn.New( - m.theme, - m.conversationID, - m.accountID, - msg.messageID, - input, - m.width, - m.streamRunner, - m.streamErrorMapper, - m.assistantPersister, - m.effectCtx, - m.toolRegistry, - m.scope, - ) - - m.turns = append(m.turns, nextTurn) - startStream := nextTurn.StartStream(msg.messages, nil) - notifyPersist := func() tea.Msg { - return msgs.ToolResultMessagePersisted{Message: msg.toolResultMessage} - } - return tea.Batch(startStream, notifyPersist) -} diff --git a/internal/app/chat/messagelist/round/round_model.go b/internal/app/chat/messagelist/round/round_model.go deleted file mode 100644 index 2d0bb243..00000000 --- a/internal/app/chat/messagelist/round/round_model.go +++ /dev/null @@ -1,97 +0,0 @@ -package round - -import ( - "time" - - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks" - "github.com/usetero/cli/internal/domain" -) - -// Blocks returns all visual blocks from all turns in this round. -// The thinking animation is appended at the end while the round is active. -func (m *Model) Blocks() []block.Block { - var result []block.Block - for _, t := range m.turns { - result = append(result, t.Blocks()...) - } - if m.IsActive() { - result = append(result, blocks.NewThinkingAnimBlock(m.thinking)) - } - return result -} - -// SetWidth sets the width for all turns. -func (m *Model) SetWidth(width int) { - m.width = width - for _, t := range m.turns { - t.SetWidth(width) - } -} - -// Cancel stops all in-flight turns and marks the round cancelled. -func (m *Model) Cancel() { - for _, t := range m.turns { - t.Cancel() - } - m.state = StateCancelled - m.endTime = time.Now() - m.scope.Info("round cancelled") -} - -// State returns the round's current state. -func (m *Model) State() State { - return m.state -} - -// ID returns the round's ID (first user message ID). -func (m *Model) ID() domain.MessageID { - return m.id -} - -// Err returns the error that caused the round to fail, or nil. -func (m *Model) Err() error { - return m.lastErr -} - -// HasAssistantContent returns true if any turn has assistant blocks. -func (m *Model) HasAssistantContent() bool { - for _, t := range m.turns { - if len(t.Blocks()) > 1 { // more than just the user message block - return true - } - } - return false -} - -// LastTurnMessageIDs returns the message IDs that should be deleted on failure. -// For turn 1: the user message ID. -// For turn 2+: the tool result message ID (current turn) + the previous turn's assistant message ID. -func (m *Model) LastTurnMessageIDs() []domain.MessageID { - if len(m.turns) == 0 { - return nil - } - - last := m.turns[len(m.turns)-1] - - if len(m.turns) == 1 { - // Turn 1: just the user message - return []domain.MessageID{last.UserMessageID()} - } - - // Turn 2+: tool result message + previous assistant message - prev := m.turns[len(m.turns)-2] - ids := []domain.MessageID{last.UserMessageID()} - if aid := prev.AssistantMessageID(); aid != "" { - ids = append(ids, aid) - } - return ids -} - -// Duration returns the elapsed time for this round. -func (m *Model) Duration() time.Duration { - if m.endTime.IsZero() { - return time.Since(m.startTime) - } - return m.endTime.Sub(m.startTime) -} diff --git a/internal/app/chat/messagelist/round/round_reducer.go b/internal/app/chat/messagelist/round/round_reducer.go deleted file mode 100644 index 7746c381..00000000 --- a/internal/app/chat/messagelist/round/round_reducer.go +++ /dev/null @@ -1,38 +0,0 @@ -package round - -func reduceOnStreamCompleted(current State, ownsTurn bool, stopReason string) (State, bool) { - if !ownsTurn { - return current, false - } - if stopReason == "tool_use" { - return current, false - } - if current == StateComplete { - return current, false - } - return StateComplete, true -} - -func reduceOnStreamFailed(current State, ownsTurn bool) (State, bool) { - if !ownsTurn { - return current, false - } - if current == StateFailed { - return current, false - } - return StateFailed, true -} - -func reduceOnToolResultsReady(current State, ownsTurn bool) (State, bool) { - if !ownsTurn || current != StateActive { - return current, false - } - return StateAwaitingNextTurn, true -} - -func reduceOnNextTurnReady(current State, roundMatches bool) (State, bool) { - if !roundMatches || current != StateAwaitingNextTurn { - return current, false - } - return StateActive, true -} diff --git a/internal/app/chat/messagelist/round/round_reducer_test.go b/internal/app/chat/messagelist/round/round_reducer_test.go deleted file mode 100644 index 86021479..00000000 --- a/internal/app/chat/messagelist/round/round_reducer_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package round - -import "testing" - -func TestRoundReducer(t *testing.T) { - t.Parallel() - - t.Run("stream completed tool_use stays active", func(t *testing.T) { - next, changed := reduceOnStreamCompleted(StateActive, true, "tool_use") - if next != StateActive || changed { - t.Fatalf("next=%v changed=%v", next, changed) - } - }) - - t.Run("stream completed end_turn completes", func(t *testing.T) { - next, changed := reduceOnStreamCompleted(StateActive, true, "end_turn") - if next != StateComplete || !changed { - t.Fatalf("next=%v changed=%v", next, changed) - } - }) - - t.Run("tool results ready only from active", func(t *testing.T) { - next, changed := reduceOnToolResultsReady(StateAwaitingNextTurn, true) - if next != StateAwaitingNextTurn || changed { - t.Fatalf("next=%v changed=%v", next, changed) - } - }) - - t.Run("next turn ready only from awaiting", func(t *testing.T) { - next, changed := reduceOnNextTurnReady(StateAwaitingNextTurn, true) - if next != StateActive || !changed { - t.Fatalf("next=%v changed=%v", next, changed) - } - }) -} diff --git a/internal/app/chat/messagelist/round/round_test.go b/internal/app/chat/messagelist/round/round_test.go deleted file mode 100644 index e589020e..00000000 --- a/internal/app/chat/messagelist/round/round_test.go +++ /dev/null @@ -1,430 +0,0 @@ -package round - -import ( - "errors" - "testing" - - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/app/chat/usecase" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/styles" -) - -func newTestRound(t *testing.T) *Model { - t.Helper() - theme := styles.NewTheme(true) - scope := logtest.NewScope(t) - - input := msgs.UserSubmittedInput{Text: "hello"} - return New(theme, "conv-1", "acct-1", "user-1", input, 80, usecase.RuntimeDeps{}, nil, scope) -} - -func hasBlockKind(blocks []block.Block, kind block.Kind) bool { - for _, b := range blocks { - if b.Kind() == kind { - return true - } - } - return false -} - -func TestNew(t *testing.T) { - t.Parallel() - - m := newTestRound(t) - - if m.State() != StateActive { - t.Errorf("expected StateActive, got %d", m.State()) - } - if m.ID() != "user-1" { - t.Errorf("expected ID user-1, got %s", m.ID()) - } - if len(m.turns) != 1 { - t.Errorf("expected 1 turn, got %d", len(m.turns)) - } -} - -func TestBlocks(t *testing.T) { - t.Parallel() - - t.Run("includes thinking animation while active", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - blocks := m.Blocks() - if !hasBlockKind(blocks, block.KindThinkingAnimation) { - t.Error("expected thinking animation block while active") - } - }) - - t.Run("excludes thinking animation when complete", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - m.Update(msgs.StreamCompleted{ - TurnID: "user-1", - Message: domain.Message{ID: "asst-1", StopReason: "end_turn"}, - }) - - if m.State() != StateComplete { - t.Fatalf("expected StateComplete, got %d", m.State()) - } - if hasBlockKind(m.Blocks(), block.KindThinkingAnimation) { - t.Error("thinking animation should be removed after completion") - } - }) - - t.Run("excludes thinking animation when cancelled", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - m.Cancel() - - if m.State() != StateCancelled { - t.Fatalf("expected StateCancelled, got %d", m.State()) - } - if hasBlockKind(m.Blocks(), block.KindThinkingAnimation) { - t.Error("thinking animation should be removed after cancel") - } - }) -} - -func TestCancel(t *testing.T) { - t.Parallel() - - t.Run("sets state to cancelled", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - m.Cancel() - - if m.State() != StateCancelled { - t.Errorf("expected StateCancelled, got %d", m.State()) - } - }) - - t.Run("sets end time", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - m.Cancel() - - if m.endTime.IsZero() { - t.Error("expected endTime to be set after cancel") - } - }) - - t.Run("propagates to turns", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - m.Cancel() - - for i, turn := range m.turns { - if turn.State() != 3 { // turn.StateComplete - t.Errorf("turn %d: expected StateComplete, got %d", i, turn.State()) - } - } - }) -} - -func TestUpdate(t *testing.T) { - t.Parallel() - - t.Run("StreamCompleted with end_turn completes round", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - m.Update(msgs.StreamCompleted{ - TurnID: "user-1", - Message: domain.Message{ID: "asst-1", StopReason: "end_turn"}, - }) - - if m.State() != StateComplete { - t.Errorf("expected StateComplete, got %d", m.State()) - } - }) - - t.Run("StreamCompleted with tool_use stays active", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - m.Update(msgs.StreamCompleted{ - TurnID: "user-1", - Message: domain.Message{ID: "asst-1", StopReason: "tool_use"}, - }) - - if m.State() != StateActive { - t.Errorf("expected StateActive after tool_use, got %d", m.State()) - } - }) - - t.Run("ignores messages for unknown turns", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - m.Update(msgs.StreamCompleted{ - TurnID: "unknown-turn", - Message: domain.Message{ID: "asst-1", StopReason: "end_turn"}, - }) - - if m.State() != StateActive { - t.Errorf("expected StateActive (unchanged), got %d", m.State()) - } - }) - - t.Run("skips forwarding to turns after cancel", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - m.Cancel() - - // StreamCompleted for our turn should not change state back - m.Update(msgs.StreamCompleted{ - TurnID: "user-1", - Message: domain.Message{ID: "asst-1", StopReason: "end_turn"}, - }) - - // State should still be cancelled, not complete - if m.State() != StateCancelled { - t.Errorf("expected StateCancelled (unchanged), got %d", m.State()) - } - }) -} - -func TestToolResultsReadyDoesNotDoubleFire(t *testing.T) { - t.Parallel() - - t.Run("transitions to StateAwaitingNextTurn", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - m.Update(msgs.ToolResultsReady{ - TurnID: "user-1", - Results: []tools.Result{{ToolUseID: "tool-1"}}, - }) - - if m.state != StateAwaitingNextTurn { - t.Fatalf("expected StateAwaitingNextTurn, got %d", m.state) - } - }) - - t.Run("second ToolResultsReady is ignored", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - m.Update(msgs.ToolResultsReady{ - TurnID: "user-1", - Results: []tools.Result{{ToolUseID: "tool-1"}}, - }) - m.Update(msgs.ToolResultsReady{ - TurnID: "user-1", - Results: []tools.Result{{ToolUseID: "tool-1"}}, - }) - - if m.state != StateAwaitingNextTurn { - t.Fatalf("expected StateAwaitingNextTurn (unchanged), got %d", m.state) - } - if len(m.turns) != 1 { - t.Errorf("expected 1 turn (no duplicate), got %d", len(m.turns)) - } - }) -} - -func TestStateAwaitingNextTurn(t *testing.T) { - t.Parallel() - - t.Run("IsActive returns true", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - m.state = StateAwaitingNextTurn - - if !m.IsActive() { - t.Error("expected IsActive() true for StateAwaitingNextTurn") - } - }) - - t.Run("shows thinking animation", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - m.state = StateAwaitingNextTurn - - if !hasBlockKind(m.Blocks(), block.KindThinkingAnimation) { - t.Error("expected thinking animation in StateAwaitingNextTurn") - } - }) - - t.Run("cancel works from awaiting state", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - m.state = StateAwaitingNextTurn - - m.Cancel() - - if m.State() != StateCancelled { - t.Errorf("expected StateCancelled, got %d", m.State()) - } - }) - - t.Run("nextTurnReady ignored after cancel", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - m.state = StateAwaitingNextTurn - - m.Cancel() - - m.Update(nextTurnReady{ - roundID: "user-1", - messageID: "tool-result-1", - messages: []domain.Message{}, - }) - - if m.State() != StateCancelled { - t.Errorf("expected StateCancelled (unchanged), got %d", m.State()) - } - if len(m.turns) != 1 { - t.Errorf("expected 1 turn (nextTurnReady ignored), got %d", len(m.turns)) - } - }) -} - -func TestStreamFailed(t *testing.T) { - t.Parallel() - - t.Run("transitions to StateFailed", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - m.Update(msgs.StreamFailed{TurnID: "user-1", Err: errors.New("connection lost")}) - - if m.State() != StateFailed { - t.Errorf("expected StateFailed, got %d", m.State()) - } - }) - - t.Run("stores the error", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - err := errors.New("connection lost") - m.Update(msgs.StreamFailed{TurnID: "user-1", Err: err}) - - if !errors.Is(m.Err(), err) { - t.Errorf("expected stored error %v, got %v", err, m.Err()) - } - }) - - t.Run("excludes thinking animation", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - m.Update(msgs.StreamFailed{TurnID: "user-1", Err: errors.New("fail")}) - - if hasBlockKind(m.Blocks(), block.KindThinkingAnimation) { - t.Error("thinking animation should be removed after failure") - } - }) - - t.Run("ignores subsequent messages", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - m.Update(msgs.StreamFailed{TurnID: "user-1", Err: errors.New("fail")}) - m.Update(msgs.StreamCompleted{ - TurnID: "user-1", - Message: domain.Message{ID: "asst-1", StopReason: "end_turn"}, - }) - - if m.State() != StateFailed { - t.Errorf("expected StateFailed (unchanged), got %d", m.State()) - } - }) -} - -func TestHasAssistantContent(t *testing.T) { - t.Parallel() - - t.Run("false for fresh round", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - if m.HasAssistantContent() { - t.Error("expected false for fresh round with no assistant content") - } - }) -} - -func TestLastTurnMessageIDs(t *testing.T) { - t.Parallel() - - t.Run("returns user message ID for turn 1", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - ids := m.LastTurnMessageIDs() - - if len(ids) != 1 { - t.Fatalf("expected 1 ID, got %d", len(ids)) - } - if ids[0] != "user-1" { - t.Errorf("expected user-1, got %s", ids[0]) - } - }) -} - -func TestDuration(t *testing.T) { - t.Parallel() - - t.Run("returns positive duration while active", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - if m.Duration() <= 0 { - t.Error("expected positive duration while active") - } - }) - - t.Run("returns fixed duration after cancel", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - m.Cancel() - d1 := m.Duration() - d2 := m.Duration() - - if d1 != d2 { - t.Error("expected fixed duration after cancel") - } - }) -} - -func TestSummarizeToolResults(t *testing.T) { - t.Parallel() - - results := []tools.Result{ - { - ToolUseID: "tool-1", - Content: map[string]any{ - "rows": []map[string]any{{"service_id": "ad"}, {"service_id": "email"}}, - }, - }, - { - ToolUseID: "tool-2", - Error: &tools.ErrorResult{Message: "boom"}, - }, - } - - summaries := summarizeToolResults(results) - if len(summaries) != 2 { - t.Fatalf("len(summaries)=%d, want 2", len(summaries)) - } - if got := summaries[0]; got != "tool_use_id=tool-1 is_error=false rows=2" { - t.Fatalf("summaries[0]=%q", got) - } - if got := summaries[1]; got != "tool_use_id=tool-2 is_error=true" { - t.Fatalf("summaries[1]=%q", got) - } -} diff --git a/internal/app/chat/messagelist/round/round_update.go b/internal/app/chat/messagelist/round/round_update.go deleted file mode 100644 index d803f780..00000000 --- a/internal/app/chat/messagelist/round/round_update.go +++ /dev/null @@ -1,85 +0,0 @@ -package round - -import ( - "time" - - tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/domain" -) - -// Update handles messages. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - // Terminal states — no state transitions, no forwarding. - if m.state == StateCancelled || m.state == StateFailed { - return nil - } - - var cmds []tea.Cmd - - switch msg := msg.(type) { - case msgs.StreamCompleted: - next, changed := reduceOnStreamCompleted(m.state, m.isOurTurn(msg.TurnID), msg.Message.StopReason) - if changed { - if m.session != nil { - m.session.RecordAssistantMessage(msg.Message) - } - m.state = next - m.endTime = time.Now() - m.scope.Info("round complete", "stop_reason", msg.Message.StopReason) - } else if m.isOurTurn(msg.TurnID) && m.session != nil { - // tool_use path still records assistant for next-turn history - m.session.RecordAssistantMessage(msg.Message) - } - - case msgs.StreamFailed: - next, changed := reduceOnStreamFailed(m.state, m.isOurTurn(msg.TurnID)) - if changed { - m.state = next - m.lastErr = msg.Err - m.endTime = time.Now() - m.scope.Info("round failed", "error", msg.Err) - } - - case msgs.ToolResultsReady: - next, changed := reduceOnToolResultsReady(m.state, m.isOurTurn(msg.TurnID)) - if changed { - m.state = next - cmds = append(cmds, m.startNextTurn(msg.Results)) - } - - case nextTurnReady: - next, changed := reduceOnNextTurnReady(m.state, msg.roundID == m.id) - if changed { - m.state = next - cmds = append(cmds, m.handleNextTurnReady(msg)) - } - } - - // Forward thinking ticks while active - if m.IsActive() { - cmds = append(cmds, m.thinking.Update(msg)) - } - - // Forward to all turns - for _, t := range m.turns { - cmds = append(cmds, t.Update(msg)) - } - - return tea.Batch(cmds...) -} - -// isOurTurn checks if the given turn ID belongs to this round. -func (m *Model) isOurTurn(turnID domain.MessageID) bool { - for _, t := range m.turns { - if t.UserMessageID() == turnID { - return true - } - } - return false -} - -// HasTurn reports whether this round owns turnID. -func (m *Model) HasTurn(turnID domain.MessageID) bool { - return m.isOurTurn(turnID) -} diff --git a/internal/app/chat/messagelist/round/turn/assistant/assistant.go b/internal/app/chat/messagelist/round/turn/assistant/assistant.go deleted file mode 100644 index eba5c8a7..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/assistant.go +++ /dev/null @@ -1,177 +0,0 @@ -package assistant - -import ( - tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/action" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/query" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/show" - chattools "github.com/usetero/cli/internal/app/chattools" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/styles" -) - -// Model renders an assistant message and manages its content blocks. -// It is a fixed-height component - height is determined by content. -type Model struct { - theme styles.Theme - blockTheme styles.Theme // theme with elevated bg for blocks - scope log.Scope - id domain.MessageID - turnID domain.MessageID - blocks []blocks.Block - width int - toolRegistry *chattools.Registry -} - -// New creates a new assistant message view. -func New(theme styles.Theme, turnID, id domain.MessageID, width int, toolRegistry *chattools.Registry, scope log.Scope) *Model { - scope = scope.Child("assistant") - return &Model{ - theme: theme, - blockTheme: theme.WithBg(theme.BgElevated), - scope: scope, - id: id, - turnID: turnID, - width: width, - toolRegistry: toolRegistry, - } -} - -// Update handles messages. Turn filters by TurnID before forwarding. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - var cmds []tea.Cmd - - switch msg := msg.(type) { - case msgs.AssistantContentUpdated: - cmds = append(cmds, m.ensureBlocks(msg.Message.Content)) - case msgs.StreamCompleted: - cmds = append(cmds, m.ensureBlocks(msg.Message.Content)) - } - - for _, b := range m.blocks { - cmds = append(cmds, b.Update(msg)) - } - - return tea.Batch(cmds...) -} - -// SetWidth sets the width. -func (m *Model) SetWidth(width int) { - m.width = width - // Blocks get the width inside the border; they handle their own internal padding. - contentWidth := width - block.BorderWidth - for _, b := range m.blocks { - b.SetWidth(contentWidth) - } -} - -// AddBlock adds a block directly (for testing). -func (m *Model) AddBlock(b blocks.Block) { - m.blocks = append(m.blocks, b) -} - -// SetContent populates blocks from content. Used for initial population to avoid empty render. -func (m *Model) SetContent(content []domain.Block) { - m.ensureBlocks(content) -} - -// ID returns the message ID. -func (m *Model) ID() domain.MessageID { - return m.id -} - -// SetID sets the message ID. -func (m *Model) SetID(id domain.MessageID) { - m.id = id -} - -// Cancel stops all in-progress tool animations. -func (m *Model) Cancel() { - for _, b := range m.blocks { - if t, ok := b.(*tools.Model); ok { - t.Cancel() - } - } -} - -// Blocks returns all visual blocks for the viewport. -func (m *Model) Blocks() []block.Block { - var result []block.Block - for _, b := range m.blocks { - result = append(result, b) - } - return result -} - -// ensureBlocks creates block models as needed. Blocks handle their own updates via messages. -// Returns a command to initialize any new tool animations. -func (m *Model) ensureBlocks(content []domain.Block) tea.Cmd { - var cmds []tea.Cmd - contentWidth := m.width - block.BorderWidth - for _, b := range content { - if m.hasBlock(b.Index) { - continue // Block exists, handles its own updates - } - - // Create new block with content width (minus padding) - switch b.Type { - case domain.BlockTypeText: - if b.Text != nil { - m.blocks = append(m.blocks, blocks.NewTextBlock(m.theme, b.Index, b.Text.Content, contentWidth)) - } - case domain.BlockTypeThinking: - if b.Thinking != nil { - m.blocks = append(m.blocks, blocks.NewThinkingBlock(m.theme, b.Index, b.Thinking.Content, contentWidth)) - } - case domain.BlockTypeToolUse: - if b.ToolUse != nil { - tool := m.newToolBlock(b.Index, b.ToolUse, contentWidth) - m.blocks = append(m.blocks, tool) - cmds = append(cmds, tool.Init()) - } - case domain.BlockTypeToolResult: - // Tool results are handled separately, not rendered as blocks - } - } - return tea.Batch(cmds...) -} - -// hasBlock checks if a block with the given index already exists. -func (m *Model) hasBlock(index int) bool { - for _, b := range m.blocks { - if b.Index() == index { - return true - } - } - return false -} - -// newToolBlock creates the appropriate tool model wrapped in chrome. -func (m *Model) newToolBlock(index int, toolUse *domain.ToolUse, width int) *tools.Model { - if m.toolRegistry == nil { - entry := chattools.UnknownTool(toolUse.Name) - child := action.New(index, m.turnID, toolUse.ID, width, entry.Config, entry.Exec, m.scope) - return tools.New(m.blockTheme, index, m.turnID, toolUse.ID, width, child) - } - - var child tools.Child - switch { - case m.toolRegistry.Query != nil && toolUse.Name == m.toolRegistry.Query.Name(): - child = query.New(m.blockTheme, index, m.turnID, toolUse.ID, width, m.toolRegistry.Query, m.scope) - case m.toolRegistry.Show != nil && toolUse.Name == m.toolRegistry.Show.Name(): - child = show.New(m.blockTheme, index, m.turnID, toolUse.ID, width, m.toolRegistry.Show, m.scope) - default: - entry, ok := m.toolRegistry.Lookup(toolUse.Name) - if !ok { - m.scope.Warn("unknown tool, using generic action", "name", toolUse.Name) - entry = chattools.UnknownTool(toolUse.Name) - } - child = action.New(index, m.turnID, toolUse.ID, width, entry.Config, entry.Exec, m.scope) - } - return tools.New(m.blockTheme, index, m.turnID, toolUse.ID, width, child) -} diff --git a/internal/app/chat/messagelist/round/turn/assistant/assistant_test.go b/internal/app/chat/messagelist/round/turn/assistant/assistant_test.go deleted file mode 100644 index f54e7ddb..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/assistant_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package assistant - -import ( - "fmt" - "strings" - "testing" - - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/query" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/teatest" -) - -func TestBlocksNoWrapping(t *testing.T) { - t.Parallel() - theme := styles.NewTheme(true) - scope := logtest.NewScope(t) - - // Wide 10-column query result — the exact data that was wrapping - rows := []map[string]any{ - {"name": "accounting", "log_analyzing_count": 0, "log_discovering_count": 0, "log_error": nil, "log_event_count": 6, "log_percent_complete": 96.74829044065488, "log_saved_count": 0, "log_status": "READY", "log_valuable_count": 0, "log_waste_count": 6}, - {"name": "ad", "log_analyzing_count": 0, "log_discovering_count": 0, "log_error": nil, "log_event_count": 5, "log_percent_complete": 95.47126675672867, "log_saved_count": 0, "log_status": "READY", "log_valuable_count": 0, "log_waste_count": 5}, - {"name": "cart", "log_analyzing_count": 0, "log_discovering_count": 0, "log_error": nil, "log_event_count": 11, "log_percent_complete": 100, "log_saved_count": 0, "log_status": "READY", "log_valuable_count": 0, "log_waste_count": 11}, - {"name": "checkout", "log_analyzing_count": 0, "log_discovering_count": 0, "log_error": nil, "log_event_count": 41, "log_percent_complete": 100, "log_saved_count": 0, "log_status": "READY", "log_valuable_count": 0, "log_waste_count": 41}, - {"name": "currency", "log_analyzing_count": 0, "log_discovering_count": 0, "log_error": nil, "log_event_count": 1, "log_percent_complete": 95.20486903597435, "log_saved_count": 0, "log_status": "READY", "log_valuable_count": 0, "log_waste_count": 1}, - } - - for _, termWidth := range []int{80, 120, 160, 200} { - t.Run(fmt.Sprintf("term_%d", termWidth), func(t *testing.T) { - // Real width chain: app subtracts 2 for horizontal padding - assistantWidth := termWidth - 2 - contentWidth := assistantWidth - block.BorderWidth - - // Real assistant model - m := New(theme, "turn-1", "test-msg", assistantWidth, nil, scope) - // Real query model — pass contentWidth (same as production in newToolBlock) - q := query.New(theme, 0, "turn-1", "tool-1", contentWidth, nil, scope) - q.SetRows(rows) - - // Real tool model wrapping query (same as production) - tool := tools.New(theme, 0, "turn-1", "tool-1", contentWidth, q) - tool.ForceStatus(tools.StatusSuccess) - - // Add tool block to assistant - m.AddBlock(tool) - - // Verify each block renders within contentWidth. - // The viewport applies a border; blocks handle their own internal padding. - // Blocks must fit within contentWidth. - for _, b := range m.Blocks() { - b.SetWidth(contentWidth) - output := b.View() - teatest.AssertMaxWidth(t, contentWidth, output) - } - }) - } -} - -func TestCancel(t *testing.T) { - t.Parallel() - - t.Run("cancels tool blocks", func(t *testing.T) { - t.Parallel() - theme := styles.NewTheme(true) - scope := logtest.NewScope(t) - - m := New(theme, "turn-1", "test-msg", 80, nil, scope) - - // Add a tool block in pending state - q := query.New(theme, 0, "turn-1", "tool-1", 78, nil, scope) - tool := tools.New(theme, 0, "turn-1", "tool-1", 78, q) - m.AddBlock(tool) - - m.Cancel() - - // Tool should render as cancelled — padding + icon/name + padding (3 lines) - view := tool.View() - if lines := strings.Count(view, "\n"); lines != 2 { - t.Errorf("expected 3-line render for cancelled tool, got %d lines", lines+1) - } - if !strings.Contains(view, "Query") { - t.Error("expected tool name in cancelled view") - } - }) - - t.Run("no-op with no tool blocks", func(t *testing.T) { - t.Parallel() - theme := styles.NewTheme(true) - scope := logtest.NewScope(t) - - m := New(theme, "turn-1", "test-msg", 80, nil, scope) - m.Cancel() // should not panic - }) -} - -func TestNilRegistryToolUseDoesNotPanic(t *testing.T) { - t.Parallel() - - theme := styles.NewTheme(true) - scope := logtest.NewScope(t) - m := New(theme, "turn-1", "test-msg", 80, nil, scope) - - cmd := m.Update(msgs.AssistantContentUpdated{ - TurnID: "turn-1", - Message: domain.Message{ - Content: []domain.Block{ - { - Index: 0, - Type: domain.BlockTypeToolUse, - ToolUse: &domain.ToolUse{ - ID: "tool-1", - Name: "unknown_tool", - Input: []byte(`{}`), - InputComplete: true, - }, - }, - }, - }, - }) - - if cmd == nil { - t.Fatal("expected non-nil cmd to initialize tool block") - } - if len(m.Blocks()) != 1 { - t.Fatalf("expected 1 block, got %d", len(m.Blocks())) - } -} diff --git a/internal/app/chat/messagelist/round/turn/assistant/blocks/block.go b/internal/app/chat/messagelist/round/turn/assistant/blocks/block.go deleted file mode 100644 index 10e2d19d..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/blocks/block.go +++ /dev/null @@ -1,12 +0,0 @@ -package blocks - -import "github.com/usetero/cli/internal/app/chat/messagelist/block" - -// Block is the interface for all content blocks in an assistant message. -// Embeds block.Block for viewport integration and adds Index for content tracking. -type Block interface { - block.Block - - // Index returns the block's position in the content array. - Index() int -} diff --git a/internal/app/chat/messagelist/round/turn/assistant/blocks/text.go b/internal/app/chat/messagelist/round/turn/assistant/blocks/text.go deleted file mode 100644 index 6f2db94c..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/blocks/text.go +++ /dev/null @@ -1,123 +0,0 @@ -package blocks - -import ( - "strings" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/styles" -) - -// TextBlock renders a text content block. -// It is a fixed-height component - height is determined by content. -// Implements block.Block. -type TextBlock struct { - theme styles.Theme - index int - text string - width int - rendered string // cached rendered output - focused bool -} - -// NewTextBlock creates a new text block with the given content. -func NewTextBlock(theme styles.Theme, index int, text string, width int) *TextBlock { - b := &TextBlock{ - theme: theme, - index: index, - text: text, - width: width, - } - b.render() - return b -} - -// Update handles messages. -func (m *TextBlock) Update(msg tea.Msg) tea.Cmd { - switch msg := msg.(type) { - case msgs.AssistantContentUpdated: - m.updateFromContent(msg.Message.Content) - case msgs.StreamCompleted: - m.updateFromContent(msg.Message.Content) - } - return nil -} - -// updateFromContent finds this block's content by index and updates. -func (m *TextBlock) updateFromContent(content []domain.Block) { - for _, b := range content { - if b.Index == m.index && b.Type == domain.BlockTypeText && b.Text != nil { - m.SetText(b.Text.Content) - return - } - } -} - -// Index returns the block index. -func (m *TextBlock) Index() int { - return m.index -} - -// View renders the text block. -func (m *TextBlock) View() string { - return m.rendered -} - -// Height returns the number of lines this block renders. -func (m *TextBlock) Height() int { - if m.rendered == "" { - return 0 - } - return lipgloss.Height(m.rendered) -} - -// SetText sets the text and re-renders. -func (m *TextBlock) SetText(text string) { - if m.text == text { - return - } - m.text = text - m.render() -} - -// SetWidth sets the width and re-renders. -func (m *TextBlock) SetWidth(width int) { - if m.width == width { - return - } - m.width = width - m.render() -} - -// Kind implements block.Block. -func (m *TextBlock) Kind() block.Kind { - return block.KindAssistantText -} - -// SetFocused implements block.Block. -func (m *TextBlock) SetFocused(focused bool) { - m.focused = focused -} - -// Focused implements block.Block. -func (m *TextBlock) Focused() bool { - return m.focused -} - -func (m *TextBlock) render() { - if m.text == "" { - m.rendered = "" - return - } - rendered := styles.RenderMarkdown(m.theme, m.text, m.width-block.PaddingX*2) - rendered = strings.TrimRight(rendered, "\n") - m.rendered = lipgloss.NewStyle(). - Background(m.theme.Bg). - Foreground(m.theme.Text). - Padding(block.PaddingY, block.PaddingX). - Width(m.width). - Render(rendered) -} diff --git a/internal/app/chat/messagelist/round/turn/assistant/blocks/thinking.go b/internal/app/chat/messagelist/round/turn/assistant/blocks/thinking.go deleted file mode 100644 index 59f556b6..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/blocks/thinking.go +++ /dev/null @@ -1,152 +0,0 @@ -package blocks - -import ( - "fmt" - "strings" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/styles" -) - -// Body padding matches the tool block. -const ( - thinkingBodyPaddingLeft = 2 - thinkingBodyPaddingRight = 1 - thinkingBodyPaddingH = thinkingBodyPaddingLeft + thinkingBodyPaddingRight -) - -// ThinkingBlock renders a thinking content block. -// Can be expanded or collapsed. It is a fixed-height component. -// Implements block.Block. -type ThinkingBlock struct { - theme styles.Theme - index int - text string - expanded bool - width int - focused bool -} - -// NewThinkingBlock creates a new thinking block with the given content. -func NewThinkingBlock(theme styles.Theme, index int, text string, width int) *ThinkingBlock { - return &ThinkingBlock{ - theme: theme, - index: index, - text: text, - expanded: false, - width: width, - } -} - -// Update handles messages. -func (m *ThinkingBlock) Update(msg tea.Msg) tea.Cmd { - switch msg := msg.(type) { - case msgs.AssistantContentUpdated: - m.updateFromContent(msg.Message.Content) - case msgs.StreamCompleted: - m.updateFromContent(msg.Message.Content) - } - return nil -} - -// updateFromContent finds this block's content by index and updates. -func (m *ThinkingBlock) updateFromContent(content []domain.Block) { - for _, b := range content { - if b.Index == m.index && b.Type == domain.BlockTypeThinking && b.Thinking != nil { - m.SetText(b.Thinking.Content) - return - } - } -} - -// Index returns the block index. -func (m *ThinkingBlock) Index() int { - return m.index -} - -// View renders the thinking block. -func (m *ThinkingBlock) View() string { - colors := m.theme - mutedStyle := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) - nameStyle := lipgloss.NewStyle().Foreground(colors.Accent).Background(colors.Bg) - - chevron := mutedStyle.Render("▶") - if m.expanded { - chevron = mutedStyle.Render("▼") - } - - header := fmt.Sprintf("%s %s", chevron, nameStyle.Render("Thinking")) - - var content string - if !m.expanded { - content = header - } else { - // Render body with markdown styling, wrapped to available width - bodyWidth := m.width - thinkingBodyPaddingH - if bodyWidth < 1 { - bodyWidth = 1 - } - rendered := styles.RenderMarkdown(m.theme, m.text, bodyWidth) - rendered = strings.TrimRight(rendered, "\n") - - body := lipgloss.NewStyle(). - Padding(1, thinkingBodyPaddingRight, 1, thinkingBodyPaddingLeft). - Render(rendered) - - content = header + "\n\n" + body - } - - return lipgloss.NewStyle(). - Background(colors.Bg). - Padding(0, block.PaddingX). - Width(m.width). - Render(content) -} - -// Height returns the number of lines this block renders. -func (m *ThinkingBlock) Height() int { - return lipgloss.Height(m.View()) -} - -// SetText sets the text. -func (m *ThinkingBlock) SetText(text string) { - m.text = text -} - -// SetWidth sets the width. -func (m *ThinkingBlock) SetWidth(width int) { - m.width = width -} - -// SetExpanded sets the expanded state. -func (m *ThinkingBlock) SetExpanded(expanded bool) { - m.expanded = expanded -} - -// Toggle toggles the expanded state. -// Only toggles when clicking the header line (y == 0, no top padding). -func (m *ThinkingBlock) Toggle(y int) { - if m.expanded && y != 0 { - return - } - m.expanded = !m.expanded -} - -// Kind implements block.Block. -func (m *ThinkingBlock) Kind() block.Kind { - return block.KindThinking -} - -// SetFocused implements block.Block. -func (m *ThinkingBlock) SetFocused(focused bool) { - m.focused = focused -} - -// Focused implements block.Block. -func (m *ThinkingBlock) Focused() bool { - return m.focused -} diff --git a/internal/app/chat/messagelist/round/turn/assistant/blocks/thinking_test.go b/internal/app/chat/messagelist/round/turn/assistant/blocks/thinking_test.go deleted file mode 100644 index c0c42b58..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/blocks/thinking_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package blocks - -import ( - "strings" - "testing" - - "charm.land/lipgloss/v2" - "github.com/charmbracelet/x/ansi" - "github.com/usetero/cli/internal/styles" -) - -func newTestThinking(t *testing.T, text string) *ThinkingBlock { - t.Helper() - theme := styles.NewTheme(true) - return NewThinkingBlock(theme, 0, text, 80) -} - -func TestThinkingCollapsedByDefault(t *testing.T) { - t.Parallel() - m := newTestThinking(t, "some internal reasoning") - view := m.View() - plain := ansi.Strip(view) - - if strings.Contains(plain, "reasoning") { - t.Error("expected collapsed view to hide content") - } - if !strings.Contains(plain, "▶") { - t.Error("expected collapsed chevron") - } - if !strings.Contains(plain, "Thinking") { - t.Error("expected Thinking label") - } -} - -func TestThinkingExpanded(t *testing.T) { - t.Parallel() - m := newTestThinking(t, "some internal reasoning") - m.Toggle(0) - view := m.View() - plain := ansi.Strip(view) - - if !strings.Contains(plain, "reasoning") { - t.Errorf("expected expanded view to show content, got:\n%s", plain) - } - if !strings.Contains(plain, "▼") { - t.Error("expected expanded chevron") - } - if !strings.Contains(plain, "Thinking") { - t.Error("expected Thinking label") - } -} - -func TestThinkingToggle(t *testing.T) { - t.Parallel() - m := newTestThinking(t, "reasoning text here") - - collapsedView := m.View() - m.Toggle(0) - expandedView := m.View() - - collapsedLines := lipgloss.Height(collapsedView) - expandedLines := lipgloss.Height(expandedView) - if collapsedLines >= expandedLines { - t.Errorf("collapsed (%d lines) should be shorter than expanded (%d lines)", collapsedLines, expandedLines) - } - - // Toggle back to collapsed - m.Toggle(0) - plain := ansi.Strip(m.View()) - if strings.Contains(plain, "reasoning") { - t.Error("expected content hidden after toggling back") - } -} - -func TestThinkingEmptyText(t *testing.T) { - t.Parallel() - m := newTestThinking(t, "") - m.Toggle(0) - plain := ansi.Strip(m.View()) - - // Should still render header even with empty text - if !strings.Contains(plain, "Thinking") { - t.Error("expected Thinking label even with empty text") - } -} diff --git a/internal/app/chat/messagelist/round/turn/assistant/blocks/thinkinganim.go b/internal/app/chat/messagelist/round/turn/assistant/blocks/thinkinganim.go deleted file mode 100644 index 915e6054..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/blocks/thinkinganim.go +++ /dev/null @@ -1,55 +0,0 @@ -package blocks - -import ( - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/tea/components/thinking" -) - -// ThinkingAnimBlock wraps a thinking animation as a block. -// This is the streaming indicator shown while the assistant is generating. -type ThinkingAnimBlock struct { - thinking *thinking.Model - focused bool -} - -// NewThinkingAnimBlock creates a new thinking animation block. -func NewThinkingAnimBlock(t *thinking.Model) *ThinkingAnimBlock { - return &ThinkingAnimBlock{thinking: t} -} - -// View implements block.Block. -func (m *ThinkingAnimBlock) View() string { - return lipgloss.NewStyle(). - Padding(0, block.PaddingX). - Render(m.thinking.View()) -} - -// Height implements block.Block. -func (m *ThinkingAnimBlock) Height() int { - return lipgloss.Height(m.View()) -} - -// Update implements block.Block. -func (m *ThinkingAnimBlock) Update(msg tea.Msg) tea.Cmd { - return m.thinking.Update(msg) -} - -// SetWidth implements block.Block. -func (m *ThinkingAnimBlock) SetWidth(_ int) {} - -// SetFocused implements block.Block. -func (m *ThinkingAnimBlock) SetFocused(focused bool) { - m.focused = focused -} - -// Focused implements block.Block. -func (m *ThinkingAnimBlock) Focused() bool { - return m.focused -} - -// Kind implements block.Block. -func (m *ThinkingAnimBlock) Kind() block.Kind { - return block.KindThinkingAnimation -} diff --git a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/action/action.go b/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/action/action.go deleted file mode 100644 index b2ee8810..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/action/action.go +++ /dev/null @@ -1,165 +0,0 @@ -// Package action provides a generic tool UI model for simple tools -// that accumulate input, execute, and show status — with no custom body. -package action - -import ( - "encoding/json" - - tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools" - "github.com/usetero/cli/internal/domain" - domaintools "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/log" -) - -// Executor runs a tool and returns a result. -type Executor func(input json.RawMessage) (domaintools.Result, error) - -// Config provides display strings for the chrome wrapper. -type Config struct { - DisplayName func(input json.RawMessage) string - Status func(input json.RawMessage) string - Result func(result domaintools.Result) string -} - -// Model is a generic tool UI model implementing tools.Child. -type Model struct { - scope log.Scope - index int - turnID domain.MessageID - toolID string - state tools.State - config Config - executor Executor - width int - - input json.RawMessage - result domaintools.Result - err error -} - -type actionExecutionCompletedMsg struct { - toolID string - result domaintools.Result - err error -} - -// New creates a new generic action tool model. -func New(index int, turnID domain.MessageID, toolID string, width int, config Config, executor Executor, scope log.Scope) *Model { - return &Model{ - scope: scope, - index: index, - turnID: turnID, - toolID: toolID, - state: tools.StateAccumulating, - config: config, - executor: executor, - width: width, - } -} - -// Update handles messages. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - switch msg := msg.(type) { - case msgs.AssistantContentUpdated: - return m.handleContent(msg.Message.Content) - case msgs.StreamCompleted: - return m.handleContent(msg.Message.Content) - case actionExecutionCompletedMsg: - if msg.toolID != m.toolID { - return nil - } - if msg.err != nil { - m.err = msg.err - m.state = tools.StateComplete - m.scope.Error("action failed", "name", m.config.DisplayName(m.input), "error", msg.err) - return m.fireCompleted() - } - - m.result = msg.result - m.state = tools.StateComplete - m.scope.Info("action completed", "name", m.config.DisplayName(m.input)) - return m.fireCompleted() - } - return nil -} - -func (m *Model) handleContent(content []domain.Block) tea.Cmd { - if m.state != tools.StateAccumulating { - return nil - } - - for _, b := range content { - if b.Index == m.index && b.Type == domain.BlockTypeToolUse && b.ToolUse != nil { - m.input = b.ToolUse.Input - if b.ToolUse.InputComplete { - return m.execute() - } - return nil - } - } - return nil -} - -func (m *Model) execute() tea.Cmd { - m.state = tools.StateExecuting - m.scope.Info("executing action", "name", m.config.DisplayName(m.input), "input", string(m.input)) - input := append(json.RawMessage(nil), m.input...) - executor := m.executor - return func() tea.Msg { - result, err := executor(input) - return actionExecutionCompletedMsg{toolID: m.toolID, result: result, err: err} - } -} - -func (m *Model) fireCompleted() tea.Cmd { - return func() tea.Msg { - return msgs.ToolCompleted{ - TurnID: m.turnID, - ToolUseID: m.toolID, - Result: m.result, - Error: m.err, - } - } -} - -// Name returns the display name. -func (m *Model) Name() string { - return m.config.DisplayName(m.input) -} - -// Status returns the status message shown while executing. -func (m *Model) Status() string { - return m.config.Status(m.input) -} - -// Result returns the result message shown when complete. -func (m *Model) Result() string { - return m.config.Result(m.result) -} - -// View returns empty — simple tools have no body content. -func (m *Model) View() string { - return "" -} - -// SetWidth sets the width. -func (m *Model) SetWidth(width int) { - m.width = width -} - -// State returns the current state. -func (m *Model) State() tools.State { - return m.state -} - -// ToolID returns the tool use ID. -func (m *Model) ToolID() string { - return m.toolID -} - -// Err returns any error from execution. -func (m *Model) Err() error { - return m.err -} diff --git a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/action/action_test.go b/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/action/action_test.go deleted file mode 100644 index 10be2fd4..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/action/action_test.go +++ /dev/null @@ -1,262 +0,0 @@ -package action - -import ( - "encoding/json" - "errors" - "testing" - - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools" - "github.com/usetero/cli/internal/domain" - domaintools "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/log/logtest" -) - -func testConfig() Config { - return Config{ - DisplayName: func(_ json.RawMessage) string { return "Test Tool" }, - Status: func(_ json.RawMessage) string { return "Running test" }, - Result: func(_ domaintools.Result) string { return "Done" }, - } -} - -func TestUpdate(t *testing.T) { - t.Parallel() - - t.Run("accumulates input from content blocks", func(t *testing.T) { - t.Parallel() - - m := New(0, "turn-1", "tool-1", 80, testConfig(), nil, logtest.NewScope(t)) - - m.Update(msgs.AssistantContentUpdated{ - Message: domain.Message{ - Content: []domain.Block{ - {Index: 0, Type: domain.BlockTypeToolUse, ToolUse: &domain.ToolUse{ - ID: "tool-1", - Input: json.RawMessage(`{"key":"val`), - InputComplete: false, - }}, - }, - }, - }) - - if m.state != tools.StateAccumulating { - t.Errorf("expected StateAccumulating, got %d", m.state) - } - }) - - t.Run("ignores blocks with wrong index", func(t *testing.T) { - t.Parallel() - - m := New(0, "turn-1", "tool-1", 80, testConfig(), nil, logtest.NewScope(t)) - - m.Update(msgs.AssistantContentUpdated{ - Message: domain.Message{ - Content: []domain.Block{ - {Index: 5, Type: domain.BlockTypeToolUse, ToolUse: &domain.ToolUse{ - ID: "tool-1", - Input: json.RawMessage(`{"key":"value"}`), - InputComplete: true, - }}, - }, - }, - }) - - if m.state != tools.StateAccumulating { - t.Errorf("expected StateAccumulating (wrong index ignored), got %d", m.state) - } - }) - - t.Run("executes on InputComplete and fires ToolCompleted", func(t *testing.T) { - t.Parallel() - - executor := func(input json.RawMessage) (domaintools.Result, error) { - return domaintools.Result{Content: map[string]any{"ok": true}}, nil - } - - m := New(0, "turn-1", "tool-1", 80, testConfig(), executor, logtest.NewScope(t)) - - cmd := m.Update(msgs.StreamCompleted{ - Message: domain.Message{ - Content: []domain.Block{ - {Index: 0, Type: domain.BlockTypeToolUse, ToolUse: &domain.ToolUse{ - ID: "tool-1", - Input: json.RawMessage(`{}`), - InputComplete: true, - }}, - }, - }, - }) - - if m.state != tools.StateExecuting { - t.Fatalf("expected StateExecuting, got %d", m.state) - } - if cmd == nil { - t.Fatal("expected execution cmd") - } - - completeCmd := m.Update(cmd()) - if m.state != tools.StateComplete { - t.Fatalf("expected StateComplete after execution, got %d", m.state) - } - if m.err != nil { - t.Fatalf("unexpected error: %v", m.err) - } - if completeCmd == nil { - t.Fatal("expected completion cmd") - } - - // Execute the command and check the message - msg := completeCmd() - completed, ok := msg.(msgs.ToolCompleted) - if !ok { - t.Fatalf("expected msgs.ToolCompleted, got %T", msg) - } - if completed.ToolUseID != "tool-1" { - t.Errorf("ToolUseID = %q, want %q", completed.ToolUseID, "tool-1") - } - if completed.TurnID != "turn-1" { - t.Errorf("TurnID = %q, want %q", completed.TurnID, "turn-1") - } - if completed.Error != nil { - t.Errorf("unexpected error in completed: %v", completed.Error) - } - }) - - t.Run("fires ToolCompleted with error on failure", func(t *testing.T) { - t.Parallel() - - executor := func(input json.RawMessage) (domaintools.Result, error) { - return domaintools.Result{}, errors.New("exec failed") - } - - m := New(0, "turn-1", "tool-1", 80, testConfig(), executor, logtest.NewScope(t)) - - cmd := m.Update(msgs.StreamCompleted{ - Message: domain.Message{ - Content: []domain.Block{ - {Index: 0, Type: domain.BlockTypeToolUse, ToolUse: &domain.ToolUse{ - ID: "tool-1", - Input: json.RawMessage(`{}`), - InputComplete: true, - }}, - }, - }, - }) - - if m.state != tools.StateExecuting { - t.Fatalf("expected StateExecuting, got %d", m.state) - } - if cmd == nil { - t.Fatal("expected execution cmd") - } - - completeCmd := m.Update(cmd()) - if m.state != tools.StateComplete { - t.Fatalf("expected StateComplete after execution, got %d", m.state) - } - if m.err == nil { - t.Fatal("expected error, got nil") - } - if completeCmd == nil { - t.Fatal("expected completion cmd") - } - - msg := completeCmd() - completed, ok := msg.(msgs.ToolCompleted) - if !ok { - t.Fatalf("expected msgs.ToolCompleted, got %T", msg) - } - if completed.Error == nil { - t.Error("expected error in completed message") - } - }) - - t.Run("does not re-execute after completion", func(t *testing.T) { - t.Parallel() - - callCount := 0 - executor := func(input json.RawMessage) (domaintools.Result, error) { - callCount++ - return domaintools.Result{Content: map[string]any{"ok": true}}, nil - } - - m := New(0, "turn-1", "tool-1", 80, testConfig(), executor, logtest.NewScope(t)) - - content := []domain.Block{ - {Index: 0, Type: domain.BlockTypeToolUse, ToolUse: &domain.ToolUse{ - ID: "tool-1", - Input: json.RawMessage(`{}`), - InputComplete: true, - }}, - } - - cmd := m.Update(msgs.StreamCompleted{Message: domain.Message{Content: content}}) - if cmd == nil { - t.Fatal("expected execution cmd") - } - m.Update(cmd()) - m.Update(msgs.StreamCompleted{Message: domain.Message{Content: content}}) - - if callCount != 1 { - t.Errorf("executor called %d times, want 1", callCount) - } - }) - - t.Run("ignores execution completion from different tool instance", func(t *testing.T) { - t.Parallel() - - m := New(0, "turn-1", "tool-1", 80, testConfig(), nil, logtest.NewScope(t)) - foreign := actionExecutionCompletedMsg{ - toolID: "tool-2", - result: domaintools.Result{Content: map[string]any{"ok": true}}, - } - - cmd := m.Update(foreign) - if cmd != nil { - t.Fatal("expected nil cmd for foreign completion") - } - if m.state != tools.StateAccumulating { - t.Fatalf("state = %d, want StateAccumulating", m.state) - } - if m.result.Content != nil { - t.Fatal("result should remain untouched") - } - }) -} - -func TestConfigDelegation(t *testing.T) { - t.Parallel() - - config := Config{ - DisplayName: func(input json.RawMessage) string { - var m map[string]any - if err := json.Unmarshal(input, &m); err != nil { - return "Enable" - } - if m["action"] == "disable" { - return "Disable" - } - return "Enable" - }, - Status: func(_ json.RawMessage) string { return "Working" }, - Result: func(r domaintools.Result) string { - name, _ := r.Content["name"].(string) - return name + " done" - }, - } - - m := New(0, "turn-1", "tool-1", 80, config, nil, logtest.NewScope(t)) - m.input = json.RawMessage(`{"action":"disable"}`) - m.result = domaintools.Result{Content: map[string]any{"name": "svc"}} - - if got := m.Name(); got != "Disable" { - t.Errorf("Name() = %q, want %q", got, "Disable") - } - if got := m.Status(); got != "Working" { - t.Errorf("Status() = %q, want %q", got, "Working") - } - if got := m.Result(); got != "svc done" { - t.Errorf("Result() = %q, want %q", got, "svc done") - } -} diff --git a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/query/query.go b/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/query/query.go deleted file mode 100644 index 60e6bff7..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/query/query.go +++ /dev/null @@ -1,279 +0,0 @@ -package query - -import ( - "encoding/json" - "fmt" - "sort" - "strings" - "time" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/x/ansi" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools" - chattools "github.com/usetero/cli/internal/app/chattools" - "github.com/usetero/cli/internal/domain" - domaintools "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/components/table" -) - -// Model handles query tool execution and content rendering. -// Chrome (icon, name) is handled by the parent tools.Model. -type Model struct { - theme styles.Theme - scope log.Scope - index int - turnID domain.MessageID - toolID string - state tools.State - executor *chattools.QueryTool - width int - - // Input accumulation - input string - - // Parsed input - sql string - status string - resultTemplate string - - // Results - rows []map[string]any - rowsDropped int - err error - duration time.Duration -} - -type queryExecutionCompletedMsg struct { - toolID string - result domaintools.QueryResult - err error - duration time.Duration -} - -// New creates a new query tool model. -func New(theme styles.Theme, index int, turnID domain.MessageID, toolID string, width int, executor *chattools.QueryTool, scope log.Scope) *Model { - scope = scope.Child("query") - return &Model{ - theme: theme, - scope: scope, - index: index, - turnID: turnID, - toolID: toolID, - state: tools.StateAccumulating, - executor: executor, - width: width, - } -} - -// Update handles messages. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - switch msg := msg.(type) { - case msgs.AssistantContentUpdated: - return m.handleContent(msg.Message.Content) - case msgs.StreamCompleted: - return m.handleContent(msg.Message.Content) - case queryExecutionCompletedMsg: - if msg.toolID != m.toolID { - return nil - } - m.duration = msg.duration - if msg.err != nil { - m.err = msg.err - m.state = tools.StateComplete - m.scope.Error("query failed", "error", msg.err) - return m.fireCompleted() - } - - m.rows = msg.result.Rows - m.rowsDropped = msg.result.RowsDropped - m.state = tools.StateComplete - m.scope.Info("query completed", "row_count", len(m.rows), "rows_dropped", m.rowsDropped, "duration", m.duration) - return m.fireCompleted() - } - return nil -} - -// handleContent finds this tool's data by index and updates state. -func (m *Model) handleContent(content []domain.Block) tea.Cmd { - if m.state != tools.StateAccumulating { - return nil - } - - for _, b := range content { - if b.Index == m.index && b.Type == domain.BlockTypeToolUse && b.ToolUse != nil { - m.input = string(b.ToolUse.Input) - if b.ToolUse.InputComplete { - return m.execute() - } - return nil - } - } - return nil -} - -// Status returns the status message shown while executing. -func (m *Model) Status() string { - return m.status -} - -// Result returns the result message with {count} substituted. -func (m *Model) Result() string { - if m.err != nil { - return "Query failed" - } - var base string - if m.resultTemplate == "" { - base = fmt.Sprintf("%d rows", len(m.rows)) - } else { - base = strings.Replace(m.resultTemplate, "{count}", fmt.Sprintf("%d", len(m.rows)), 1) - } - if m.duration > 0 { - base += fmt.Sprintf(" (%.1fs)", m.duration.Seconds()) - } - return base -} - -const maxPreviewRows = 5 - -// View renders a table preview of query results, clipped to the available width. -func (m *Model) View() string { - if len(m.rows) == 0 { - return "" - } - - // Collect column headers from first row, sorted for stable order. - // Promote "name" to the front if it exists. - first := m.rows[0] - var headers []string - hasName := false - for k := range first { - if k == "name" { - hasName = true - } else { - headers = append(headers, k) - } - } - sort.Strings(headers) - if hasName { - headers = append([]string{"name"}, headers...) - } - - // Cap rows - showRows := m.rows - truncatedRows := 0 - if len(showRows) > maxPreviewRows { - truncatedRows = len(showRows) - maxPreviewRows - showRows = showRows[:maxPreviewRows] - } - - // Build table — no explicit width so columns size to content - tbl := table.New(m.theme, table.WithFitHeaders(), table.WithBackground(m.theme.Bg)) - tbl.Headers(headers...) - - for _, row := range showRows { - cells := make([]string, len(headers)) - for i, h := range headers { - cells[i] = fmt.Sprintf("%v", row[h]) - } - tbl.Row(cells...) - } - - result := tbl.View() - - if truncatedRows > 0 { - muted := lipgloss.NewStyle().Foreground(m.theme.TextMuted).Background(m.theme.Bg).PaddingLeft(1) - result += "\n\n" + muted.Render(fmt.Sprintf("+%d more rows", truncatedRows)) - } - - // Clip each line to available width so wide tables don't wrap - if m.width > 0 { - lines := strings.Split(result, "\n") - for i, line := range lines { - lines[i] = ansi.Truncate(line, m.width, "") - } - result = strings.Join(lines, "\n") - } - - return result -} - -// SetRows sets the result rows directly (for testing). -func (m *Model) SetRows(rows []map[string]any) { - m.rows = rows - m.state = tools.StateComplete -} - -// SetWidth sets the width. -func (m *Model) SetWidth(width int) { - m.width = width -} - -func (m *Model) execute() tea.Cmd { - m.state = tools.StateExecuting - - // Parse input - var in domaintools.QueryInput - if err := json.Unmarshal([]byte(m.input), &in); err == nil { - m.sql = in.SQL - m.status = in.Status - m.resultTemplate = in.Result - } - - m.scope.Info("executing query", "sql", m.sql, "status", m.status) - - start := time.Now() - - if m.executor == nil { - m.err = fmt.Errorf("no executor") - m.state = tools.StateComplete - m.scope.Error("query failed", "error", m.err) - return m.fireCompleted() - } - input := append([]byte(nil), []byte(m.input)...) - executor := m.executor - return func() tea.Msg { - result, err := executor.Execute(json.RawMessage(input)) - return queryExecutionCompletedMsg{ - toolID: m.toolID, - result: result, - err: err, - duration: time.Since(start), - } - } -} - -func (m *Model) fireCompleted() tea.Cmd { - return func() tea.Msg { - result := domaintools.QueryResult{Rows: m.rows, RowsDropped: m.rowsDropped} - return msgs.ToolCompleted{ - TurnID: m.turnID, - ToolUseID: m.toolID, - Result: domaintools.Result{Content: result.ToMap()}, - Error: m.err, - } - } -} - -// Name returns the tool's display name. -func (m *Model) Name() string { - return "Query" -} - -// ToolID returns the tool's ID. -func (m *Model) ToolID() string { - return m.toolID -} - -// State returns the tool's current state. -func (m *Model) State() tools.State { - return m.state -} - -// Err returns any error from execution. -func (m *Model) Err() error { - return m.err -} diff --git a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/query/query_test.go b/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/query/query_test.go deleted file mode 100644 index 85b2bd54..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/query/query_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package query - -import ( - "fmt" - "testing" - "time" - - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools" - domaintools "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/teatest" -) - -func TestViewClipsToWidth(t *testing.T) { - t.Parallel() - theme := styles.NewTheme(true) - scope := logtest.NewScope(t) - - rows := wideRows() - - m := &Model{ - theme: theme, - scope: scope, - rows: rows, - width: 80, - } - - output := m.View() - teatest.AssertMaxWidth(t, 80, output) -} - -func TestViewClipsToWidth_NarrowTerminal(t *testing.T) { - t.Parallel() - theme := styles.NewTheme(true) - scope := logtest.NewScope(t) - - rows := []map[string]any{ - {"name": "accounting", "status": "READY", "count": 42}, - {"name": "checkout", "status": "READY", "count": 11}, - } - - for _, width := range []int{40, 60, 80, 120} { - t.Run(fmt.Sprintf("width_%d", width), func(t *testing.T) { - m := &Model{ - theme: theme, - scope: scope, - rows: rows, - width: width, - } - - output := m.View() - teatest.AssertMaxWidth(t, width, output) - }) - } -} - -func TestViewWidthZero(t *testing.T) { - t.Parallel() - theme := styles.NewTheme(true) - scope := logtest.NewScope(t) - - rows := []map[string]any{ - {"name": "test", "value": 123}, - } - - m := &Model{ - theme: theme, - scope: scope, - rows: rows, - width: 0, - } - - output := m.View() - t.Logf("width=0 output:\n%s", output) - - // With width 0, no clipping happens — just make sure it doesn't panic - if output == "" { - t.Error("expected non-empty output even with width=0") - } -} - -func TestUpdate_IgnoresExecutionCompletionFromDifferentToolInstance(t *testing.T) { - t.Parallel() - theme := styles.NewTheme(true) - scope := logtest.NewScope(t) - - m := New(theme, 0, "turn-1", "tool-1", 80, nil, scope) - cmd := m.Update(queryExecutionCompletedMsg{ - toolID: "tool-2", - result: domaintools.QueryResult{Rows: []map[string]any{{"name": "wrong"}}}, - duration: 100 * time.Millisecond, - }) - if cmd != nil { - t.Fatal("expected nil cmd for foreign completion") - } - if m.state != tools.StateAccumulating { - t.Fatalf("state = %d, want StateAccumulating", m.state) - } - if len(m.rows) != 0 { - t.Fatalf("rows = %d, want 0", len(m.rows)) - } -} - -func wideRows() []map[string]any { - return []map[string]any{ - {"name": "accounting", "log_analyzing_count": 0, "log_discovering_count": 0, "log_error": nil, "log_event_count": 6, "log_percent_complete": 96.74829044065488, "log_saved_count": 0, "log_status": "READY", "log_valuable_count": 0, "log_waste_count": 6}, - {"name": "ad", "log_analyzing_count": 0, "log_discovering_count": 0, "log_error": nil, "log_event_count": 5, "log_percent_complete": 95.47126675672867, "log_saved_count": 0, "log_status": "READY", "log_valuable_count": 0, "log_waste_count": 5}, - {"name": "cart", "log_analyzing_count": 0, "log_discovering_count": 0, "log_error": nil, "log_event_count": 11, "log_percent_complete": 100, "log_saved_count": 0, "log_status": "READY", "log_valuable_count": 0, "log_waste_count": 11}, - {"name": "checkout", "log_analyzing_count": 0, "log_discovering_count": 0, "log_error": nil, "log_event_count": 41, "log_percent_complete": 100, "log_saved_count": 0, "log_status": "READY", "log_valuable_count": 0, "log_waste_count": 41}, - {"name": "currency", "log_analyzing_count": 0, "log_discovering_count": 0, "log_error": nil, "log_event_count": 1, "log_percent_complete": 95.20486903597435, "log_saved_count": 0, "log_status": "READY", "log_valuable_count": 0, "log_waste_count": 1}, - {"name": "email", "log_analyzing_count": 0, "log_discovering_count": 0, "log_error": nil, "log_event_count": 2, "log_percent_complete": 100, "log_saved_count": 0, "log_status": "READY", "log_valuable_count": 0, "log_waste_count": 2}, - } -} diff --git a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/show/show.go b/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/show/show.go deleted file mode 100644 index 84038675..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/show/show.go +++ /dev/null @@ -1,224 +0,0 @@ -// Package show renders entity cards inside tool chrome. -// The parent tools.Model handles icon, name, collapse/expand, and status. -// Entity rendering is dispatched by type: policies → policycard, etc. -package show - -import ( - "encoding/json" - "fmt" - - tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools" - chattools "github.com/usetero/cli/internal/app/chattools" - "github.com/usetero/cli/internal/domain" - domaintools "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/format" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/components/policycard" -) - -// Model handles show tool execution and renders entity content. -// Chrome (icon, name, expand/collapse) is handled by the parent tools.Model. -type Model struct { - theme styles.Theme - scope log.Scope - index int - turnID domain.MessageID - toolID string - state tools.State - executor *chattools.ShowTool - width int - - // Input accumulation - input string - - // Parsed input - entity domaintools.EntityType - - // Results - result domaintools.ShowResult - err error - policy *domain.Policy - - // Entity-specific child components (created on execute based on entity type) - card *policycard.Model -} - -type showExecutionCompletedMsg struct { - toolID string - result domaintools.ShowResult - err error -} - -// New creates a new show tool model. -func New(theme styles.Theme, index int, turnID domain.MessageID, toolID string, width int, executor *chattools.ShowTool, scope log.Scope) *Model { - return &Model{ - theme: theme, - scope: scope.Child("show"), - index: index, - turnID: turnID, - toolID: toolID, - state: tools.StateAccumulating, - executor: executor, - width: width, - } -} - -// AutoExpand implements tools.AutoExpander — show results are always expanded. -func (m *Model) AutoExpand() bool { return true } - -// Update handles messages. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - switch msg := msg.(type) { - case msgs.AssistantContentUpdated: - return m.handleContent(msg.Message.Content) - case msgs.StreamCompleted: - return m.handleContent(msg.Message.Content) - case showExecutionCompletedMsg: - if msg.toolID != m.toolID { - return nil - } - if msg.err != nil { - m.err = msg.err - m.state = tools.StateComplete - m.scope.Error("show failed", "error", msg.err) - return m.fireCompleted() - } - - m.result = msg.result - m.state = tools.StateComplete - - // Create entity-specific child components based on result type. - if msg.result.Entity == domaintools.EntityPolicy && msg.result.Data != nil { - if p, ok := msg.result.Data["policy"].(*domain.Policy); ok { - m.policy = p - m.card = policycard.New(m.theme) - m.card.SetPolicy(p) - m.card.SetWidth(m.width) - } - } - - m.scope.Info("show completed", "entity", string(msg.result.Entity), "id", msg.result.ID) - return m.fireCompleted() - } - return nil -} - -func (m *Model) handleContent(content []domain.Block) tea.Cmd { - if m.state != tools.StateAccumulating { - return nil - } - for _, b := range content { - if b.Index == m.index && b.Type == domain.BlockTypeToolUse && b.ToolUse != nil { - m.input = string(b.ToolUse.Input) - if b.ToolUse.InputComplete { - return m.execute() - } - return nil - } - } - return nil -} - -func (m *Model) execute() tea.Cmd { - m.state = tools.StateExecuting - - var in domaintools.ShowInput - if err := json.Unmarshal([]byte(m.input), &in); err == nil { - m.entity = in.Entity - } - - m.scope.Info("executing show", "entity", string(m.entity)) - - if m.executor == nil { - m.err = fmt.Errorf("no executor") - m.state = tools.StateComplete - return m.fireCompleted() - } - input := append([]byte(nil), []byte(m.input)...) - executor := m.executor - return func() tea.Msg { - result, err := executor.Execute(json.RawMessage(input)) - return showExecutionCompletedMsg{toolID: m.toolID, result: result, err: err} - } -} - -func (m *Model) fireCompleted() tea.Cmd { - return func() tea.Msg { - return msgs.ToolCompleted{ - TurnID: m.turnID, - ToolUseID: m.toolID, - Result: domaintools.Result{Content: m.result.ToMap()}, - Error: m.err, - } - } -} - -// ─── tools.Child interface ────────────────────────────────────────────────── - -// Name returns the display name for the chrome header. -func (m *Model) Name() string { - if m.policy != nil { - categoryName := m.policy.CategoryDisplayName - if categoryName == "" { - categoryName = format.TitleCase(string(m.policy.Category)) - } - if categoryName != "" { - return "Policy · pol-" + m.result.IDShort + " · " + categoryName - } - } - return "Policy" -} - -// Status returns the message shown while executing. -func (m *Model) Status() string { - return "Fetching policy" -} - -// Result returns the message shown when complete. -func (m *Model) Result() string { - if m.err != nil { - return "Failed" - } - if m.policy != nil { - if m.policy.ServiceName != "" && m.policy.LogEventName != "" { - return m.policy.ServiceName + " / " + m.policy.LogEventName - } - if m.policy.ServiceName != "" { - return m.policy.ServiceName - } - } - return "" -} - -// View renders the policy body content (shown when expanded). -func (m *Model) View() string { - if m.policy == nil { - return "" - } - switch m.result.Entity { - case domaintools.EntityPolicy: - return m.card.View() - default: - return "" - } -} - -// SetWidth sets the available width for content rendering. -func (m *Model) SetWidth(width int) { - m.width = width - if m.card != nil { - m.card.SetWidth(width) - } -} - -// State returns the current execution state. -func (m *Model) State() tools.State { return m.state } - -// ToolID returns the tool use ID. -func (m *Model) ToolID() string { return m.toolID } - -// Err returns any execution error. -func (m *Model) Err() error { return m.err } diff --git a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/show/show_test.go b/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/show/show_test.go deleted file mode 100644 index 5eed4296..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/show/show_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package show - -import ( - "encoding/json" - "errors" - "testing" - - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools" - chattools "github.com/usetero/cli/internal/app/chattools" - "github.com/usetero/cli/internal/domain" - domaintools "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/styles" -) - -func TestUpdate(t *testing.T) { - t.Parallel() - - t.Run("executes on InputComplete and emits ToolCompleted", func(t *testing.T) { - t.Parallel() - - m := New(styles.NewTheme(true), 0, "turn-1", "tool-1", 80, nil, logtest.NewScope(t)) - - cmd := m.Update(msgs.StreamCompleted{ - Message: domain.Message{ - Content: []domain.Block{ - {Index: 0, Type: domain.BlockTypeToolUse, ToolUse: &domain.ToolUse{ - ID: "tool-1", - Input: json.RawMessage(`{"entity":"policy","id":"p-1"}`), - InputComplete: true, - }}, - }, - }, - }) - - if m.state != 2 { // tools.StateComplete - t.Fatalf("expected complete state, got %d", m.state) - } - - msg := cmd() - completed, ok := msg.(msgs.ToolCompleted) - if !ok { - t.Fatalf("expected ToolCompleted, got %T", msg) - } - if completed.TurnID != "turn-1" { - t.Fatalf("TurnID = %q, want turn-1", completed.TurnID) - } - if completed.ToolUseID != "tool-1" { - t.Fatalf("ToolUseID = %q, want tool-1", completed.ToolUseID) - } - if completed.Error == nil { - t.Fatal("expected error (no executor)") - } - }) - - t.Run("does not execute when index mismatches", func(t *testing.T) { - t.Parallel() - m := New(styles.NewTheme(true), 0, "turn-1", "tool-1", 80, nil, logtest.NewScope(t)) - cmd := m.Update(msgs.AssistantContentUpdated{ - Message: domain.Message{ - Content: []domain.Block{ - {Index: 1, Type: domain.BlockTypeToolUse, ToolUse: &domain.ToolUse{ - ID: "tool-1", - Input: json.RawMessage(`{"entity":"policy","id":"p-1"}`), - InputComplete: true, - }}, - }, - }, - }) - if cmd != nil { - t.Fatal("expected nil cmd for mismatched index") - } - }) -} - -func TestResultAndName(t *testing.T) { - t.Parallel() - - m := New(styles.NewTheme(true), 0, "turn-1", "tool-1", 80, nil, logtest.NewScope(t)) - - m.err = errors.New("fail") - if got := m.Result(); got != "Failed" { - t.Fatalf("Result() = %q, want Failed", got) - } - - m.err = nil - m.result = domaintools.ShowResult{IDShort: "abcd"} - m.policy = &domain.Policy{ - Category: domain.CategoryPIILeakage, - CategoryDisplayName: "PII", - ServiceName: "api", - LogEventName: "request", - } - if got := m.Name(); got == "" { - t.Fatal("Name() should not be empty") - } - if got := m.Result(); got != "api / request" { - t.Fatalf("Result() = %q, want %q", got, "api / request") - } - - m.policy.LogEventName = "" - if got := m.Result(); got != "api" { - t.Fatalf("Result() = %q, want %q", got, "api") - } -} - -func TestAutoExpand(t *testing.T) { - t.Parallel() - m := New(styles.NewTheme(true), 0, "turn-1", "tool-1", 80, &chattools.ShowTool{}, logtest.NewScope(t)) - if !m.AutoExpand() { - t.Fatal("AutoExpand() = false, want true") - } -} - -func TestUpdate_IgnoresExecutionCompletionFromDifferentToolInstance(t *testing.T) { - t.Parallel() - - m := New(styles.NewTheme(true), 0, "turn-1", "tool-1", 80, nil, logtest.NewScope(t)) - cmd := m.Update(showExecutionCompletedMsg{ - toolID: "tool-2", - result: domaintools.ShowResult{Entity: domaintools.EntityPolicy, ID: "p-2"}, - }) - if cmd != nil { - t.Fatal("expected nil cmd for foreign completion") - } - if m.state != tools.StateAccumulating { - t.Fatalf("state = %d, want StateAccumulating", m.state) - } - if m.result.ID != "" { - t.Fatalf("unexpected result id %q", m.result.ID) - } -} diff --git a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/tool.go b/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/tool.go deleted file mode 100644 index 1532bad9..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/tool.go +++ /dev/null @@ -1,336 +0,0 @@ -package tools - -import ( - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/components/thinking" -) - -// State represents the current state of tool execution. -type State int - -const ( - StateAccumulating State = iota - StateExecuting - StateComplete -) - -// Status represents the outcome of a completed tool. -type Status int - -const ( - StatusPending Status = iota - StatusRunning - StatusSuccess - StatusError - StatusCancelled -) - -// Icons for different statuses. -const ( - IconPending = "●" - IconSuccess = "✓" - IconError = "×" - IconCancelled = "○" -) - -// Body padding: left=bodyPaddingLeft, right=bodyPaddingRight, top/bottom=1. -const ( - bodyPaddingLeft = 2 - bodyPaddingRight = 1 - bodyPaddingH = bodyPaddingLeft + bodyPaddingRight -) - -// Child is the interface that specific tool models must implement. -type Child interface { - Update(tea.Msg) tea.Cmd - View() string - SetWidth(int) - Name() string - Status() string // Message shown while executing (e.g., "Checking service status") - Result() string // Message shown when complete (e.g., "Found 14 services") - State() State - ToolID() string - Err() error -} - -// AutoExpander is an optional interface for tools that should show their body -// immediately on completion instead of starting collapsed. -type AutoExpander interface { - AutoExpand() bool -} - -// Model is the chrome wrapper for tool blocks. -// It handles icon rendering, name display, animation, and content indentation. -// The actual tool logic lives in the embedded child. -// It is a fixed-height component. Implements block.Block. -type Model struct { - theme styles.Theme - index int - turnID domain.MessageID - toolID string - width int - status Status - - child Child - thinking *thinking.Model - focused bool - expanded bool // whether the body content is visible (only applies to completed tools) - autoExpanded bool // true once auto-expand has fired, prevents re-expanding on every tick -} - -// New creates a new tool model wrapping the given child. -func New(theme styles.Theme, index int, turnID domain.MessageID, toolID string, width int, child Child) *Model { - // Child gets width minus outer padding and body padding - child.SetWidth(width - block.PaddingX*2 - bodyPaddingH) - return &Model{ - theme: theme, - index: index, - turnID: turnID, - toolID: toolID, - width: width, - status: StatusPending, - child: child, - thinking: thinking.New(theme, thinking.Settings{Size: 10}), - expanded: false, - } -} - -// Init starts the thinking animation. -func (m *Model) Init() tea.Cmd { - return m.thinking.Init() -} - -// Cancel stops the tool's thinking animation and marks it cancelled. -func (m *Model) Cancel() { - m.status = StatusCancelled -} - -// Update handles messages - updates status and forwards to child. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - var cmds []tea.Cmd - - // Only tick the thinking animation while still pending/running - if m.status == StatusPending || m.status == StatusRunning { - cmds = append(cmds, m.thinking.Update(msg)) - } - - // Update status based on child state changes - m.updateStatus() - - // Listen for completion messages to update status - if completed, ok := msg.(msgs.ToolCompleted); ok { - if completed.TurnID == m.turnID && completed.ToolUseID == m.toolID { - if completed.Error != nil { - m.status = StatusError - } else { - m.status = StatusSuccess - } - } - } - - // Forward to child - cmds = append(cmds, m.child.Update(msg)) - - return tea.Batch(cmds...) -} - -// updateStatus syncs status with child state. -func (m *Model) updateStatus() { - switch m.child.State() { - case StateAccumulating: - m.status = StatusPending - case StateExecuting: - m.status = StatusRunning - // Update thinking label to show status message with reveal animation - m.thinking.SetLabel(m.child.Status()) - case StateComplete: - if m.child.Err() != nil { - m.status = StatusError - } else { - m.status = StatusSuccess - if !m.autoExpanded { - if ae, ok := m.child.(AutoExpander); ok && ae.AutoExpand() { - m.expanded = true - m.autoExpanded = true - } - } - } - } -} - -// View renders the tool with chrome. -func (m *Model) View() string { - colors := m.theme - icon := m.renderIcon() - nameStyle := lipgloss.NewStyle().Foreground(colors.Accent).Background(colors.Bg) - mutedStyle := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) - sp := mutedStyle.Render(" ") - - var content string - - switch m.status { - case StatusPending: - // ● Query ████████████ - content = icon + sp + nameStyle.Render(m.child.Name()) + sp + m.thinking.View() - - case StatusRunning: - // ● Query · Checking service status - // - // ████████████ - header := icon + sp + nameStyle.Render(m.child.Name()) - if status := m.child.Status(); status != "" { - header = icon + sp + nameStyle.Render(m.child.Name()) + sp + mutedStyle.Render("· "+status) - } - body := lipgloss.NewStyle(). - PaddingLeft(bodyPaddingLeft). - Render(m.thinking.View()) - content = header + "\n\n" + body - - case StatusSuccess: - // ✓ ▶ Query · Found 14 services (collapsed) - // ✓ ▼ Query · Found 14 services (expanded) - // - // - result := m.child.Result() - chevron := mutedStyle.Render("▶") - if m.expanded { - chevron = mutedStyle.Render("▼") - } - header := icon + sp + chevron + sp + nameStyle.Render(m.child.Name()) - if result != "" { - header = icon + sp + chevron + sp + nameStyle.Render(m.child.Name()) + sp + mutedStyle.Render("· "+result) - } - if !m.expanded || m.child.View() == "" { - content = header - } else { - body := m.bodyStyle().Render(m.child.View()) - content = header + "\n\n" + body - } - - case StatusError: - // × ▶ Query · Query rejected (collapsed) - // × ▼ Query · Query rejected (expanded) - // - // ERROR full table scan detected on a JOINed table... - result := m.child.Result() - chevron := mutedStyle.Render("▶") - if m.expanded { - chevron = mutedStyle.Render("▼") - } - header := icon + sp + chevron + sp + nameStyle.Render(m.child.Name()) - if result != "" { - header = icon + sp + chevron + sp + nameStyle.Render(m.child.Name()) + sp + mutedStyle.Render("· "+result) - } - if !m.expanded { - content = header - } else { - errTag := lipgloss.NewStyle(). - Background(colors.Error). - Foreground(colors.OnError). - Padding(0, 1). - Render("ERROR") - errMsg := m.child.Err().Error() - body := m.bodyStyle().Render(errTag + sp + mutedStyle.Render(errMsg)) - content = header + "\n\n" + body - } - - case StatusCancelled: - // ○ Query - content = icon + sp + nameStyle.Render(m.child.Name()) - } - - return lipgloss.NewStyle(). - Background(colors.Bg). - Padding(block.PaddingY, block.PaddingX). - Width(m.width). - Render(content) -} - -// Height returns the number of lines this block renders. -func (m *Model) Height() int { - return lipgloss.Height(m.View()) -} - -// bodyStyle returns the style for the tool's content area. -func (m *Model) bodyStyle() lipgloss.Style { - return lipgloss.NewStyle(). - Padding(0, bodyPaddingRight, 0, bodyPaddingLeft). - Background(m.theme.Bg) -} - -// renderIcon returns the colored status icon. -func (m *Model) renderIcon() string { - colors := m.theme - bg := colors.Bg - - switch m.status { - case StatusSuccess: - return lipgloss.NewStyle().Foreground(colors.Success).Background(bg).Render(IconSuccess) - case StatusError: - return lipgloss.NewStyle().Foreground(colors.Error).Background(bg).Render(IconError) - case StatusCancelled: - return lipgloss.NewStyle().Foreground(colors.TextMuted).Background(bg).Render(IconCancelled) - default: - return lipgloss.NewStyle().Foreground(colors.TextMuted).Background(bg).Render(IconPending) - } -} - -// ForceStatus sets the status directly (for testing). -func (m *Model) ForceStatus(status Status) { - m.status = status -} - -// SetWidth sets the width. -func (m *Model) SetWidth(width int) { - m.width = width - m.child.SetWidth(width - block.PaddingX*2 - bodyPaddingH) -} - -// Index returns the block index. -func (m *Model) Index() int { - return m.index -} - -// ToolID returns the tool ID. -func (m *Model) ToolID() string { - return m.toolID -} - -// Name returns the tool name. -func (m *Model) Name() string { - return m.child.Name() -} - -// State returns the child's state. -func (m *Model) State() State { - return m.child.State() -} - -// Kind implements block.Block. -func (m *Model) Kind() block.Kind { - return block.KindTool -} - -// SetFocused implements block.Block. -func (m *Model) SetFocused(focused bool) { - m.focused = focused -} - -// Focused implements block.Block. -func (m *Model) Focused() bool { - return m.focused -} - -// Toggle implements block.Toggleable — expands/collapses the tool body. -// Only toggles when clicking the header line (y == PaddingY). -func (m *Model) Toggle(y int) { - if m.expanded && y != block.PaddingY { - return - } - m.expanded = !m.expanded -} diff --git a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/tool_test.go b/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/tool_test.go deleted file mode 100644 index f3c08c85..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/tool_test.go +++ /dev/null @@ -1,236 +0,0 @@ -package tools - -import ( - "errors" - "strings" - "testing" - - tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/styles" -) - -// stubChild implements Child with fixed return values. -type stubChild struct { - name string - status string - result string - state State - toolID string - err error - width int - view string -} - -func (s *stubChild) Update(tea.Msg) tea.Cmd { return nil } -func (s *stubChild) View() string { return s.view } -func (s *stubChild) SetWidth(w int) { s.width = w } -func (s *stubChild) Name() string { return s.name } -func (s *stubChild) Status() string { return s.status } -func (s *stubChild) Result() string { return s.result } -func (s *stubChild) State() State { return s.state } -func (s *stubChild) ToolID() string { return s.toolID } -func (s *stubChild) Err() error { return s.err } - -func newTestTool(t *testing.T, child *stubChild) *Model { - t.Helper() - theme := styles.NewTheme(true) - return New(theme, 0, "turn-1", "tool-1", 80, child) -} - -func TestStatusRendering(t *testing.T) { - t.Parallel() - - t.Run("pending shows icon and name", func(t *testing.T) { - t.Parallel() - child := &stubChild{name: "Query", state: StateAccumulating} - m := newTestTool(t, child) - m.updateStatus() - view := m.View() - - if !strings.Contains(view, IconPending) { - t.Error("expected pending icon") - } - if !strings.Contains(view, "Query") { - t.Error("expected tool name") - } - }) - - t.Run("running shows status message", func(t *testing.T) { - t.Parallel() - child := &stubChild{name: "Query", state: StateExecuting, status: "Checking services"} - m := newTestTool(t, child) - m.updateStatus() - view := m.View() - - if !strings.Contains(view, IconPending) { - t.Error("expected pending icon for running state") - } - if !strings.Contains(view, "Checking services") { - t.Error("expected status message in view") - } - }) - - t.Run("success shows result with chevron", func(t *testing.T) { - t.Parallel() - child := &stubChild{name: "Query", state: StateComplete, result: "Found 14 services"} - m := newTestTool(t, child) - m.updateStatus() - view := m.View() - - if !strings.Contains(view, IconSuccess) { - t.Error("expected success icon") - } - if !strings.Contains(view, "Found 14 services") { - t.Error("expected result message in view") - } - if !strings.Contains(view, "▶") { - t.Error("expected collapsed chevron when default collapsed") - } - }) - - t.Run("error collapsed shows icon and result", func(t *testing.T) { - t.Parallel() - child := &stubChild{ - name: "Query", - result: "Query failed", - state: StateComplete, - err: errors.New("connection timeout"), - } - m := newTestTool(t, child) - m.updateStatus() - view := m.View() - - if !strings.Contains(view, IconError) { - t.Error("expected error icon") - } - if !strings.Contains(view, "Query failed") { - t.Error("expected result in collapsed error view") - } - if strings.Contains(view, "ERROR") { - t.Error("ERROR tag should be hidden when collapsed") - } - }) - - t.Run("error expanded shows error tag and message", func(t *testing.T) { - t.Parallel() - child := &stubChild{ - name: "Query", - result: "Query failed", - state: StateComplete, - err: errors.New("connection timeout"), - } - m := newTestTool(t, child) - m.updateStatus() - m.Toggle(block.PaddingY) - view := m.View() - - if !strings.Contains(view, "ERROR") { - t.Error("expected ERROR tag in expanded view") - } - if !strings.Contains(view, "connection timeout") { - t.Error("expected error message in expanded view") - } - }) -} - -func TestCancel(t *testing.T) { - t.Parallel() - - t.Run("sets status to cancelled", func(t *testing.T) { - t.Parallel() - child := &stubChild{name: "Query", state: StateAccumulating} - m := newTestTool(t, child) - - m.Cancel() - - if m.status != StatusCancelled { - t.Errorf("expected StatusCancelled, got %d", m.status) - } - }) - - t.Run("renders name without spinner", func(t *testing.T) { - t.Parallel() - child := &stubChild{name: "Query", state: StateExecuting, status: "Checking"} - m := newTestTool(t, child) - m.updateStatus() - - m.Cancel() - view := m.View() - - if !strings.Contains(view, IconCancelled) { - t.Error("expected cancelled icon") - } - if !strings.Contains(view, "Query") { - t.Error("expected tool name") - } - // Should be 3 lines: top padding + content + bottom padding (no body, no spinner) - if lines := strings.Count(view, "\n"); lines != 2 { - t.Errorf("expected 3-line render (padding + content + padding) for cancelled tool, got %d lines", lines+1) - } - }) -} - -func TestToggle(t *testing.T) { - t.Parallel() - - child := &stubChild{ - name: "Query", - state: StateComplete, - result: "Found items", - view: "detailed output here", - } - m := newTestTool(t, child) - m.updateStatus() - - // Default is collapsed - collapsedView := m.View() - if strings.Contains(collapsedView, "detailed output here") { - t.Error("expected no body content when collapsed by default") - } - if !strings.Contains(collapsedView, "▶") { - t.Error("expected collapsed chevron") - } - - // Toggle to expand - m.Toggle(block.PaddingY) - expandedView := m.View() - if !strings.Contains(expandedView, "detailed output here") { - t.Error("expected body content when expanded") - } - if !strings.Contains(expandedView, "▼") { - t.Error("expected expanded chevron") - } - - // Expanded should be taller - expandedLines := strings.Count(expandedView, "\n") - collapsedLines := strings.Count(collapsedView, "\n") - if collapsedLines >= expandedLines { - t.Errorf("collapsed (%d lines) should be shorter than expanded (%d lines)", collapsedLines, expandedLines) - } -} - -func TestKind(t *testing.T) { - t.Parallel() - child := &stubChild{name: "Query", state: StateAccumulating} - m := newTestTool(t, child) - if m.Kind() != block.KindTool { - t.Errorf("expected KindTool, got %d", m.Kind()) - } -} - -func TestUpdate_IgnoresToolCompletedFromDifferentTurn(t *testing.T) { - t.Parallel() - child := &stubChild{name: "Query", state: StateAccumulating} - m := newTestTool(t, child) - - m.Update(msgs.ToolCompleted{ - TurnID: "turn-2", - ToolUseID: "tool-1", - }) - - if m.status != StatusPending { - t.Fatalf("status = %d, want StatusPending", m.status) - } -} diff --git a/internal/app/chat/messagelist/round/turn/reducer.go b/internal/app/chat/messagelist/round/turn/reducer.go deleted file mode 100644 index b93776ac..00000000 --- a/internal/app/chat/messagelist/round/turn/reducer.go +++ /dev/null @@ -1,28 +0,0 @@ -package turn - -// reduceOnStreamDone returns the turn state after the stream finishes. -func reduceOnStreamDone(stopReason string, collected, pending int) State { - if stopReason != "tool_use" { - return StateComplete - } - if collected >= pending { - return StateComplete - } - return StateAwaitingToolResults -} - -// reduceOnToolCompleted returns the turn state after collecting one tool result. -func reduceOnToolCompleted(current State, collected, pending int) State { - if current != StateAwaitingToolResults { - return current - } - if collected >= pending { - return StateComplete - } - return StateAwaitingToolResults -} - -// shouldFireToolResults returns true when results can be emitted to the round. -func shouldFireToolResults(state State, persisted bool, collected, pending int) bool { - return state == StateComplete && persisted && pending > 0 && collected >= pending -} diff --git a/internal/app/chat/messagelist/round/turn/tool_results_tracker.go b/internal/app/chat/messagelist/round/turn/tool_results_tracker.go deleted file mode 100644 index d47c9f98..00000000 --- a/internal/app/chat/messagelist/round/turn/tool_results_tracker.go +++ /dev/null @@ -1,66 +0,0 @@ -package turn - -import ( - tea "charm.land/bubbletea/v2" - - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/domain/tools" -) - -type toolResultTracker struct { - pendingTools int - pendingToolIDs map[string]bool - results []tools.Result - persisted bool - fired bool -} - -func (t *toolResultTracker) accepts(toolUseID string) bool { - // Before pendingToolIDs is set (during streaming), accept all tools. - if t.pendingToolIDs == nil { - return true - } - return t.pendingToolIDs[toolUseID] -} - -func (t *toolResultTracker) collect(result tools.Result) { - t.results = append(t.results, result) -} - -func (t *toolResultTracker) collectedCount() int { - return len(t.results) -} - -func (t *toolResultTracker) pendingCount() int { - return t.pendingTools -} - -func (t *toolResultTracker) setPendingFromContent(content []domain.Block) { - t.pendingTools, t.pendingToolIDs = collectToolUseIDs(content) -} - -func (t *toolResultTracker) markPersisted() { - t.persisted = true -} - -func (t *toolResultTracker) shouldFire(state State) bool { - return shouldFireToolResults(state, t.persisted, len(t.results), t.pendingTools) -} - -func (t *toolResultTracker) fire(turnID domain.MessageID, scopeReporter func(msg string)) tea.Cmd { - if t.fired { - if scopeReporter != nil { - scopeReporter("fireToolResults called twice, ignoring") - } - return nil - } - t.fired = true - results := t.results - return func() tea.Msg { - return msgs.ToolResultsReady{ - TurnID: turnID, - Results: results, - } - } -} diff --git a/internal/app/chat/messagelist/round/turn/turn.go b/internal/app/chat/messagelist/round/turn/turn.go deleted file mode 100644 index 5956f9aa..00000000 --- a/internal/app/chat/messagelist/round/turn/turn.go +++ /dev/null @@ -1,124 +0,0 @@ -package turn - -import ( - "context" - "errors" - "time" - - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/user" - "github.com/usetero/cli/internal/app/chat/usecase" - chattools "github.com/usetero/cli/internal/app/chattools" - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/styles" -) - -// State represents the current state of a turn. -type State int - -const ( - StateIdle State = iota - StateStreaming - StateAwaitingToolResults - StateComplete -) - -const dbOpTimeout = 2 * time.Second - -// Model represents a single user→assistant exchange. -// It is a fixed-height component - height is determined by content. -type Model struct { - theme styles.Theme - scope log.Scope - - conversationID domain.ConversationID - accountID domain.AccountID - - userMessage *user.Model - assistantMessage *assistant.Model - - state State - width int - stream *streamState - - // Tool result lifecycle (pending IDs/results/persisted/fired gate). - toolTracker toolResultTracker - - // Protocol guard telemetry (incremented on dropped/malformed lifecycle events). - protocolViolationCount int - - streamRunner usecase.StreamRunner - streamErrorMapper usecase.StreamErrorMapper - assistantPersister usecase.AssistantPersister - effectCtx context.Context - toolRegistry *chattools.Registry -} - -// streamState holds the channel for receiving stream updates. -type streamState struct { - updates chan streamUpdate - cancel context.CancelCauseFunc - done bool -} - -// streamUpdate is sent through the channel as the stream progresses. -type streamUpdate struct { - message *domain.Message - status corechat.StreamStatus - abort string - result *corechat.StreamResult // final result, only set on done - err error - done bool -} - -var errUserCancelled = errors.New("user_cancelled") - -// turnStreamUpdateMsg is the internal message for stream handling. -type turnStreamUpdateMsg struct { - turnID domain.MessageID - update streamUpdate -} - -// assistantPersisted is fired after the assistant message is written to the DB. -// It gates fireToolResults to prevent racing with persistence. -type assistantPersisted struct { - turnID domain.MessageID - messageID domain.MessageID -} - -// New creates a new turn from a user submission. -func New( - theme styles.Theme, - conversationID domain.ConversationID, - accountID domain.AccountID, - userMessageID domain.MessageID, - input msgs.UserSubmittedInput, - width int, - streamRunner usecase.StreamRunner, - streamErrorMapper usecase.StreamErrorMapper, - assistantPersister usecase.AssistantPersister, - effectCtx context.Context, - toolRegistry *chattools.Registry, - scope log.Scope, -) *Model { - scope = scope.Child("turn") - return &Model{ - theme: theme, - scope: scope, - conversationID: conversationID, - accountID: accountID, - userMessage: user.New(theme.WithBg(theme.BgElevated), userMessageID, input, width-block.BorderWidth), - assistantMessage: assistant.New(theme, userMessageID, "", width, toolRegistry, scope), - state: StateIdle, - width: width, - streamRunner: streamRunner, - streamErrorMapper: streamErrorMapper, - assistantPersister: assistantPersister, - effectCtx: effectCtx, - toolRegistry: toolRegistry, - } -} diff --git a/internal/app/chat/messagelist/round/turn/turn_effects.go b/internal/app/chat/messagelist/round/turn/turn_effects.go deleted file mode 100644 index 5927c7df..00000000 --- a/internal/app/chat/messagelist/round/turn/turn_effects.go +++ /dev/null @@ -1,65 +0,0 @@ -package turn - -import ( - tea "charm.land/bubbletea/v2" - "github.com/usetero/cli/internal/app/chat/usecase" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/sqlite" -) - -// persistAssistantMessage saves the assistant message to the database. -func (m *Model) persistAssistantMessage(msg *domain.Message) tea.Cmd { - if msg == nil { - return nil - } - - return func() tea.Msg { - ctx, cancel := sqlite.WithTimeout(m.effectCtx, dbOpTimeout) - defer cancel() - - msgID, err := m.assistantPersister.PersistAssistant(ctx, usecase.PersistAssistantInput{ - AccountID: m.accountID, - ConversationID: m.conversationID, - Message: *msg, - }) - if err != nil { - m.scope.Error("failed to persist assistant message", "error", err) - return nil - } - - m.scope.Info("assistant message persisted", "message_id", msgID) - return assistantPersisted{ - turnID: m.userMessage.ID(), - messageID: msgID, - } - } -} - -// fireToolResults fires ToolResultsReady for the round to handle. -// Guarded by firedToolResults to ensure it can only fire once per turn. -func (m *Model) fireToolResults() tea.Cmd { - return m.toolTracker.fire(m.userMessage.ID(), func(message string) { - m.scope.Warn(message) - }) -} - -// collectToolUseIDs returns the count and set of tool_use IDs in content. -func collectToolUseIDs(content []domain.Block) (int, map[string]bool) { - ids := make(map[string]bool) - for _, b := range content { - if b.Type == domain.BlockTypeToolUse && b.ToolUse != nil && b.ToolUse.ID != "" { - ids[b.ToolUse.ID] = true - } - } - return len(ids), ids -} - -func (m *Model) reportProtocolViolation(reason string, kv ...any) { - m.protocolViolationCount++ - fields := []any{ - "reason", reason, - "count", m.protocolViolationCount, - } - fields = append(fields, kv...) - m.scope.Warn("protocol violation", fields...) -} diff --git a/internal/app/chat/messagelist/round/turn/turn_model.go b/internal/app/chat/messagelist/round/turn/turn_model.go deleted file mode 100644 index 43eed1f2..00000000 --- a/internal/app/chat/messagelist/round/turn/turn_model.go +++ /dev/null @@ -1,59 +0,0 @@ -package turn - -import ( - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/domain" -) - -// Blocks returns all visual blocks for the viewport. -// Includes user message (if visible) followed by assistant blocks. -func (m *Model) Blocks() []block.Block { - var result []block.Block - if m.userMessage.IsVisible() { - result = append(result, m.userMessage) - } - result = append(result, m.assistantMessage.Blocks()...) - return result -} - -// SetWidth sets the width. -func (m *Model) SetWidth(width int) { - m.width = width - // User block gets content width minus border+padding, same as assistant blocks. - // The border decoration is applied by renderBlock in the message list. - m.userMessage.SetWidth(width - block.BorderWidth) - m.assistantMessage.SetWidth(width) -} - -// State returns the turn's current state. -func (m *Model) State() State { - return m.state -} - -// UserMessageID returns the user message ID. -func (m *Model) UserMessageID() domain.MessageID { - return m.userMessage.ID() -} - -// UserInput returns the input that created this turn's user message. -func (m *Model) UserInput() msgs.UserSubmittedInput { - return m.userMessage.Input() -} - -// AssistantMessageID returns the persisted assistant message ID. -// Returns empty string if the assistant message was never persisted. -func (m *Model) AssistantMessageID() domain.MessageID { - return m.assistantMessage.ID() -} - -// Cancel stops the in-flight stream and marks the turn complete. -// The partial content remains rendered but nothing is persisted. -func (m *Model) Cancel() { - if m.stream != nil && !m.stream.done { - m.stream.cancel(errUserCancelled) - m.stream.done = true - } - m.assistantMessage.Cancel() - m.state = StateComplete -} diff --git a/internal/app/chat/messagelist/round/turn/turn_stream.go b/internal/app/chat/messagelist/round/turn/turn_stream.go deleted file mode 100644 index d179140c..00000000 --- a/internal/app/chat/messagelist/round/turn/turn_stream.go +++ /dev/null @@ -1,184 +0,0 @@ -package turn - -import ( - "context" - - tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/usecase" - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" -) - -// StartStream begins streaming the assistant response. -func (m *Model) StartStream(messages []domain.Message, chatContext []domain.ContextEntity) tea.Cmd { - m.scope.Debug("starting stream", "message_count", len(messages)) - m.state = StateStreaming - - ctx, cancel := context.WithCancelCause(context.Background()) - updates := make(chan streamUpdate, 10) - m.stream = &streamState{updates: updates, cancel: cancel} - - req := usecase.StreamRequest{ - ConversationID: m.conversationID, - Messages: messages, - ContextEntities: chatContext, - } - - runUpdates := m.streamRunner.Start(ctx, req) - go func() { - defer close(updates) - for u := range runUpdates { - updates <- streamUpdate{ - message: u.Message, - status: u.Status, - abort: u.AbortReason, - result: u.Result, - err: u.Err, - done: u.Done, - } - } - }() - - return m.nextStreamUpdate() -} - -// handleStreamUpdate processes a stream update and fires messages. -func (m *Model) handleStreamUpdate(update streamUpdate) tea.Cmd { - if update.err != nil { - // context.Canceled is expected when Cancel() was called — don't show an error. - if m.stream != nil && m.stream.done { - return nil - } - m.scope.Error("stream error", "class", usecase.ClassifyStreamError(m.streamErrorMapper, update.err), "error", update.err) - m.assistantMessage.Cancel() - m.state = StateComplete - turnID := m.userMessage.ID() - return func() tea.Msg { - return msgs.StreamFailed{TurnID: turnID, Err: update.err} - } - } - - if update.message == nil { - return m.nextStreamUpdate() - } - - // Set assistant message ID once we have it from the stream - if m.assistantMessage.ID() == "" && update.message.ID != "" { - m.assistantMessage.SetID(update.message.ID) - // Populate content immediately to avoid empty render before message round-trips - m.assistantMessage.SetContent(update.message.Content) - } - - if update.done { - if update.status == corechat.StreamStatusAborted { - reason := update.abort - if reason == "" { - reason = "context_canceled" - } - m.scope.Info("stream aborted", "reason", reason) - m.assistantMessage.Cancel() - m.state = StateComplete - - // User-cancelled stream: keep current behavior (do not persist). - if reason == errUserCancelled.Error() { - return nil - } - - msg := update.message - if msg == nil { - msg = &domain.Message{} - } - if msg.ID == "" { - msg.ID = domain.NewMessageID() - } - msg.StopReason = "aborted" - - turnID := m.userMessage.ID() - return tea.Batch( - func() tea.Msg { - return msgs.StreamCompleted{ - TurnID: turnID, - Message: *msg, - } - }, - m.persistAssistantMessage(msg), - ) - } - - if update.message.ID == "" { - update.message.ID = domain.NewMessageID() - } - m.scope.Info("stream completed", "stop_reason", update.message.StopReason) - - // Extract metadata from stream result if present - var title string - var contextWindow, inputTokens, outputTokens int - if update.result != nil && update.result.Metadata != nil { - title = update.result.Metadata.Title - contextWindow = update.result.Metadata.ContextWindow - inputTokens = update.result.Metadata.InputTokens - outputTokens = update.result.Metadata.OutputTokens - } - - if update.message.StopReason == "tool_use" { - m.toolTracker.setPendingFromContent(update.message.Content) - m.scope.Info("awaiting tool results", "pending", m.toolTracker.pendingCount(), "already_collected", m.toolTracker.collectedCount()) - m.state = reduceOnStreamDone(update.message.StopReason, m.toolTracker.collectedCount(), m.toolTracker.pendingCount()) - if m.state == StateComplete { - m.scope.Info("all tools already completed") - } - } else { - m.state = reduceOnStreamDone(update.message.StopReason, m.toolTracker.collectedCount(), m.toolTracker.pendingCount()) - } - - // Fire StreamCompleted and persist - turnID := m.userMessage.ID() - return tea.Batch( - func() tea.Msg { - return msgs.StreamCompleted{ - TurnID: turnID, - Message: *update.message, - Title: title, - ContextWindow: contextWindow, - InputTokens: inputTokens, - OutputTokens: outputTokens, - } - }, - m.persistAssistantMessage(update.message), - ) - } - - // Fire AssistantContentUpdated and continue - turnID := m.userMessage.ID() - return tea.Batch( - func() tea.Msg { - return msgs.AssistantContentUpdated{TurnID: turnID, Message: *update.message} - }, - m.nextStreamUpdate(), - ) -} - -// nextStreamUpdate returns a command that waits for the next stream update. -func (m *Model) nextStreamUpdate() tea.Cmd { - if m.stream == nil || m.stream.done { - return nil - } - - userMsgID := m.userMessage.ID() - updates := m.stream.updates - - return func() tea.Msg { - update, ok := <-updates - if !ok { - return turnStreamUpdateMsg{ - turnID: userMsgID, - update: streamUpdate{done: true}, - } - } - return turnStreamUpdateMsg{ - turnID: userMsgID, - update: update, - } - } -} diff --git a/internal/app/chat/messagelist/round/turn/turn_test.go b/internal/app/chat/messagelist/round/turn/turn_test.go deleted file mode 100644 index 31142d90..00000000 --- a/internal/app/chat/messagelist/round/turn/turn_test.go +++ /dev/null @@ -1,457 +0,0 @@ -package turn - -import ( - "context" - "errors" - "testing" - - msgs "github.com/usetero/cli/internal/app/chat/events" - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/styles" -) - -func newTestTurn(t *testing.T) *Model { - t.Helper() - theme := styles.NewTheme(true) - scope := logtest.NewScope(t) - - // Stream runner and persister are nil — these tests exercise the state machine, - // not persistence or streaming. The returned tea.Cmds are never executed. - return New(theme, "conv-1", "acct-1", "user-1", - msgs.UserSubmittedInput{Text: "hi"}, 80, nil, nil, nil, context.Background(), nil, scope) -} - -func TestHandleStreamUpdate(t *testing.T) { - t.Parallel() - - t.Run("end_turn completes", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateStreaming - m.assistantMessage.SetID("asst-1") // skip SetContent path - - m.handleStreamUpdate(streamUpdate{ - message: &domain.Message{ - ID: "asst-1", - StopReason: "end_turn", - }, - done: true, - }) - - if m.state != StateComplete { - t.Errorf("expected StateComplete, got %d", m.state) - } - }) - - t.Run("tool_use transitions to awaiting", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateStreaming - m.assistantMessage.SetID("asst-1") - - m.handleStreamUpdate(streamUpdate{ - message: &domain.Message{ - ID: "asst-1", - StopReason: "tool_use", - Content: []domain.Block{ - {Index: 1, Type: domain.BlockTypeToolUse, ToolUse: &domain.ToolUse{ID: "tool-1", Name: "query"}}, - }, - }, - done: true, - }) - - if m.state != StateAwaitingToolResults { - t.Errorf("expected StateAwaitingToolResults, got %d", m.state) - } - if m.toolTracker.pendingTools != 1 { - t.Errorf("expected 1 pending tool, got %d", m.toolTracker.pendingTools) - } - }) - - t.Run("tools already completed skips awaiting", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateStreaming - m.assistantMessage.SetID("asst-1") - // Tool completed during streaming, before stream finished - m.toolTracker.results = []tools.Result{{ToolUseID: "tool-1"}} - - m.handleStreamUpdate(streamUpdate{ - message: &domain.Message{ - ID: "asst-1", - StopReason: "tool_use", - Content: []domain.Block{ - {Index: 0, Type: domain.BlockTypeToolUse, ToolUse: &domain.ToolUse{ID: "tool-1", Name: "query"}}, - }, - }, - done: true, - }) - - if m.state != StateComplete { - t.Errorf("expected StateComplete (tools already done), got %d", m.state) - } - }) - - t.Run("stream error completes", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateStreaming - - m.handleStreamUpdate(streamUpdate{ - err: errors.New("connection failed"), - done: true, - }) - - if m.state != StateComplete { - t.Errorf("expected StateComplete on error, got %d", m.state) - } - }) - - t.Run("intermediate update stays streaming", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateStreaming - m.assistantMessage.SetID("asst-1") - - m.handleStreamUpdate(streamUpdate{ - message: &domain.Message{ - ID: "asst-1", - }, - done: false, - }) - - if m.state != StateStreaming { - t.Errorf("expected StateStreaming during intermediate update, got %d", m.state) - } - }) -} - -func TestCancel(t *testing.T) { - t.Parallel() - - t.Run("sets state to complete", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateStreaming - - m.Cancel() - - if m.state != StateComplete { - t.Errorf("expected StateComplete after Cancel, got %d", m.state) - } - }) - - t.Run("suppresses stream error after cancel", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateStreaming - m.stream = &streamState{ - updates: make(chan streamUpdate), - cancel: func(error) {}, - done: false, - } - - m.Cancel() - - // Simulate the error that arrives after context cancellation - cmd := m.handleStreamUpdate(streamUpdate{ - err: errors.New("context canceled"), - done: true, - }) - - // Should return nil (no error toast), not an error command - if cmd != nil { - t.Error("expected nil command after cancel, got non-nil (error was not suppressed)") - } - }) - - t.Run("idempotent on idle turn", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - - m.Cancel() // no stream, no panic - - if m.state != StateComplete { - t.Errorf("expected StateComplete, got %d", m.state) - } - }) - - t.Run("aborted user_cancelled does not emit completion cmd", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateStreaming - - cmd := m.handleStreamUpdate(streamUpdate{ - message: &domain.Message{ID: "asst-1"}, - status: corechat.StreamStatusAborted, - abort: "user_cancelled", - done: true, - }) - - if cmd != nil { - t.Error("expected nil cmd for user_cancelled abort") - } - }) - - t.Run("aborted non-user emits completion cmd", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateStreaming - - cmd := m.handleStreamUpdate(streamUpdate{ - message: &domain.Message{ID: "asst-1"}, - status: corechat.StreamStatusAborted, - abort: "context_canceled", - done: true, - }) - - if cmd == nil { - t.Error("expected non-nil cmd for non-user abort") - } - }) -} - -func TestHandleToolCompleted(t *testing.T) { - t.Parallel() - - t.Run("collects results while streaming", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateStreaming - - m.handleToolCompleted("tool-1", tools.Result{ToolUseID: "tool-1"}) - - if len(m.toolTracker.results) != 1 { - t.Errorf("expected 1 collected result, got %d", len(m.toolTracker.results)) - } - // Should not change state — still streaming - if m.state != StateStreaming { - t.Errorf("expected StateStreaming, got %d", m.state) - } - }) - - t.Run("fires results when all collected and persisted", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateAwaitingToolResults - m.toolTracker.persisted = true - m.toolTracker.pendingTools = 2 - m.toolTracker.results = []tools.Result{{ToolUseID: "tool-1"}} - - cmd := m.handleToolCompleted("tool-2", tools.Result{ToolUseID: "tool-2"}) - - if m.state != StateComplete { - t.Errorf("expected StateComplete when all tools done, got %d", m.state) - } - if len(m.toolTracker.results) != 2 { - t.Errorf("expected 2 results, got %d", len(m.toolTracker.results)) - } - if cmd == nil { - t.Error("expected non-nil cmd (fireToolResults)") - } - }) - - t.Run("does not fire until all tools complete", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateAwaitingToolResults - m.toolTracker.pendingTools = 2 - - m.handleToolCompleted("tool-1", tools.Result{ToolUseID: "tool-1"}) - - if m.state != StateAwaitingToolResults { - t.Errorf("expected StateAwaitingToolResults with 1 of 2 tools, got %d", m.state) - } - }) - - t.Run("waits for persist before firing results", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateAwaitingToolResults - m.toolTracker.persisted = false - m.toolTracker.pendingTools = 1 - - cmd := m.handleToolCompleted("tool-1", tools.Result{ToolUseID: "tool-1"}) - - if m.state != StateComplete { - t.Errorf("expected StateComplete, got %d", m.state) - } - if cmd != nil { - t.Error("expected nil cmd — persist hasn't completed yet") - } - }) - - t.Run("unknown tool completion increments protocol violation counter", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateAwaitingToolResults - m.toolTracker.pendingTools = 1 - m.toolTracker.pendingToolIDs = map[string]bool{"tool-1": true} - - m.handleToolCompleted("tool-x", tools.Result{ToolUseID: "tool-x"}) - - if got := m.protocolViolationCount; got != 1 { - t.Fatalf("protocolViolationCount = %d, want 1", got) - } - if len(m.toolTracker.results) != 0 { - t.Fatalf("toolResults len = %d, want 0", len(m.toolTracker.results)) - } - }) -} - -func TestInterleavedToolUseFlow(t *testing.T) { - t.Parallel() - - m := newTestTurn(t) - m.state = StateStreaming - m.assistantMessage.SetID("asst-1") - - // Stream completes with two tool_use blocks from interleaved tool input deltas. - m.handleStreamUpdate(streamUpdate{ - message: &domain.Message{ - ID: "asst-1", - StopReason: "tool_use", - Content: []domain.Block{ - {Index: 0, Type: domain.BlockTypeToolUse, ToolUse: &domain.ToolUse{ID: "tool-a", Name: "query"}}, - {Index: 1, Type: domain.BlockTypeToolUse, ToolUse: &domain.ToolUse{ID: "tool-b", Name: "query"}}, - }, - }, - done: true, - }) - - if m.state != StateAwaitingToolResults { - t.Fatalf("expected StateAwaitingToolResults, got %d", m.state) - } - if m.toolTracker.pendingTools != 2 { - t.Fatalf("expected 2 pending tools, got %d", m.toolTracker.pendingTools) - } - - // Unknown tool result is ignored once pending IDs are fixed. - m.handleToolCompleted("tool-c", tools.Result{ToolUseID: "tool-c"}) - if len(m.toolTracker.results) != 0 { - t.Fatalf("expected 0 collected results after unknown tool, got %d", len(m.toolTracker.results)) - } - - // Interleaved completions should only complete once all known tools are done. - m.handleToolCompleted("tool-b", tools.Result{ToolUseID: "tool-b"}) - if m.state != StateAwaitingToolResults { - t.Fatalf("expected awaiting state after first completion, got %d", m.state) - } - m.handleToolCompleted("tool-a", tools.Result{ToolUseID: "tool-a"}) - if m.state != StateComplete { - t.Fatalf("expected complete state after all tools, got %d", m.state) - } -} - -func TestPersistBeforeFireToolResults(t *testing.T) { - t.Parallel() - - t.Run("tools pre-completed fires after persist", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateComplete - m.toolTracker.pendingTools = 1 - m.toolTracker.results = []tools.Result{{ToolUseID: "tool-1"}} - m.toolTracker.persisted = false - - // Simulate assistantPersisted arriving - cmd := m.Update(assistantPersisted{turnID: m.userMessage.ID(), messageID: "asst-1"}) - - if !m.toolTracker.persisted { - t.Error("expected persisted = true") - } - if cmd == nil { - t.Error("expected non-nil cmd (fireToolResults)") - } - }) - - t.Run("tools complete after persist fires immediately", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateAwaitingToolResults - m.toolTracker.persisted = true - m.toolTracker.pendingTools = 1 - - cmd := m.handleToolCompleted("tool-1", tools.Result{ToolUseID: "tool-1"}) - - if cmd == nil { - t.Error("expected non-nil cmd (fireToolResults) — already persisted") - } - }) - - t.Run("no-op when no pending tools", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateComplete - m.toolTracker.pendingTools = 0 - - cmd := m.Update(assistantPersisted{turnID: m.userMessage.ID(), messageID: "asst-1"}) - - if !m.toolTracker.persisted { - t.Error("expected persisted = true") - } - if cmd != nil { - t.Error("expected nil cmd — no tools to fire") - } - }) -} - -func TestFireToolResultsOnlyOnce(t *testing.T) { - t.Parallel() - - t.Run("second call returns nil", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateAwaitingToolResults - m.toolTracker.persisted = true - m.toolTracker.pendingTools = 1 - - cmd1 := m.handleToolCompleted("tool-1", tools.Result{ToolUseID: "tool-1"}) - if cmd1 == nil { - t.Fatal("expected non-nil cmd on first fire") - } - - cmd2 := m.fireToolResults() - if cmd2 != nil { - t.Error("expected nil cmd on second fireToolResults call") - } - }) -} - -func TestUpdate_TurnScopedRouting(t *testing.T) { - t.Parallel() - - t.Run("tool completed turn mismatch is ignored", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - - m.Update(msgs.ToolCompleted{ - TurnID: "user-other", - ToolUseID: "tool-1", - }) - - if got := m.protocolViolationCount; got != 0 { - t.Fatalf("protocolViolationCount = %d, want 0", got) - } - }) - - t.Run("assistant persisted turn mismatch is ignored", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - - m.Update(assistantPersisted{ - turnID: "user-other", - messageID: "asst-1", - }) - - if got := m.protocolViolationCount; got != 0 { - t.Fatalf("protocolViolationCount = %d, want 0", got) - } - if m.toolTracker.persisted { - t.Fatal("persisted should remain false for mismatched turn") - } - }) -} diff --git a/internal/app/chat/messagelist/round/turn/turn_update.go b/internal/app/chat/messagelist/round/turn/turn_update.go deleted file mode 100644 index 2b9bb195..00000000 --- a/internal/app/chat/messagelist/round/turn/turn_update.go +++ /dev/null @@ -1,94 +0,0 @@ -package turn - -import ( - tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/domain/tools" -) - -// Update handles messages. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - var cmds []tea.Cmd - - switch msg := msg.(type) { - case turnStreamUpdateMsg: - if msg.turnID != m.userMessage.ID() { - return nil - } - cmds = append(cmds, m.handleStreamUpdate(msg.update)) - - case msgs.AssistantContentUpdated: - if msg.TurnID != m.userMessage.ID() { - return nil - } - cmds = append(cmds, m.assistantMessage.Update(msg)) - return tea.Batch(cmds...) - - case msgs.StreamCompleted: - if msg.TurnID != m.userMessage.ID() { - return nil - } - cmds = append(cmds, m.assistantMessage.Update(msg)) - return tea.Batch(cmds...) - - case msgs.ToolCompleted: - if msg.TurnID != m.userMessage.ID() { - // Message buses fan out to all turns; non-owner turns ignore. - return nil - } - cmds = append(cmds, m.handleToolCompleted(msg.ToolUseID, msg.ResultOrError())) - - case assistantPersisted: - if msg.turnID != m.userMessage.ID() { - // Internal completion events are broadcast; non-owner turns ignore. - return nil - } - if msg.messageID != "" { - m.assistantMessage.SetID(msg.messageID) - } - m.toolTracker.markPersisted() - if m.toolTracker.shouldFire(m.state) { - return m.fireToolResults() - } - return nil - } - - cmds = append(cmds, m.userMessage.Update(msg)) - cmds = append(cmds, m.assistantMessage.Update(msg)) - - return tea.Batch(cmds...) -} - -func (m *Model) handleToolCompleted(toolUseID string, result tools.Result) tea.Cmd { - // Ignore tools that don't belong to this turn. - // Before pendingToolIDs is set (during streaming), accept all tools — - // they'll be validated once the stream completes and IDs are known. - if !m.toolTracker.accepts(toolUseID) { - m.reportProtocolViolation( - "tool_completed_unknown_tool_use_id", - "tool_use_id", toolUseID, - "pending", m.toolTracker.pendingCount(), - "collected", m.toolTracker.collectedCount(), - ) - return nil - } - - // Collect results during streaming or awaiting - tools may complete before StreamCompleted - m.toolTracker.collect(result) - m.scope.Info("tool completed", "tool_use_id", toolUseID, "collected", m.toolTracker.collectedCount(), "pending", m.toolTracker.pendingCount()) - - // Only fire results once we're awaiting and have all of them - next := reduceOnToolCompleted(m.state, m.toolTracker.collectedCount(), m.toolTracker.pendingCount()) - if next == m.state { - return nil - } - m.state = next - if m.state == StateComplete { - m.scope.Info("all tools completed") - if m.toolTracker.shouldFire(m.state) { - return m.fireToolResults() - } - return nil - } - return nil -} diff --git a/internal/app/chat/messagelist/round/turn/user/user.go b/internal/app/chat/messagelist/round/turn/user/user.go deleted file mode 100644 index c24310b7..00000000 --- a/internal/app/chat/messagelist/round/turn/user/user.go +++ /dev/null @@ -1,97 +0,0 @@ -package user - -import ( - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/styles" -) - -// Model renders a user message. -// It is a fixed-height component - height is determined by content. -// Implements block.Block. -type Model struct { - theme styles.Theme - id domain.MessageID - input msgs.UserSubmittedInput - width int - focused bool -} - -// New creates a new user message view. -func New(theme styles.Theme, id domain.MessageID, input msgs.UserSubmittedInput, width int) *Model { - return &Model{ - theme: theme, - id: id, - input: input, - width: width, - } -} - -// Update handles messages. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - return nil -} - -// View renders the user message content without border decoration. -// The border is applied by renderBlock in the message list. -func (m *Model) View() string { - // Tool result messages are not rendered visually - if len(m.input.ToolResults) > 0 { - return "" - } - - style := lipgloss.NewStyle(). - Foreground(m.theme.Text). - Background(m.theme.Bg). - Padding(block.PaddingY, block.PaddingX). - Width(m.width) - - return style.Render(m.input.Text) -} - -// Height returns the number of lines this component renders. -func (m *Model) Height() int { - // Tool result messages have no visual representation - if len(m.input.ToolResults) > 0 { - return 0 - } - return lipgloss.Height(m.View()) -} - -// SetWidth sets the width. -func (m *Model) SetWidth(width int) { - m.width = width -} - -// ID returns the message ID. -func (m *Model) ID() domain.MessageID { - return m.id -} - -// Input returns the submitted input for this user message. -func (m *Model) Input() msgs.UserSubmittedInput { - return m.input -} - -// Kind implements block.Block. -func (m *Model) Kind() block.Kind { - return block.KindUser -} - -// SetFocused implements block.Block. -func (m *Model) SetFocused(focused bool) { - m.focused = focused -} - -// Focused implements block.Block. -func (m *Model) Focused() bool { - return m.focused -} - -// IsVisible returns false for tool result messages (they have no visual representation). -func (m *Model) IsVisible() bool { - return len(m.input.ToolResults) == 0 -} diff --git a/internal/app/chat/messagelist/round/turn/user/user_test.go b/internal/app/chat/messagelist/round/turn/user/user_test.go deleted file mode 100644 index b274b7bf..00000000 --- a/internal/app/chat/messagelist/round/turn/user/user_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package user - -import ( - "strings" - "testing" - - "github.com/charmbracelet/x/ansi" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/styles" -) - -func newTestUser(t *testing.T, text string, width int) *Model { - t.Helper() - theme := styles.NewTheme(true) - return New(theme, "user-1", msgs.UserSubmittedInput{Text: text}, width) -} - -func TestView(t *testing.T) { - t.Parallel() - - t.Run("renders within width", func(t *testing.T) { - t.Parallel() - for _, width := range []int{40, 60, 80, 120} { - m := newTestUser(t, "Hello, world!", width) - view := m.View() - for i, line := range strings.Split(view, "\n") { - w := ansi.StringWidth(line) - if w > width { - t.Errorf("width=%d line %d: got %d chars, want <=%d", width, i, w, width) - } - } - } - }) - - t.Run("wraps long text", func(t *testing.T) { - t.Parallel() - long := strings.Repeat("word ", 30) // ~150 chars - m := newTestUser(t, long, 40) - h := m.Height() - if h < 2 { - t.Errorf("expected wrapping (height >= 2), got height %d", h) - } - }) - - t.Run("tool results are invisible", func(t *testing.T) { - t.Parallel() - theme := styles.NewTheme(true) - m := New(theme, "user-1", msgs.UserSubmittedInput{ - ToolResults: []tools.Result{{ToolUseID: "tool-1"}}, - }, 80) - - if m.View() != "" { - t.Error("expected empty view for tool result message") - } - if m.Height() != 0 { - t.Errorf("expected height 0 for tool result message, got %d", m.Height()) - } - if m.IsVisible() { - t.Error("expected IsVisible() == false for tool result message") - } - }) -} - -func TestKind(t *testing.T) { - t.Parallel() - m := newTestUser(t, "hi", 80) - if m.Kind() != block.KindUser { - t.Errorf("expected KindUser, got %d", m.Kind()) - } -} - -func TestFocused(t *testing.T) { - t.Parallel() - m := newTestUser(t, "hi", 80) - - if m.Focused() { - t.Error("expected unfocused by default") - } - m.SetFocused(true) - if !m.Focused() { - t.Error("expected focused after SetFocused(true)") - } -} diff --git a/internal/app/chat/messagelist/rounds.go b/internal/app/chat/messagelist/rounds.go deleted file mode 100644 index b6b5b774..00000000 --- a/internal/app/chat/messagelist/rounds.go +++ /dev/null @@ -1,124 +0,0 @@ -package messagelist - -import ( - tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/round" - "github.com/usetero/cli/internal/domain" -) - -// HasActiveRound returns true if the last round is still active. -func (m *Model) HasActiveRound() bool { - if len(m.rounds) == 0 { - return false - } - return m.rounds[len(m.rounds)-1].IsActive() -} - -// LastRound returns the last round or nil. -func (m *Model) LastRound() *round.Model { - if len(m.rounds) == 0 { - return nil - } - return m.rounds[len(m.rounds)-1] -} - -// HasTurn returns true when any round owns the given turn/user-message ID. -func (m *Model) HasTurn(turnID domain.MessageID) bool { - for _, r := range m.rounds { - if r.HasTurn(turnID) { - return true - } - } - return false -} - -// RemoveLastRound removes the last round and rebuilds blocks. -func (m *Model) RemoveLastRound() { - if len(m.rounds) == 0 { - return - } - m.rounds = m.rounds[:len(m.rounds)-1] - m.rebuildBlocks() -} - -// CancelActiveRound cancels the last round if it is still active. -func (m *Model) CancelActiveRound() { - if len(m.rounds) == 0 { - return - } - last := m.rounds[len(m.rounds)-1] - if last.IsActive() { - last.Cancel() - m.rebuildBlocks() - } -} - -// StartTurn creates a new round and begins streaming. -func (m *Model) StartTurn( - conversationID domain.ConversationID, - accountID domain.AccountID, - userMessageID domain.MessageID, - input msgs.UserSubmittedInput, - messages []domain.Message, - context []domain.ContextEntity, -) tea.Cmd { - m.scope.Debug("starting turn", "user_message_id", userMessageID) - - r := round.New( - m.theme, - conversationID, - accountID, - userMessageID, - input, - m.contentWidth(), - m.runtimeDeps, - m.toolRegistry, - m.scope, - ) - - m.rounds = append(m.rounds, r) - m.rebuildBlocks() - - startCmd := func() tea.Msg { - return msgs.TurnStarted{ - UserMessageID: userMessageID, - ConversationID: conversationID, - } - } - - return tea.Batch(r.Init(), r.StartStream(messages, context), startCmd) -} - -// rebuildBlocks collects blocks from the round hierarchy into a flat list -// and syncs the viewport with current heights/gaps. -func (m *Model) rebuildBlocks() { - var entries []blockEntry - for i, r := range m.rounds { - for _, b := range r.Blocks() { - entries = append(entries, blockEntry{block: b, roundIndex: i}) - } - } - m.blocks = entries - m.syncViewportItems() -} - -// syncViewportItems rebuilds the viewport's height/gap slices from the -// current block list. Called after rebuildBlocks and after toggles -// (which change block heights without changing the block list). -func (m *Model) syncViewportItems() { - items := projectItems(m.blocks, m.blockHeight) - m.layout = projectLayout(items, func(roundIndex int) bool { - return m.rounds[roundIndex].IsActive() - }) - m.vp.SetItems(m.layout.heights, m.layout.gapHeights()) - m.vp.SetTrailingHeight(m.layout.trailingHeight()) -} - -// updateRoundWidths sets the width on all rounds. -func (m *Model) updateRoundWidths() { - w := m.contentWidth() - for _, r := range m.rounds { - r.SetWidth(w) - } -} diff --git a/internal/app/chat/messagelist/selection_reducer.go b/internal/app/chat/messagelist/selection_reducer.go deleted file mode 100644 index a0e86b96..00000000 --- a/internal/app/chat/messagelist/selection_reducer.go +++ /dev/null @@ -1,90 +0,0 @@ -package messagelist - -import tea "charm.land/bubbletea/v2" - -type selectionState struct { - mouseDown bool - mouseDownBlock int - mouseDownX int - mouseDownY int - mouseDragBlock int - mouseDragX int - mouseDragY int -} - -type selectionPoint struct { - block int - x int - y int -} - -type clickDecision struct { - setFocusIdx bool - focusIdx int -} - -type releaseDecision struct { - handle bool - clickBlock int - clickY int -} - -type releaseAction int - -const ( - releaseActionNoop releaseAction = iota - releaseActionCopy - releaseActionClick -) - -func reduceSelectionClick(state selectionState, button tea.MouseButton, point selectionPoint, hit bool) (selectionState, clickDecision) { - if button != tea.MouseLeft || !hit { - return state, clickDecision{} - } - - state.mouseDown = true - state.mouseDownBlock = point.block - state.mouseDownX = point.x - state.mouseDownY = point.y - state.mouseDragBlock = point.block - state.mouseDragX = point.x - state.mouseDragY = point.y - - return state, clickDecision{setFocusIdx: true, focusIdx: point.block} -} - -func reduceSelectionMotion(state selectionState, button tea.MouseButton, point selectionPoint, hit bool) selectionState { - if !state.mouseDown || button != tea.MouseLeft || !hit { - return state - } - - state.mouseDragBlock = point.block - state.mouseDragX = point.x - state.mouseDragY = point.y - return state -} - -func reduceSelectionRelease(state selectionState) (selectionState, releaseDecision) { - if !state.mouseDown { - return state, releaseDecision{} - } - - decision := releaseDecision{ - handle: true, - clickBlock: state.mouseDownBlock, - clickY: state.mouseDownY, - } - state.mouseDown = false - return state, decision -} - -func reduceReleaseAction(hasHighlight bool, highlightedText string) releaseAction { - if !hasHighlight { - return releaseActionClick - } - if highlightedText == "" { - // Treat empty extraction as a click so collapsible blocks still toggle. - return releaseActionClick - } - return releaseActionCopy -} diff --git a/internal/app/chat/messagelist/selection_reducer_test.go b/internal/app/chat/messagelist/selection_reducer_test.go deleted file mode 100644 index 69f06fef..00000000 --- a/internal/app/chat/messagelist/selection_reducer_test.go +++ /dev/null @@ -1,142 +0,0 @@ -package messagelist - -import ( - "testing" - - tea "charm.land/bubbletea/v2" -) - -func TestReduceSelectionClick(t *testing.T) { - t.Parallel() - - t.Run("non-left click ignored", func(t *testing.T) { - t.Parallel() - start := selectionState{mouseDownBlock: -1, mouseDragBlock: -1} - next, d := reduceSelectionClick(start, tea.MouseRight, selectionPoint{block: 2, x: 4, y: 5}, true) - if next != start { - t.Fatalf("state changed unexpectedly: %+v -> %+v", start, next) - } - if d.setFocusIdx { - t.Fatalf("unexpected decision: %+v", d) - } - }) - - t.Run("miss click ignored", func(t *testing.T) { - t.Parallel() - start := selectionState{mouseDownBlock: -1, mouseDragBlock: -1} - next, d := reduceSelectionClick(start, tea.MouseLeft, selectionPoint{block: -1, x: 4, y: 5}, false) - if next != start { - t.Fatalf("state changed unexpectedly: %+v -> %+v", start, next) - } - if d.setFocusIdx { - t.Fatalf("unexpected decision: %+v", d) - } - }) - - t.Run("left click sets anchor and focus", func(t *testing.T) { - t.Parallel() - start := selectionState{mouseDownBlock: -1, mouseDragBlock: -1} - next, d := reduceSelectionClick(start, tea.MouseLeft, selectionPoint{block: 3, x: 7, y: 9}, true) - if !next.mouseDown || next.mouseDownBlock != 3 || next.mouseDownX != 7 || next.mouseDownY != 9 { - t.Fatalf("unexpected down state: %+v", next) - } - if next.mouseDragBlock != 3 || next.mouseDragX != 7 || next.mouseDragY != 9 { - t.Fatalf("unexpected drag state: %+v", next) - } - if !d.setFocusIdx || d.focusIdx != 3 { - t.Fatalf("unexpected decision: %+v", d) - } - }) -} - -func TestReduceSelectionMotion(t *testing.T) { - t.Parallel() - - t.Run("ignored when not dragging", func(t *testing.T) { - t.Parallel() - start := selectionState{mouseDown: false, mouseDragBlock: 1} - next := reduceSelectionMotion(start, tea.MouseLeft, selectionPoint{block: 2, x: 6, y: 8}, true) - if next != start { - t.Fatalf("state changed unexpectedly: %+v -> %+v", start, next) - } - }) - - t.Run("ignored when no hit", func(t *testing.T) { - t.Parallel() - start := selectionState{mouseDown: true, mouseDragBlock: 1} - next := reduceSelectionMotion(start, tea.MouseLeft, selectionPoint{block: -1, x: 6, y: 8}, false) - if next != start { - t.Fatalf("state changed unexpectedly: %+v -> %+v", start, next) - } - }) - - t.Run("updates drag cursor", func(t *testing.T) { - t.Parallel() - start := selectionState{mouseDown: true, mouseDragBlock: 1, mouseDragX: 2, mouseDragY: 3} - next := reduceSelectionMotion(start, tea.MouseLeft, selectionPoint{block: 4, x: 10, y: 11}, true) - if next.mouseDragBlock != 4 || next.mouseDragX != 10 || next.mouseDragY != 11 { - t.Fatalf("unexpected drag state: %+v", next) - } - }) -} - -func TestReduceSelectionRelease(t *testing.T) { - t.Parallel() - - t.Run("ignored when not dragging", func(t *testing.T) { - t.Parallel() - start := selectionState{mouseDown: false} - next, d := reduceSelectionRelease(start) - if next != start { - t.Fatalf("state changed unexpectedly: %+v -> %+v", start, next) - } - if d.handle { - t.Fatalf("unexpected decision: %+v", d) - } - }) - - t.Run("release clears mouseDown and preserves click anchor", func(t *testing.T) { - t.Parallel() - start := selectionState{ - mouseDown: true, - mouseDownBlock: 5, - mouseDownY: 12, - mouseDragBlock: 7, - } - next, d := reduceSelectionRelease(start) - if next.mouseDown { - t.Fatalf("mouseDown should be false: %+v", next) - } - if !d.handle || d.clickBlock != 5 || d.clickY != 12 { - t.Fatalf("unexpected decision: %+v", d) - } - }) -} - -func TestReduceReleaseAction(t *testing.T) { - t.Parallel() - - t.Run("no highlight becomes click", func(t *testing.T) { - t.Parallel() - got := reduceReleaseAction(false, "") - if got != releaseActionClick { - t.Fatalf("action=%v, want %v", got, releaseActionClick) - } - }) - - t.Run("empty extracted highlight becomes click", func(t *testing.T) { - t.Parallel() - got := reduceReleaseAction(true, "") - if got != releaseActionClick { - t.Fatalf("action=%v, want %v", got, releaseActionClick) - } - }) - - t.Run("non-empty highlight becomes copy", func(t *testing.T) { - t.Parallel() - got := reduceReleaseAction(true, "hello") - if got != releaseActionCopy { - t.Fatalf("action=%v, want %v", got, releaseActionCopy) - } - }) -} diff --git a/internal/app/chat/messagelist/update.go b/internal/app/chat/messagelist/update.go deleted file mode 100644 index 7fbbbd47..00000000 --- a/internal/app/chat/messagelist/update.go +++ /dev/null @@ -1,40 +0,0 @@ -package messagelist - -import tea "charm.land/bubbletea/v2" - -// Update handles messages. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - var cmds []tea.Cmd - - switch msg := msg.(type) { - case tea.KeyPressMsg: - m.handleKeyPress(msg) - - case tea.MouseClickMsg: - m.handleMouseClick(msg) - - case tea.MouseMotionMsg: - m.handleMouseMotion(msg) - - case tea.MouseReleaseMsg: - cmds = append(cmds, m.handleMouseRelease(msg)...) - - case tea.MouseWheelMsg: - m.handleMouseWheel(msg) - - default: - if lifecycleCmds, handled := m.handleLifecycle(msg); handled { - cmds = append(cmds, lifecycleCmds...) - return tea.Batch(cmds...) - } - } - - // Forward to all rounds - for _, r := range m.rounds { - if cmd := r.Update(msg); cmd != nil { - cmds = append(cmds, cmd) - } - } - - return tea.Batch(cmds...) -} diff --git a/internal/app/chat/messagelist/update_key.go b/internal/app/chat/messagelist/update_key.go deleted file mode 100644 index b7ff1fa0..00000000 --- a/internal/app/chat/messagelist/update_key.go +++ /dev/null @@ -1,16 +0,0 @@ -package messagelist - -import tea "charm.land/bubbletea/v2" - -func (m *Model) handleKeyPress(msg tea.KeyPressMsg) { - decision := reduceKeyPress(msg, m.focused) - if decision.focusDelta < 0 { - m.vp.FocusPrev() - } else if decision.focusDelta > 0 { - m.vp.FocusNext() - } - if decision.scrollDelta != 0 { - m.vp.ScrollBy(decision.scrollDelta) - m.vp.UpdateFocusFromScroll() - } -} diff --git a/internal/app/chat/messagelist/update_lifecycle.go b/internal/app/chat/messagelist/update_lifecycle.go deleted file mode 100644 index 1615d953..00000000 --- a/internal/app/chat/messagelist/update_lifecycle.go +++ /dev/null @@ -1,34 +0,0 @@ -package messagelist - -import tea "charm.land/bubbletea/v2" - -func (m *Model) handleLifecycle(msg tea.Msg) ([]tea.Cmd, bool) { - decision := reduceLifecycle(msg, m.vp.AtBottom()) - if !decision.handle { - return nil, false - } - - var cmds []tea.Cmd - if decision.forwardRounds { - // Forward to rounds first so streaming state is updated - // before rebuildBlocks reads Blocks(). - for _, r := range m.rounds { - if cmd := r.Update(msg); cmd != nil { - cmds = append(cmds, cmd) - } - } - } - if decision.clearSelection { - m.clearSelection() - } - if decision.rebuild { - m.rebuildBlocks() - } - if decision.scrollToBottom { - m.vp.ScrollToBottom() - } - if decision.focusLastAtBottom && len(m.blocks) > 0 { - m.vp.SetFocusIdx(len(m.blocks) - 1) - } - return cmds, true -} diff --git a/internal/app/chat/messagelist/update_mouse.go b/internal/app/chat/messagelist/update_mouse.go deleted file mode 100644 index 1fd6d602..00000000 --- a/internal/app/chat/messagelist/update_mouse.go +++ /dev/null @@ -1,89 +0,0 @@ -package messagelist - -import ( - tea "charm.land/bubbletea/v2" - "github.com/atotto/clipboard" - appevents "github.com/usetero/cli/internal/app/events" -) - -func (m *Model) handleMouseClick(msg tea.MouseClickMsg) { - if msg.Button != tea.MouseLeft { - return - } - - target := resolveMouseTarget(msg.X, msg.Y, m.originX, m.originY, m.vp.ItemAtY) - m.scope.Debug("click", - "msgX", msg.X, "msgY", msg.Y, - "originX", m.originX, "originY", m.originY, - "viewX", target.viewX, "viewY", target.viewY, - "blockIdx", target.blockIdx, "blockY", target.blockY, - "numBlocks", len(m.blocks), - "vpHeight", m.height) - - state, decision := reduceSelectionClick( - m.selectionState(), - msg.Button, - selectionPoint{block: target.blockIdx, x: target.viewX, y: target.blockY}, - target.hit, - ) - m.setSelectionState(state) - if decision.setFocusIdx { - m.vp.SetFocusIdx(decision.focusIdx) - } -} - -func (m *Model) handleMouseMotion(msg tea.MouseMotionMsg) { - target := resolveMouseTarget(msg.X, msg.Y, m.originX, m.originY, m.vp.ItemAtY) - state := reduceSelectionMotion( - m.selectionState(), - msg.Button, - selectionPoint{block: target.blockIdx, x: target.viewX, y: target.blockY}, - target.hit, - ) - m.setSelectionState(state) -} - -func (m *Model) handleMouseRelease(_ tea.MouseReleaseMsg) []tea.Cmd { - state, decision := reduceSelectionRelease(m.selectionState()) - m.setSelectionState(state) - if !decision.handle { - return nil - } - - hl := m.hasHighlight() - text := "" - if hl { - text = m.extractHighlight() - } - action := reduceReleaseAction(hl, text) - m.scope.Debug("release", - "mouseDownBlock", m.mouseDownBlock, - "hasHighlight", hl, - "action", action, - "downX", m.mouseDownX, "downY", m.mouseDownY, - "dragBlock", m.mouseDragBlock, - "dragX", m.mouseDragX, "dragY", m.mouseDragY) - - switch action { - case releaseActionNoop: - return nil - case releaseActionCopy: - return []tea.Cmd{ - tea.SetClipboard(text), - func() tea.Msg { - _ = clipboard.WriteAll(text) - return appevents.SuccessToastPublished{Message: "Copied to clipboard"} - }, - } - case releaseActionClick: - m.handleBlockClick(decision.clickBlock, decision.clickY) - } - return nil -} - -func (m *Model) handleMouseWheel(msg tea.MouseWheelMsg) { - if delta := reduceMouseWheel(msg.Button); delta != 0 { - m.vp.ScrollBy(delta) - m.vp.UpdateFocusFromScroll() - } -} diff --git a/internal/app/chat/messagelist/update_test.go b/internal/app/chat/messagelist/update_test.go deleted file mode 100644 index bfe0bc3a..00000000 --- a/internal/app/chat/messagelist/update_test.go +++ /dev/null @@ -1,169 +0,0 @@ -package messagelist - -import ( - "context" - "testing" - - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/app/chat/messagelist/round" - "github.com/usetero/cli/internal/app/chat/usecase" - "github.com/usetero/cli/internal/boundary/chat" - "github.com/usetero/cli/internal/boundary/chat/chattest" - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/styles" -) - -func hasBlockKind(entries []blockEntry, kind block.Kind) bool { - for _, e := range entries { - if e.block.Kind() == kind { - return true - } - } - return false -} - -func countBlockKind(entries []blockEntry, kind block.Kind) int { - n := 0 - for _, e := range entries { - if e.block.Kind() == kind { - n++ - } - } - return n -} - -// newStreamingMessageList creates a messagelist with a mock client that streams -// one text block then blocks forever. Suitable for testing cancel and thinking lifecycle. -func newStreamingMessageList(t *testing.T) *Model { - t.Helper() - theme := styles.NewTheme(true) - scope := logtest.NewScope(t) - db := dbtest.OpenTestDB(t) - - client := &chattest.MockClient{ - StreamFunc: func(_ context.Context, _ chat.Request, onMessage func(*domain.Message)) (*corechat.StreamResult, error) { - msg := &domain.Message{ID: "asst-1", Content: []domain.Block{ - {Index: 0, Type: domain.BlockTypeText, Text: &domain.TextBlock{Content: "hello"}}, - }} - onMessage(msg) - // Block forever to simulate in-progress stream - select {} - }, - } - runtimeDeps := usecase.NewRuntimeDeps(db, client) - - m := New(theme, runtimeDeps, nil, scope) - m.SetSize(80, 40) - return m -} - -func TestUpdate_StreamCompleted(t *testing.T) { - t.Parallel() - - t.Run("thinking animation removed after StreamCompleted", func(t *testing.T) { - t.Parallel() - - m := newStreamingMessageList(t) - - userMsgID := domain.MessageID("user-1") - m.StartTurn("conv-1", "acct-1", userMsgID, msgs.UserSubmittedInput{Text: "hi"}, nil, nil) - - // After StartTurn, thinking animation should be present. - if !hasBlockKind(m.blocks, block.KindThinkingAnimation) { - t.Fatal("expected thinking animation block after StartTurn") - } - - // StreamCompleted must update streaming state before rebuildBlocks reads it. - m.Update(msgs.StreamCompleted{ - TurnID: userMsgID, - Message: domain.Message{ - ID: "asst-1", - StopReason: "end_turn", - Content: []domain.Block{ - {Index: 0, Type: domain.BlockTypeText, Text: &domain.TextBlock{Content: "hello"}}, - }, - }, - }) - - // Thinking animation should be gone. - if hasBlockKind(m.blocks, block.KindThinkingAnimation) { - t.Error("thinking animation block should be removed after StreamCompleted") - } - - // Text content should remain. - if !hasBlockKind(m.blocks, block.KindAssistantText) { - t.Error("expected text block to remain after StreamCompleted") - } - }) -} - -func TestCancelActiveRound(t *testing.T) { - t.Parallel() - - t.Run("cancels active round and removes thinking", func(t *testing.T) { - t.Parallel() - m := newStreamingMessageList(t) - - m.StartTurn("conv-1", "acct-1", "user-1", msgs.UserSubmittedInput{Text: "hi"}, nil, nil) - - if !hasBlockKind(m.blocks, block.KindThinkingAnimation) { - t.Fatal("expected thinking animation block after StartTurn") - } - - m.CancelActiveRound() - - if hasBlockKind(m.blocks, block.KindThinkingAnimation) { - t.Error("thinking animation should be removed after cancel") - } - if m.rounds[0].State() != round.StateCancelled { - t.Errorf("expected StateCancelled, got %d", m.rounds[0].State()) - } - }) - - t.Run("no-op when no rounds exist", func(t *testing.T) { - t.Parallel() - m := newStreamingMessageList(t) - - m.CancelActiveRound() // should not panic - - if len(m.rounds) != 0 { - t.Error("expected no rounds") - } - }) - - t.Run("no-op when last round is complete", func(t *testing.T) { - t.Parallel() - m := newStreamingMessageList(t) - - m.StartTurn("conv-1", "acct-1", "user-1", msgs.UserSubmittedInput{Text: "hi"}, nil, nil) - - // Complete it via StreamCompleted - m.Update(msgs.StreamCompleted{ - TurnID: "user-1", - Message: domain.Message{ID: "asst-1", StopReason: "end_turn"}, - }) - - m.CancelActiveRound() // should not change state - - if m.rounds[0].State() != round.StateComplete { - t.Errorf("expected StateComplete (unchanged), got %d", m.rounds[0].State()) - } - }) - - t.Run("each active round has one thinking block", func(t *testing.T) { - t.Parallel() - m := newStreamingMessageList(t) - - m.StartTurn("conv-1", "acct-1", "user-1", msgs.UserSubmittedInput{Text: "first"}, nil, nil) - m.StartTurn("conv-1", "acct-1", "user-2", msgs.UserSubmittedInput{Text: "second"}, nil, nil) - - count := countBlockKind(m.blocks, block.KindThinkingAnimation) - if count != 2 { - t.Errorf("expected 2 thinking blocks (one per active round), got %d", count) - } - }) -} diff --git a/internal/app/chat/model.go b/internal/app/chat/model.go deleted file mode 100644 index d3965c9c..00000000 --- a/internal/app/chat/model.go +++ /dev/null @@ -1,126 +0,0 @@ -package chat - -import ( - "time" - - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - - "github.com/usetero/cli/internal/app/chat/inputbar" - "github.com/usetero/cli/internal/app/chat/messagelist" - "github.com/usetero/cli/internal/app/chat/usecase" - "github.com/usetero/cli/internal/app/chattools" - "github.com/usetero/cli/internal/auth" - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/sqlite" - "github.com/usetero/cli/internal/styles" -) - -const dbOpTimeout = 2 * time.Second - -// Chat-specific key bindings. -var ( - scrollUp = key.NewBinding( - key.WithKeys("up"), - key.WithHelp("↑↓", "scroll"), - ) - focusInputBar = key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "focus input"), - ) - focusChat = key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "focus chat"), - ) -) - -// focus tracks which component has keyboard focus. -type focus int - -const ( - focusEditor focus = iota - focusMessages -) - -// Model is the main chat model. -// It is a flexible component - it renders exactly the size given by SetSize. -type Model struct { - scope log.Scope - focus focus - - inputBar *inputbar.Model - messageList *messagelist.Model - - // Conversation is created lazily on first message - conversationID domain.ConversationID - session *corechat.Session - - user *auth.User - account domain.Account - workspace domain.Workspace - theme styles.Theme - width int - height int - originX int - originY int - - // Empty state - policySummary *domain.AccountSummary - - // Dependencies - db sqlite.DB - runtimeDeps usecase.RuntimeDeps - toolRegistry *tools.Registry -} - -// emptyStatePollTickMsg triggers a policy summary fetch for the empty state. -type emptyStatePollTickMsg struct{} - -// emptyStateSummaryLoadedMsg carries an async empty-state summary fetch result. -type emptyStateSummaryLoadedMsg struct { - summary domain.AccountSummary - err error -} - -// New creates a new chat model. -func New( - user *auth.User, - account domain.Account, - workspace domain.Workspace, - theme styles.Theme, - db sqlite.DB, - runtimeDeps usecase.RuntimeDeps, - toolRegistry *tools.Registry, - scope log.Scope, -) *Model { - scope = scope.Child("chat") - - return &Model{ - scope: scope, - inputBar: inputbar.New(user, theme, scope), - messageList: messagelist.New(theme, runtimeDeps, toolRegistry, scope), - user: user, - account: account, - workspace: workspace, - theme: theme, - db: db, - runtimeDeps: runtimeDeps, - toolRegistry: toolRegistry, - } -} - -// Init initializes the model. -func (m *Model) Init() tea.Cmd { - return tea.Batch( - m.inputBar.Init(), - m.pollEmptyState(), - ) -} - -func (m *Model) pollEmptyState() tea.Cmd { - return tea.Tick(2*time.Second, func(time.Time) tea.Msg { - return emptyStatePollTickMsg{} - }) -} diff --git a/internal/app/chat/update.go b/internal/app/chat/update.go deleted file mode 100644 index a2e66c1e..00000000 --- a/internal/app/chat/update.go +++ /dev/null @@ -1,107 +0,0 @@ -package chat - -import ( - tea "charm.land/bubbletea/v2" - - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/usecase" - appevents "github.com/usetero/cli/internal/app/events" -) - -// Update handles messages. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - var cmds []tea.Cmd - - switch msg := msg.(type) { - case tea.KeyPressMsg: - res := m.handleKeyPress(msg) - if res.stop { - return res.cmd - } - if res.handled && res.cmd != nil { - cmds = append(cmds, res.cmd) - } - - case emptyStatePollTickMsg: - return m.handleEmptyStatePoll() - - case emptyStateSummaryLoadedMsg: - m.handleEmptyStateSummary(msg) - return nil - - case tea.MouseClickMsg: - if cmd := m.handleMouseClick(msg); cmd != nil { - cmds = append(cmds, cmd) - } - default: - res := m.handleLifecycleMessage(msg) - if res.stop { - return res.cmd - } - if res.handled && res.cmd != nil { - cmds = append(cmds, res.cmd) - } - } - - // Forward to children. - cmds = append(cmds, m.inputBar.Update(msg)) - cmds = append(cmds, m.messageList.Update(msg)) - - return tea.Batch(cmds...) -} - -func (m *Model) handleStreamFailed(msg msgs.StreamFailed) tea.Cmd { - m.scope.Warn("stream failed", "class", usecase.ClassifyStreamError(m.runtimeDeps.StreamErrorMapper, msg.Err), "error", msg.Err) - - // Forward to round so it transitions to StateFailed. - cmds := []tea.Cmd{m.messageList.Update(msg)} - - // Clean up orphaned messages from DB (async to avoid blocking UI). - if last := m.messageList.LastRound(); last != nil { - ids := last.LastTurnMessageIDs() - if len(ids) > 0 { - if m.session != nil { - m.session.RemoveMessagesByID(ids) - } - cmds = append(cmds, m.cleanupOrphanedMessages(ids)) - } - - // Turn 1: remove round entirely, input bar restores text via pendingText. - if !last.HasAssistantContent() { - m.messageList.RemoveLastRound() - } - // Turn 2+: round stays visible with red error divider. - } - - cmds = append(cmds, m.inputBar.Update(msg)) - cmds = append(cmds, appevents.PublishErrorToastCmd(usecase.UserFacingStreamError(m.runtimeDeps.StreamErrorMapper, msg.Err), msg.Err, false)) - return tea.Batch(cmds...) -} - -// toggleFocus switches focus between editor and messages. -func (m *Model) toggleFocus() tea.Cmd { - switch m.focus { - case focusEditor: - if !m.hasMessages() { - return nil // nothing to focus - } - return m.setFocus(focusMessages) - default: - return m.setFocus(focusEditor) - } -} - -// setFocus sets focus to the given target. -func (m *Model) setFocus(f focus) tea.Cmd { - m.focus = f - switch f { - case focusEditor: - m.messageList.SetFocused(false) - return m.inputBar.Focus() - case focusMessages: - m.inputBar.Blur() - m.messageList.SetFocused(true) - return nil - } - return nil -} diff --git a/internal/app/chat/update_handlers.go b/internal/app/chat/update_handlers.go deleted file mode 100644 index f7032c1a..00000000 --- a/internal/app/chat/update_handlers.go +++ /dev/null @@ -1,114 +0,0 @@ -package chat - -import ( - "time" - - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - - msgs "github.com/usetero/cli/internal/app/chat/events" - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/sqlite" - "github.com/usetero/cli/internal/tea/keymap" -) - -type updateDispatch struct { - cmd tea.Cmd - handled bool - stop bool -} - -func (m *Model) handleKeyPress(msg tea.KeyPressMsg) updateDispatch { - if key.Matches(msg, keymap.Tab) { - return updateDispatch{cmd: m.toggleFocus(), handled: true, stop: true} - } - - if m.focus == focusMessages { - // Enter or esc returns to editor. - if key.Matches(msg, keymap.Send) || key.Matches(msg, keymap.Exit) { - return updateDispatch{cmd: m.setFocus(focusEditor), handled: true, stop: true} - } - // Only forward to message list when it's focused. - return updateDispatch{cmd: m.messageList.Update(msg), handled: true, stop: true} - } - - return updateDispatch{} -} - -func (m *Model) handleEmptyStatePoll() tea.Cmd { - if m.hasMessages() || m.db == nil { - return nil // stop polling once messages exist - } - return tea.Batch(m.fetchEmptyStateSummary(), m.pollEmptyState()) -} - -func (m *Model) fetchEmptyStateSummary() tea.Cmd { - db := m.db - return func() tea.Msg { - ctx, cancel := sqlite.WithTimeout(m.runtimeDeps.EffectContext, time.Second) - defer cancel() - summary, err := db.DatadogAccountStatuses().GetSummary(ctx) - return emptyStateSummaryLoadedMsg{summary: summary, err: err} - } -} - -func (m *Model) handleEmptyStateSummary(msg emptyStateSummaryLoadedMsg) { - if msg.err != nil { - return - } - m.policySummary = &msg.summary -} - -func (m *Model) handleMouseClick(msg tea.MouseClickMsg) tea.Cmd { - // Click on the message list area focuses it. - if m.hasMessages() && msg.Y >= m.originY && msg.Y < m.originY+m.height-m.inputBar.Height() { - if m.focus != focusMessages { - return m.setFocus(focusMessages) - } - return nil - } - if msg.Y >= m.originY+m.height-m.inputBar.Height() && m.focus != focusEditor { - return m.setFocus(focusEditor) - } - return nil -} - -func (m *Model) handleLifecycleMessage(msg tea.Msg) updateDispatch { - switch msg := msg.(type) { - case msgs.UserSubmittedInput: - return updateDispatch{cmd: m.handleUserInput(msg), handled: true} - - case conversationCreated: - m.scope.Info("conversation created", "id", msg.conversationID) - m.conversationID = msg.conversationID - return updateDispatch{cmd: m.persistUserMessage(msg.input), handled: true} - - case userMessagePersisted: - m.scope.Debug("received userMessagePersisted", "message_id", msg.messageID) - return updateDispatch{cmd: m.handlePersistedMessage(msg), handled: true} - - case msgs.StreamCompleted: - if m.session != nil && m.messageList.HasTurn(msg.TurnID) { - m.session.RecordAssistantMessage(msg.Message) - } - return updateDispatch{handled: true} - - case msgs.ToolResultMessagePersisted: - if m.session == nil { - m.session = corechat.NewSession(m.conversationID, nil) - } - m.session.AppendMessage(msg.Message) - return updateDispatch{handled: true} - - case msgs.StreamFailed: - return updateDispatch{cmd: m.handleStreamFailed(msg), handled: true, stop: true} - - case orphanedMessagesCleanupCompleted: - if msg.err != nil { - m.scope.Error("failed to cleanup orphaned messages", "count", len(msg.ids), "error", msg.err) - } - return updateDispatch{handled: true} - } - - return updateDispatch{} -} diff --git a/internal/app/chat/usecase/assistant_persistence.go b/internal/app/chat/usecase/assistant_persistence.go deleted file mode 100644 index 8a284f42..00000000 --- a/internal/app/chat/usecase/assistant_persistence.go +++ /dev/null @@ -1,50 +0,0 @@ -package usecase - -import ( - "context" - - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/sqlite" -) - -type PersistAssistantInput struct { - AccountID domain.AccountID - ConversationID domain.ConversationID - Message domain.Message -} - -type AssistantPersister interface { - PersistAssistant(ctx context.Context, input PersistAssistantInput) (domain.MessageID, error) -} - -type SQLiteAssistantPersister struct { - db sqlite.DB -} - -func NewSQLiteAssistantPersister(db sqlite.DB) *SQLiteAssistantPersister { - return &SQLiteAssistantPersister{db: db} -} - -func (p *SQLiteAssistantPersister) PersistAssistant(ctx context.Context, input PersistAssistantInput) (domain.MessageID, error) { - msgID, err := p.db.Messages().CreateAssistantMessage( - ctx, - input.AccountID, - input.ConversationID, - input.Message.Model, - ) - if err != nil { - return "", err - } - - content, err := domain.EncodeBlocks(input.Message.Content) - if err != nil { - return "", err - } - if err := p.db.Messages().UpdateContent(ctx, msgID, content); err != nil { - return "", err - } - if err := p.db.Messages().UpdateMeta(ctx, msgID, input.Message.Model, input.Message.StopReason); err != nil { - return "", err - } - return msgID, nil -} diff --git a/internal/app/chat/usecase/assistant_persistence_test.go b/internal/app/chat/usecase/assistant_persistence_test.go deleted file mode 100644 index d8f40042..00000000 --- a/internal/app/chat/usecase/assistant_persistence_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package usecase - -import ( - "context" - "testing" - - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/sqlite/sqlitetest" -) - -func TestSQLiteAssistantPersister_PersistAssistant_Success(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - p := NewSQLiteAssistantPersister(db) - - msgID, err := p.PersistAssistant(context.Background(), PersistAssistantInput{ - AccountID: "acct-1", - ConversationID: "conv-1", - Message: domain.Message{ - Model: "claude-3", - StopReason: "end_turn", - Content: []domain.Block{ - {Index: 0, Type: domain.BlockTypeText, Text: &domain.TextBlock{Content: "hello"}}, - }, - }, - }) - if err != nil { - t.Fatalf("PersistAssistant() error = %v", err) - } - if msgID == "" { - t.Fatal("PersistAssistant() returned empty message ID") - } - - got, err := db.Messages().Get(context.Background(), msgID) - if err != nil { - t.Fatalf("db.Messages().Get() error = %v", err) - } - if got.Role != domain.RoleAssistant { - t.Fatalf("role = %q, want %q", got.Role, domain.RoleAssistant) - } - if got.Model != "claude-3" { - t.Fatalf("model = %q, want claude-3", got.Model) - } - if got.StopReason != "end_turn" { - t.Fatalf("stop_reason = %q, want end_turn", got.StopReason) - } - if len(got.Content) != 1 || got.Content[0].Text == nil || got.Content[0].Text.Content != "hello" { - t.Fatalf("content mismatch: %+v", got.Content) - } -} - -func TestSQLiteAssistantPersister_PersistAssistant_ErrorOnMissingSchema(t *testing.T) { - t.Parallel() - - db := sqlitetest.OpenBareDB(t) // no schema tables applied - p := NewSQLiteAssistantPersister(db) - - _, err := p.PersistAssistant(context.Background(), PersistAssistantInput{ - AccountID: "acct-1", - ConversationID: "conv-1", - Message: domain.Message{Model: "claude-3"}, - }) - if err == nil { - t.Fatal("expected error, got nil") - } -} diff --git a/internal/app/chat/usecase/chat_gateway.go b/internal/app/chat/usecase/chat_gateway.go deleted file mode 100644 index b28998fc..00000000 --- a/internal/app/chat/usecase/chat_gateway.go +++ /dev/null @@ -1,12 +0,0 @@ -package usecase - -import ( - "context" - - corechat "github.com/usetero/cli/internal/core/chat" -) - -// ChatGateway is the use-case boundary for chat streaming. -type ChatGateway interface { - StreamSnapshots(ctx context.Context, req StreamRequest, onSnapshot func(corechat.StreamSnapshot)) (*corechat.StreamResult, error) -} diff --git a/internal/app/chat/usecase/chat_gateway_boundary.go b/internal/app/chat/usecase/chat_gateway_boundary.go deleted file mode 100644 index 8aadfccf..00000000 --- a/internal/app/chat/usecase/chat_gateway_boundary.go +++ /dev/null @@ -1,29 +0,0 @@ -package usecase - -import ( - "context" - - chatboundary "github.com/usetero/cli/internal/boundary/chat" - corechat "github.com/usetero/cli/internal/core/chat" -) - -// ChatBoundaryGateway adapts chatboundary.Client to the use-case ChatGateway. -type ChatBoundaryGateway struct { - client chatboundary.Client -} - -func NewChatBoundaryGateway(client chatboundary.Client) *ChatBoundaryGateway { - return &ChatBoundaryGateway{client: client} -} - -func (g *ChatBoundaryGateway) StreamSnapshots(ctx context.Context, req StreamRequest, onSnapshot func(corechat.StreamSnapshot)) (*corechat.StreamResult, error) { - if g == nil || g.client == nil { - return nil, nil - } - wireReq := chatboundary.Request{ - ConversationID: req.ConversationID.String(), - Messages: req.Messages, - ContextEntities: req.ContextEntities, - } - return g.client.StreamSnapshots(ctx, wireReq, onSnapshot) -} diff --git a/internal/app/chat/usecase/chat_gateway_boundary_test.go b/internal/app/chat/usecase/chat_gateway_boundary_test.go deleted file mode 100644 index d135734c..00000000 --- a/internal/app/chat/usecase/chat_gateway_boundary_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package usecase - -import ( - "context" - "testing" - - chat "github.com/usetero/cli/internal/boundary/chat" - "github.com/usetero/cli/internal/boundary/chat/chattest" - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" -) - -func TestChatBoundaryGateway_StreamSnapshots_MapsRequestAndForwards(t *testing.T) { - t.Parallel() - - var captured chat.Request - wantMsg := &domain.Message{ID: "asst-1"} - client := &chattest.MockClient{ - StreamSnapshotsFunc: func(_ context.Context, req chat.Request, onSnapshot func(corechat.StreamSnapshot)) (*corechat.StreamResult, error) { - captured = req - onSnapshot(corechat.StreamSnapshot{ - ConversationID: req.ConversationID, - TurnID: "turn-1", - Seq: 1, - Status: corechat.StreamStatusCompleted, - Done: true, - Message: wantMsg, - }) - return &corechat.StreamResult{Message: wantMsg}, nil - }, - } - - gateway := NewChatBoundaryGateway(client) - var gotSnapshots []corechat.StreamSnapshot - result, err := gateway.StreamSnapshots(context.Background(), StreamRequest{ - ConversationID: "conv-1", - Messages: []domain.Message{{ID: "user-1", Role: domain.RoleUser}}, - }, func(s corechat.StreamSnapshot) { - gotSnapshots = append(gotSnapshots, s) - }) - if err != nil { - t.Fatalf("StreamSnapshots() error = %v", err) - } - if captured.ConversationID != "conv-1" { - t.Fatalf("conversation_id = %q, want conv-1", captured.ConversationID) - } - if len(captured.Messages) != 1 || captured.Messages[0].ID != "user-1" { - t.Fatalf("messages mapping mismatch: %+v", captured.Messages) - } - if len(gotSnapshots) != 1 || gotSnapshots[0].Message != wantMsg { - t.Fatalf("snapshot forwarding mismatch: %+v", gotSnapshots) - } - if result == nil || result.Message != wantMsg { - t.Fatalf("result forwarding mismatch: %+v", result) - } -} - -func TestChatBoundaryGateway_StreamSnapshots_NilGatewayOrClient(t *testing.T) { - t.Parallel() - - var nilGateway *ChatBoundaryGateway - result, err := nilGateway.StreamSnapshots(context.Background(), StreamRequest{ConversationID: "conv-1"}, nil) - if err != nil || result != nil { - t.Fatalf("nil gateway = (%v, %v), want (nil, nil)", result, err) - } - - gateway := NewChatBoundaryGateway(nil) - result, err = gateway.StreamSnapshots(context.Background(), StreamRequest{ConversationID: "conv-1"}, nil) - if err != nil || result != nil { - t.Fatalf("nil client = (%v, %v), want (nil, nil)", result, err) - } -} diff --git a/internal/app/chat/usecase/orphan_cleanup.go b/internal/app/chat/usecase/orphan_cleanup.go deleted file mode 100644 index 02116eaa..00000000 --- a/internal/app/chat/usecase/orphan_cleanup.go +++ /dev/null @@ -1,30 +0,0 @@ -package usecase - -import ( - "context" - - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/sqlite" -) - -// OrphanMessageCleaner removes uncommitted/orphaned messages after cancellation/failure. -type OrphanMessageCleaner interface { - CleanupMessages(ctx context.Context, ids []domain.MessageID) error -} - -type SQLiteOrphanMessageCleaner struct { - db sqlite.DB -} - -func NewSQLiteOrphanMessageCleaner(db sqlite.DB) *SQLiteOrphanMessageCleaner { - return &SQLiteOrphanMessageCleaner{db: db} -} - -func (c *SQLiteOrphanMessageCleaner) CleanupMessages(ctx context.Context, ids []domain.MessageID) error { - for _, id := range ids { - if err := c.db.Messages().Delete(ctx, id); err != nil { - return err - } - } - return nil -} diff --git a/internal/app/chat/usecase/orphan_cleanup_test.go b/internal/app/chat/usecase/orphan_cleanup_test.go deleted file mode 100644 index 2a384abe..00000000 --- a/internal/app/chat/usecase/orphan_cleanup_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package usecase - -import ( - "context" - "testing" - - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/sqlite/sqlitetest" -) - -func TestSQLiteOrphanMessageCleaner_CleanupMessages_Success(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - cleaner := NewSQLiteOrphanMessageCleaner(db) - - ids := make([]domain.MessageID, 0, 2) - for _, text := range []string{"hello", "world"} { - id, err := db.Messages().CreateUserMessage(context.Background(), "acct-1", "conv-1", text) - if err != nil { - t.Fatalf("CreateUserMessage() error = %v", err) - } - ids = append(ids, id) - } - - if err := cleaner.CleanupMessages(context.Background(), ids); err != nil { - t.Fatalf("CleanupMessages() error = %v", err) - } - - for _, id := range ids { - if _, err := db.Messages().Get(context.Background(), id); err == nil { - t.Fatalf("message %s still exists after cleanup", id) - } - } -} - -func TestSQLiteOrphanMessageCleaner_CleanupMessages_ErrorOnMissingSchema(t *testing.T) { - t.Parallel() - - db := sqlitetest.OpenBareDB(t) - cleaner := NewSQLiteOrphanMessageCleaner(db) - - err := cleaner.CleanupMessages(context.Background(), []domain.MessageID{"msg-1"}) - if err == nil { - t.Fatal("expected error, got nil") - } -} diff --git a/internal/app/chat/usecase/runtime_deps.go b/internal/app/chat/usecase/runtime_deps.go deleted file mode 100644 index 21c4c199..00000000 --- a/internal/app/chat/usecase/runtime_deps.go +++ /dev/null @@ -1,39 +0,0 @@ -package usecase - -import ( - "context" - - chatboundary "github.com/usetero/cli/internal/boundary/chat" - "github.com/usetero/cli/internal/sqlite" -) - -// RuntimeDeps contains orchestration dependencies for app/chat. -type RuntimeDeps struct { - StreamRunner StreamRunner - StreamErrorMapper StreamErrorMapper - AssistantPersister AssistantPersister - ToolLoop ToolLoop - OrphanCleaner OrphanMessageCleaner - EffectContext context.Context -} - -func NewRuntimeDeps(db sqlite.DB, client chatboundary.Client) RuntimeDeps { - gateway := NewChatBoundaryGateway(client) - return RuntimeDeps{ - StreamRunner: NewChatStreamRunner(gateway), - StreamErrorMapper: NewChatBoundaryStreamErrorMapper(), - AssistantPersister: NewSQLiteAssistantPersister(db), - ToolLoop: NewSQLiteToolLoop(db), - OrphanCleaner: NewSQLiteOrphanMessageCleaner(db), - EffectContext: context.Background(), - } -} - -// WithEffectContext returns a copy that uses ctx as the base for UI-triggered effects. -func (d RuntimeDeps) WithEffectContext(ctx context.Context) RuntimeDeps { - if ctx == nil { - ctx = context.Background() - } - d.EffectContext = ctx - return d -} diff --git a/internal/app/chat/usecase/stream_errors.go b/internal/app/chat/usecase/stream_errors.go deleted file mode 100644 index b5b89636..00000000 --- a/internal/app/chat/usecase/stream_errors.go +++ /dev/null @@ -1,20 +0,0 @@ -package usecase - -type StreamErrorMapper interface { - Classify(err error) string - UserFacing(err error) string -} - -func ClassifyStreamError(mapper StreamErrorMapper, err error) string { - if mapper == nil { - return "unknown" - } - return mapper.Classify(err) -} - -func UserFacingStreamError(mapper StreamErrorMapper, err error) string { - if mapper == nil { - return "Chat stream failed. Please try again." - } - return mapper.UserFacing(err) -} diff --git a/internal/app/chat/usecase/stream_errors_boundary.go b/internal/app/chat/usecase/stream_errors_boundary.go deleted file mode 100644 index b2742733..00000000 --- a/internal/app/chat/usecase/stream_errors_boundary.go +++ /dev/null @@ -1,17 +0,0 @@ -package usecase - -import chatboundary "github.com/usetero/cli/internal/boundary/chat" - -type ChatBoundaryStreamErrorMapper struct{} - -func NewChatBoundaryStreamErrorMapper() *ChatBoundaryStreamErrorMapper { - return &ChatBoundaryStreamErrorMapper{} -} - -func (m *ChatBoundaryStreamErrorMapper) Classify(err error) string { - return string(chatboundary.ClassifyStreamError(err)) -} - -func (m *ChatBoundaryStreamErrorMapper) UserFacing(err error) string { - return chatboundary.UserFacingStreamError(err) -} diff --git a/internal/app/chat/usecase/stream_errors_boundary_test.go b/internal/app/chat/usecase/stream_errors_boundary_test.go deleted file mode 100644 index e611a6ae..00000000 --- a/internal/app/chat/usecase/stream_errors_boundary_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package usecase - -import ( - "context" - "testing" -) - -func TestChatBoundaryStreamErrorMapper(t *testing.T) { - t.Parallel() - - m := NewChatBoundaryStreamErrorMapper() - if got := m.Classify(context.Canceled); got == "" { - t.Fatal("classify should not be empty") - } - if got := m.UserFacing(context.Canceled); got == "" { - t.Fatal("user-facing should not be empty") - } -} diff --git a/internal/app/chat/usecase/stream_errors_test.go b/internal/app/chat/usecase/stream_errors_test.go deleted file mode 100644 index 926ef3d7..00000000 --- a/internal/app/chat/usecase/stream_errors_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package usecase - -import ( - "errors" - "testing" -) - -type fakeErrorMapper struct { - classify string - userFacing string -} - -func (f fakeErrorMapper) Classify(error) string { return f.classify } -func (f fakeErrorMapper) UserFacing(error) string { return f.userFacing } - -func TestClassifyStreamError(t *testing.T) { - t.Parallel() - - if got := ClassifyStreamError(nil, errors.New("x")); got != "unknown" { - t.Fatalf("nil mapper classify = %q, want unknown", got) - } - if got := ClassifyStreamError(fakeErrorMapper{classify: "timeout"}, errors.New("x")); got != "timeout" { - t.Fatalf("classify = %q, want timeout", got) - } -} - -func TestUserFacingStreamError(t *testing.T) { - t.Parallel() - - if got := UserFacingStreamError(nil, errors.New("x")); got == "" { - t.Fatal("nil mapper user-facing should not be empty") - } - if got := UserFacingStreamError(fakeErrorMapper{userFacing: "Readable"}, errors.New("x")); got != "Readable" { - t.Fatalf("user-facing = %q, want Readable", got) - } -} diff --git a/internal/app/chat/usecase/stream_request.go b/internal/app/chat/usecase/stream_request.go deleted file mode 100644 index d4555e8d..00000000 --- a/internal/app/chat/usecase/stream_request.go +++ /dev/null @@ -1,10 +0,0 @@ -package usecase - -import "github.com/usetero/cli/internal/domain" - -// StreamRequest is the app-level request for one assistant turn stream. -type StreamRequest struct { - ConversationID domain.ConversationID - Messages []domain.Message - ContextEntities []domain.ContextEntity -} diff --git a/internal/app/chat/usecase/stream_runner.go b/internal/app/chat/usecase/stream_runner.go deleted file mode 100644 index f7beac4c..00000000 --- a/internal/app/chat/usecase/stream_runner.go +++ /dev/null @@ -1,75 +0,0 @@ -package usecase - -import ( - "context" - - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" -) - -// StreamUpdate is a normalized stream event consumed by UI models. -type StreamUpdate struct { - Message *domain.Message - Status corechat.StreamStatus - AbortReason string - Result *corechat.StreamResult - Err error - Done bool -} - -// StreamRunner executes one chat stream request and emits ordered updates. -type StreamRunner interface { - Start(ctx context.Context, req StreamRequest) <-chan StreamUpdate -} - -// ChatStreamRunner bridges boundary chat snapshots into use-case updates. -type ChatStreamRunner struct { - gateway ChatGateway -} - -func NewChatStreamRunner(gateway ChatGateway) *ChatStreamRunner { - return &ChatStreamRunner{gateway: gateway} -} - -func (r *ChatStreamRunner) Start(ctx context.Context, req StreamRequest) <-chan StreamUpdate { - updates := make(chan StreamUpdate, 10) - if r == nil || r.gateway == nil { - close(updates) - return updates - } - - go func() { - defer close(updates) - - var lastSnapshot *corechat.StreamSnapshot - result, err := r.gateway.StreamSnapshots(ctx, req, func(s corechat.StreamSnapshot) { - ss := s - lastSnapshot = &ss - if !s.Done { - updates <- StreamUpdate{Message: s.Message, Status: s.Status} - } - }) - if err != nil { - updates <- StreamUpdate{Err: err, Done: true} - return - } - - var finalMsg *domain.Message - var status corechat.StreamStatus - var abort string - if lastSnapshot != nil { - finalMsg = lastSnapshot.Message - status = lastSnapshot.Status - abort = lastSnapshot.AbortReason - } - updates <- StreamUpdate{ - Message: finalMsg, - Status: status, - AbortReason: abort, - Result: result, - Done: true, - } - }() - - return updates -} diff --git a/internal/app/chat/usecase/stream_runner_test.go b/internal/app/chat/usecase/stream_runner_test.go deleted file mode 100644 index 293bef59..00000000 --- a/internal/app/chat/usecase/stream_runner_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package usecase - -import ( - "context" - "errors" - "testing" - - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" -) - -type fakeGateway struct { - streamSnapshots func(ctx context.Context, req StreamRequest, onSnapshot func(corechat.StreamSnapshot)) (*corechat.StreamResult, error) -} - -func (f *fakeGateway) StreamSnapshots(ctx context.Context, req StreamRequest, onSnapshot func(corechat.StreamSnapshot)) (*corechat.StreamResult, error) { - if f.streamSnapshots != nil { - return f.streamSnapshots(ctx, req, onSnapshot) - } - return &corechat.StreamResult{}, nil -} - -func TestChatStreamRunner_Start_ForwardsSnapshotsAndFinalResult(t *testing.T) { - t.Parallel() - - reqs := make([]StreamRequest, 0, 1) - streamMsg := &domain.Message{ID: "asst-1", Role: domain.RoleAssistant} - finalResult := &corechat.StreamResult{ - Message: streamMsg, - Metadata: &corechat.StreamMetadata{ - Title: "Hello", - ContextWindow: 200000, - InputTokens: 12, - OutputTokens: 4, - }, - } - - gateway := &fakeGateway{ - streamSnapshots: func(_ context.Context, req StreamRequest, onSnapshot func(corechat.StreamSnapshot)) (*corechat.StreamResult, error) { - reqs = append(reqs, req) - onSnapshot(corechat.StreamSnapshot{ - ConversationID: req.ConversationID.String(), - TurnID: "turn-1", - Seq: 1, - Status: corechat.StreamStatusStreaming, - Message: streamMsg, - }) - onSnapshot(corechat.StreamSnapshot{ - ConversationID: req.ConversationID.String(), - TurnID: "turn-1", - Seq: 2, - Status: corechat.StreamStatusCompleted, - Done: true, - Message: streamMsg, - Metadata: finalResult.Metadata, - }) - return finalResult, nil - }, - } - - runner := NewChatStreamRunner(gateway) - updates := runner.Start(context.Background(), StreamRequest{ - ConversationID: "conv-1", - Messages: []domain.Message{{ID: "user-1", Role: domain.RoleUser}}, - }) - - var got []StreamUpdate - for u := range updates { - got = append(got, u) - } - if len(got) != 2 { - t.Fatalf("updates = %d, want 2", len(got)) - } - if got[0].Done { - t.Fatal("first update should be non-terminal") - } - if got[0].Status != corechat.StreamStatusStreaming { - t.Fatalf("first status = %q, want %q", got[0].Status, corechat.StreamStatusStreaming) - } - if !got[1].Done { - t.Fatal("last update should be terminal") - } - if got[1].Status != corechat.StreamStatusCompleted { - t.Fatalf("last status = %q, want %q", got[1].Status, corechat.StreamStatusCompleted) - } - if got[1].Result == nil || got[1].Result.Metadata == nil || got[1].Result.Metadata.Title != "Hello" { - t.Fatalf("terminal result metadata missing/invalid: %+v", got[1].Result) - } - if len(reqs) != 1 || reqs[0].ConversationID != "conv-1" { - t.Fatalf("request mapping mismatch: %+v", reqs) - } -} - -func TestChatStreamRunner_Start_EmitsTerminalErrorUpdate(t *testing.T) { - t.Parallel() - - gateway := &fakeGateway{ - streamSnapshots: func(_ context.Context, _ StreamRequest, _ func(corechat.StreamSnapshot)) (*corechat.StreamResult, error) { - return nil, errors.New("network timeout") - }, - } - - runner := NewChatStreamRunner(gateway) - updates := runner.Start(context.Background(), StreamRequest{ConversationID: "conv-1"}) - var got []StreamUpdate - for u := range updates { - got = append(got, u) - } - if len(got) != 1 { - t.Fatalf("updates = %d, want 1", len(got)) - } - if !got[0].Done { - t.Fatal("error update must be terminal") - } - if got[0].Err == nil || got[0].Err.Error() != "network timeout" { - t.Fatalf("err = %v, want network timeout", got[0].Err) - } -} - -func TestChatStreamRunner_Start_NilClientClosesImmediately(t *testing.T) { - t.Parallel() - - var runner *ChatStreamRunner - updates := runner.Start(context.Background(), StreamRequest{ConversationID: "conv-1"}) - if _, ok := <-updates; ok { - t.Fatal("expected closed updates channel for nil runner") - } -} diff --git a/internal/app/chat/usecase/tool_loop.go b/internal/app/chat/usecase/tool_loop.go deleted file mode 100644 index b65351f0..00000000 --- a/internal/app/chat/usecase/tool_loop.go +++ /dev/null @@ -1,66 +0,0 @@ -package usecase - -import ( - "context" - - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" - domaintools "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/sqlite" -) - -type PrepareNextTurnInput struct { - AccountID domain.AccountID - ConversationID domain.ConversationID - Results []domaintools.Result - Session *corechat.Session -} - -type PreparedNextTurn struct { - MessageID domain.MessageID - Messages []domain.Message - ToolResultMessage domain.Message -} - -type ToolLoop interface { - PrepareNextTurn(ctx context.Context, input PrepareNextTurnInput) (PreparedNextTurn, error) -} - -type SQLiteToolLoop struct { - db sqlite.DB -} - -func NewSQLiteToolLoop(db sqlite.DB) *SQLiteToolLoop { - return &SQLiteToolLoop{db: db} -} - -func (t *SQLiteToolLoop) PrepareNextTurn(ctx context.Context, input PrepareNextTurnInput) (PreparedNextTurn, error) { - domainResults := make([]domain.ToolResult, len(input.Results)) - for i, r := range input.Results { - domainResults[i] = domain.ToolResult{ - ToolUseID: r.ToolUseID, - IsError: r.IsError(), - Content: r.ToMap(), - } - if r.Error != nil { - domainResults[i].Error = r.Error.Message - } - } - - msgID, err := t.db.Messages().CreateToolResultMessage(ctx, input.AccountID, input.ConversationID, domainResults) - if err != nil { - msgID = domain.NewMessageID() - } - - session := input.Session - if session == nil { - session = corechat.NewSession(input.ConversationID, nil) - } - toolResultMessage := session.AppendUserToolResultsMessage(msgID, domainResults) - - return PreparedNextTurn{ - MessageID: msgID, - Messages: session.Messages(), - ToolResultMessage: toolResultMessage, - }, err -} diff --git a/internal/app/chat/usecase/tool_loop_test.go b/internal/app/chat/usecase/tool_loop_test.go deleted file mode 100644 index a9bdb6de..00000000 --- a/internal/app/chat/usecase/tool_loop_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package usecase - -import ( - "context" - "testing" - - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" - domaintools "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/sqlite/sqlitetest" -) - -func TestSQLiteToolLoop_PrepareNextTurn_AppendsToolResultToSession(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - loop := NewSQLiteToolLoop(db) - - session := corechat.NewSession("conv-1", []domain.Message{ - { - ID: "user-1", - Role: domain.RoleUser, - Content: []domain.Block{ - {Type: domain.BlockTypeText, Text: &domain.TextBlock{Content: "hi"}}, - }, - }, - }) - - out, err := loop.PrepareNextTurn(context.Background(), PrepareNextTurnInput{ - AccountID: "acct-1", - ConversationID: "conv-1", - Results: []domaintools.Result{ - { - ToolUseID: "tool-1", - Content: map[string]any{"ok": true}, - }, - }, - Session: session, - }) - if err != nil { - t.Fatalf("PrepareNextTurn() error = %v", err) - } - if out.MessageID == "" { - t.Fatal("MessageID is empty") - } - if out.ToolResultMessage.ID != out.MessageID { - t.Fatalf("tool result message id = %q, want %q", out.ToolResultMessage.ID, out.MessageID) - } - if len(out.Messages) != 2 { - t.Fatalf("messages len = %d, want 2", len(out.Messages)) - } - last := out.Messages[len(out.Messages)-1] - if last.Role != domain.RoleUser { - t.Fatalf("last role = %q, want %q", last.Role, domain.RoleUser) - } - if len(last.Content) != 1 || last.Content[0].Type != domain.BlockTypeToolResult { - t.Fatalf("last content mismatch: %+v", last.Content) - } - if last.Content[0].ToolResult == nil || last.Content[0].ToolResult.ToolUseID != "tool-1" { - t.Fatalf("tool_result mismatch: %+v", last.Content[0].ToolResult) - } -} - -func TestSQLiteToolLoop_PrepareNextTurn_ContinuesOnPersistenceFailure(t *testing.T) { - t.Parallel() - - db := sqlitetest.OpenBareDB(t) // missing schema to force persistence failure - loop := NewSQLiteToolLoop(db) - - out, err := loop.PrepareNextTurn(context.Background(), PrepareNextTurnInput{ - AccountID: "acct-1", - ConversationID: "conv-1", - Results: []domaintools.Result{ - {ToolUseID: "tool-1", Content: map[string]any{"k": "v"}}, - }, - Session: nil, - }) - if err == nil { - t.Fatal("expected persistence error, got nil") - } - if out.MessageID == "" { - t.Fatal("expected fallback generated MessageID") - } - if len(out.Messages) != 1 { - t.Fatalf("messages len = %d, want 1", len(out.Messages)) - } - if out.ToolResultMessage.ID == "" || out.ToolResultMessage.ID != out.MessageID { - t.Fatalf("toolResultMessage ID mismatch: %+v", out.ToolResultMessage) - } -} diff --git a/internal/app/chat/view.go b/internal/app/chat/view.go deleted file mode 100644 index 8727a023..00000000 --- a/internal/app/chat/view.go +++ /dev/null @@ -1,132 +0,0 @@ -package chat - -import ( - "fmt" - "math" - "strings" - - "charm.land/lipgloss/v2" - "github.com/usetero/cli/internal/domain" -) - -// View renders the chat. This is a flexible component - renders exactly to SetSize dimensions. -func (m *Model) View() string { - if m.width == 0 || m.height == 0 { - return "" - } - - // Empty state: context-aware greeting + suggestions + input bar. - if !m.hasMessages() { - emptyHeight := m.height - m.inputBar.Height() - emptyView := lipgloss.NewStyle(). - Width(m.width). - Height(emptyHeight). - Align(lipgloss.Center, lipgloss.Center). - Render(m.emptyStateContent()) - - return lipgloss.JoinVertical(lipgloss.Left, emptyView, m.inputBar.View()) - } - - // Normal state: message list + spacer + input bar. - spacer := lipgloss.NewStyle().Width(m.width).Background(m.theme.Bg).Render("") - return lipgloss.JoinVertical(lipgloss.Left, m.messageList.View(), spacer, m.inputBar.View()) -} - -// emptyStateContent renders the context-aware empty state. -func (m *Model) emptyStateContent() string { - colors := m.theme - text := lipgloss.NewStyle().Foreground(colors.Text).Background(colors.Bg) - muted := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) - - name := "" - if m.user != nil && m.user.FirstName != "" { - name = m.user.FirstName - } - - var headline string - var suggestions []string - - s := m.policySummary - if s == nil { - // Still loading. - if name != "" { - headline = fmt.Sprintf("Hey %s — loading your environment...", name) - } else { - headline = "Loading your environment..." - } - } else if s.ActiveServices == 0 { - // No services enabled — guide them to get started. - if name != "" { - headline = fmt.Sprintf("Welcome, %s — let's get your environment set up.", name) - } else { - headline = "Let's get your environment set up." - } - suggestions = []string{ - "Help me get started", - "What services do I have?", - "What does Tero do?", - } - } else if s.PendingPolicyCount > 0 { - // Pending work. - wp := wastePercent(*s) - if name != "" { - headline = fmt.Sprintf("Hey %s — %d%% waste across your services. Let's dig in:", name, wp) - } else { - headline = fmt.Sprintf("%d%% waste across your services. Let's dig in:", wp) - } - suggestions = []string{ - "Walk me through the pending policies", - "Which policies should I approve first?", - "Show me what's driving the most waste", - } - } else if s.ApprovedPolicyCount > 0 { - // Mid-journey — approved some, none pending. - if name != "" { - headline = fmt.Sprintf("Nice work, %s — %d policies approved. Let's keep going:", name, s.ApprovedPolicyCount) - } else { - headline = fmt.Sprintf("%d policies approved. Let's keep going:", s.ApprovedPolicyCount) - } - suggestions = []string{ - "How are the approved policies performing?", - "Are there any new recommendations?", - "Show me observed savings so far", - } - } else { - // All clean — no pending, no approved. - if name != "" { - headline = fmt.Sprintf("Looking good, %s — I'm watching for changes.", name) - } else { - headline = "Looking good — I'm watching for changes." - } - suggestions = []string{ - "What services are generating the most logs?", - "Show me a summary of my environment", - "Any optimization opportunities?", - } - } - - var lines []string - lines = append(lines, text.Render(headline)) - lines = append(lines, "") - for _, s := range suggestions { - lines = append(lines, muted.Render(" → "+s)) - } - - return strings.Join(lines, "\n") -} - -// wastePercent computes waste % preferring bytes. -func wastePercent(s domain.AccountSummary) int { - if s.TotalBytesPerHour != nil && *s.TotalBytesPerHour > 0 && - s.EstimatedBytesPerHour != nil && *s.EstimatedBytesPerHour > 0 { - return int(math.Round(*s.EstimatedBytesPerHour / *s.TotalBytesPerHour * 100)) - } - if s.EstimatedCostPerHour != nil && s.TotalCostPerHour != nil && *s.TotalCostPerHour > 0 { - return int(math.Round(*s.EstimatedCostPerHour / *s.TotalCostPerHour * 100)) - } - if s.TotalVolumePerHour != nil && *s.TotalVolumePerHour > 0 && - s.EstimatedVolumePerHour != nil && *s.EstimatedVolumePerHour > 0 { - return int(math.Round(*s.EstimatedVolumePerHour / *s.TotalVolumePerHour * 100)) - } - return 0 -} diff --git a/internal/app/chattools/approvepolicy.go b/internal/app/chattools/approvepolicy.go deleted file mode 100644 index a0332c64..00000000 --- a/internal/app/chattools/approvepolicy.go +++ /dev/null @@ -1,69 +0,0 @@ -package tools - -import ( - "encoding/json" - "fmt" - - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/action" - "github.com/usetero/cli/internal/boundary/chat" - "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/sqlite" -) - -// NewApprovePolicyAction creates an ActionTool for approve_policy. -// userIDFunc is called at execution time to get the current user's ID. -func NewApprovePolicyAction(db sqlite.DB, userIDFunc func() string) ActionTool { - def := chat.Tool{ - Name: "approve_policy", - Description: "Approve a log event policy for enforcement. This marks the policy as approved and queues it for the enforcement pipeline.", - InputSchema: chat.NewObjectSchema( - map[string]chat.Property{ - "policy_id": { - Type: "string", - Description: "UUID of the policy (e.g., '4a3b1c2d-...'). Use the query tool to look up policy IDs: SELECT policy_id, service_name, log_event_name, category FROM log_event_policy_statuses_cache WHERE status = 'PENDING'", - }, - }, - []string{"policy_id"}, - ), - } - - executor := func(input json.RawMessage) (tools.Result, error) { - var in tools.ApprovePolicyInput - if err := json.Unmarshal(input, &in); err != nil { - return tools.Result{}, err - } - - ctx, cancel := withToolTimeout() - defer cancel() - - if err := db.LogEventPolicies().Approve(ctx, in.PolicyID, userIDFunc()); err != nil { - return tools.Result{}, fmt.Errorf("approve policy: %w", err) - } - - return tools.Result{ - Content: tools.ApprovePolicyResult{ - PolicyID: in.PolicyID, - Status: "APPROVED", - }.ToMap(), - }, nil - } - - config := action.Config{ - DisplayName: func(_ json.RawMessage) string { - return "Approve Policy" - }, - Status: func(input json.RawMessage) string { - var in tools.ApprovePolicyInput - if json.Unmarshal(input, &in) != nil { - return "" - } - return fmt.Sprintf("Approving %s", in.PolicyID) - }, - Result: func(result tools.Result) string { - id, _ := result.Content["policy_id"].(string) - return fmt.Sprintf("Policy %s approved", id) - }, - } - - return NewActionTool(def, executor, config) -} diff --git a/internal/app/chattools/query.go b/internal/app/chattools/query.go deleted file mode 100644 index 9c300ea5..00000000 --- a/internal/app/chattools/query.go +++ /dev/null @@ -1,225 +0,0 @@ -package tools - -import ( - "context" - _ "embed" - "encoding/json" - "fmt" - "strings" - "time" - - "github.com/usetero/cli/internal/boundary/chat" - "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/sqlite" -) - -//go:embed query_schema.sql -var querySchema string - -// QueryTool executes read-only SQL queries against the local catalog. -type QueryTool struct { - db sqlite.DB - scope log.Scope -} - -// NewQueryTool creates a new query tool. -func NewQueryTool(db sqlite.DB, scope log.Scope) *QueryTool { - return &QueryTool{db: db, scope: scope.Child("query_tool")} -} - -// Name returns the tool name. -func (t *QueryTool) Name() string { - return "query" -} - -// Definition returns the tool definition for the chat API. -func (t *QueryTool) Definition() chat.Tool { - return chat.Tool{ - Name: t.Name(), - Description: fmt.Sprintf(`Read-only SQL against the local SQLite catalog (synced from Tero control plane). - -## Schema - -%s - -## Query rules - -1. SELECT only — the database is read-only. -2. Prefer *_cache tables for current status and metrics. -3. Timestamps are ISO 8601 strings. Use LIKE for pattern matching. - -## JOINs - -JOIN on a table's id column is fast: - JOIN log_events le ON le.id = p.log_event_id -- PK lookup, indexed - -JOIN on any other column (service_id, log_event_id, etc.) is a full table scan and will freeze the UI. Use a correlated subquery instead: - - -- WRONG: hangs for 30-60s - LEFT JOIN log_event_statuses_cache les ON les.log_event_id = p.log_event_id - - -- RIGHT: instant - (SELECT les.volume_per_hour FROM log_event_statuses_cache les WHERE les.log_event_id = p.log_event_id) AS volume_per_hour - -Pull each column you need as a separate subquery. This applies to all tables.`, querySchema), - InputSchema: chat.NewObjectSchema( - map[string]chat.Property{ - "sql": { - Type: "string", - Description: "The SQL query to execute", - }, - "status": { - Type: "string", - Description: "Message shown while the query runs (e.g., 'Checking service status', 'Looking for errors')", - }, - "result": { - Type: "string", - Description: "Message shown when complete. Use {count} for row count if relevant (e.g., 'Found {count} services'). Omit {count} for aggregates (e.g., 'Calculated')", - }, - }, - []string{"sql", "status", "result"}, - ), - } -} - -// Execute runs the query and returns a typed result. -func (t *QueryTool) Execute(input json.RawMessage) (tools.QueryResult, error) { - start := time.Now() - var in tools.QueryInput - if err := json.Unmarshal(input, &in); err != nil { - return tools.QueryResult{}, err - } - - ctx, cancel := withToolTimeout() - defer cancel() - - // Reject queries with full table scans on JOINed tables — these hang for 30-60s. - if err := t.checkQueryPlan(ctx, in.SQL); err != nil { - return tools.QueryResult{}, err - } - - // Use the read pool — every connection has query_only = ON enforced by the driver - rows, err := t.db.ReadRaw().QueryContext(ctx, in.SQL) - if err != nil { - return tools.QueryResult{}, err - } - defer rows.Close() - - cols, err := rows.Columns() - if err != nil { - return tools.QueryResult{}, err - } - - results := make([]map[string]any, 0, 64) - rowsDropped := 0 - totalBytes := 2 // [] in JSON - for rows.Next() { - values := make([]any, len(cols)) - ptrs := make([]any, len(cols)) - for i := range values { - ptrs[i] = &values[i] - } - - if err := rows.Scan(ptrs...); err != nil { - return tools.QueryResult{}, err - } - - row := make(map[string]any, len(cols)) - for i, col := range cols { - row[col] = values[i] - } - capRowFields(row) - - rowBytes, err := json.Marshal(row) - if err != nil { - return tools.QueryResult{}, err - } - nextTotal := totalBytes + len(rowBytes) - if len(results) > 0 { - nextTotal++ // JSON comma between rows - } - - if len(results) < maxResultRows && nextTotal <= maxResultBytes { - results = append(results, row) - totalBytes = nextTotal - continue - } - rowsDropped++ - } - - if err := rows.Err(); err != nil { - return tools.QueryResult{}, err - } - - result := tools.QueryResult{Rows: results, RowsDropped: rowsDropped} - duration := time.Since(start) - if rowsDropped > 0 { - t.scope.Info("query result capped", - "duration", duration, - "rows_returned", len(result.Rows), - "rows_dropped", rowsDropped, - ) - } else { - t.scope.Debug("query executed", - "duration", duration, - "rows_returned", len(result.Rows), - ) - } - return result, nil -} - -const ( - maxResultRows = 500 - maxFieldBytes = 4096 // truncate any string value longer than this - maxResultBytes = 102400 // drop trailing rows when total JSON exceeds this -) - -func capRowFields(row map[string]any) { - for k, v := range row { - s, ok := v.(string) - if ok && len(s) > maxFieldBytes { - row[k] = s[:maxFieldBytes] + "…(truncated)" - } - } -} - -// checkQueryPlan runs EXPLAIN QUERY PLAN and rejects queries that would do -// a full table scan on a JOINed table (SCAN ... JOIN). These take 30-60s -// due to a SQLite limitation with expression indexes through views. -func (t *QueryTool) checkQueryPlan(ctx context.Context, sql string) error { - rows, err := t.db.ReadRaw().QueryContext(ctx, "EXPLAIN QUERY PLAN "+sql) - if err != nil { - return nil // let the actual query surface the error - } - defer rows.Close() - - var scans []string - for rows.Next() { - var id, parent, notUsed int - var detail string - if err := rows.Scan(&id, &parent, ¬Used, &detail); err != nil { - return nil - } - if strings.Contains(detail, "SCAN") && strings.Contains(detail, "JOIN") { - scans = append(scans, detail) - } - } - - if err := rows.Err(); err != nil { - return nil - } - - if len(scans) == 0 { - return nil - } - - return fmt.Errorf( - "query rejected: full table scan detected on a JOINed table, which would hang for 30-60 seconds.\n\n"+ - "Problem: %s\n\n"+ - "Fix: replace the JOIN with a correlated subquery. "+ - "JOIN on a table's id column is fast (e.g., JOIN log_events le ON le.id = p.log_event_id). "+ - "For any other column, use: (SELECT col FROM table WHERE foreign_key = outer.value) AS alias", - strings.Join(scans, "; "), - ) -} diff --git a/internal/app/chattools/query_schema.sql b/internal/app/chattools/query_schema.sql deleted file mode 100644 index 65b13e4e..00000000 --- a/internal/app/chattools/query_schema.sql +++ /dev/null @@ -1,431 +0,0 @@ --- Tero Client Schema --- --- This describes the local SQLite database synced from the Tero control plane. --- All data is scoped to the authenticated user's account. --- --- Key concepts: --- services - Applications producing logs (e.g., 'checkout-service') --- log_events - Distinct event patterns within a service --- policies - AI-identified waste (health checks, duplicate fields, bloat) --- *_cache - Pre-computed status and metrics (query these for current state) --- --- All queries are READ-ONLY. This is a local sync of server data. - --- Live working set of entities (services, log events) referenced in a conversation -CREATE TABLE conversation_contexts ( - id TEXT, -- Unique identifier - account_id TEXT, -- Denormalized for tenant isolation. Auto-set via trigger from conversation.account_id. - added_by TEXT, -- Who added this entity to context. user: added via @-reference, assistant: added by AI during chat. - conversation_id TEXT, -- Conversation this context belongs to - created_at TEXT, -- When the entity was added to context - entity_id TEXT, -- ID of the context entity - entity_type TEXT -- Type of the context entity. service: an application producing logs, log_event: a specific event pattern. -); - --- Chat session between a user and the AI assistant within a workspace -CREATE TABLE conversations ( - id TEXT, -- Unique identifier - account_id TEXT, -- Denormalized for tenant isolation. Auto-set via trigger from workspace.account_id. - created_at TEXT, -- When the conversation was created - title TEXT, -- AI-generated title, set after first exchange - user_id TEXT, -- WorkOS user ID who owns this conversation - view_id TEXT, -- If set, this conversation is for iterating on a specific view - workspace_id TEXT -- Workspace this conversation belongs to -); - --- Cache table for datadog_account_statuses view. Refreshed by cron service. -CREATE TABLE datadog_account_statuses_cache ( - id TEXT, - account_id TEXT, -- Account ID (denormalized from Datadog account) - datadog_account_id TEXT, -- The Datadog account this status belongs to - disabled_services INTEGER, -- Services with DISABLED health - estimated_bytes_reduction_per_hour REAL, -- Account-wide estimated bytes reduction - estimated_cost_reduction_per_hour_bytes_usd REAL, -- Account-wide estimated bytes-based USD/hour savings - estimated_cost_reduction_per_hour_usd REAL, -- Account-wide estimated total USD/hour savings - estimated_cost_reduction_per_hour_volume_usd REAL, -- Account-wide estimated volume-based USD/hour savings - estimated_volume_reduction_per_hour REAL, -- Account-wide estimated volume reduction - health TEXT, -- Overall health of the Datadog account. DISABLED (integration turned off), INACTIVE (no data received), OK (healthy). - inactive_services INTEGER, -- Services with INACTIVE health - log_active_services INTEGER, -- Services not DISABLED or INACTIVE - log_event_analyzed_count INTEGER, -- Number of log events that have been analyzed - log_event_bytes_per_hour REAL, -- Discovered log event throughput in bytes/hour across all services - log_event_cost_per_hour_bytes_usd REAL, -- Discovered log event ingestion cost in USD/hour across all services - log_event_cost_per_hour_usd REAL, -- Discovered log event total cost in USD/hour across all services - log_event_cost_per_hour_volume_usd REAL, -- Discovered log event indexing cost in USD/hour across all services - log_event_count INTEGER, -- Total log events across all services - log_event_volume_per_hour REAL, -- Discovered log event throughput in events/hour across all services - log_service_count INTEGER, -- Total number of services - observed_bytes_per_hour_after REAL, -- Account-wide observed current bytes - observed_bytes_per_hour_before REAL, -- Account-wide observed pre-approval bytes - observed_cost_per_hour_after_bytes_usd REAL, -- Account-wide measured bytes-based USD/hour cost after approval - observed_cost_per_hour_after_usd REAL, -- Account-wide measured total USD/hour cost after approval - observed_cost_per_hour_after_volume_usd REAL, -- Account-wide measured volume-based USD/hour cost after approval - observed_cost_per_hour_before_bytes_usd REAL, -- Account-wide measured bytes-based USD/hour cost before approval - observed_cost_per_hour_before_usd REAL, -- Account-wide measured total USD/hour cost before approval - observed_cost_per_hour_before_volume_usd REAL, -- Account-wide measured volume-based USD/hour cost before approval - observed_volume_per_hour_after REAL, -- Account-wide observed current volume - observed_volume_per_hour_before REAL, -- Account-wide observed pre-approval volume - ok_services INTEGER, -- Services with OK health - policy_approved_count INTEGER, -- Policies approved by user - policy_dismissed_count INTEGER, -- Policies dismissed by user - policy_pending_count INTEGER, -- Policies awaiting user action - policy_pending_critical_count INTEGER, -- Pending policies with critical compliance severity - policy_pending_high_count INTEGER, -- Pending policies with high compliance severity - policy_pending_low_count INTEGER, -- Pending policies with low compliance severity - policy_pending_medium_count INTEGER, -- Pending policies with medium compliance severity - ready_for_use INTEGER, -- True when at least 1 log event has been analyzed - service_cost_per_hour_volume_usd REAL, -- Service-level indexing cost in USD/hour across all services - service_volume_per_hour REAL -- Ground-truth throughput in events/hour from service_log_volumes across all services -); - --- Datadog integration configuration for an account, one per account -CREATE TABLE datadog_accounts ( - id TEXT, -- Unique identifier of the Datadog configuration - account_id TEXT, -- Parent account this configuration belongs to - cost_per_gb_ingested REAL, -- Cost per GB of log data ingested (USD). NULL = using Datadog's published rate ($0.10/GB). Set to override with actual contract rate. - created_at TEXT, -- When the Datadog account was created - name TEXT, -- Display name for this Datadog account - site TEXT -- Datadog regional site. US1: datadoghq.com, US3: us3.datadoghq.com, US5: us5.datadoghq.com, EU1: datadoghq.eu, US1_FED: ddog-gov.com, AP1: ap1.datadoghq.com, AP2: ap2.datadoghq.com. -); - --- Discovered Datadog log index where logs are stored (e.g., main, security, compliance) -CREATE TABLE datadog_log_indexes ( - id TEXT, -- Unique identifier for this index record - account_id TEXT, -- Denormalized for tenant isolation. Auto-set via trigger from datadog_account.account_id. - cost_per_million_events_indexed REAL, -- Cost per million events indexed in this index (USD). NULL = using Datadog's published rate ($1.70/M). SIEM indexes cost more — set accordingly. - created_at TEXT, -- When this index was first discovered - datadog_account_id TEXT, -- The Datadog account this index belongs to - name TEXT -- Index name from Datadog (e.g., 'main', 'security', 'compliance') - this is the stable identifier -); - --- Ground truth record for a field in a log event. Accumulates metadata as more production data is observed. -CREATE TABLE log_event_fields ( - id TEXT, -- Unique identifier - account_id TEXT, -- Denormalized for tenant isolation. Auto-set via trigger from log_event.account_id. - baseline_avg_bytes REAL, -- Current trailing 7-day volume-weighted average bytes for this attribute. Refreshed on volume ingestion. - created_at TEXT, -- When this field was first discovered - field_path TEXT, -- Unambiguous path segments, e.g. {attributes, http, status} - log_event_id TEXT, -- The log event this field belongs to - -- Top-N observed values with proportions. Populated on-demand for fields that need faceting (e.g., user agents for bot detection). - -- Opaque JSON data. Query using SQLite json_extract() or json_each(). - value_distribution TEXT -); - --- AI-generated recommendation for a specific quality category on a log event, scoped to a workspace for approval -CREATE TABLE log_event_policies ( - id TEXT, -- Unique identifier - account_id TEXT, -- Denormalized for tenant isolation. Auto-set via trigger from workspace.account_id. - action TEXT, -- What this policy does when enforced: 'drop' (remove all events), 'sample' (keep at reduced rate), 'filter' (drop subset by field value), 'trim' (remove/truncate fields), 'none' (informational only). Auto-set via trigger. - -- Category-specific analysis from AI. JSON object with one field populated matching the category, containing the analysis and recommended actions. - -- JSON object. Fields: - -- $.pii_leakage - PII leakage analysis (optional) - -- $.pii_leakage.fields[] - List of fields that may contain PII - -- $.pii_leakage.fields[].path[] string[] - Path to field as array of segments - -- $.pii_leakage.fields[].types[] string[] - List of sensitive data types this field may contain - -- $.pii_leakage.fields[].observed boolean - Whether actual sensitive data was seen in examples - -- $.secrets_leakage - Secrets leakage analysis (optional) - -- $.secrets_leakage.fields[] - List of fields that may contain secrets - -- $.phi_leakage - PHI leakage analysis (optional) - -- $.phi_leakage.fields[] - List of fields that may contain PHI - -- $.payment_data_leakage - Payment data leakage analysis (optional) - -- $.payment_data_leakage.fields[] - List of fields that may contain payment data - -- $.health_checks - Health checks analysis (optional) - -- $.bot_traffic - Bot traffic analysis (optional) - -- $.bot_traffic.user_agent_field[] string[] - Path to user-agent field as array of segments - -- $.bot_traffic.bot_proportion number - Fraction of traffic identified as bot/crawler (optional) - -- $.debug_artifacts - Debug artifacts analysis (optional) - -- $.malformed - Malformed data analysis (optional) - -- $.broken_records - Broken records analysis (optional) - -- $.broken_records.min_interval_seconds number - Suggested minimum interval between kept events in seconds - -- $.commodity_traffic - Commodity traffic analysis (optional) - -- $.commodity_traffic.min_interval_seconds number - Suggested minimum interval between kept events in seconds - -- $.redundant_events - Redundant events analysis (optional) - -- $.dead_weight - Dead weight analysis (optional) - -- $.duplicate_fields - Duplicate fields analysis (optional) - -- $.duplicate_fields.pairs[] - List of duplicate field pairs - -- $.duplicate_fields.pairs[].remove[] string[][] - List of duplicate field paths to remove - -- $.duplicate_fields.pairs[].keep[] string[] - Canonical field path to keep - -- $.instrumentation_bloat - Instrumentation bloat analysis (optional) - -- $.instrumentation_bloat.fields[] string[][] - List of field paths that are instrumentation bloat - -- $.oversized_fields - Oversized fields analysis (optional) - -- $.oversized_fields.fields[] string[][] - List of field paths that are oversized - -- $.wrong_level - Wrong level analysis (optional) - -- $.wrong_level.current_level string - Current normalized severity level - -- $.wrong_level.suggested_level string - Suggested normalized severity level - -- - -- Example: json_extract(analysis, '$.field_name') - analysis TEXT, - approved_at TEXT, -- When this policy was approved by a user - approved_baseline_avg_bytes REAL, -- Baseline avg bytes frozen at approval time. Snapshot of log_event.baseline_avg_bytes. - approved_baseline_volume_per_hour REAL, -- Baseline volume/hour frozen at approval time. Snapshot of log_event.baseline_volume_per_hour. - approved_by TEXT, -- User ID who approved this policy - category TEXT, -- Quality issue category this policy addresses. Compliance: pii_leakage, secrets_leakage, phi_leakage, payment_data_leakage. Waste: health_checks, bot_traffic, debug_artifacts, malformed, broken_records, commodity_traffic, redundant_events, dead_weight. Quality: duplicate_fields, instrumentation_bloat, oversized_fields, wrong_level. - category_type TEXT, -- Type of problem: compliance (legal/security risk), waste (event-level cuts), or quality (field-level improvements). Auto-set via trigger from CategoryMeta. - created_at TEXT, -- When this policy was created - dismissed_at TEXT, -- When this policy was dismissed by a user - dismissed_by TEXT, -- User ID who dismissed this policy - log_event_id TEXT, -- The log event this policy applies to - severity TEXT, -- Max compliance severity across sensitivity types. NULL for non-compliance categories. Auto-set via trigger. Values: low, medium, high, critical. - subjective INTEGER, -- Whether this category requires AI judgment (true) vs mechanically verifiable (false). Auto-set via trigger from CategoryMeta. - workspace_id TEXT -- The workspace that owns this policy -); - --- Cache table for per-category policy aggregations. Refreshed by cron service. -CREATE TABLE log_event_policy_category_statuses_cache ( - id TEXT, - account_id TEXT, -- Account ID for tenant isolation - action TEXT, -- What the policy does: drop (remove events), sample (reduce rate), filter (drop subset), trim (modify fields), none (informational) - approved_count INTEGER, -- Policies approved by user in this category - boundary TEXT, -- Where this category stops applying — what NOT to flag - category TEXT, -- Quality issue category (e.g., pii_leakage, noise, health_checks) - category_type TEXT, -- Type of problem: compliance (legal/security risk), waste (event-level cuts), quality (field-level improvements). - dismissed_count INTEGER, -- Policies dismissed by user in this category - display_name TEXT, -- Human-readable category name (e.g., 'PII Leakage') - estimated_bytes_reduction_per_hour REAL, -- Bytes/hour saved by all pending policies in this category combined - estimated_cost_reduction_per_hour_bytes_usd REAL, -- Estimated ingestion savings in USD/hour from pending policies in this category - estimated_cost_reduction_per_hour_usd REAL, -- Estimated total savings in USD/hour from pending policies in this category - estimated_cost_reduction_per_hour_volume_usd REAL, -- Estimated indexing savings in USD/hour from pending policies in this category - estimated_volume_reduction_per_hour REAL, -- Events/hour saved by all pending policies in this category combined - events_with_volumes INTEGER, -- Log events in this category that have volume data (subset of total_event_count) - pending_count INTEGER, -- Policies awaiting user review in this category - policy_pending_critical_count INTEGER, -- Pending policies with critical compliance severity - policy_pending_high_count INTEGER, -- Pending policies with high compliance severity - policy_pending_low_count INTEGER, -- Pending policies with low compliance severity - policy_pending_medium_count INTEGER, -- Pending policies with medium compliance severity - principle TEXT, -- What this category detects — the fundamental test for membership - subjective INTEGER, -- Whether this category requires AI judgment (true) vs mechanically verifiable (false) - total_event_count INTEGER -- Total log events that have a policy in this category -); - --- Cache table for log_event_policy_statuses view. Refreshed by cron service. -CREATE TABLE log_event_policy_statuses_cache ( - id TEXT, - account_id TEXT, -- Account ID for tenant isolation - action TEXT, -- What the policy does: drop (remove events), sample (reduce rate), filter (drop subset), trim (modify fields), none (informational) - approved_at TEXT, -- When this policy was approved by a user - bytes_per_hour REAL, -- Current throughput of the targeted log event in bytes/hour - category TEXT, -- Quality issue category this policy addresses (e.g., pii_leakage, noise, health_checks) - category_type TEXT, -- Type of problem: compliance (legal/security risk), waste (event-level cuts), quality (field-level improvements). - created_at TEXT, -- When this policy was created - dismissed_at TEXT, -- When this policy was dismissed by a user - estimated_bytes_reduction_per_hour REAL, -- Bytes/hour saved if this policy applied alone. NULL if not estimable. - estimated_cost_reduction_per_hour_bytes_usd REAL, -- Estimated ingestion savings in USD/hour from bytes reduction - estimated_cost_reduction_per_hour_usd REAL, -- Estimated total savings in USD/hour (bytes + volume) - estimated_cost_reduction_per_hour_volume_usd REAL, -- Estimated indexing savings in USD/hour from volume reduction - estimated_volume_reduction_per_hour REAL, -- Events/hour saved if this policy applied alone. NULL if not estimable. - log_event_id TEXT, -- The log event this policy targets - log_event_name TEXT, -- Name of the targeted log event (denormalized for display) - policy_id TEXT, -- The policy this status row represents - service_id TEXT, -- Service that produces the targeted log event (denormalized) - service_name TEXT, -- Name of the service (denormalized for display) - severity TEXT, -- Max compliance severity across sensitivity types. NULL for non-compliance categories. Values: low, medium, high, critical. - status TEXT, -- User decision on this policy. PENDING (awaiting review), APPROVED (accepted for enforcement), DISMISSED (rejected by user). - subjective INTEGER, -- Whether this category requires AI judgment (true) vs mechanically verifiable (false) - survival_rate REAL, -- Fraction of events that survive this policy (0.0 = all dropped, 1.0 = all kept). NULL if not estimable. - volume_per_hour REAL, -- Current throughput of the targeted log event in events/hour - workspace_id TEXT -- The workspace that owns this policy -); - --- Cache table for log_event_statuses view. Refreshed by cron service. -CREATE TABLE log_event_statuses_cache ( - id TEXT, - account_id TEXT, -- Account ID for tenant isolation - approved_policy_count INTEGER, -- Policies approved by user - bytes_per_hour REAL, -- Current throughput in bytes/hour (rolling 7-day) - cost_per_hour_bytes_usd REAL, -- Current ingestion cost in USD/hour - cost_per_hour_usd REAL, -- Current total cost in USD/hour (bytes + volume) - cost_per_hour_volume_usd REAL, -- Current indexing cost in USD/hour - dismissed_policy_count INTEGER, -- Policies dismissed by user - estimated_bytes_reduction_per_hour REAL, -- Bytes/hour saved by all policies combined - estimated_cost_reduction_per_hour_bytes_usd REAL, -- Estimated ingestion savings in USD/hour - estimated_cost_reduction_per_hour_usd REAL, -- Estimated total savings in USD/hour (bytes + volume) - estimated_cost_reduction_per_hour_volume_usd REAL, -- Estimated indexing savings in USD/hour - estimated_volume_reduction_per_hour REAL, -- Events/hour saved by all policies combined - has_been_analyzed INTEGER, -- Whether AI has analyzed this log event - has_volumes INTEGER, -- Whether volume data exists for this log event - log_event_id TEXT, -- The log event this status belongs to - observed_bytes_per_hour_after REAL, -- Measured bytes/hour after policy approval (current) - observed_bytes_per_hour_before REAL, -- Measured bytes/hour before first policy approval - observed_cost_per_hour_after_bytes_usd REAL, -- Measured ingestion cost after approval (current) - observed_cost_per_hour_after_usd REAL, -- Measured total cost after approval (current) - observed_cost_per_hour_after_volume_usd REAL, -- Measured indexing cost after approval (current) - observed_cost_per_hour_before_bytes_usd REAL, -- Measured ingestion cost before approval - observed_cost_per_hour_before_usd REAL, -- Measured total cost before approval - observed_cost_per_hour_before_volume_usd REAL, -- Measured indexing cost before approval - observed_volume_per_hour_after REAL, -- Measured events/hour after policy approval (current) - observed_volume_per_hour_before REAL, -- Measured events/hour before first policy approval - pending_policy_count INTEGER, -- Policies awaiting user action - policy_count INTEGER, -- Total non-dismissed policies - policy_pending_critical_count INTEGER, -- Pending policies with critical compliance severity - policy_pending_high_count INTEGER, -- Pending policies with high compliance severity - policy_pending_low_count INTEGER, -- Pending policies with low compliance severity - policy_pending_medium_count INTEGER, -- Pending policies with medium compliance severity - service_id TEXT, -- Service ID (denormalized from log_event) - volume_per_hour REAL -- Current throughput in events/hour (rolling 7-day) -); - --- Distinct log message pattern discovered within a service. Defines how to parse and match logs using codecs and matchers. -CREATE TABLE log_events ( - id TEXT, -- Unique identifier of the log event - account_id TEXT, -- Denormalized for tenant isolation. Auto-set via trigger from service.account_id. - baseline_avg_bytes REAL, -- Current trailing 7-day volume-weighted average bytes/event. Refreshed on volume ingestion. - baseline_volume_per_hour REAL, -- Current trailing 7-day average events/hour. Refreshed on volume ingestion. - created_at TEXT, -- When the log event was created - description TEXT, -- What the event is and what data instances carry. Helps engineers decide whether to look here. - event_nature TEXT, -- What this event records: system (internal mechanics), traffic (request flow), activity (actor+action+resource), control (access/permission decisions). - -- Sample log records captured during discovery, used for AI analysis and pattern validation - -- JSON array of objects. Each element: - -- $[0].timestamp - When the log event occurred (RFC3339) - -- $[0].body string - Log message content - -- $[0].severity_text string - Severity level as text (e.g., INFO, ERROR) - -- $[0].severity_number number - OTel severity level number (1-24) - -- $[0].trace_id string - Distributed trace ID (optional) - -- $[0].span_id string - Span ID within trace (optional) - -- $[0].attributes object - Log-level attributes (http.status, error.message, etc.) - -- $[0].resource_attributes object - Resource attributes (service.name, deployment.environment, etc.) - -- $[0].scope_attributes object - Instrumentation scope attributes (optional) - -- - -- Example: json_extract(examples, '$[0].field_name') - examples TEXT, - -- JSON rules that match incoming logs to this event. Each matcher specifies a field path, operator, and value. - -- JSON array of objects. Each element: - -- $[0].field_path[] string[] - Path to field as array of segments - -- $[0].match_type string - Match operator: exact, contains, starts_with, ends_with, regex, exists, missing - -- $[0].match_value string - Value to match against - -- $[0].case_insensitive boolean - Whether matching is case-insensitive (optional) - -- $[0].negate boolean - Whether to invert match result (optional) - -- - -- Example: json_extract(matchers, '$[0].field_name') - matchers TEXT, - name TEXT, -- Snake_case identifier unique per service, e.g. nginx_access_log - service_id TEXT, -- Service that produces this event - severity TEXT, -- Predominant log severity level, derived from example records. Nullable when examples have no severity info. Values: debug, info, warn, error, other. - signal_purpose TEXT -- What role this event serves: diagnostic (investigate incidents), operational (system behavior), lifecycle (state transitions), ephemeral (transient state). -); - --- Single message in a chat conversation. Append-only — never updated or deleted. -CREATE TABLE messages ( - id TEXT, -- Unique identifier - account_id TEXT, -- Denormalized for tenant isolation. Auto-set via trigger from conversation.account_id. - -- Array of typed content blocks: text, thinking, tool_use, tool_result - -- JSON array of objects. Each element: - -- $[0].type string - Block type discriminator: text, thinking, tool_use, tool_result - -- $[0].text - Text content (when type=text) - -- $[0].text.content string - Text content - -- $[0].thinking - AI reasoning content (when type=thinking) - -- $[0].thinking.content string - AI reasoning content - -- $[0].tool_use - Tool call (when type=tool_use) - -- $[0].tool_use.id string - Unique tool call identifier - -- $[0].tool_use.name string - Tool name - -- $[0].tool_use.input[] number[] - Tool input parameters as JSON object - -- $[0].tool_result - Tool response (when type=tool_result) - -- $[0].tool_result.tool_use_id string - ID of the tool use this result corresponds to - -- $[0].tool_result.is_error boolean - Whether the tool call failed - -- $[0].tool_result.error string - Human-readable error message (when is_error=true) - -- $[0].tool_result.content[] number[] - Structured result data (when is_error=false) - -- - -- Example: json_extract(content, '$[0].field_name') - content TEXT, - conversation_id TEXT, -- Conversation this message belongs to - created_at TEXT, -- When the message was created - model TEXT, -- AI model that produced this message. Null for user messages. - role TEXT, -- Who sent this message. user: human-originated, assistant: AI-originated. - stop_reason TEXT -- Why the assistant stopped generating. end_turn: completed response, tool_use: paused to call a tool. Null for user messages. -); - --- Cache table for service_statuses view. Refreshed by cron service. -CREATE TABLE service_statuses_cache ( - id TEXT, - account_id TEXT, -- Account ID (denormalized from service) - datadog_account_id TEXT, -- The Datadog account performing discovery - estimated_bytes_reduction_per_hour REAL, -- Estimated bytes reduction from active policies - estimated_cost_reduction_per_hour_bytes_usd REAL, -- Estimated bytes-based USD/hour savings from active policies - estimated_cost_reduction_per_hour_usd REAL, -- Estimated total USD/hour savings from active policies - estimated_cost_reduction_per_hour_volume_usd REAL, -- Estimated volume-based USD/hour savings from active policies - estimated_volume_reduction_per_hour REAL, -- Estimated volume reduction from active policies - health TEXT, -- Overall health of the service. DISABLED (integration turned off), INACTIVE (no data received), OK (healthy). - log_event_analyzed_count INTEGER, -- Number of log events that have been analyzed - log_event_bytes_per_hour REAL, -- Discovered log event throughput in bytes/hour from rolling 7-day window - log_event_cost_per_hour_bytes_usd REAL, -- Discovered log event ingestion cost in USD/hour - log_event_cost_per_hour_usd REAL, -- Discovered log event total cost in USD/hour (bytes + volume) - log_event_cost_per_hour_volume_usd REAL, -- Discovered log event indexing cost in USD/hour - log_event_count INTEGER, -- Total number of log events discovered for this service - log_event_volume_per_hour REAL, -- Discovered log event throughput in events/hour from rolling 7-day window - observed_bytes_per_hour_after REAL, -- Measured bytes/hour after policy approval - observed_bytes_per_hour_before REAL, -- Measured bytes/hour before first policy approval - observed_cost_per_hour_after_bytes_usd REAL, -- Measured bytes-based USD/hour cost after approval - observed_cost_per_hour_after_usd REAL, -- Measured total USD/hour cost after approval - observed_cost_per_hour_after_volume_usd REAL, -- Measured volume-based USD/hour cost after approval - observed_cost_per_hour_before_bytes_usd REAL, -- Measured bytes-based USD/hour cost before approval - observed_cost_per_hour_before_usd REAL, -- Measured total USD/hour cost before approval - observed_cost_per_hour_before_volume_usd REAL, -- Measured volume-based USD/hour cost before approval - observed_volume_per_hour_after REAL, -- Measured events/hour after policy approval - observed_volume_per_hour_before REAL, -- Measured events/hour before first policy approval - policy_approved_count INTEGER, -- Policies approved by user - policy_dismissed_count INTEGER, -- Policies dismissed by user - policy_pending_count INTEGER, -- Policies awaiting user action - policy_pending_critical_count INTEGER, -- Pending policies with critical compliance severity - policy_pending_high_count INTEGER, -- Pending policies with high compliance severity - policy_pending_low_count INTEGER, -- Pending policies with low compliance severity - policy_pending_medium_count INTEGER, -- Pending policies with medium compliance severity - service_cost_per_hour_volume_usd REAL, -- Service-level indexing cost in USD/hour based on total service volume - service_debug_volume_per_hour REAL, -- Debug-level events/hour from rolling 7-day window - service_error_volume_per_hour REAL, -- Error-level events/hour from rolling 7-day window - service_id TEXT, -- The service this status belongs to - service_info_volume_per_hour REAL, -- Info-level events/hour from rolling 7-day window - service_other_volume_per_hour REAL, -- Other-level events/hour (trace, fatal, critical, unknown) from rolling 7-day window - service_volume_per_hour REAL, -- Ground-truth service throughput in events/hour from service_log_volumes rolling 7-day window - service_warn_volume_per_hour REAL -- Warn-level events/hour from rolling 7-day window -); - --- Application or microservice that produces logs. Central entity in the data catalog. -CREATE TABLE services ( - id TEXT, -- Unique identifier of the service - account_id TEXT, -- Parent account this service belongs to - created_at TEXT, -- When the service was created - description TEXT, -- AI-generated description of what this service does and its telemetry characteristics - enabled INTEGER, -- Whether log analysis and policy generation is active for this service - initial_weekly_log_count INTEGER, -- Approximate weekly log count from initial discovery (7-day period from Datadog) - name TEXT -- Service identifier in telemetry (e.g., 'checkout-service') -); - --- Group of users within a workspace that reviews policies and manages services -CREATE TABLE teams ( - id TEXT, -- Unique identifier of the team - account_id TEXT, -- Denormalized for tenant isolation. Auto-set via trigger from workspace.account_id. - created_at TEXT, -- When the team was created - name TEXT, -- Human-readable name within the workspace - workspace_id TEXT -- Parent workspace this team belongs to -); - --- User's saved reference to a view, elevating it from conversation history to their personal collection -CREATE TABLE view_favorites ( - id TEXT, -- Unique identifier - account_id TEXT, -- Denormalized for tenant isolation. Auto-set via trigger from view.account_id. - created_at TEXT, -- When the view was favorited - user_id TEXT, -- WorkOS user ID who favorited this view - view_id TEXT -- The view being favorited -); - --- Saved SQL query against the local catalog, created by the AI assistant. Immutable — editing creates a fork. -CREATE TABLE views ( - id TEXT, -- Unique identifier - account_id TEXT, -- Denormalized for tenant isolation. Auto-set via trigger from message.account_id. - conversation_id TEXT, -- Denormalized from message for easier queries - created_at TEXT, -- When the view was created - created_by TEXT, -- WorkOS user ID who triggered this view creation - entity_type TEXT, -- Which catalog entity this view queries. service: applications, log_event: event patterns, policy: quality recommendations. - forked_from_id TEXT, -- Parent view if this is a refinement/iteration - message_id TEXT, -- Assistant message that created this view via show_view tool call - query TEXT -- Raw SQL query executed against the client's local SQLite database -); - --- Purpose-aligned environment for reviewing and classifying telemetry. Each workspace has its own policies and teams. -CREATE TABLE workspaces ( - id TEXT, -- Unique identifier of the workspace - account_id TEXT, -- Parent account this workspace belongs to - created_at TEXT, -- When the workspace was created - name TEXT, -- Human-readable name within the account - purpose TEXT -- Primary purpose determining evaluation strategy. observability: performance and reliability, security: threat detection, compliance: regulatory requirements. -); - diff --git a/internal/app/chattools/query_test.go b/internal/app/chattools/query_test.go deleted file mode 100644 index 57a78428..00000000 --- a/internal/app/chattools/query_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "testing" - - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/sqlite/sqlitetest" -) - -func TestCheckQueryPlan(t *testing.T) { - t.Parallel() - - db := sqlitetest.OpenBareDB(t) - ctx := context.Background() - - // Simulate PowerSync pattern: underlying tables store JSON, views expose columns. - // Expression indexes exist but SQLite can't use them through views for JOINs. - for _, ddl := range []string{ - `CREATE TABLE ps_data__parent (id TEXT PRIMARY KEY, data TEXT)`, - `CREATE TABLE ps_data__child (id TEXT PRIMARY KEY, data TEXT)`, - `CREATE INDEX idx_child_parent_id ON ps_data__child (CAST(json_extract(data, '$.parent_id') AS TEXT))`, - `CREATE VIEW parent AS SELECT id, CAST(json_extract(data, '$.name') AS TEXT) as name FROM ps_data__parent`, - `CREATE VIEW child AS SELECT id, CAST(json_extract(data, '$.parent_id') AS TEXT) as parent_id, CAST(json_extract(data, '$.value') AS TEXT) as value FROM ps_data__child`, - } { - if _, err := db.Raw().ExecContext(ctx, ddl); err != nil { - t.Fatalf("DDL: %v", err) - } - } - - tool := NewQueryTool(db, logtest.NewScope(t)) - - t.Run("simple select passes", func(t *testing.T) { - t.Parallel() - err := tool.checkQueryPlan(ctx, "SELECT * FROM parent") - if err != nil { - t.Errorf("expected no error, got: %v", err) - } - }) - - t.Run("PK join passes", func(t *testing.T) { - t.Parallel() - err := tool.checkQueryPlan(ctx, - "SELECT c.value FROM child c JOIN parent p ON p.id = c.parent_id") - if err != nil { - t.Errorf("expected no error for PK join, got: %v", err) - } - }) - - t.Run("non-PK left join rejected", func(t *testing.T) { - t.Parallel() - // LEFT JOIN on child.parent_id through a view — full table scan. - err := tool.checkQueryPlan(ctx, - "SELECT p.name FROM parent p LEFT JOIN child c ON c.parent_id = p.id") - if err == nil { - t.Fatal("expected error for SCAN join, got nil") - } - if !strings.Contains(err.Error(), "query rejected") { - t.Errorf("expected 'query rejected' in error, got: %v", err) - } - if !strings.Contains(err.Error(), "SCAN") { - t.Errorf("expected 'SCAN' in error details, got: %v", err) - } - }) - - t.Run("invalid SQL returns nil", func(t *testing.T) { - t.Parallel() - err := tool.checkQueryPlan(ctx, "NOT VALID SQL AT ALL") - if err != nil { - t.Errorf("expected nil for invalid SQL, got: %v", err) - } - }) -} - -func TestQueryToolExecute_CapsLargeResults(t *testing.T) { - t.Parallel() - - db := sqlitetest.OpenBareDB(t) - ctx := context.Background() - - if _, err := db.Raw().ExecContext(ctx, `CREATE TABLE test_rows (id INTEGER PRIMARY KEY, payload TEXT)`); err != nil { - t.Fatalf("create table: %v", err) - } - - payload := strings.Repeat("x", 2000) - for i := 0; i < 200; i++ { - if _, err := db.Raw().ExecContext(ctx, `INSERT INTO test_rows (payload) VALUES (?)`, fmt.Sprintf("%s-%d", payload, i)); err != nil { - t.Fatalf("insert row %d: %v", i, err) - } - } - - tool := NewQueryTool(db, logtest.NewScope(t)) - input, err := json.Marshal(map[string]any{ - "sql": "SELECT id, payload FROM test_rows ORDER BY id", - "status": "running", - "result": "done", - }) - if err != nil { - t.Fatalf("marshal input: %v", err) - } - - result, err := tool.Execute(input) - if err != nil { - t.Fatalf("Execute() error = %v", err) - } - if len(result.Rows) == 0 { - t.Fatalf("expected at least one row") - } - if result.RowsDropped == 0 { - t.Fatalf("expected rows to be dropped by cap") - } -} diff --git a/internal/app/chattools/registry.go b/internal/app/chattools/registry.go deleted file mode 100644 index 9dacc0db..00000000 --- a/internal/app/chattools/registry.go +++ /dev/null @@ -1,84 +0,0 @@ -package tools - -import ( - "encoding/json" - - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/action" - "github.com/usetero/cli/internal/boundary/chat" - domaintools "github.com/usetero/cli/internal/domain/tools" -) - -// ActionTool bundles a definition, executor, and display config for the generic action model. -type ActionTool struct { - Def chat.Tool - Exec action.Executor - Config action.Config -} - -// Registry holds tool instances and provides definitions. -type Registry struct { - Query *QueryTool // kept for direct access from query UI model - Show *ShowTool // kept for direct access from show UI model - actions map[string]ActionTool -} - -// NewRegistry creates a registry with the query tool, show tool, and a map of action tools. -func NewRegistry(query *QueryTool, show *ShowTool, actions map[string]ActionTool) *Registry { - return &Registry{ - Query: query, - Show: show, - actions: actions, - } -} - -// Definitions returns tool definitions for the chat API. -func (r *Registry) Definitions() []chat.Tool { - var defs []chat.Tool - if r.Query != nil { - defs = append(defs, r.Query.Definition()) - } - if r.Show != nil { - defs = append(defs, r.Show.Definition()) - } - for _, a := range r.actions { - defs = append(defs, a.Def) - } - return defs -} - -// Lookup returns the action tool for the given name. -func (r *Registry) Lookup(name string) (ActionTool, bool) { - a, ok := r.actions[name] - return a, ok -} - -// NewActionTool is a helper to build an ActionTool from parts. -func NewActionTool( - def chat.Tool, - executor func(input json.RawMessage) (domaintools.Result, error), - config action.Config, -) ActionTool { - return ActionTool{ - Def: def, - Exec: executor, - Config: config, - } -} - -// UnknownTool returns an ActionTool for tools not in the registry. -// It shows the tool name and completes with an error so the result -// is reported back to the model. -func UnknownTool(name string) ActionTool { - return ActionTool{ - Exec: func(_ json.RawMessage) (domaintools.Result, error) { - return domaintools.Result{ - Content: map[string]any{"error": "unknown tool"}, - }, nil - }, - Config: action.Config{ - DisplayName: func(_ json.RawMessage) string { return name }, - Status: func(_ json.RawMessage) string { return "Executing" }, - Result: func(_ domaintools.Result) string { return "Unknown tool" }, - }, - } -} diff --git a/internal/app/chattools/runtime.go b/internal/app/chattools/runtime.go deleted file mode 100644 index 56f8c3c1..00000000 --- a/internal/app/chattools/runtime.go +++ /dev/null @@ -1,14 +0,0 @@ -package tools - -import ( - "context" - "time" - - "github.com/usetero/cli/internal/sqlite" -) - -const toolDBTimeout = 3 * time.Second - -func withToolTimeout() (context.Context, context.CancelFunc) { - return sqlite.WithTimeout(context.Background(), toolDBTimeout) -} diff --git a/internal/app/chattools/setserviceenabled.go b/internal/app/chattools/setserviceenabled.go deleted file mode 100644 index 64919052..00000000 --- a/internal/app/chattools/setserviceenabled.go +++ /dev/null @@ -1,113 +0,0 @@ -package tools - -import ( - "database/sql" - "encoding/json" - "errors" - "fmt" - - "github.com/google/uuid" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/action" - "github.com/usetero/cli/internal/boundary/chat" - "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/sqlite" -) - -// NewSetServiceEnabledAction creates an ActionTool for set_service_enabled. -func NewSetServiceEnabledAction(db sqlite.DB) ActionTool { - def := chat.Tool{ - Name: "set_service_enabled", - Description: "Enable or disable a service for log analysis. Enabling triggers the analysis pipeline.", - InputSchema: chat.NewObjectSchema( - map[string]chat.Property{ - "service_id": { - Type: "string", - Description: "UUID of the service (e.g., '4a3b1c2d-...'). Use the query tool to look up service IDs: SELECT id, name FROM services", - }, - "enabled": { - Type: "boolean", - Description: "true to enable, false to disable", - }, - }, - []string{"service_id", "enabled"}, - ), - } - - executor := func(input json.RawMessage) (tools.Result, error) { - var in tools.SetServiceEnabledInput - if err := json.Unmarshal(input, &in); err != nil { - return tools.Result{}, err - } - - ctx, cancel := withToolTimeout() - defer cancel() - - svc, err := db.Services().Get(ctx, in.ServiceID) - if errors.Is(err, sql.ErrNoRows) { - if _, parseErr := uuid.Parse(in.ServiceID.String()); parseErr != nil { - return tools.Result{}, fmt.Errorf( - "no service found with ID %q — this looks like a name, not a UUID. "+ - "Use the query tool: SELECT id, name FROM services WHERE name LIKE '%%%s%%'", - in.ServiceID, in.ServiceID, - ) - } - return tools.Result{}, fmt.Errorf( - "no service found with ID %q. Use the query tool: SELECT id, name FROM services", - in.ServiceID, - ) - } - if err != nil { - return tools.Result{}, fmt.Errorf("get service: %w", err) - } - - if err := db.Services().SetEnabled(ctx, in.ServiceID, in.Enabled); err != nil { - return tools.Result{}, fmt.Errorf("set service enabled: %w", err) - } - - var serviceName string - if svc.Name != nil { - serviceName = *svc.Name - } - - return tools.Result{ - Content: tools.SetServiceEnabledResult{ - ServiceID: in.ServiceID, - ServiceName: serviceName, - Enabled: in.Enabled, - }.ToMap(), - }, nil - } - - config := action.Config{ - DisplayName: func(input json.RawMessage) string { - var in tools.SetServiceEnabledInput - if json.Unmarshal(input, &in) == nil && !in.Enabled { - return "Disable Service" - } - return "Enable Service" - }, - Status: func(input json.RawMessage) string { - var in tools.SetServiceEnabledInput - if json.Unmarshal(input, &in) != nil { - return "" - } - if in.Enabled { - return fmt.Sprintf("Enabling %s", in.ServiceID) - } - return fmt.Sprintf("Disabling %s", in.ServiceID) - }, - Result: func(result tools.Result) string { - name, _ := result.Content["service_name"].(string) - if name == "" { - name, _ = result.Content["service_id"].(string) - } - enabled, _ := result.Content["enabled"].(bool) - if enabled { - return fmt.Sprintf("%s enabled", name) - } - return fmt.Sprintf("%s disabled", name) - }, - } - - return NewActionTool(def, executor, config) -} diff --git a/internal/app/chattools/show.go b/internal/app/chattools/show.go deleted file mode 100644 index a5f3938a..00000000 --- a/internal/app/chattools/show.go +++ /dev/null @@ -1,219 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "github.com/usetero/cli/internal/boundary/chat" - "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/sqlite" -) - -// ShowTool resolves entity IDs and fetches card data from the local catalog. -type ShowTool struct { - db sqlite.DB - resolvers map[tools.EntityType]entityResolver -} - -// entityResolver fetches card data for a specific entity type. -type entityResolver struct { - fetch func(ctx context.Context, id string) (tools.ShowResult, error) -} - -// NewShowTool creates a new show tool. -func NewShowTool(db sqlite.DB) *ShowTool { - policyStatuses := db.LogEventPolicyStatuses() - return &ShowTool{ - db: db, - resolvers: map[tools.EntityType]entityResolver{ - tools.EntityPolicy: {fetch: func(ctx context.Context, id string) (tools.ShowResult, error) { - return fetchPolicyCard(ctx, policyStatuses, id) - }}, - }, - } -} - -// Name returns the tool name. -func (t *ShowTool) Name() string { return "show" } - -// Definition returns the tool definition for the chat API. -func (t *ShowTool) Definition() chat.Tool { - return chat.Tool{ - Name: t.Name(), - Description: `Show a single entity as a rich, formatted card rendered inline in the conversation. The user sees the card directly — do NOT repeat or summarize the card contents in your response. Just reference it conversationally. - -## How it works - -The card fetches and displays all relevant data automatically from the entity ID. You provide the entity type and either an ID or a lookup query. - -## Input - -Provide either: -- "id" — the entity's UUID (if you already have it from a previous query result) -- "sql" — a SELECT that returns exactly one row with an "id" column (when you need to look up the entity) - -## Entity: policy - -Look up policy IDs from log_event_policy_statuses_cache: - SELECT policy_id AS id FROM log_event_policy_statuses_cache WHERE service_name = '...' AND log_event_name = '...' AND category = '...' - -The card displays: category, service, log event, action, status, severity, volume, throughput, and estimated savings. - -## When to use show vs query - -- Use "show" when presenting a specific entity to the user — it renders a styled card. -- Use "query" for data exploration, aggregations, comparisons, or when you need raw tabular results.`, - InputSchema: chat.NewObjectSchema( - map[string]chat.Property{ - "entity": { - Type: "string", - Enum: []string{"policy"}, - Description: "The entity type to show.", - }, - "id": { - Type: "string", - Description: "UUID of the entity. Provide this OR sql, not both.", - }, - "sql": { - Type: "string", - Description: "SQL query returning exactly one row with an 'id' column. Used when you need to look up the entity. Provide this OR id, not both.", - }, - "title": { - Type: "string", - Description: "Optional title shown above the card.", - }, - }, - []string{"entity"}, - ), - } -} - -// Execute resolves the entity ID and fetches card data. -func (t *ShowTool) Execute(input json.RawMessage) (tools.ShowResult, error) { - var in tools.ShowInput - if err := json.Unmarshal(input, &in); err != nil { - return tools.ShowResult{}, err - } - - resolver, ok := t.resolvers[in.Entity] - if !ok { - return tools.ShowResult{}, fmt.Errorf("unsupported entity type: %q", in.Entity) - } - - id := in.ID - if id == "" && in.SQL != "" { - resolved, err := t.resolveIDFromSQL(in.SQL) - if err != nil { - return tools.ShowResult{}, err - } - id = resolved - } - if id == "" { - return tools.ShowResult{}, fmt.Errorf("either id or sql must be provided") - } - - ctx, cancel := withToolTimeout() - defer cancel() - result, err := resolver.fetch(ctx, id) - if err != nil { - return tools.ShowResult{}, err - } - result.Title = in.Title - return result, nil -} - -// resolveIDFromSQL runs a SQL query and extracts the id column from the single result row. -func (t *ShowTool) resolveIDFromSQL(query string) (string, error) { - ctx, cancel := withToolTimeout() - defer cancel() - - if err := t.checkQueryPlan(ctx, query); err != nil { - return "", err - } - - rows, err := t.db.ReadRaw().QueryContext(ctx, query) - if err != nil { - return "", fmt.Errorf("sql lookup failed: %w", err) - } - defer rows.Close() - - cols, err := rows.Columns() - if err != nil { - return "", err - } - - idIdx := -1 - for i, col := range cols { - if col == "id" { - idIdx = i - break - } - } - if idIdx == -1 { - return "", fmt.Errorf("sql query must return an 'id' column; got columns: %v", cols) - } - - if !rows.Next() { - return "", fmt.Errorf("sql query returned no rows") - } - - values := make([]any, len(cols)) - ptrs := make([]any, len(cols)) - for i := range values { - ptrs[i] = &values[i] - } - if err := rows.Scan(ptrs...); err != nil { - return "", err - } - - if rows.Next() { - return "", fmt.Errorf("sql query returned more than one row; must return exactly one") - } - - if err := rows.Err(); err != nil { - return "", err - } - - id, ok := values[idIdx].(string) - if !ok { - return "", fmt.Errorf("id column is not a string: %v", values[idIdx]) - } - return id, nil -} - -// checkQueryPlan rejects queries with full table scans on JOINed tables. -func (t *ShowTool) checkQueryPlan(ctx context.Context, query string) error { - rows, err := t.db.ReadRaw().QueryContext(ctx, "EXPLAIN QUERY PLAN "+query) - if err != nil { - return nil // let the actual query surface the error - } - defer rows.Close() - - var scans []string - for rows.Next() { - var id, parent, notUsed int - var detail string - if err := rows.Scan(&id, &parent, ¬Used, &detail); err != nil { - return nil - } - if strings.Contains(detail, "SCAN") && strings.Contains(detail, "JOIN") { - scans = append(scans, detail) - } - } - - if err := rows.Err(); err != nil { - return nil - } - - if len(scans) == 0 { - return nil - } - - return fmt.Errorf( - "query rejected: full table scan detected on a JOINed table, "+ - "problem: %s, fix: replace the JOIN with a correlated subquery", - strings.Join(scans, "; "), - ) -} diff --git a/internal/app/chattools/show_policy.go b/internal/app/chattools/show_policy.go deleted file mode 100644 index 2309ca2e..00000000 --- a/internal/app/chattools/show_policy.go +++ /dev/null @@ -1,208 +0,0 @@ -package tools - -import ( - "context" - "fmt" - "strings" - - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/format" - "github.com/usetero/cli/internal/sqlite" -) - -// fetchPolicyCard loads a policy card via the typed wrapper. -func fetchPolicyCard(ctx context.Context, statuses sqlite.LogEventPolicyStatuses, id string) (tools.ShowResult, error) { - card, err := statuses.GetPolicyCard(ctx, id) - if err != nil { - return tools.ShowResult{}, fmt.Errorf("fetch policy card: %w", err) - } - - policy := domain.ParsePolicy(card) - idShort := shortID(policy.ID.String()) - - // Build the data map for the AI context. The AI sees these fields so it can - // reference the policy conversationally without repeating the card. - data := map[string]any{ - "policy": policy, // typed policy for TUI rendering - "policy_id": policy.ID.String(), - "service_name": policy.ServiceName, - "log_event_name": policy.LogEventName, - "category": policy.Category, - "category_type": string(policy.CategoryType), - "action": string(policy.Action), - "status": string(policy.Status), - } - - if policy.CategoryDisplayName != "" { - data["category_display_name"] = policy.CategoryDisplayName - } - if policy.Severity != "" { - data["severity"] = string(policy.Severity) - } - if policy.Analysis != nil { - data["rationale"] = policy.Analysis.Rationale() - if subtitle := policy.Analysis.Subtitle(); subtitle != "" { - data["subtitle"] = subtitle - } - if detail := policy.Analysis.ActionDetail(); detail != "" { - data["action_detail"] = detail - } - } - if policy.VolumePerHour != nil { - data["volume_per_hour"] = *policy.VolumePerHour - } - if policy.BytesPerHour != nil { - data["bytes_per_hour"] = *policy.BytesPerHour - } - if policy.EstimatedCostPerHour != nil { - data["estimated_cost_per_hour"] = *policy.EstimatedCostPerHour - } - if policy.EstimatedVolumePerHour != nil { - data["estimated_volume_per_hour"] = *policy.EstimatedVolumePerHour - } - if policy.EstimatedBytesPerHour != nil { - data["estimated_bytes_per_hour"] = *policy.EstimatedBytesPerHour - } - if policy.SurvivalRate != nil { - data["survival_rate"] = *policy.SurvivalRate - } - - // Recommendation — same methods the card's viewRecommendation uses. - if h := policy.Headline(); h != "" { - data["headline"] = h - } - if m := policy.Mechanism(); m != "" { - data["mechanism"] = m - } - - // Impact — same method the card's viewImpact uses. - if impact := policy.Impact(); impact != nil { - impactMap := map[string]string{} - if impact.VolumeFrom != "" { - impactMap["volume_from"] = impact.VolumeFrom - impactMap["volume_to"] = impact.VolumeTo - impactMap["volume_reduction"] = impact.VolumePct - } - if impact.StorageFrom != "" { - impactMap["storage_from"] = impact.StorageFrom - impactMap["storage_to"] = impact.StorageTo - impactMap["storage_reduction"] = impact.StoragePct - } - if impact.Savings != "" { - impactMap["savings"] = impact.Savings - } - data["impact"] = impactMap - } - - // Evidence — same method the card's viewEvidence uses. - if ev := domain.BuildEvidence(policy); ev != nil { - data["evidence"] = summarizeEvidence(ev) - } - - // Build card summary for AI context. - categoryName := policy.CategoryDisplayName - if categoryName == "" { - categoryName = format.TitleCase(string(policy.Category)) - } - summary := buildCardSummary(idShort, categoryName, policy) - - return tools.ShowResult{ - Entity: tools.EntityPolicy, - ID: policy.ID.String(), - IDShort: idShort, - CardSummary: summary, - Data: data, - }, nil -} - -// shortID returns the first 4 hex characters of a UUID (stripping dashes). -func shortID(uuid string) string { - hex := strings.ReplaceAll(uuid, "-", "") - if len(hex) > 4 { - return hex[:4] - } - return hex -} - -// buildCardSummary creates a human-readable description of what the card shows, -// so the AI knows what's on the user's screen without repeating it. -func buildCardSummary(idShort, categoryName string, p *domain.Policy) string { - var parts []string - parts = append(parts, fmt.Sprintf( - "Showing policy pol-%s: %s on %s/%s", - idShort, categoryName, p.ServiceName, p.LogEventName, - )) - - sections := []string{"category", "rationale"} - if domain.BuildEvidence(p) != nil { - sections = append(sections, "sample log") - } - if p.Action != "" && p.Action != domain.PolicyActionNone { - sections = append(sections, fmt.Sprintf("action (%s)", string(p.Action))) - } - if p.VolumePerHour != nil { - sections = append(sections, fmt.Sprintf("volume (%s evt/hr)", format.Volume(*p.VolumePerHour))) - } - if cost := p.CostPerYear(); cost != "" { - sections = append(sections, fmt.Sprintf("savings (%s)", cost)) - } - parts = append(parts, "Card displays: "+strings.Join(sections, ", ")+".") - parts = append(parts, "Status: "+string(p.Status)+".") - - if p.Analysis != nil { - if subtitle := p.Analysis.Subtitle(); subtitle != "" { - parts = append(parts, "Key detail: "+subtitle+".") - } - } - - return strings.Join(parts, " ") -} - -// summarizeEvidence creates a flat map describing the evidence shown on the card. -// Uses the same domain types that the policycard's viewEvidence dispatches on. -func summarizeEvidence(ev domain.Evidence) map[string]any { - switch ev := ev.(type) { - case *domain.ConstantVariesEvidence: - constantKeys := make([]string, len(ev.Constant)) - for i, f := range ev.Constant { - constantKeys[i] = f.Key + "=" + f.Value - } - varyingKeys := make([]string, len(ev.Varying)) - for i, f := range ev.Varying { - varyingKeys[i] = f.Key - } - return map[string]any{ - "type": "constant_varies", - "example_count": ev.ExampleCount, - "constant_count": len(ev.Constant), - "varying_count": len(ev.Varying), - "total_fields": len(ev.Constant) + len(ev.Varying), - "constant_fields": constantKeys, - "varying_fields": varyingKeys, - } - case *domain.HighlightedExampleEvidence: - relevant := make([]string, len(ev.RelevantKeys)) - for i, k := range ev.RelevantKeys { - relevant[i] = k.String() - } - return map[string]any{ - "type": "highlighted_example", - "total_fields": len(ev.Attrs), - "relevant_fields": relevant, - } - case *domain.FieldListEvidence: - fields := make([]string, len(ev.Fields)) - for i, f := range ev.Fields { - fields[i] = f.Key - } - return map[string]any{ - "type": "field_list", - "fields": fields, - "total_bytes": ev.TotalBytes, - "bytes_fraction": ev.BytesFraction, - } - default: - return nil - } -} diff --git a/internal/app/commands.go b/internal/app/commands.go index ccbb0305..45f4c206 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -12,13 +12,12 @@ import ( func (m *Model) paletteCommands() []palette.Command { return []palette.Command{ { - Name: "New Conversation", + Name: "Refresh Issues", Handler: func() tea.Cmd { - m.chat = m.newChat() - m.statusBar.SetTitle("") - m.windowTitle = "" - m.updateLayout() - return m.chat.Init() + if m.explorer == nil { + return nil + } + return m.explorer.Refresh() }, }, { diff --git a/internal/app/events/sync.go b/internal/app/events/sync.go deleted file mode 100644 index 788064a3..00000000 --- a/internal/app/events/sync.go +++ /dev/null @@ -1,8 +0,0 @@ -package events - -import "github.com/usetero/cli/internal/powersync" - -// SyncStateChanged is emitted when sync state changes. -type SyncStateChanged struct { - State powersync.State -} diff --git a/internal/app/explorer/explorer.go b/internal/app/explorer/explorer.go new file mode 100644 index 00000000..d59c053b --- /dev/null +++ b/internal/app/explorer/explorer.go @@ -0,0 +1,231 @@ +// Package explorer renders a minimal, read-only view of the account's active +// issues. It is the default interactive surface now that chat is gone. +package explorer + +import ( + "context" + "fmt" + "image/color" + "strings" + "time" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + graphql "github.com/usetero/cli/internal/boundary/graphql" + "github.com/usetero/cli/internal/domain" + "github.com/usetero/cli/internal/log" + "github.com/usetero/cli/internal/styles" +) + +const fetchTimeout = 5 * time.Second + +var ( + keyUp = key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑↓", "navigate")) + keyDown = key.NewBinding(key.WithKeys("down", "j")) + keyRefresh = key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")) +) + +// Model is the issue explorer. +type Model struct { + services graphql.ServiceSet + scope log.Scope + theme styles.Theme + + width, height int + originX, originY int + + issues []domain.Issue + summary domain.IssueSummary + cursor int + loading bool + err error +} + +type issuesLoadedMsg struct { + issues []domain.Issue + summary domain.IssueSummary + err error +} + +// New creates a new explorer model. +func New(services graphql.ServiceSet, theme styles.Theme, scope log.Scope) *Model { + return &Model{ + services: services, + theme: theme, + scope: scope.Child("explorer"), + loading: true, + } +} + +// Init fetches the initial issue list. +func (m *Model) Init() tea.Cmd { + return m.fetch() +} + +// Refresh re-fetches the issue list. +func (m *Model) Refresh() tea.Cmd { + m.loading = true + return m.fetch() +} + +func (m *Model) fetch() tea.Cmd { + services := m.services + scope := m.scope + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + issues, err := services.Issues.List(ctx) + if err != nil { + scope.Error("list issues", "err", err) + return issuesLoadedMsg{err: err} + } + summary, err := services.Issues.GetSummary(ctx) + if err != nil { + scope.Error("issue summary", "err", err) + return issuesLoadedMsg{issues: issues, err: err} + } + return issuesLoadedMsg{issues: issues, summary: summary} + } +} + +// Update handles messages. +func (m *Model) Update(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case issuesLoadedMsg: + m.loading = false + m.err = msg.err + m.issues = msg.issues + m.summary = msg.summary + if m.cursor >= len(m.issues) { + m.cursor = max(0, len(m.issues)-1) + } + case tea.KeyPressMsg: + switch { + case key.Matches(msg, keyUp): + if m.cursor > 0 { + m.cursor-- + } + case key.Matches(msg, keyDown): + if m.cursor < len(m.issues)-1 { + m.cursor++ + } + case key.Matches(msg, keyRefresh): + return m.Refresh() + } + } + return nil +} + +// SetSize updates the dimensions. +func (m *Model) SetSize(width, height int) { + m.width = width + m.height = height +} + +// SetOrigin records the page origin (kept for layout parity with other pages). +func (m *Model) SetOrigin(x, y int) { + m.originX = x + m.originY = y +} + +// ShortHelp returns the key bindings shown in the keybar. +func (m *Model) ShortHelp() []key.Binding { + return []key.Binding{keyUp, keyRefresh} +} + +// View renders the explorer. +func (m *Model) View() string { + colors := m.theme + title := lipgloss.NewStyle().Foreground(colors.Text).Background(colors.Bg).Bold(true) + muted := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) + + var b strings.Builder + b.WriteString(title.Render("Active issues")) + b.WriteString(" ") + b.WriteString(muted.Render(m.headline())) + b.WriteString("\n\n") + + switch { + case m.loading: + b.WriteString(muted.Render("Loading issues…")) + return b.String() + case m.err != nil: + b.WriteString(lipgloss.NewStyle().Foreground(colors.Error).Background(colors.Bg).Render("Failed to load issues: " + m.err.Error())) + b.WriteString("\n\n") + b.WriteString(muted.Render("Press r to retry.")) + return b.String() + case len(m.issues) == 0: + b.WriteString(muted.Render("No active issues. 🎉")) + return b.String() + } + + for i, issue := range m.issues { + b.WriteString(m.renderRow(i, issue)) + b.WriteString("\n") + } + return b.String() +} + +func (m *Model) headline() string { + if m.loading || m.err != nil { + return "" + } + return fmt.Sprintf("%d open · %d high · %d medium · %d low", + m.summary.Open, m.summary.HighCount, m.summary.MediumCount, m.summary.LowCount) +} + +func (m *Model) renderRow(index int, issue domain.Issue) string { + colors := m.theme + cursor := " " + nameStyle := lipgloss.NewStyle().Foreground(colors.Text).Background(colors.Bg) + if index == m.cursor { + cursor = lipgloss.NewStyle().Foreground(colors.Accent).Background(colors.Bg).Render("▶ ") + nameStyle = nameStyle.Foreground(colors.Accent) + } + + prio := lipgloss.NewStyle().Background(colors.Bg).Foreground(priorityColor(colors, issue.Priority)).Render(pad(string(issue.Priority), 6)) + muted := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) + + meta := issue.DisplayID + if issue.ServiceName != "" { + meta += " · " + issue.ServiceName + } + + line := cursor + prio + " " + nameStyle.Render(truncate(issue.Title, m.titleWidth())) + " " + muted.Render(meta) + return line +} + +func (m *Model) titleWidth() int { + w := m.width - 30 + if w < 20 { + w = 20 + } + return w +} + +func priorityColor(theme styles.Theme, p domain.IssuePriority) color.Color { + switch p { + case domain.IssuePriorityHigh: + return theme.Error + case domain.IssuePriorityMedium: + return theme.Warning + default: + return theme.TextMuted + } +} + +func pad(s string, n int) string { + if len(s) >= n { + return s + } + return s + strings.Repeat(" ", n-len(s)) +} + +func truncate(s string, n int) string { + if n <= 1 || len(s) <= n { + return s + } + return s[:n-1] + "…" +} diff --git a/internal/app/onboarding/datadog/check_test.go b/internal/app/onboarding/datadog/check_test.go index 28aac1f4..2eca94ce 100644 --- a/internal/app/onboarding/datadog/check_test.go +++ b/internal/app/onboarding/datadog/check_test.go @@ -19,7 +19,7 @@ func TestCheckHasDatadogEmitsReady(t *testing.T) { mockDatadog.HasAccountFunc = func(context.Context, domain.AccountID) (bool, error) { return true, nil } - services := apitest.NewMockServiceSet(nil, nil, nil, mockDatadog) + services := apitest.NewMockServiceSet(nil, nil, mockDatadog) m := NewCheck(context.Background(), styles.NewTheme(true), domain.Account{ID: "acc-1"}, services, logtest.NewScope(t)) cmd := m.Update(datadogCheckCompletedMsg{hasDatadog: true}) @@ -38,7 +38,7 @@ func TestCheckNoDatadogEmitsNeeded(t *testing.T) { mockDatadog.HasAccountFunc = func(context.Context, domain.AccountID) (bool, error) { return false, nil } - services := apitest.NewMockServiceSet(nil, nil, nil, mockDatadog) + services := apitest.NewMockServiceSet(nil, nil, mockDatadog) m := NewCheck(context.Background(), styles.NewTheme(true), domain.Account{ID: "acc-1"}, services, logtest.NewScope(t)) cmd := m.Update(datadogCheckCompletedMsg{hasDatadog: false}) @@ -57,7 +57,7 @@ func TestCheckErrorEnablesRetry(t *testing.T) { mockDatadog.HasAccountFunc = func(context.Context, domain.AccountID) (bool, error) { return false, errors.New("boom") } - services := apitest.NewMockServiceSet(nil, nil, nil, mockDatadog) + services := apitest.NewMockServiceSet(nil, nil, mockDatadog) m := NewCheck(context.Background(), styles.NewTheme(true), domain.Account{ID: "acc-1"}, services, logtest.NewScope(t)) if cmd := m.Update(datadogCheckCompletedMsg{err: errors.New("boom")}); cmd == nil { diff --git a/internal/app/onboarding/datadog/discovery_test.go b/internal/app/onboarding/datadog/discovery_test.go index 23db780e..fb914eba 100644 --- a/internal/app/onboarding/datadog/discovery_test.go +++ b/internal/app/onboarding/datadog/discovery_test.go @@ -21,7 +21,7 @@ func TestDiscoveryPollTickSchedulesAsyncFetch(t *testing.T) { callCount++ return &graphql.DatadogAccountStatus{}, nil } - services := apitest.NewMockServiceSet(nil, nil, nil, mockDatadog) + services := apitest.NewMockServiceSet(nil, nil, mockDatadog) m := NewDiscovery(context.Background(), styles.NewTheme(true), "dd-1", services, logtest.NewScope(t)) cmd := m.Update(discoveryPollTickMsg{}) @@ -48,7 +48,7 @@ func TestDiscoveryStatusSchedulesTimerTick(t *testing.T) { context.Background(), styles.NewTheme(true), "dd-1", - apitest.NewMockServiceSet(nil, nil, nil, apitest.NewMockDatadogAccounts()), + apitest.NewMockServiceSet(nil, nil, apitest.NewMockDatadogAccounts()), logtest.NewScope(t), ) @@ -70,7 +70,7 @@ func TestDiscoveryStatusReadyCompletesStep(t *testing.T) { context.Background(), styles.NewTheme(true), "dd-1", - apitest.NewMockServiceSet(nil, nil, nil, apitest.NewMockDatadogAccounts()), + apitest.NewMockServiceSet(nil, nil, apitest.NewMockDatadogAccounts()), logtest.NewScope(t), ) diff --git a/internal/app/onboarding/gate_definitions.go b/internal/app/onboarding/gate_definitions.go index fbb04274..ce0bc7c3 100644 --- a/internal/app/onboarding/gate_definitions.go +++ b/internal/app/onboarding/gate_definitions.go @@ -10,8 +10,6 @@ import ( "github.com/usetero/cli/internal/app/onboarding/preflight" "github.com/usetero/cli/internal/app/onboarding/role" "github.com/usetero/cli/internal/app/onboarding/runtimeinit" - "github.com/usetero/cli/internal/app/onboarding/sync" - "github.com/usetero/cli/internal/app/onboarding/workspaces" "github.com/usetero/cli/internal/core/bootstrap" ) @@ -53,10 +51,6 @@ func (m *Model) newStepForGate(gate Gate) (Step, error) { return datadog.NewAppKey(m.ctx, m.theme, *m.state.Account, m.state.DDSite, m.state.DDAPIKey, m.services, m.scope), nil case bootstrap.GateDatadogDiscovery: return datadog.NewDiscovery(m.ctx, m.theme, m.state.DDAccount, m.services, m.scope), nil - case bootstrap.GateWorkspaceSelect: - return workspaces.NewSelect(m.ctx, m.theme, *m.state.Account, m.services, m.orgPrefs, m.scope), nil - case bootstrap.GateSync: - return sync.New(m.theme, m.syncer, m.scope), nil default: return nil, fmt.Errorf("unsupported gate %q", gate) } @@ -69,8 +63,6 @@ func (m *Model) validateGateState(gate Gate) error { return fmt.Errorf("gate %q requires org", gate) case req.NeedsAccount && m.state.Account == nil: return fmt.Errorf("gate %q requires account", gate) - case req.NeedsWorkspace && m.state.Workspace == nil: - return fmt.Errorf("gate %q requires workspace", gate) case req.NeedsDDSite && m.state.DDSite == "": return fmt.Errorf("gate %q requires datadog site", gate) case req.NeedsDDAPIKey && m.state.DDAPIKey == "": diff --git a/internal/app/onboarding/gate_definitions_test.go b/internal/app/onboarding/gate_definitions_test.go index 45a86984..da2b776a 100644 --- a/internal/app/onboarding/gate_definitions_test.go +++ b/internal/app/onboarding/gate_definitions_test.go @@ -15,7 +15,6 @@ func TestNewStepForGateCoverage(t *testing.T) { m.state.DDSite = "US1" m.state.DDAPIKey = "api-key" m.state.DDAccount = "dd-1" - m.state.Workspace = ptrWorkspace("ws-1") expected := []Gate{ bootstrap.GatePreflight, @@ -31,8 +30,6 @@ func TestNewStepForGateCoverage(t *testing.T) { bootstrap.GateDatadogAPIKey, bootstrap.GateDatadogAppKey, bootstrap.GateDatadogDiscovery, - bootstrap.GateWorkspaceSelect, - bootstrap.GateSync, } for _, gate := range expected { diff --git a/internal/app/onboarding/gate_requirements_test.go b/internal/app/onboarding/gate_requirements_test.go index 18a1e807..fdd50f5e 100644 --- a/internal/app/onboarding/gate_requirements_test.go +++ b/internal/app/onboarding/gate_requirements_test.go @@ -21,8 +21,7 @@ func TestRewindGateFor(t *testing.T) { {name: "datadog api rewinds to region when site missing", target: bootstrap.GateDatadogAPIKey, state: bootstrap.State{Org: ptrOrg("org-1"), Account: ptrAccount("acc-1")}, want: bootstrap.GateDatadogRegion}, {name: "datadog app rewinds to api key when api key missing", target: bootstrap.GateDatadogAppKey, state: bootstrap.State{Org: ptrOrg("org-1"), Account: ptrAccount("acc-1"), DDSite: "US1"}, want: bootstrap.GateDatadogAPIKey}, {name: "discovery rewinds to datadog check without dd account", target: bootstrap.GateDatadogDiscovery, state: bootstrap.State{Org: ptrOrg("org-1"), Account: ptrAccount("acc-1")}, want: bootstrap.GateDatadogCheck}, - {name: "sync rewinds to workspace", target: bootstrap.GateSync, state: bootstrap.State{Org: ptrOrg("org-1"), Account: ptrAccount("acc-1")}, want: bootstrap.GateWorkspaceSelect}, - {name: "sync stays when requirements met", target: bootstrap.GateSync, state: bootstrap.State{Org: ptrOrg("org-1"), Account: ptrAccount("acc-1"), Workspace: ptrWorkspace("ws-1")}, want: bootstrap.GateSync}, + {name: "datadog check rewinds to account without account", target: bootstrap.GateDatadogCheck, state: bootstrap.State{Org: ptrOrg("org-1")}, want: bootstrap.GateAccountSelect}, } for _, tc := range tests { diff --git a/internal/app/onboarding/onboarding.go b/internal/app/onboarding/onboarding.go index e58f8e42..50dcb746 100644 --- a/internal/app/onboarding/onboarding.go +++ b/internal/app/onboarding/onboarding.go @@ -11,7 +11,6 @@ import ( graphql "github.com/usetero/cli/internal/boundary/graphql" "github.com/usetero/cli/internal/core/bootstrap" "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/powersync" "github.com/usetero/cli/internal/preferences" "github.com/usetero/cli/internal/styles" ) @@ -25,7 +24,6 @@ type Model struct { userPrefs preferences.UserPreferences orgPrefs preferences.OrgPreferences auth iauth.Auth - syncer powersync.Syncer scope log.Scope // Accumulated state from step completions @@ -50,7 +48,6 @@ func New( userPrefs preferences.UserPreferences, orgPrefs preferences.OrgPreferences, authService iauth.Auth, - syncer powersync.Syncer, scope log.Scope, ) *Model { if ctx == nil { @@ -65,9 +62,6 @@ func New( if authService == nil { panic("authService is nil") } - if syncer == nil { - panic("syncer is nil") - } scope = scope.Child("onboarding") @@ -78,7 +72,6 @@ func New( userPrefs: userPrefs, orgPrefs: orgPrefs, auth: authService, - syncer: syncer, scope: scope, } } diff --git a/internal/app/onboarding/preflight/preflight_effects.go b/internal/app/onboarding/preflight/preflight_effects.go index 56a7810a..8cf2edc3 100644 --- a/internal/app/onboarding/preflight/preflight_effects.go +++ b/internal/app/onboarding/preflight/preflight_effects.go @@ -6,6 +6,7 @@ import ( tea "charm.land/bubbletea/v2" + "github.com/usetero/cli/internal/auth" "github.com/usetero/cli/internal/core/bootstrap" "github.com/usetero/cli/internal/domain" ) @@ -13,14 +14,23 @@ import ( func (m *Model) checkAuth() tea.Cmd { return func() tea.Msg { hasValidAuth := false + var user *auth.User if m.auth.IsAuthenticated() { if _, err := m.auth.GetAccessToken(m.ctx); err == nil { hasValidAuth = true + // Capture the user identity here: when auth is already valid the + // onboarding auth gate is skipped, so this is the only place the + // resumed-session user is resolved for the completion payload. + if userID, idErr := m.auth.GetUserID(m.ctx); idErr == nil && userID != "" { + user = &auth.User{ID: userID} + } else if idErr != nil { + m.scope.Warn("preflight could not resolve user id", "error", idErr) + } } else { _ = m.auth.ClearTokens() } } - return preflightAuthCheckCompletedMsg{hasValidAuth: hasValidAuth} + return preflightAuthCheckCompletedMsg{hasValidAuth: hasValidAuth, user: user} } } diff --git a/internal/app/onboarding/preflight/preflight_model.go b/internal/app/onboarding/preflight/preflight_model.go index 033b7e9d..cca9eb8d 100644 --- a/internal/app/onboarding/preflight/preflight_model.go +++ b/internal/app/onboarding/preflight/preflight_model.go @@ -75,11 +75,10 @@ func New( func (m *Model) Init() tea.Cmd { m.started = time.Now() m.state = bootstrap.PreflightState{ - Outcome: bootstrap.PreflightOutcomeResolved, - Role: m.userPref.GetRole(), - ActiveOrgID: m.userPref.GetActiveOrgID(), - DefaultAccountID: m.orgPref.GetDefaultAccountID(), - DefaultWorkspaceID: m.orgPref.GetDefaultWorkspaceID(), + Outcome: bootstrap.PreflightOutcomeResolved, + Role: m.userPref.GetRole(), + ActiveOrgID: m.userPref.GetActiveOrgID(), + DefaultAccountID: m.orgPref.GetDefaultAccountID(), } m.stage = stageAuth return tea.Batch(m.spinner.Tick, m.checkAuth()) diff --git a/internal/app/onboarding/preflight/preflight_types.go b/internal/app/onboarding/preflight/preflight_types.go index eef1e875..e0c57e35 100644 --- a/internal/app/onboarding/preflight/preflight_types.go +++ b/internal/app/onboarding/preflight/preflight_types.go @@ -1,6 +1,7 @@ package preflight import ( + "github.com/usetero/cli/internal/auth" "github.com/usetero/cli/internal/core/bootstrap" "github.com/usetero/cli/internal/domain" ) @@ -11,6 +12,7 @@ type preflightResolutionCompletedMsg struct { type preflightAuthCheckCompletedMsg struct { hasValidAuth bool + user *auth.User } type preflightOrganizationsLoadedMsg struct { diff --git a/internal/app/onboarding/preflight/preflight_update.go b/internal/app/onboarding/preflight/preflight_update.go index 9ad688bf..4a4a70ab 100644 --- a/internal/app/onboarding/preflight/preflight_update.go +++ b/internal/app/onboarding/preflight/preflight_update.go @@ -29,6 +29,7 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd { func (m *Model) handleAuthChecked(msg preflightAuthCheckCompletedMsg) tea.Cmd { m.state.HasValidAuth = msg.hasValidAuth + m.state.User = msg.user if !m.state.HasValidAuth { return m.emitResult() } @@ -67,7 +68,6 @@ func (m *Model) handleResult(msg preflightResolutionCompletedMsg) tea.Cmd { "role", msg.state.Role, "active_org_id", msg.state.ActiveOrgID, "default_account_id", msg.state.DefaultAccountID, - "default_workspace_id", msg.state.DefaultWorkspaceID, "outcome", msg.state.Outcome, "org", msg.state.Org != nil, "account", msg.state.Account != nil, diff --git a/internal/app/onboarding/sync/model.go b/internal/app/onboarding/sync/model.go deleted file mode 100644 index 0006c68e..00000000 --- a/internal/app/onboarding/sync/model.go +++ /dev/null @@ -1,70 +0,0 @@ -// Package sync provides the sync step for onboarding. -package sync - -import ( - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/spinner" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - - "github.com/usetero/cli/internal/core/bootstrap" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/powersync" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/components/progress" -) - -// Model waits for sync to complete. -type Model struct { - theme styles.Theme - syncer powersync.Syncer - scope log.Scope - spinner spinner.Model - progress *progress.Model - width int - height int -} - -// New creates a new sync step. -func New(theme styles.Theme, syncer powersync.Syncer, scope log.Scope) *Model { - if syncer == nil { - panic("syncer is nil") - } - - scope.Debug("initialized") - - sp := spinner.New() - sp.Spinner = spinner.Dot - sp.Style = lipgloss.NewStyle().Foreground(theme.Accent).Background(theme.Bg) - - return &Model{ - theme: theme, - syncer: syncer, - scope: scope, - spinner: sp, - progress: progress.New(theme, 50), - } -} - -// Init starts the spinner and checks if already ready. -func (m *Model) Init() tea.Cmd { - if m.syncer.IsReady() { - m.scope.Info("sync already complete") - return func() tea.Msg { return bootstrap.SyncComplete{} } - } - m.scope.Debug("waiting for sync") - return m.spinner.Tick -} - -// SetSize updates dimensions. -func (m *Model) SetSize(width, height int) { - m.width = width - m.height = height - m.progress.SetWidth(min(width, 50)) -} - -// ShortHelp returns the key bindings for the short help view. -func (m *Model) ShortHelp() []key.Binding { - // Sync is automatic, no user action needed. - return nil -} diff --git a/internal/app/onboarding/sync/step_contract.go b/internal/app/onboarding/sync/step_contract.go deleted file mode 100644 index a22c637f..00000000 --- a/internal/app/onboarding/sync/step_contract.go +++ /dev/null @@ -1,9 +0,0 @@ -package sync - -import onbstatus "github.com/usetero/cli/internal/app/onboarding/status" - -func (m *Model) Hidden() bool { return false } - -func (m *Model) Status() onbstatus.StepStatus { - return onbstatus.StepStatus{Title: "Getting ready", Details: "Syncing your account data..."} -} diff --git a/internal/app/onboarding/sync/sync_test.go b/internal/app/onboarding/sync/sync_test.go deleted file mode 100644 index 7ffd6f1d..00000000 --- a/internal/app/onboarding/sync/sync_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package sync - -import ( - "testing" - - appevents "github.com/usetero/cli/internal/app/events" - "github.com/usetero/cli/internal/core/bootstrap" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync" - "github.com/usetero/cli/internal/powersync/powersynctest" - "github.com/usetero/cli/internal/styles" -) - -func TestInitEmitsSyncCompleteWhenReady(t *testing.T) { - t.Parallel() - - syncer := powersynctest.NewMockSyncer() - syncer.IsReadyFunc = func() bool { return true } - m := New(styles.NewTheme(true), syncer, logtest.NewScope(t)) - - cmd := m.Init() - if cmd == nil { - t.Fatal("expected non-nil init cmd") - } - msg := cmd() - if _, ok := msg.(bootstrap.SyncComplete); !ok { - t.Fatalf("expected SyncComplete message, got %T", msg) - } -} - -func TestUpdateEmitsSyncCompleteOnReadyState(t *testing.T) { - t.Parallel() - - syncer := powersynctest.NewMockSyncer() - m := New(styles.NewTheme(true), syncer, logtest.NewScope(t)) - - cmd := m.Update(appevents.SyncStateChanged{State: powersync.NewReady()}) - if cmd == nil { - t.Fatal("expected non-nil command on ready state") - } - msg := cmd() - if _, ok := msg.(bootstrap.SyncComplete); !ok { - t.Fatalf("expected SyncComplete message, got %T", msg) - } -} - -func TestUpdateIgnoresNonReadySyncState(t *testing.T) { - t.Parallel() - - syncer := powersynctest.NewMockSyncer() - m := New(styles.NewTheme(true), syncer, logtest.NewScope(t)) - - cmd := m.Update(appevents.SyncStateChanged{State: powersync.NewConnecting()}) - if cmd != nil { - t.Fatal("expected nil command for non-ready sync state") - } -} diff --git a/internal/app/onboarding/sync/update.go b/internal/app/onboarding/sync/update.go deleted file mode 100644 index 9e73ac95..00000000 --- a/internal/app/onboarding/sync/update.go +++ /dev/null @@ -1,29 +0,0 @@ -package sync - -import ( - "charm.land/bubbles/v2/spinner" - tea "charm.land/bubbletea/v2" - - appevents "github.com/usetero/cli/internal/app/events" - "github.com/usetero/cli/internal/core/bootstrap" - "github.com/usetero/cli/internal/powersync" -) - -// Update handles messages. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - switch msg := msg.(type) { - case appevents.SyncStateChanged: - if _, ok := msg.State.(*powersync.Ready); ok { - m.scope.Info("sync completed") - return func() tea.Msg { return bootstrap.SyncComplete{} } - } - return nil - - case spinner.TickMsg: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return cmd - } - - return m.progress.Update(msg) -} diff --git a/internal/app/onboarding/sync/view.go b/internal/app/onboarding/sync/view.go deleted file mode 100644 index bc95db90..00000000 --- a/internal/app/onboarding/sync/view.go +++ /dev/null @@ -1,52 +0,0 @@ -package sync - -import ( - "fmt" - - "charm.land/lipgloss/v2" - - "github.com/usetero/cli/internal/powersync" -) - -// View renders the sync UI. -func (m *Model) View() string { - s := m.theme.Styles - title := s.Title.Render("Getting ready") - - switch state := m.syncer.State().(type) { - case *powersync.Ready: - return lipgloss.JoinVertical(lipgloss.Left, title, "", s.Success.Render("Ready!")) - - case *powersync.Error: - return lipgloss.JoinVertical(lipgloss.Left, title, "", s.Error.Render(fmt.Sprintf("Error: %v", state.Err))) - - case *powersync.Connecting: - statusLine := m.spinner.View() + " " + s.Body.Render("Connecting...") - return lipgloss.JoinVertical(lipgloss.Left, title, "", statusLine) - - case *powersync.Syncing: - msg := "Syncing your account data..." - if state.Progress != nil && state.Progress.Total > 0 { - msg = fmt.Sprintf("Syncing your account data... (%s)", state.Progress) - } - statusLine := m.spinner.View() + " " + s.Body.Render(msg) - parts := []string{title, "", statusLine} - - if state.Progress != nil && state.Progress.Total > 0 { - pct := float64(state.Progress.Downloaded) / float64(state.Progress.Total) * 100 - progressBar := m.progress.ViewAs(pct) - countText := fmt.Sprintf("%d / %d rows", state.Progress.Downloaded, state.Progress.Total) - parts = append(parts, "", progressBar, "", s.Help.Render(countText)) - } - - return lipgloss.JoinVertical(lipgloss.Left, parts...) - - case *powersync.Reconnecting: - statusLine := m.spinner.View() + " " + s.Body.Render("Reconnecting...") - return lipgloss.JoinVertical(lipgloss.Left, title, "", statusLine) - - default: - statusLine := m.spinner.View() + " " + s.Body.Render("Initializing sync engine...") - return lipgloss.JoinVertical(lipgloss.Left, title, "", statusLine) - } -} diff --git a/internal/app/onboarding/transition_cmds.go b/internal/app/onboarding/transition_cmds.go index 4ee3d58c..511c2994 100644 --- a/internal/app/onboarding/transition_cmds.go +++ b/internal/app/onboarding/transition_cmds.go @@ -23,23 +23,20 @@ func (m *Model) commandForTransition(event bootstrap.Event, transition bootstrap m.scope.Info("onboarding complete", slog.String("org_id", transition.Completion.Org.ID.String()), slog.String("account_id", transition.Completion.Account.ID.String()), - slog.String("workspace_id", string(transition.Completion.Workspace.ID)), ) return func() tea.Msg { return bootstrap.OnboardingComplete{ - User: transition.Completion.User, - Org: transition.Completion.Org, - Account: transition.Completion.Account, - Workspace: transition.Completion.Workspace, + User: transition.Completion.User, + Org: transition.Completion.Org, + Account: transition.Completion.Account, } } case bootstrap.TransitionNoop: - if event.Kind == bootstrap.EventSyncComplete { - m.scope.Error("sync completed without required onboarding state", + if event.Kind == bootstrap.EventDatadogDiscoveryDone { + m.scope.Error("datadog discovery completed without required onboarding state", slog.Bool("has_user", m.state.User != nil), slog.Bool("has_org", m.state.Org != nil), slog.Bool("has_account", m.state.Account != nil), - slog.Bool("has_workspace", m.state.Workspace != nil), ) } return nil diff --git a/internal/app/onboarding/transition_policy.go b/internal/app/onboarding/transition_policy.go index 46e89412..8fa2a897 100644 --- a/internal/app/onboarding/transition_policy.go +++ b/internal/app/onboarding/transition_policy.go @@ -31,7 +31,6 @@ func (m *Model) logPreflightResolved(preflight bootstrap.PreflightResolved) { slog.String("role", preflight.State.Role), slog.String("active_org_id", preflight.State.ActiveOrgID.String()), slog.String("default_account_id", preflight.State.DefaultAccountID.String()), - slog.String("default_workspace_id", string(preflight.State.DefaultWorkspaceID)), slog.Bool("org_resolved", preflight.State.Org != nil), slog.Bool("account_resolved", preflight.State.Account != nil), slog.String("error", preflight.State.Error)) diff --git a/internal/app/onboarding/transitions_test.go b/internal/app/onboarding/transitions_test.go index 08709ade..76643bbe 100644 --- a/internal/app/onboarding/transitions_test.go +++ b/internal/app/onboarding/transitions_test.go @@ -11,7 +11,6 @@ import ( "github.com/usetero/cli/internal/core/bootstrap" "github.com/usetero/cli/internal/domain" "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/powersynctest" "github.com/usetero/cli/internal/preferences/preferencestest" "github.com/usetero/cli/internal/styles" ) @@ -99,49 +98,36 @@ func TestHandleTransitionPreflightRouting(t *testing.T) { } } -func TestHandleTransitionDatadogBranchRouting(t *testing.T) { +func TestHandleTransitionDatadogNeededRoutesToRegion(t *testing.T) { t.Parallel() - tests := []struct { - name string - msg any - wantGate Gate - }{ - {name: "datadog ready goes to workspace select", msg: bootstrap.DatadogReady{}, wantGate: bootstrap.GateWorkspaceSelect}, - {name: "datadog needed goes to region", msg: bootstrap.DatadogNeeded{}, wantGate: bootstrap.GateDatadogRegion}, - {name: "discovery complete goes to workspace select", msg: bootstrap.DatadogDiscoveryComplete{}, wantGate: bootstrap.GateWorkspaceSelect}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - m := newTestModel(t) - m.state.Org = ptrOrg("org-1") - m.state.Account = ptrAccount("acc-1") - _ = m.handleTransition(tc.msg) - if m.gate != tc.wantGate { - t.Fatalf("gate = %s, want %s", m.gate, tc.wantGate) - } - }) + m := newTestModel(t) + m.state.Org = ptrOrg("org-1") + m.state.Account = ptrAccount("acc-1") + _ = m.handleTransition(bootstrap.DatadogNeeded{}) + if m.gate != bootstrap.GateDatadogRegion { + t.Fatalf("gate = %s, want %s", m.gate, bootstrap.GateDatadogRegion) } } -func TestHandleTransitionWorkspaceSelectedSetsState(t *testing.T) { +func TestHandleTransitionDatadogCompletesOnboarding(t *testing.T) { t.Parallel() - m := newTestModel(t) - m.state.Org = ptrOrg("org-1") - m.state.Account = ptrAccount("acc-1") - workspace := domain.Workspace{ID: "ws-1", Name: "Workspace 1"} - - if cmd := m.handleTransition(bootstrap.WorkspaceSelected{Workspace: workspace}); cmd == nil { - t.Fatal("expected transition command") - } - if m.gate != bootstrap.GateSync { - t.Fatalf("gate = %s, want %s", m.gate, bootstrap.GateSync) - } - if m.state.Workspace == nil || m.state.Workspace.ID != workspace.ID { - t.Fatalf("workspace state not set correctly: %+v", m.state.Workspace) + // Datadog ready and discovery-complete are both terminal: they complete + // onboarding rather than advancing to a workspace or sync gate. + for _, msg := range []any{bootstrap.DatadogReady{}, bootstrap.DatadogDiscoveryComplete{}} { + m := newTestModel(t) + m.state.User = ptrUser("user-1") + m.state.Org = ptrOrg("org-1") + m.state.Account = ptrAccount("acc-1") + + cmd := m.handleTransition(msg) + if cmd == nil { + t.Fatalf("expected transition command for %T", msg) + } + if _, ok := cmd().(bootstrap.OnboardingComplete); !ok { + t.Fatalf("expected OnboardingComplete command for %T", msg) + } } } @@ -226,38 +212,15 @@ func TestHandleTransitionOrgSelectedClearsServiceAccountScope(t *testing.T) { } } -func TestHandleTransitionSyncComplete(t *testing.T) { - t.Parallel() - - m := newTestModel(t) - m.state.User = ptrUser("user-1") - m.state.Org = ptrOrg("org-1") - m.state.Account = ptrAccount("acc-1") - m.state.Workspace = ptrWorkspace("ws-1") - - cmd := m.handleTransition(bootstrap.SyncComplete{}) - if cmd == nil { - t.Fatal("expected completion command") - } - msg := cmd() - complete, ok := msg.(bootstrap.OnboardingComplete) - if !ok { - t.Fatalf("message type = %T, want bootstrap.OnboardingComplete", msg) - } - if complete.Org.ID != "org-1" || complete.Account.ID != "acc-1" || complete.Workspace.ID != "ws-1" || complete.User.ID != "user-1" { - t.Fatalf("unexpected completion payload: %+v", complete) - } -} - -func TestHandleTransitionSyncCompleteMissingStateNoops(t *testing.T) { +func TestHandleTransitionDatadogCompleteMissingStateNoops(t *testing.T) { t.Parallel() m := newTestModel(t) m.state.User = ptrUser("user-1") m.state.Org = ptrOrg("org-1") - // Missing account/workspace should not panic or emit completion payload. + // Missing account should not panic or emit a completion payload. - cmd := m.handleTransition(bootstrap.SyncComplete{}) + cmd := m.handleTransition(bootstrap.DatadogDiscoveryComplete{}) if cmd != nil { t.Fatal("expected nil command when completion state is incomplete") } @@ -278,9 +241,8 @@ func newTestModelWithClient(t *testing.T) (*Model, *apitest.MockClient) { userPrefs := preferencestest.NewMockUserPreferences() orgPrefs := preferencestest.NewMockOrgPreferences() authSvc := &authtest.MockAuth{} - syncer := powersynctest.NewMockSyncer() - m := New(context.Background(), styles.NewTheme(true), services, userPrefs, orgPrefs, authSvc, syncer, scope) + m := New(context.Background(), styles.NewTheme(true), services, userPrefs, orgPrefs, authSvc, scope) m.SetSize(120, 40) return m, client } @@ -293,10 +255,6 @@ func ptrAccount(id string) *domain.Account { return &domain.Account{ID: domain.AccountID(id), Name: id} } -func ptrWorkspace(id string) *domain.Workspace { - return &domain.Workspace{ID: domain.WorkspaceID(id), Name: id} -} - func ptrUser(id string) *iauth.User { return &iauth.User{ID: id} } diff --git a/internal/app/onboarding/workspaces/select_effects.go b/internal/app/onboarding/workspaces/select_effects.go deleted file mode 100644 index 963ad089..00000000 --- a/internal/app/onboarding/workspaces/select_effects.go +++ /dev/null @@ -1,34 +0,0 @@ -package workspaces - -import ( - "log/slog" - - tea "charm.land/bubbletea/v2" - - "github.com/usetero/cli/internal/core/bootstrap" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/tea/components/remotelist" -) - -func (m *SelectModel) loadWorkspaces() tea.Cmd { - return func() tea.Msg { - workspaces, err := m.services.Workspaces.List(m.ctx, m.account.ID) - if err != nil { - m.scope.Error("failed to load workspaces", slog.Any("error", err)) - return remotelist.LoadResult{Err: err} - } - - m.scope.Debug("loaded workspaces", slog.Int("count", len(workspaces))) - items := make([]remotelist.Item, len(workspaces)) - for i, ws := range workspaces { - items[i] = ws // domain.Workspace implements FilterValue() - } - return remotelist.LoadResult{Items: items} - } -} - -func (m *SelectModel) emitSelected(ws domain.Workspace) tea.Cmd { - return func() tea.Msg { - return bootstrap.WorkspaceSelected{Workspace: ws} - } -} diff --git a/internal/app/onboarding/workspaces/select_model.go b/internal/app/onboarding/workspaces/select_model.go deleted file mode 100644 index 46639f04..00000000 --- a/internal/app/onboarding/workspaces/select_model.go +++ /dev/null @@ -1,94 +0,0 @@ -// Package workspaces provides workspace selection steps. -package workspaces - -import ( - "context" - "log/slog" - - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - - onbstatus "github.com/usetero/cli/internal/app/onboarding/status" - "github.com/usetero/cli/internal/app/onboarding/stepkit" - graphql "github.com/usetero/cli/internal/boundary/graphql" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/preferences" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/components/remotelist" -) - -// SelectModel handles workspace selection. -type SelectModel struct { - ctx context.Context - theme styles.Theme - services graphql.ServiceSet - prefs preferences.OrgPreferences - account domain.Account - scope log.Scope - - list *remotelist.Model - workspaces []domain.Workspace - width int - height int -} - -// NewSelect creates a new workspace select step. -func NewSelect( - ctx context.Context, - theme styles.Theme, - account domain.Account, - services graphql.ServiceSet, - prefs preferences.OrgPreferences, - scope log.Scope, -) *SelectModel { - if ctx == nil { - panic("ctx is nil") - } - if prefs == nil { - panic("prefs is nil") - } - - scope.Debug("initialized") - - return &SelectModel{ - ctx: ctx, - theme: theme, - services: services, - prefs: prefs, - account: account, - scope: scope, - list: remotelist.New(theme, "Loading workspaces"), - } -} - -// Init starts loading workspaces. -func (m *SelectModel) Init() tea.Cmd { - m.scope.Debug("loading workspaces", slog.String("account_id", m.account.ID.String())) - return m.list.InitWithLoader(m.loadWorkspaces()) -} - -// SetSize updates dimensions. -func (m *SelectModel) SetSize(width, height int) { - m.width = width - m.height = height - m.list.SetWidth(width) -} - -// ShortHelp returns the key bindings for the short help view. -func (m *SelectModel) ShortHelp() []key.Binding { - return stepkit.RemoteListShortHelp(m.list, - key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")), - ) -} - -func (m *SelectModel) Hidden() bool { - return m.list.IsLoading() -} - -func (m *SelectModel) Status() onbstatus.StepStatus { - return onbstatus.StepStatus{ - Title: "Select workspace", - Details: "Loading workspaces...", - } -} diff --git a/internal/app/onboarding/workspaces/select_test.go b/internal/app/onboarding/workspaces/select_test.go deleted file mode 100644 index f95e2782..00000000 --- a/internal/app/onboarding/workspaces/select_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package workspaces - -import ( - "testing" - - "github.com/usetero/cli/internal/domain" -) - -func TestFindWorkspaceByID(t *testing.T) { - t.Parallel() - - workspaces := []domain.Workspace{ - {ID: "ws-1", Name: "One"}, - {ID: "ws-2", Name: "Two"}, - } - - got := findWorkspaceByID(workspaces, "ws-2") - if got == nil || got.ID != "ws-2" { - t.Fatalf("expected ws-2, got %#v", got) - } - - if got := findWorkspaceByID(workspaces, "missing"); got != nil { - t.Fatalf("expected nil for missing workspace, got %#v", got) - } - - if got := findWorkspaceByID(workspaces, ""); got != nil { - t.Fatalf("expected nil for empty id, got %#v", got) - } -} diff --git a/internal/app/onboarding/workspaces/select_types.go b/internal/app/onboarding/workspaces/select_types.go deleted file mode 100644 index 73e5615a..00000000 --- a/internal/app/onboarding/workspaces/select_types.go +++ /dev/null @@ -1,16 +0,0 @@ -package workspaces - -import "github.com/usetero/cli/internal/domain" - -func findWorkspaceByID(workspaces []domain.Workspace, id domain.WorkspaceID) *domain.Workspace { - if id == "" { - return nil - } - for _, ws := range workspaces { - if ws.ID == id { - resolved := ws - return &resolved - } - } - return nil -} diff --git a/internal/app/onboarding/workspaces/select_update.go b/internal/app/onboarding/workspaces/select_update.go deleted file mode 100644 index 8dbcca60..00000000 --- a/internal/app/onboarding/workspaces/select_update.go +++ /dev/null @@ -1,63 +0,0 @@ -package workspaces - -import ( - "log/slog" - - tea "charm.land/bubbletea/v2" - - appevents "github.com/usetero/cli/internal/app/events" - "github.com/usetero/cli/internal/app/onboarding/stepkit" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/tea/components/remotelist" -) - -// Update handles messages. -func (m *SelectModel) Update(msg tea.Msg) tea.Cmd { - switch msg := msg.(type) { - case remotelist.LoadResult: - return m.handleLoadResult(msg) - - case tea.KeyPressMsg: - if m.list.IsLoading() { - return nil - } - switch msg.String() { - case "enter": - if item := m.list.SelectedItem(); item != nil { - if ws, ok := item.(domain.Workspace); ok { - _ = m.prefs.SetDefaultWorkspaceID(ws.ID) - m.scope.Info("workspace selected", slog.String("workspace_id", string(ws.ID))) - return m.emitSelected(ws) - } - } - case "r": - if m.list.HasError() { - m.scope.Debug("retrying workspace load") - return m.list.Retry() - } - } - } - - return m.list.Update(msg) -} - -func (m *SelectModel) handleLoadResult(msg remotelist.LoadResult) tea.Cmd { - if msg.Err != nil { - return tea.Batch(m.list.Update(msg), appevents.PublishErrorToastCmd("Failed to load workspaces", msg.Err, false)) - } - m.workspaces = stepkit.CastItems[domain.Workspace](msg.Items) - - if prefWS := findWorkspaceByID(m.workspaces, m.prefs.GetDefaultWorkspaceID()); prefWS != nil { - m.scope.Info("workspace restored from preference", slog.String("workspace_id", string(prefWS.ID))) - return m.emitSelected(*prefWS) - } - - if len(m.workspaces) == 1 { - ws := m.workspaces[0] - _ = m.prefs.SetDefaultWorkspaceID(ws.ID) - m.scope.Info("workspace auto-selected", slog.String("workspace_id", string(ws.ID))) - return m.emitSelected(ws) - } - - return m.list.Update(msg) -} diff --git a/internal/app/onboarding/workspaces/select_view.go b/internal/app/onboarding/workspaces/select_view.go deleted file mode 100644 index c811f994..00000000 --- a/internal/app/onboarding/workspaces/select_view.go +++ /dev/null @@ -1,35 +0,0 @@ -package workspaces - -import ( - "charm.land/lipgloss/v2" - - "github.com/usetero/cli/internal/app/onboarding/errorfmt" -) - -// View renders the workspace selection UI. -func (m *SelectModel) View() string { - s := m.theme.Styles - - if m.list.IsLoading() { - return m.list.View() - } - - if m.list.HasError() { - return lipgloss.JoinVertical( - lipgloss.Left, - s.Error.Render(errorfmt.UserFacing(m.list.Error(), "Failed to load workspaces.")), - s.Help.Render("Press 'r' to retry."), - ) - } - - title := s.Title.Render("Select your workspace") - subtitle := s.Help.Render("Workspaces organize your conversations") - - return lipgloss.JoinVertical( - lipgloss.Left, - title, - subtitle, - "", - m.list.View(), - ) -} diff --git a/internal/app/onboarding_orchestration.go b/internal/app/onboarding_orchestration.go index 62e6dee6..c9eed8f5 100644 --- a/internal/app/onboarding_orchestration.go +++ b/internal/app/onboarding_orchestration.go @@ -1,12 +1,10 @@ package app import ( - "context" "time" tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" appevents "github.com/usetero/cli/internal/app/events" "github.com/usetero/cli/internal/core/bootstrap" ) @@ -44,54 +42,20 @@ func (m *Model) handleOnboardingMessage(msg tea.Msg) (tea.Cmd, bool) { return catalogCmd, true case bootstrap.OnboardingComplete: - m.state = stateChat + m.state = stateExplorer m.user = msg.User m.account = msg.Account - m.workspace = msg.Workspace m.scope.Info("onboarding complete", "org", msg.Org.Name, "account", msg.Account.Name, - "workspace", msg.Workspace.Name, ) - // Create chat model (sizing happens via updateLayout) - m.chat = m.newChat() - - // Size the new chat component + // Create the issue explorer (sizing happens via updateLayout). + m.explorer = m.newExplorer() m.updateLayout() - return m.chat.Init(), true + return m.explorer.Init(), true } return nil, false } - -func (m *Model) handleStreamCompleted(msg tea.Msg) { - stream, ok := msg.(msgs.StreamCompleted) - if !ok { - return - } - - if stream.Title != "" && m.db != nil && m.chat != nil { - m.statusBar.SetTitle(stream.Title) - m.windowTitle = "Tero: " + stream.Title - db := m.db - conversationID := m.chat.ConversationID() - title := stream.Title - scope := m.scope - ctx := m.ctx - // Persist title in background using immutable captured values. - go func() { - writeCtx, cancel := context.WithTimeout(ctx, 2*time.Second) - defer cancel() - if err := db.Conversations().UpdateTitle(writeCtx, conversationID, title); err != nil { - scope.Error("failed to update conversation title", "error", err) - } - }() - } - // Update context window usage in statusbar - if stream.InputTokens > 0 && stream.ContextWindow > 0 { - pct := (stream.InputTokens*100 + stream.ContextWindow - 1) / stream.ContextWindow // round up - m.statusBar.SetContextPercent(pct) - } -} diff --git a/internal/app/perf_logging.go b/internal/app/perf_logging.go index 278bc09f..d5f04410 100644 --- a/internal/app/perf_logging.go +++ b/internal/app/perf_logging.go @@ -59,7 +59,7 @@ func (m *Model) stateName() string { switch m.state { case stateOnboarding: return "onboarding" - case stateChat: + case stateExplorer: return "chat" default: return "unknown" diff --git a/internal/app/runtime_database.go b/internal/app/runtime_database.go deleted file mode 100644 index 3aafa1c1..00000000 --- a/internal/app/runtime_database.go +++ /dev/null @@ -1,27 +0,0 @@ -package app - -import "github.com/usetero/cli/internal/sqlite" - -// openDatabase opens the SQLite database for the given account. -func (m *Model) openDatabase(accountID string) error { - if m.db != nil { - if err := m.db.Close(); err != nil { - m.scope.Warn("failed to close previous database", "error", err) - } - m.db = nil - } - - dbPath, err := m.storage.DatabasePath(accountID) - if err != nil { - return err - } - - db, err := sqlite.Open(m.ctx, dbPath) - if err != nil { - return err - } - - m.db = db - m.scope.Info("database opened", "path", dbPath) - return nil -} diff --git a/internal/app/runtime_session_test.go b/internal/app/runtime_session_test.go index cfdde5e9..14647c05 100644 --- a/internal/app/runtime_session_test.go +++ b/internal/app/runtime_session_test.go @@ -2,95 +2,19 @@ package app import ( "context" - "path/filepath" "testing" - "github.com/usetero/cli/internal/auth/authtest" graphql "github.com/usetero/cli/internal/boundary/graphql" "github.com/usetero/cli/internal/boundary/graphql/apitest" - "github.com/usetero/cli/internal/config" "github.com/usetero/cli/internal/domain" "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/powersynctest" - "github.com/usetero/cli/internal/sqlite" - "github.com/usetero/cli/internal/sqlite/sqlitetest" - "github.com/usetero/cli/internal/upload" ) -type testStorage struct { - dbPath string -} - -func (s testStorage) DatabasePath(accountID string) (string, error) { - return s.dbPath, nil -} - -func (s testStorage) ClearDatabase(accountID string) error { - return nil -} - -func (s testStorage) Clear() error { - return nil -} - -type testUploader struct{} - -func (testUploader) Run(ctx context.Context) error { - <-ctx.Done() - return ctx.Err() -} - -func (testUploader) Events() <-chan upload.Event { - return nil -} - -func TestOpenDatabase_ClosesPreviousDatabase(t *testing.T) { - ctx := context.Background() - tmp := t.TempDir() - storage := testStorage{dbPath: filepath.Join(tmp, "next.sqlite")} - prev := sqlitetest.NewMockDB() - - m := &Model{ - ctx: ctx, - scope: logtest.NewScope(t), - storage: storage, - db: prev, - } - - if err := m.openDatabase("acc_123"); err != nil { - t.Fatalf("openDatabase() error = %v", err) - } - - if !prev.Closed { - t.Fatalf("expected previous database to be closed") - } - if m.db == nil { - t.Fatalf("expected new database to be set") - } - if m.db == prev { - t.Fatalf("expected new database instance") - } - - if err := m.db.Close(); err != nil { - t.Fatalf("close new database: %v", err) - } -} - -func TestShutdown_CleansRuntimeResources(t *testing.T) { - db := sqlitetest.NewMockDB() - syncer := powersynctest.NewMockSyncer() - syncerStopped := false - syncer.StopFunc = func() { - syncerStopped = true - } +func TestShutdown_CancelsSession(t *testing.T) { cancelled := false - m := &Model{ scope: logtest.NewScope(t), - syncer: syncer, sessionCancel: func() { cancelled = true }, - db: db, - uploader: testUploader{}, } m.shutdown() @@ -98,47 +22,18 @@ func TestShutdown_CleansRuntimeResources(t *testing.T) { if !cancelled { t.Fatalf("expected session cancel to be called") } - if !syncerStopped { - t.Fatalf("expected syncer stop to be called") - } - if !db.Closed { - t.Fatalf("expected db to be closed") - } if m.sessionCancel != nil { t.Fatalf("expected sessionCancel to be cleared") } - if m.db != nil { - t.Fatalf("expected db to be nil") - } - if m.uploader != nil { - t.Fatalf("expected uploader to be nil") - } } -func TestStartSync_RequiresOpenDatabase(t *testing.T) { - m := &Model{} - - if err := m.startSync("acc_123"); err == nil { - t.Fatalf("expected error when db is not open") - } +func TestShutdown_NoSessionIsSafe(t *testing.T) { + m := &Model{scope: logtest.NewScope(t)} + m.shutdown() // must not panic with no active session } -func TestStartSync_InitializesSessionAndUploader(t *testing.T) { +func TestStartSession_ScopesServicesToAccount(t *testing.T) { scope := logtest.NewScope(t) - db := sqlitetest.OpenBareDB(t) - - syncer := powersynctest.NewMockSyncer() - startCalled := false - syncer.StartFunc = func(ctx context.Context, gotDB sqlite.DB, accountID string, onFirstSync func()) error { - startCalled = true - if gotDB != db { - t.Fatalf("syncer received unexpected db instance") - } - if accountID != "acc_123" { - t.Fatalf("syncer received accountID=%q", accountID) - } - return nil - } mockClient := apitest.NewMockClient() var scopedAccountID domain.AccountID @@ -146,38 +41,43 @@ func TestStartSync_InitializesSessionAndUploader(t *testing.T) { scopedAccountID = accountID } - authService := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - <-ctx.Done() - return "", ctx.Err() - }, - } - m := &Model{ - ctx: context.Background(), - scope: scope, - cfg: &config.CLIConfig{PowerSyncEndpoint: "https://powersync.example.com"}, - db: db, - syncer: syncer, - services: graphql.NewServiceSetFromClient(mockClient, scope), - authService: authService, + ctx: context.Background(), + scope: scope, + services: graphql.NewServiceSetFromClient(mockClient, scope), } - if err := m.startSync("acc_123"); err != nil { - t.Fatalf("startSync() error = %v", err) - } + m.startSession("acc_123") t.Cleanup(m.shutdown) - if !startCalled { - t.Fatalf("expected syncer start to be called") - } if m.sessionCancel == nil { t.Fatalf("expected session cancel to be initialized") } - if m.uploader == nil { - t.Fatalf("expected uploader to be initialized") + if m.sessionCtx == nil { + t.Fatalf("expected session context to be initialized") } if scopedAccountID != domain.AccountID("acc_123") { t.Fatalf("expected services account scope to be set, got %q", scopedAccountID) } } + +func TestStartSession_ReplacesPreviousSession(t *testing.T) { + scope := logtest.NewScope(t) + m := &Model{ + ctx: context.Background(), + scope: scope, + services: graphql.NewServiceSetFromClient(apitest.NewMockClient(), scope), + } + + m.startSession("acc_1") + firstCtx := m.sessionCtx + m.startSession("acc_2") + t.Cleanup(m.shutdown) + + if firstCtx.Err() == nil { + t.Fatalf("expected previous session context to be cancelled") + } + if m.sessionCtx == firstCtx { + t.Fatalf("expected a new session context") + } +} diff --git a/internal/app/runtime_shutdown.go b/internal/app/runtime_shutdown.go index 6e75a9ec..3db80b4b 100644 --- a/internal/app/runtime_shutdown.go +++ b/internal/app/runtime_shutdown.go @@ -1,21 +1,9 @@ package app func (m *Model) shutdown() { - db := m.db - if m.sessionCancel != nil { m.sessionCancel() m.sessionCancel = nil } m.sessionCtx = nil - if m.syncer != nil { - m.syncer.Stop() - } - if db != nil { - if err := db.Close(); err != nil { - m.scope.Warn("failed to close database", "error", err) - } - } - m.db = nil - m.uploader = nil } diff --git a/internal/app/runtime_sync.go b/internal/app/runtime_sync.go index 5c1952f9..f8e32f26 100644 --- a/internal/app/runtime_sync.go +++ b/internal/app/runtime_sync.go @@ -2,110 +2,31 @@ package app import ( "context" - "errors" - "fmt" tea "charm.land/bubbletea/v2" - "github.com/usetero/cli/internal/app/chat/usecase" - chattools "github.com/usetero/cli/internal/app/chattools" - chatboundary "github.com/usetero/cli/internal/boundary/chat" - psapi "github.com/usetero/cli/internal/boundary/powersync" "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/upload" ) -// startSync starts the syncer and uploader with the open database. -func (m *Model) startSync(accountID string) error { - if m.db == nil { - return fmt.Errorf("database not open") - } +// startSession scopes the API services to the account and opens a session +// context that is cancelled on shutdown. There is no local database or sync +// engine: reads and writes go straight to the control plane over GraphQL. +func (m *Model) startSession(accountID string) { if m.sessionCancel != nil { m.shutdown() - if err := m.openDatabase(accountID); err != nil { - return err - } } - // Create a session context that is cancelled on shutdown. sessionCtx, cancel := context.WithCancel(m.ctx) m.sessionCtx = sessionCtx m.sessionCancel = cancel - if err := m.syncer.Start(sessionCtx, m.db, accountID, nil); err != nil { - cancel() - m.sessionCtx = nil - m.sessionCancel = nil - return err - } - m.scope.Info("syncer started", "account_id", accountID) - // Scope API services to the active account. m.services = m.services.WithAccountID(domain.AccountID(accountID)) - - // Create PowerSync API client for write checkpoints - psClient := psapi.NewClient(m.cfg.PowerSyncEndpoint) - - // Create and start uploader - syncer := m.syncer - scope := m.scope - uploader := upload.New( - m.db, - psClient, - m.authService, - upload.MutationDeps{ - Conversations: m.services.Conversations, - Messages: m.services.Messages, - Services: m.services.Services, - Policies: m.services.Policies, - }, - scope, - upload.WithBatchCompletedHook(func(ctx context.Context) error { - return syncer.NotifyUploadCompleted(ctx) - }), - ) - m.uploader = uploader - go func() { - if err := uploader.Run(sessionCtx); err != nil && !errors.Is(err, context.Canceled) { - scope.Error("uploader error", "error", err) - } - }() - scope.Info("uploader started", "account_id", accountID) - - return nil + m.scope.Info("session started", "account_id", accountID) } -// ensureRuntime opens account database, starts sync, and initializes dependent runtime services. +// ensureRuntime scopes the session to the account and starts the status +// surfaces, which read from the account-scoped control-plane services. func (m *Model) ensureRuntime(accountID string) (tea.Cmd, error) { - if err := m.openDatabase(accountID); err != nil { - return nil, err - } - - if err := m.startSync(accountID); err != nil { - return nil, err - } - - // Start catalog status polling now that db is ready - catalogCmd := m.statusBar.SetDB(m.db) - - // Create tool registry with executors - m.toolRegistry = chattools.NewRegistry( - chattools.NewQueryTool(m.db, m.scope), - chattools.NewShowTool(m.db), - map[string]chattools.ActionTool{ - "set_service_enabled": chattools.NewSetServiceEnabledAction(m.db), - "approve_policy": chattools.NewApprovePolicyAction(m.db, func() string { - if m.user != nil { - return m.user.ID - } - return "" - }), - }, - ) - - // Create chat client with tool definitions - m.chatClient = chatboundary.NewClient(m.cfg.ChatEndpoint, m.authService, m.scope, m.toolRegistry.Definitions()). - WithAccountID(domain.AccountID(accountID)) - m.runtimeDeps = usecase.NewRuntimeDeps(m.db, m.chatClient).WithEffectContext(m.sessionCtx) - - return catalogCmd, nil + m.startSession(accountID) + return m.statusBar.SetServices(m.services), nil } diff --git a/internal/app/statusbar/compliance/compliance.go b/internal/app/statusbar/compliance/compliance.go deleted file mode 100644 index 8d84ad7a..00000000 --- a/internal/app/statusbar/compliance/compliance.go +++ /dev/null @@ -1,444 +0,0 @@ -// Package compliance renders the compliance indicator in the status bar. -package compliance - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "time" - - "charm.land/bubbles/v2/progress" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - - "github.com/usetero/cli/internal/app/statusbar/listdetail" - "github.com/usetero/cli/internal/app/statusbar/policytab" - "github.com/usetero/cli/internal/app/statusbar/tabpoll" - "github.com/usetero/cli/internal/app/statusbar/viewkit" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/format" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/sqlite" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/components/table" -) - -const ( - dbTimeout = 2 * time.Second - pollSource = "compliance" -) - -type fetchedData struct { - summary domain.AccountSummary - categories []domain.PolicyCategoryStatus -} - -// complianceDetailLoadedMsg carries the result of an async detail fetch. -type complianceDetailLoadedMsg struct { - cat domain.PolicyCategoryStatus - policies []domain.CompliancePolicy - err error -} - -// Model renders compliance status: 4 categories (PII, Secrets, PHI, Payment Data). -type Model struct { - theme styles.Theme - scope log.Scope - core policytab.Base - - summary domain.AccountSummary - categories []domain.PolicyCategoryStatus - - // Drawer navigation - detail *detail // non-nil when viewing a single category's policies -} - -// New creates a new compliance status model. -func New(theme styles.Theme, scope log.Scope) *Model { - return &Model{ - theme: theme, - scope: scope.Child("compliance"), - core: policytab.New(pollSource), - } -} - -// SetDB sets the database and starts polling. -func (m *Model) SetDB(db sqlite.DB) tea.Cmd { - return m.core.SetDB(db) -} - -// Init starts polling. -func (m *Model) Init() tea.Cmd { - return m.core.Init() -} - -// Update handles messages. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - if cmd, handled := policytab.UpdatePoll(&m.core, - msg, - m.fetchData(), - func(data fetchedData) { - key := m.buildStateKey(data.summary, data.categories) - m.core.ApplyIfChanged(key, len(data.categories), func() { - m.summary = data.summary - m.categories = data.categories - m.core.SetHasData(len(data.categories) > 0) - }) - }, - ); handled { - return cmd - } - - switch msg := msg.(type) { - case complianceDetailLoadedMsg: - if msg.err == nil { - m.detail = newDetail(m.theme, msg.cat, msg.policies) - } - } - - return nil -} - -// fetchData returns a Cmd that queries compliance data off the event loop. -func (m *Model) fetchData() tea.Cmd { - db := m.core.DB() - scope := m.scope - return tabpoll.Fetch(dbTimeout, func(ctx context.Context) (fetchedData, error) { - summary, err := db.DatadogAccountStatuses().GetSummary(ctx) - if err != nil { - scope.Error("get summary", "err", err) - return fetchedData{}, err - } - - categories, err := db.LogEventPolicyCategoryStatuses().ListComplianceCategoryStatuses(ctx) - if err != nil { - scope.Error("list compliance category statuses", "err", err) - categories = nil - } - - // Merge observed (leaking) counts into category statuses. - observed, err := db.LogEventPolicyCategoryStatuses().CountObservedByComplianceCategory(ctx) - if err != nil { - scope.Error("count observed by compliance category", "err", err) - } else { - for i := range categories { - categories[i].ObservedCount = observed[categories[i].Category] - } - } - - return fetchedData{summary: summary, categories: categories}, nil - }) -} - -// fetchDetail returns a Cmd that queries category detail off the event loop. -func (m *Model) fetchDetail(cat domain.PolicyCategoryStatus) tea.Cmd { - db := m.core.DB() - scope := m.scope - return tabpoll.FetchDetail(dbTimeout, func(ctx context.Context) ([]domain.CompliancePolicy, error) { - policies, err := db.CompliancePolicies().ListPendingPoliciesByCategory(ctx, cat.Category, 25) - if err != nil { - scope.Error("list pending policies by category", "category", cat.Category, "err", err) - return nil, err - } - return policies, nil - }, func(policies []domain.CompliancePolicy, err error) tea.Msg { - if err != nil { - return complianceDetailLoadedMsg{err: err} - } - return complianceDetailLoadedMsg{cat: cat, policies: policies} - }) -} - -// buildStateKey builds a string key for change detection. -func (m *Model) buildStateKey(summary domain.AccountSummary, cats []domain.PolicyCategoryStatus) string { - key := complianceStateKey{ - Summary: complianceSummaryKey{ - EventCount: summary.EventCount, - AnalyzedCount: summary.AnalyzedCount, - }, - Categories: make([]complianceCategoryKey, 0, len(cats)), - } - for _, c := range cats { - key.Categories = append(key.Categories, complianceCategoryKey{ - Category: string(c.Category), - PendingCount: c.PendingCount, - ApprovedCount: c.ApprovedCount, - DismissedCount: c.DismissedCount, - ObservedCount: c.ObservedCount, - }) - } - data, err := json.Marshal(key) - if err != nil { - return "" - } - return string(data) -} - -type complianceStateKey struct { - Summary complianceSummaryKey `json:"summary"` - Categories []complianceCategoryKey `json:"categories"` -} - -type complianceSummaryKey struct { - EventCount int64 `json:"event_count"` - AnalyzedCount int64 `json:"analyzed_count"` -} - -type complianceCategoryKey struct { - Category string `json:"category"` - PendingCount int64 `json:"pending_count"` - ApprovedCount int64 `json:"approved_count"` - DismissedCount int64 `json:"dismissed_count"` - ObservedCount int64 `json:"observed_count"` -} - -// HasData returns true when compliance policy data has been loaded. -func (m *Model) HasData() bool { - return m.core.HasData() -} - -// HandleKeyPress handles keyboard navigation in the expanded drawer view. -func (m *Model) HandleKeyPress(msg tea.KeyPressMsg) tea.Cmd { - return m.navController().HandleKeyPress(msg) -} - -// InDetail returns true when the detail sub-view is active. -func (m *Model) InDetail() bool { - return m.detail != nil -} - -// CloseDetail exits the detail sub-view, returning to the category list. -func (m *Model) CloseDetail() { - m.detail = nil -} - -func (m *Model) navController() listdetail.Controller { - return m.core.NavController( - func() int { return len(m.categories) }, - func(index int) tea.Cmd { - cat := m.categories[index] - if cat.PendingCount == 0 { - return nil - } - return m.fetchDetail(cat) - }, - func() listdetail.Detail { return m.detail }, - func() { m.detail = nil }, - ) -} - -// CompactView renders the compliance indicator for the collapsed statusbar. -func (m *Model) CompactView() string { - if !m.core.HasData() { - return "" - } - - colors := m.theme - muted := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) - sep := muted.Render(" · ") - - var segments []string - - leaking := totalObserved(m.categories) - pending := totalPending(m.categories) - atRisk := pending - leaking - approved := totalApproved(m.categories) - - if leaking > 0 { - dot := lipgloss.NewStyle().Foreground(colors.Error).Background(colors.Bg).Render("●") - segments = append(segments, dot+" "+muted.Render(fmt.Sprintf("%d leaking", leaking))) - } - - if atRisk > 0 { - dot := lipgloss.NewStyle().Foreground(colors.Warning).Background(colors.Bg).Render("●") - segments = append(segments, dot+" "+muted.Render(fmt.Sprintf("%d at risk", atRisk))) - } - - if approved > 0 { - ok := lipgloss.NewStyle().Foreground(colors.Success).Background(colors.Bg) - segments = append(segments, ok.Render(fmt.Sprintf("%d fixed", approved))) - } - - if len(segments) == 0 { - dot := lipgloss.NewStyle().Foreground(colors.Success).Background(colors.Bg).Render("●") - return dot + " " + muted.Render("compliant") - } - - return strings.Join(segments, sep) -} - -// ExpandedView renders the detailed compliance status for the drawer. -func (m *Model) ExpandedView(width, height int) string { - if !m.core.HasData() { - return viewkit.RenderPolicyEmptyState( - m.theme, - m.core.DB() != nil, - m.summary, - "Enable services to start compliance scanning.", - "No compliance issues detected.", - ) - } - - // Detail sub-view for a single category. - if m.detail != nil { - return m.detail.View(width) - } - - return viewkit.ComposeSummaryTableView( - m.theme, - m.renderHeadline(), - m.renderCategoryTable(width), - m.cursorPrinciple(), - ) -} - -// renderHeadline renders the compliance summary: pending/approved counts and analysis progress. -func (m *Model) renderHeadline() string { - s := m.summary - colors := m.theme - muted := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) - text := lipgloss.NewStyle().Foreground(colors.Text).Background(colors.Bg) - sep := muted.Render(" · ") - - var parts []string - - leaking := totalObserved(m.categories) - pending := totalPending(m.categories) - atRisk := pending - leaking - approved := totalApproved(m.categories) - - if leaking > 0 { - dot := lipgloss.NewStyle().Foreground(colors.Error).Background(colors.Bg).Render("●") - parts = append(parts, dot+" "+text.Render(fmt.Sprintf("%d leaking", leaking))) - } - - if atRisk > 0 { - dot := lipgloss.NewStyle().Foreground(colors.Warning).Background(colors.Bg).Render("●") - parts = append(parts, dot+" "+text.Render(fmt.Sprintf("%d at risk", atRisk))) - } - - if approved > 0 { - ok := lipgloss.NewStyle().Foreground(colors.Success).Background(colors.Bg) - parts = append(parts, ok.Render(fmt.Sprintf("%d fixed", approved))) - } - - // Analysis progress when not yet ready. - if s.EventCount > 0 && !s.AnalysisReady() { - pct := float64(s.AnalyzedCount) / float64(s.EventCount) - bar := m.analysisBar() - parts = append(parts, bar.ViewAs(pct)+" "+muted.Render(fmt.Sprintf("%d/%d analyzed", s.AnalyzedCount, s.EventCount))) - } - - if len(parts) == 0 { - dot := lipgloss.NewStyle().Foreground(colors.Success).Background(colors.Bg).Render("●") - return dot + " " + muted.Render("No compliance issues detected.") - } - - return strings.Join(parts, sep) -} - -// renderCategoryTable renders all compliance categories in a single table with cursor highlighting. -func (m *Model) renderCategoryTable(width int) string { - if len(m.categories) == 0 { - return "" - } - - tbl := table.New(m.theme, table.WithMaxValueWidth(30)) - tbl.Headers("Category", "Leaking", "At Risk", "Approved") - tbl.SetWidth(width) - - errStyle := lipgloss.NewStyle().Foreground(m.theme.Error).Background(m.theme.Bg) - warn := lipgloss.NewStyle().Foreground(m.theme.Warning).Background(m.theme.Bg) - ok := lipgloss.NewStyle().Foreground(m.theme.Success).Background(m.theme.Bg) - accent := lipgloss.NewStyle().Foreground(m.theme.Accent).Background(m.theme.Bg) - - for i, c := range m.categories { - dot := ok.Render("●") - if c.PendingCount > 0 { - if c.IsLeaking() { - dot = errStyle.Render("●") - } else { - dot = warn.Render("●") - } - } - - name := c.Name() - if i == m.core.Cursor() { - name = accent.Render("▶ " + name) - } else { - name = dot + " " + name - } - - // Clean categories: single checkmark row. - if c.PendingCount == 0 && c.ApprovedCount == 0 { - tbl.Row(name, ok.Render("✓"), "—", "—") - continue - } - - atRisk := c.PendingCount - c.ObservedCount - - leaking := "—" - if c.ObservedCount > 0 { - leaking = errStyle.Render(format.Count(c.ObservedCount)) - } - - risk := "—" - if atRisk > 0 { - risk = warn.Render(format.Count(atRisk)) - } - - tbl.Row( - name, - leaking, - risk, - format.Count(c.ApprovedCount), - ) - } - - return tbl.View() -} - -// analysisBar creates a small progress bar for inline use in the headline. -func (m *Model) analysisBar() progress.Model { - bar := progress.New( - progress.WithColors(m.theme.GradientStart, m.theme.GradientEnd), - progress.WithWidth(10), - progress.WithFillCharacters('█', '░'), - ) - bar.ShowPercentage = false - bar.EmptyColor = m.theme.TextMuted - return bar -} - -// cursorPrinciple returns the principle text for the currently selected category. -func (m *Model) cursorPrinciple() string { - if m.core.Cursor() < len(m.categories) { - return m.categories[m.core.Cursor()].Principle - } - return "" -} - -func totalObserved(cats []domain.PolicyCategoryStatus) int64 { - var n int64 - for _, c := range cats { - n += c.ObservedCount - } - return n -} - -func totalPending(cats []domain.PolicyCategoryStatus) int64 { - var n int64 - for _, c := range cats { - n += c.PendingCount - } - return n -} - -func totalApproved(cats []domain.PolicyCategoryStatus) int64 { - var n int64 - for _, c := range cats { - n += c.ApprovedCount - } - return n -} diff --git a/internal/app/statusbar/compliance/compliance_test.go b/internal/app/statusbar/compliance/compliance_test.go deleted file mode 100644 index c8dce646..00000000 --- a/internal/app/statusbar/compliance/compliance_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package compliance - -import ( - "testing" - - "github.com/usetero/cli/internal/app/statusbar/tabpoll" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/sqlite/sqlitetest" - "github.com/usetero/cli/internal/styles" -) - -func TestUpdatePollSourceFiltering(t *testing.T) { - m := New(styles.NewTheme(true), logtest.NewScope(t)) - db := sqlitetest.OpenBareDB(t) - m.SetDB(db) - - if cmd := m.Update(tabpoll.PollMsg{Source: "other"}); cmd != nil { - t.Fatalf("expected foreign poll source to be ignored") - } - - if cmd := m.Update(tabpoll.PollMsg{Source: pollSource}); cmd == nil { - t.Fatalf("expected own poll source to schedule fetch") - } -} diff --git a/internal/app/statusbar/compliance/detail.go b/internal/app/statusbar/compliance/detail.go deleted file mode 100644 index b6af3955..00000000 --- a/internal/app/statusbar/compliance/detail.go +++ /dev/null @@ -1,307 +0,0 @@ -package compliance - -import ( - "fmt" - "strings" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - - appevents "github.com/usetero/cli/internal/app/events" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/format" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/components/table" -) - -// detail renders the pending policies for a single compliance category. -type detail struct { - theme styles.Theme - category domain.PolicyCategoryStatus - policies []domain.CompliancePolicy - cursor int -} - -func (d *detail) Len() int { return len(d.policies) } -func (d *detail) Cursor() int { return d.cursor } -func (d *detail) SetCursor(v int) { d.cursor = v } - -// newDetail creates a detail view for the given category and pre-fetched policies. -func newDetail(theme styles.Theme, category domain.PolicyCategoryStatus, policies []domain.CompliancePolicy) *detail { - return &detail{ - theme: theme, - category: category, - policies: policies, - } -} - -// Prompt returns a tea.Cmd that emits a DrawerPromptRequested event for the selected policy. -func (d *detail) Prompt() tea.Cmd { - if len(d.policies) == 0 { - return nil - } - p := d.policies[d.cursor] - text := fmt.Sprintf( - "Tell me about the %s compliance issue for the %q log event in the %s service.", - d.category.Name(), p.LogEventName, p.ServiceName, - ) - return appevents.RequestDrawerPromptCmd(text) -} - -// View renders the detail: a header with category summary, then a policy table. -func (d *detail) View(width int) string { - var lines []string - lines = append(lines, d.renderHeader()) - if d.category.Principle != "" { - lines = append(lines, "") - muted := lipgloss.NewStyle().Foreground(d.theme.TextMuted).Background(d.theme.Bg) - lines = append(lines, muted.Render(d.category.Principle)) - } - lines = append(lines, "") - - if len(d.policies) == 0 { - muted := lipgloss.NewStyle().Foreground(d.theme.TextMuted).Background(d.theme.Bg) - lines = append(lines, muted.Render("No pending policies in this category.")) - } else { - lines = append(lines, d.renderTable(width)) - } - - return strings.Join(lines, "\n") -} - -// renderHeader renders the back hint + category name + summary. -func (d *detail) renderHeader() string { - colors := d.theme - muted := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) - text := lipgloss.NewStyle().Foreground(colors.Text).Background(colors.Bg) - sep := muted.Render(" · ") - - back := muted.Render("esc ◀") - name := text.Bold(true).Render(d.category.Name()) - - var parts []string - parts = append(parts, back+" "+name) - - if d.category.PendingCount > 0 { - dotColor := colors.Warning - if d.category.IsLeaking() { - dotColor = colors.Error - } - sev := lipgloss.NewStyle().Foreground(dotColor).Background(colors.Bg) - parts = append(parts, sev.Render("●")+" "+sev.Render(fmt.Sprintf("%d pending", d.category.PendingCount))) - } - - if d.category.ApprovedCount > 0 { - ok := lipgloss.NewStyle().Foreground(colors.Success).Background(colors.Bg) - parts = append(parts, ok.Render(fmt.Sprintf("%d fixed", d.category.ApprovedCount))) - } - - return strings.Join(parts, sep) -} - -// renderTable renders the per-policy table. -func (d *detail) renderTable(width int) string { - tbl := table.New(d.theme, table.WithMaxValueWidth(40)) - tbl.Headers("Log Event", "Service", "Volume", "Status") - tbl.SetWidth(width) - - accent := lipgloss.NewStyle().Foreground(d.theme.Accent).Background(d.theme.Bg) - - for i, p := range d.policies { - name := p.LogEventName - if i == d.cursor { - name = accent.Render("▶ " + name) - } else { - name = d.observedDot(p.AnyObserved) + " " + name - } - - vol := "—" - if p.VolumePerHour != nil { - vol = format.Volume(*p.VolumePerHour) + " evt/hr" - } - - status := d.formatSensitiveTypes(p.Fields, 3) - - tbl.Row( - name, - p.ServiceName, - vol, - status, - ) - } - - return tbl.View() -} - -// observedDot returns a colored dot based on whether sensitive data was observed. -// Red for observed (leaking), orange for at-risk. -func (d *detail) observedDot(observed bool) string { - if observed { - return lipgloss.NewStyle().Foreground(d.theme.Error).Background(d.theme.Bg).Render("●") - } - return lipgloss.NewStyle().Foreground(d.theme.Warning).Background(d.theme.Bg).Render("●") -} - -// formatSensitiveTypes returns deduplicated type labels from all fields, -// showing at most maxShow before truncating with "+N". -func (d *detail) formatSensitiveTypes(fields []domain.SensitiveField, maxShow int) string { - if len(fields) == 0 { - return "—" - } - - // Flatten all types across fields, deduplicate, preserve first-seen order. - seen := make(map[string]struct{}) - var types []string - for _, f := range fields { - for _, t := range f.Types { - label := displaySensitiveType(d.category.Category, t) - if _, ok := seen[label]; !ok { - seen[label] = struct{}{} - types = append(types, label) - } - } - } - - if len(types) == 0 { - return "—" - } - - visible := types - remaining := 0 - if len(types) > maxShow { - visible = types[:maxShow] - remaining = len(types) - maxShow - } - - result := strings.Join(visible, ", ") - if remaining > 0 { - muted := lipgloss.NewStyle().Foreground(d.theme.TextMuted).Background(d.theme.Bg) - result += muted.Render(fmt.Sprintf(", +%d", remaining)) - } - return result -} - -// displaySensitiveType returns a human-readable label for a sensitive type value. -func displaySensitiveType(category domain.PolicyCategory, t string) string { - // Category-specific type labels - switch category { - case domain.CategoryPIILeakage: - return displayPIIType(t) - case domain.CategorySecretsLeakage: - return displaySecretType(t) - case domain.CategoryPHILeakage: - return displayPHIType(t) - case domain.CategoryPaymentDataLeakage: - return displayPaymentType(t) - default: - return t - } -} - -// displayPIIType returns a human-readable label for a PII type. -func displayPIIType(t string) string { - switch t { - case domain.PIITypeEmail: - return "email" - case domain.PIITypeName: - return "name" - case domain.PIITypePhone: - return "phone" - case domain.PIITypeAddress: - return "address" - case domain.PIITypeSSN: - return "SSN" - case domain.PIITypeNationalID: - return "national ID" - case domain.PIITypeIPAddress: - return "IP address" - case domain.PIITypeDateOfBirth: - return "date of birth" - case domain.PIITypeDriverLicense: - return "driver license" - default: - return t - } -} - -// displaySecretType returns a human-readable label for a secret type. -func displaySecretType(t string) string { - switch t { - case domain.SecretTypeAPIKey: - return "API key" - case domain.SecretTypeBearerToken: - return "bearer token" - case domain.SecretTypeOAuthToken: - return "OAuth token" - case domain.SecretTypePassword: - return "password" - case domain.SecretTypePasswordHash: - return "password hash" - case domain.SecretTypeDatabaseCredential: - return "database credential" - case domain.SecretTypeConnectionString: - return "connection string" - case domain.SecretTypePrivateKey: - return "private key" - case domain.SecretTypeCertificate: - return "certificate" - case domain.SecretTypeEncryptionKey: - return "encryption key" - case domain.SecretTypeSigningKey: - return "signing key" - case domain.SecretTypeWebhookSecret: - return "webhook secret" - case domain.SecretTypeSessionToken: - return "session token" - default: - return t - } -} - -// displayPHIType returns a human-readable label for a PHI type. -func displayPHIType(t string) string { - switch t { - case domain.PHITypeDiagnosisCode: - return "diagnosis code" - case domain.PHITypeProcedureCode: - return "procedure code" - case domain.PHITypePrescription: - return "prescription" - case domain.PHITypeLabResult: - return "lab result" - case domain.PHITypeMedicalRecordNumber: - return "medical record number" - case domain.PHITypePatientIdentifier: - return "patient identifier" - case domain.PHITypeHealthInsuranceID: - return "health insurance ID" - case domain.PHITypeBiometric: - return "biometric" - case domain.PHITypeGeneticData: - return "genetic data" - default: - return t - } -} - -// displayPaymentType returns a human-readable label for a payment data type. -func displayPaymentType(t string) string { - switch t { - case domain.PaymentTypeCreditCard: - return "credit card" - case domain.PaymentTypeCVV: - return "CVV" - case domain.PaymentTypePIN: - return "PIN" - case domain.PaymentTypeBankAccount: - return "bank account" - case domain.PaymentTypeRoutingNumber: - return "routing number" - case domain.PaymentTypePaymentToken: - return "payment token" - case domain.PaymentTypeMagneticStripe: - return "magnetic stripe" - default: - return t - } -} diff --git a/internal/app/statusbar/dispatch_test.go b/internal/app/statusbar/dispatch_test.go index 0028cf1f..43723f54 100644 --- a/internal/app/statusbar/dispatch_test.go +++ b/internal/app/statusbar/dispatch_test.go @@ -5,16 +5,15 @@ import ( tea "charm.land/bubbletea/v2" + graphql "github.com/usetero/cli/internal/boundary/graphql" "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/powersynctest" - "github.com/usetero/cli/internal/sqlite" "github.com/usetero/cli/internal/styles" ) func TestToggleDrawerRequiresData(t *testing.T) { t.Parallel() - m := New(styles.NewTheme(true), logtest.NewScope(t), powersynctest.NewMockSyncer(), "https://api.example.com", "dev") + m := New(styles.NewTheme(true), logtest.NewScope(t), "https://api.example.com", "dev") m.tabs = []drawerTab{ stubDrawerTab{label: "A", hasData: false}, stubDrawerTab{label: "B", hasData: true}, @@ -34,7 +33,7 @@ func TestToggleDrawerRequiresData(t *testing.T) { func TestHandleEscDelegatesToActiveTab(t *testing.T) { t.Parallel() - m := New(styles.NewTheme(true), logtest.NewScope(t), powersynctest.NewMockSyncer(), "https://api.example.com", "dev") + m := New(styles.NewTheme(true), logtest.NewScope(t), "https://api.example.com", "dev") closed := false m.tabs = []drawerTab{ stubDrawerTab{ @@ -58,7 +57,7 @@ func TestHandleEscDelegatesToActiveTab(t *testing.T) { func TestHandleKeyPressUsesInteractiveTabsOnly(t *testing.T) { t.Parallel() - m := New(styles.NewTheme(true), logtest.NewScope(t), powersynctest.NewMockSyncer(), "https://api.example.com", "dev") + m := New(styles.NewTheme(true), logtest.NewScope(t), "https://api.example.com", "dev") called := false m.tabs = []drawerTab{ stubDrawerTab{ @@ -101,15 +100,15 @@ type stubDrawerTab struct { onHandle func(msg tea.KeyPressMsg) tea.Cmd } -func (s stubDrawerTab) Label() string { return s.label } -func (s stubDrawerTab) SetDB(_ sqlite.DB) tea.Cmd { return nil } -func (s stubDrawerTab) Init() tea.Cmd { return nil } -func (s stubDrawerTab) Update(_ tea.Msg) tea.Cmd { return nil } -func (s stubDrawerTab) HasData() bool { return s.hasData } -func (s stubDrawerTab) CompactView() string { return "" } -func (s stubDrawerTab) ExpandedView(_, _ int) string { return "" } -func (s stubDrawerTab) Interactive() bool { return s.interactive } -func (s stubDrawerTab) InDetail() bool { return s.detail } +func (s stubDrawerTab) Label() string { return s.label } +func (s stubDrawerTab) SetServices(_ graphql.ServiceSet) tea.Cmd { return nil } +func (s stubDrawerTab) Init() tea.Cmd { return nil } +func (s stubDrawerTab) Update(_ tea.Msg) tea.Cmd { return nil } +func (s stubDrawerTab) HasData() bool { return s.hasData } +func (s stubDrawerTab) CompactView() string { return "" } +func (s stubDrawerTab) ExpandedView(_, _ int) string { return "" } +func (s stubDrawerTab) Interactive() bool { return s.interactive } +func (s stubDrawerTab) InDetail() bool { return s.detail } func (s stubDrawerTab) CloseDetail() { if s.onClose != nil { s.onClose() diff --git a/internal/app/statusbar/policytab/base.go b/internal/app/statusbar/policytab/base.go deleted file mode 100644 index 8895742b..00000000 --- a/internal/app/statusbar/policytab/base.go +++ /dev/null @@ -1,86 +0,0 @@ -package policytab - -import ( - "time" - - tea "charm.land/bubbletea/v2" - - "github.com/usetero/cli/internal/app/statusbar/listdetail" - "github.com/usetero/cli/internal/app/statusbar/tabpoll" - "github.com/usetero/cli/internal/sqlite" -) - -const defaultPollInterval = 2 * time.Second - -// Base holds shared state/lifecycle behavior for policy-like status bar tabs. -type Base struct { - db sqlite.DB - source string - hasData bool - lastState string - fetching bool - cursor int -} - -func New(source string) Base { - return Base{source: source} -} - -func (b *Base) SetDB(db sqlite.DB) tea.Cmd { - b.db = db - return b.poll() -} - -func (b *Base) Init() tea.Cmd { - if b.db == nil { - return nil - } - return b.poll() -} - -func (b *Base) DB() sqlite.DB { return b.db } -func (b *Base) HasData() bool { return b.hasData } -func (b *Base) Cursor() int { return b.cursor } -func (b *Base) SetCursor(v int) { b.cursor = v } -func (b *Base) SetHasData(v bool) { b.hasData = v } -func (b *Base) HasList(length int) bool { return b.hasData && length > 0 } - -func (b *Base) poll() tea.Cmd { - return tabpoll.Tick(b.source, defaultPollInterval) -} - -// UpdatePoll handles the shared PollMsg/DataMsg cycle. -func UpdatePoll[T any](b *Base, msg tea.Msg, fetchData tea.Cmd, applyData func(data T)) (tea.Cmd, bool) { - return tabpoll.UpdatePollCycle( - msg, - b.source, - b.db != nil, - &b.fetching, - fetchData, - b.poll(), - applyData, - ) -} - -// ApplyIfChanged applies state updates on key changes and clamps cursor. -func (b *Base) ApplyIfChanged(nextState string, listLen int, apply func()) bool { - return tabpoll.ApplyIfChanged(&b.lastState, nextState, &b.cursor, listLen, apply) -} - -// NavController returns standard list/detail drawer navigation wiring. -func (b *Base) NavController( - listLen func() int, - onListSelect func(index int) tea.Cmd, - getDetail func() listdetail.Detail, - clearDetail func(), -) listdetail.Controller { - return listdetail.New( - func() bool { return b.HasList(listLen()) }, - func() int { return b.cursor }, - func(v int) { b.cursor = v }, - listLen, - onListSelect, - getDetail, - clearDetail, - ) -} diff --git a/internal/app/statusbar/policytab/base_test.go b/internal/app/statusbar/policytab/base_test.go deleted file mode 100644 index 3529f562..00000000 --- a/internal/app/statusbar/policytab/base_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package policytab - -import ( - "testing" - - tea "charm.land/bubbletea/v2" - "github.com/usetero/cli/internal/app/statusbar/listdetail" - "github.com/usetero/cli/internal/app/statusbar/tabpoll" -) - -type testDetail struct { - cursor int - len int -} - -func (d *testDetail) Len() int { return d.len } -func (d *testDetail) Cursor() int { return d.cursor } -func (d *testDetail) SetCursor(v int) { d.cursor = v } -func (d *testDetail) Prompt() tea.Cmd { return func() tea.Msg { return "prompt" } } - -func TestBase_ApplyIfChangedAndCursorClamp(t *testing.T) { - t.Parallel() - - b := New("x") - b.SetCursor(5) - applied := b.ApplyIfChanged("a", 2, func() {}) - if !applied { - t.Fatalf("expected applied=true") - } - if b.Cursor() != 1 { - t.Fatalf("expected cursor clamped to 1, got %d", b.Cursor()) - } - - applied = b.ApplyIfChanged("a", 2, func() { t.Fatal("should not reapply") }) - if applied { - t.Fatalf("expected applied=false on same key") - } -} - -func TestBase_HasList(t *testing.T) { - t.Parallel() - - b := New("x") - if b.HasList(1) { - t.Fatalf("expected false without data") - } - b.SetHasData(true) - if !b.HasList(1) { - t.Fatalf("expected true with data and items") - } - if b.HasList(0) { - t.Fatalf("expected false with zero items") - } -} - -func TestBase_NavController(t *testing.T) { - t.Parallel() - - b := New("x") - b.SetHasData(true) - detail := &testDetail{len: 2} - - ctrl := b.NavController( - func() int { return 3 }, - func(index int) tea.Cmd { - return func() tea.Msg { return index } - }, - func() listdetail.Detail { return detail }, - func() { detail = nil }, - ) - - // Enter detail, then back should clear it. - _ = ctrl.HandleKeyPress(tea.KeyPressMsg{}) -} - -func TestUpdatePoll_ForwardsToTabpollCycle(t *testing.T) { - t.Parallel() - - b := New("test") - b.SetHasData(true) - - // Poll without DB should be handled and no command. - cmd, handled := UpdatePoll[int](&b, tabpoll.PollMsg{Source: "test"}, nil, func(int) {}) - if !handled { - t.Fatalf("expected handled") - } - if cmd != nil { - t.Fatalf("expected nil cmd when db missing") - } -} diff --git a/internal/app/statusbar/quality/detail.go b/internal/app/statusbar/quality/detail.go deleted file mode 100644 index 20ca6030..00000000 --- a/internal/app/statusbar/quality/detail.go +++ /dev/null @@ -1,124 +0,0 @@ -package quality - -import ( - "fmt" - "strings" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - - appevents "github.com/usetero/cli/internal/app/events" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/format" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/components/table" -) - -// detail renders the top pending policies for a single quality category. -type detail struct { - theme styles.Theme - category domain.PolicyCategoryStatus - policies []domain.WastePolicy - cursor int -} - -func (d *detail) Len() int { return len(d.policies) } -func (d *detail) Cursor() int { return d.cursor } -func (d *detail) SetCursor(v int) { d.cursor = v } - -// newDetail creates a detail view for the given category and pre-fetched policies. -func newDetail(theme styles.Theme, category domain.PolicyCategoryStatus, policies []domain.WastePolicy) *detail { - return &detail{ - theme: theme, - category: category, - policies: policies, - } -} - -// Prompt returns a tea.Cmd that emits a DrawerPromptRequested event for the selected policy. -func (d *detail) Prompt() tea.Cmd { - if len(d.policies) == 0 { - return nil - } - p := d.policies[d.cursor] - text := fmt.Sprintf( - "Pull up the %q quality policy for the %q log event in the %s service.", - d.category.Name(), p.LogEventName, p.ServiceName, - ) - return appevents.RequestDrawerPromptCmd(text) -} - -// View renders the detail: a header with category summary, then a policy table. -func (d *detail) View(width int) string { - var lines []string - lines = append(lines, d.renderHeader()) - if d.category.Principle != "" { - lines = append(lines, "") - muted := lipgloss.NewStyle().Foreground(d.theme.TextMuted).Background(d.theme.Bg) - lines = append(lines, muted.Render(d.category.Principle)) - } - lines = append(lines, "") - - if len(d.policies) == 0 { - muted := lipgloss.NewStyle().Foreground(d.theme.TextMuted).Background(d.theme.Bg) - lines = append(lines, muted.Render("No pending policies in this category.")) - } else { - lines = append(lines, d.renderTable(width)) - } - - return strings.Join(lines, "\n") -} - -// renderHeader renders the back hint + category name + summary. -func (d *detail) renderHeader() string { - colors := d.theme - muted := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) - text := lipgloss.NewStyle().Foreground(colors.Text).Background(colors.Bg) - sep := muted.Render(" · ") - - back := muted.Render("esc ◀") - name := text.Bold(true).Render(d.category.Name()) - - var parts []string - parts = append(parts, back+" "+name) - - if d.category.PendingCount > 0 { - warn := lipgloss.NewStyle().Foreground(colors.Warning).Background(colors.Bg) - parts = append(parts, warn.Render("●")+" "+warn.Render(fmt.Sprintf("%d pending", d.category.PendingCount))) - } - - if d.category.EstimatedCostPerHour != nil && *d.category.EstimatedCostPerHour > 0 { - parts = append(parts, muted.Render(format.YearlyCostPtr(d.category.EstimatedCostPerHour))) - } - - return strings.Join(parts, sep) -} - -// renderTable renders the per-policy table. -// Quality policies trim fields, so we show bytes (not volume). -func (d *detail) renderTable(width int) string { - tbl := table.New(d.theme, table.WithMaxValueWidth(30)) - tbl.Headers("Log Event", "Service", "Bytes", "Est. Impact") - tbl.SetWidth(width) - - accent := lipgloss.NewStyle().Foreground(d.theme.Accent).Background(d.theme.Bg) - dot := lipgloss.NewStyle().Foreground(d.theme.Warning).Background(d.theme.Bg).Render("●") - - for i, p := range d.policies { - name := p.LogEventName - if i == d.cursor { - name = accent.Render("▶ " + name) - } else { - name = dot + " " + name - } - - bytes := "—" - if p.BytesPerHour != nil { - bytes = format.Bytes(*p.BytesPerHour) + "/hr" - } - - tbl.Row(name, p.ServiceName, bytes, format.YearlyCostPtr(p.EstimatedCostPerHour)) - } - - return tbl.View() -} diff --git a/internal/app/statusbar/quality/quality.go b/internal/app/statusbar/quality/quality.go deleted file mode 100644 index fd7c2bdd..00000000 --- a/internal/app/statusbar/quality/quality.go +++ /dev/null @@ -1,441 +0,0 @@ -// Package quality renders the quality indicator in the status bar. -package quality - -import ( - "context" - "encoding/json" - "fmt" - "math" - "strings" - "time" - - "charm.land/bubbles/v2/progress" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - - "github.com/usetero/cli/internal/app/statusbar/listdetail" - "github.com/usetero/cli/internal/app/statusbar/policytab" - "github.com/usetero/cli/internal/app/statusbar/tabpoll" - "github.com/usetero/cli/internal/app/statusbar/viewkit" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/format" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/sqlite" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/components/table" -) - -const ( - dbTimeout = 2 * time.Second - pollSource = "quality" -) - -type fetchedData struct { - summary domain.AccountSummary - categories []domain.PolicyCategoryStatus -} - -// qualityDetailLoadedMsg carries the result of an async detail fetch. -type qualityDetailLoadedMsg struct { - cat domain.PolicyCategoryStatus - policies []domain.WastePolicy - err error -} - -// Model renders the quality policy status: pending count, estimated savings. -type Model struct { - theme styles.Theme - scope log.Scope - core policytab.Base - - summary domain.AccountSummary - categories []domain.PolicyCategoryStatus - - // Drawer navigation - detail *detail // non-nil when viewing a single category's policies -} - -// New creates a new quality status model. -func New(theme styles.Theme, scope log.Scope) *Model { - return &Model{ - theme: theme, - scope: scope.Child("quality"), - core: policytab.New(pollSource), - } -} - -// SetDB sets the database and starts polling. -func (m *Model) SetDB(db sqlite.DB) tea.Cmd { - return m.core.SetDB(db) -} - -// Init starts polling. -func (m *Model) Init() tea.Cmd { - return m.core.Init() -} - -// Update handles messages. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - if cmd, handled := policytab.UpdatePoll(&m.core, - msg, - m.fetchData(), - func(data fetchedData) { - key := m.buildStateKey(data.summary, data.categories) - m.core.ApplyIfChanged(key, len(data.categories), func() { - m.summary = data.summary - m.categories = data.categories - m.core.SetHasData(len(data.categories) > 0) - }) - }, - ); handled { - return cmd - } - - switch msg := msg.(type) { - case qualityDetailLoadedMsg: - if msg.err == nil { - m.detail = newDetail(m.theme, msg.cat, msg.policies) - } - - default: - _ = msg - } - - return nil -} - -// fetchData returns a Cmd that queries quality data off the event loop. -func (m *Model) fetchData() tea.Cmd { - db := m.core.DB() - scope := m.scope - return tabpoll.Fetch(dbTimeout, func(ctx context.Context) (fetchedData, error) { - summary, err := db.DatadogAccountStatuses().GetSummary(ctx) - if err != nil { - scope.Error("get summary", "err", err) - return fetchedData{}, err - } - categories, err := db.LogEventPolicyCategoryStatuses().ListQualityCategoryStatuses(ctx) - if err != nil { - scope.Error("list quality category statuses", "err", err) - return fetchedData{}, err - } - return fetchedData{summary: summary, categories: categories}, nil - }) -} - -// fetchDetail returns a Cmd that queries category detail off the event loop. -func (m *Model) fetchDetail(cat domain.PolicyCategoryStatus) tea.Cmd { - db := m.core.DB() - scope := m.scope - return tabpoll.FetchDetail(dbTimeout, func(ctx context.Context) ([]domain.WastePolicy, error) { - policies, err := db.LogEventPolicyStatuses().ListTopPendingPoliciesByCategory(ctx, cat.Category, 25) - if err != nil { - scope.Error("list top pending policies", "category", cat.Category, "err", err) - return nil, err - } - return policies, nil - }, func(policies []domain.WastePolicy, err error) tea.Msg { - if err != nil { - return qualityDetailLoadedMsg{err: err} - } - return qualityDetailLoadedMsg{cat: cat, policies: policies} - }) -} - -// buildStateKey builds a string key for change detection. -func (m *Model) buildStateKey(summary domain.AccountSummary, cats []domain.PolicyCategoryStatus) string { - key := qualityStateKey{ - Summary: qualitySummaryKey{ - ServiceCount: summary.ServiceCount, - ActiveServices: summary.ActiveServices, - }, - Categories: make([]qualityCategoryKey, 0, len(cats)), - } - for _, c := range cats { - key.Categories = append(key.Categories, qualityCategoryKey{ - Category: string(c.Category), - PendingCount: c.PendingCount, - ApprovedCount: c.ApprovedCount, - DismissedCount: c.DismissedCount, - EstimatedVolumePerHour: c.EstimatedVolumePerHour, - EstimatedBytesPerHour: c.EstimatedBytesPerHour, - EstimatedCostPerHour: c.EstimatedCostPerHour, - EventsWithVolumes: c.EventsWithVolumes, - TotalEvents: c.TotalEvents, - }) - } - data, err := json.Marshal(key) - if err != nil { - return "" - } - return string(data) -} - -type qualityStateKey struct { - Summary qualitySummaryKey `json:"summary"` - Categories []qualityCategoryKey `json:"categories"` -} - -type qualitySummaryKey struct { - ServiceCount int64 `json:"service_count"` - ActiveServices int64 `json:"active_services"` -} - -type qualityCategoryKey struct { - Category string `json:"category"` - PendingCount int64 `json:"pending_count"` - ApprovedCount int64 `json:"approved_count"` - DismissedCount int64 `json:"dismissed_count"` - EstimatedVolumePerHour *float64 `json:"estimated_volume_per_hour"` - EstimatedBytesPerHour *float64 `json:"estimated_bytes_per_hour"` - EstimatedCostPerHour *float64 `json:"estimated_cost_per_hour"` - EventsWithVolumes int64 `json:"events_with_volumes"` - TotalEvents int64 `json:"total_events"` -} - -// HasData returns true when quality data has been loaded. -func (m *Model) HasData() bool { - return m.core.HasData() -} - -// HandleKeyPress handles keyboard navigation in the expanded drawer view. -func (m *Model) HandleKeyPress(msg tea.KeyPressMsg) tea.Cmd { - return m.navController().HandleKeyPress(msg) -} - -// InDetail returns true when the detail sub-view is active. -func (m *Model) InDetail() bool { - return m.detail != nil -} - -// CloseDetail exits the detail sub-view, returning to the category list. -func (m *Model) CloseDetail() { - m.detail = nil -} - -func (m *Model) navController() listdetail.Controller { - return m.core.NavController( - func() int { return len(m.categories) }, - func(index int) tea.Cmd { - cat := m.categories[index] - if cat.PendingCount == 0 { - return nil - } - return m.fetchDetail(cat) - }, - func() listdetail.Detail { return m.detail }, - func() { m.detail = nil }, - ) -} - -// CompactView renders the quality status for the statusbar. -func (m *Model) CompactView() string { - if !m.core.HasData() { - return "" - } - - colors := m.theme - muted := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) - - pending := totalPending(m.categories) - if pending > 0 { - dot := lipgloss.NewStyle().Foreground(colors.Warning).Background(colors.Bg).Render("●") - return dot + " " + muted.Render(fmt.Sprintf("%d quality", pending)) - } - - return "" -} - -// ExpandedView renders the detailed quality status for the drawer. -func (m *Model) ExpandedView(width, height int) string { - if !m.core.HasData() { - return viewkit.RenderPolicyEmptyState( - m.theme, - m.core.DB() != nil, - m.summary, - "Enable services to start detecting quality issues.", - "No quality issues detected.", - ) - } - - // Detail sub-view for a single category. - if m.detail != nil { - return m.detail.View(width) - } - - return viewkit.ComposeSummaryTableView( - m.theme, - m.renderHeadline(), - m.renderCategoryTable(width), - m.cursorPrinciple(), - ) -} - -// renderHeadline renders the quality summary. -func (m *Model) renderHeadline() string { - colors := m.theme - muted := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) - sep := muted.Render(" · ") - - var parts []string - - pending := totalPending(m.categories) - approved := totalApproved(m.categories) - - if pending > 0 { - dot := lipgloss.NewStyle().Foreground(colors.Warning).Background(colors.Bg).Render("●") - text := lipgloss.NewStyle().Foreground(colors.Text).Background(colors.Bg) - parts = append(parts, dot+" "+text.Render(fmt.Sprintf("%d pending", pending))) - } - - if approved > 0 { - parts = append(parts, muted.Render(fmt.Sprintf("%d approved", approved))) - } - - // Analysis progress when not yet ready. - s := m.summary - if s.EventCount > 0 && !s.AnalysisReady() { - pct := float64(s.AnalyzedCount) / float64(s.EventCount) - bar := m.discoveryBar() - parts = append(parts, bar.ViewAs(pct)+" "+muted.Render(fmt.Sprintf("%d/%d analyzed", s.AnalyzedCount, s.EventCount))) - } - - if len(parts) == 0 { - return muted.Render("All quality policies reviewed") - } - - return strings.Join(parts, sep) -} - -// renderCategoryTable renders quality categories in a table with cursor highlighting. -func (m *Model) renderCategoryTable(width int) string { - if len(m.categories) == 0 { - return "" - } - - tbl := table.New(m.theme, table.WithMaxValueWidth(35)) - tbl.Headers("Category", "Pending", "Impact", "Approved") - tbl.SetWidth(width) - - warn := lipgloss.NewStyle().Foreground(m.theme.Warning).Background(m.theme.Bg) - ok := lipgloss.NewStyle().Foreground(m.theme.Success).Background(m.theme.Bg) - accent := lipgloss.NewStyle().Foreground(m.theme.Accent).Background(m.theme.Bg) - muted := lipgloss.NewStyle().Foreground(m.theme.TextMuted).Background(m.theme.Bg) - bar := m.discoveryBar() - - totalCost := totalEstimatedCost(m.categories) - - // Find widest pending count for alignment. - maxPendingW := 1 - for _, c := range m.categories { - if w := len(format.Count(c.PendingCount)); w > maxPendingW { - maxPendingW = w - } - } - - for i, c := range m.categories { - dot := ok.Render("●") - if c.PendingCount > 0 { - dot = warn.Render("●") - } - - name := c.Name() - if i == m.core.Cursor() { - name = accent.Render("▶ " + name) - } else { - name = dot + " " + name - } - - // Clean categories: single checkmark row. - if c.PendingCount == 0 && c.ApprovedCount == 0 { - tbl.Row(name, ok.Render("✓"), "—", "—") - continue - } - - // Pending count with optional discovery progress bar. - pending := fmt.Sprintf("%-*s", maxPendingW, format.Count(c.PendingCount)) - if c.TotalEvents > 0 { - pct := int(c.EventsWithVolumes * 100 / c.TotalEvents) - if pct < 80 { - pending += " " + bar.ViewAs(float64(pct)/100) + " " + muted.Render(fmt.Sprintf("%d%%", pct)) - } - } - - tbl.Row( - name, - pending, - formatCategoryCost(c, totalCost, ok, muted), - format.Count(c.ApprovedCount), - ) - } - - return tbl.View() -} - -// discoveryBar creates a small progress bar for inline use in table cells. -func (m *Model) discoveryBar() progress.Model { - bar := progress.New( - progress.WithColors(m.theme.GradientStart, m.theme.GradientEnd), - progress.WithWidth(10), - progress.WithFillCharacters('█', '░'), - ) - bar.ShowPercentage = false - bar.EmptyColor = m.theme.TextMuted - return bar -} - -func totalPending(cats []domain.PolicyCategoryStatus) int64 { - var n int64 - for _, c := range cats { - n += c.PendingCount - } - return n -} - -func totalApproved(cats []domain.PolicyCategoryStatus) int64 { - var n int64 - for _, c := range cats { - n += c.ApprovedCount - } - return n -} - -func totalEstimatedCost(cats []domain.PolicyCategoryStatus) float64 { - var total float64 - for _, c := range cats { - if c.EstimatedCostPerHour != nil { - total += *c.EstimatedCostPerHour - } - } - return total -} - -// cursorPrinciple returns the principle text for the currently selected category. -func (m *Model) cursorPrinciple() string { - if m.core.Cursor() < len(m.categories) { - return m.categories[m.core.Cursor()].Principle - } - return "" -} - -// formatCategoryCost returns estimated yearly cost for a category, with its -// share of total estimated savings. -func formatCategoryCost(c domain.PolicyCategoryStatus, totalCostPerHour float64, success, muted lipgloss.Style) string { - if c.EstimatedCostPerHour == nil || *c.EstimatedCostPerHour <= 0 { - return format.YearlyCostPtr(c.EstimatedCostPerHour) - } - - if totalCostPerHour > 0 { - pct := int(math.Round(*c.EstimatedCostPerHour / totalCostPerHour * 100)) - if pct <= 1 { - return muted.Render("≤1%") - } - cost := success.Render(format.YearlyCostPtr(c.EstimatedCostPerHour)) - if pct < 100 { - cost += " " + muted.Render(fmt.Sprintf("(%d%%)", pct)) - } - return cost - } - - return success.Render(format.YearlyCostPtr(c.EstimatedCostPerHour)) -} diff --git a/internal/app/statusbar/quality/quality_test.go b/internal/app/statusbar/quality/quality_test.go deleted file mode 100644 index 8bb4c066..00000000 --- a/internal/app/statusbar/quality/quality_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package quality - -import ( - "testing" - - "github.com/usetero/cli/internal/app/statusbar/tabpoll" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/sqlite/sqlitetest" - "github.com/usetero/cli/internal/styles" -) - -func TestUpdatePollSourceFiltering(t *testing.T) { - m := New(styles.NewTheme(true), logtest.NewScope(t)) - db := sqlitetest.OpenBareDB(t) - m.SetDB(db) - - if cmd := m.Update(tabpoll.PollMsg{Source: "other"}); cmd != nil { - t.Fatalf("expected foreign poll source to be ignored") - } - - if cmd := m.Update(tabpoll.PollMsg{Source: pollSource}); cmd == nil { - t.Fatalf("expected own poll source to schedule fetch") - } -} diff --git a/internal/app/statusbar/services/services.go b/internal/app/statusbar/services/services.go index 3b085b35..dcb5a5da 100644 --- a/internal/app/statusbar/services/services.go +++ b/internal/app/statusbar/services/services.go @@ -15,10 +15,10 @@ import ( "github.com/usetero/cli/internal/app/statusbar/listdetail" "github.com/usetero/cli/internal/app/statusbar/tabpoll" "github.com/usetero/cli/internal/app/statusbar/viewkit" + graphql "github.com/usetero/cli/internal/boundary/graphql" "github.com/usetero/cli/internal/domain" "github.com/usetero/cli/internal/format" "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/sqlite" "github.com/usetero/cli/internal/styles" "github.com/usetero/cli/internal/tea/components/status" "github.com/usetero/cli/internal/tea/components/table" @@ -26,8 +26,7 @@ import ( const ( pollInterval = 2 * time.Second - maxServices = 50 - dbTimeout = 2 * time.Second + fetchTimeout = 2 * time.Second pollSource = "services" // levelDisplayThreshold is the minimum fraction of total volume a @@ -51,7 +50,9 @@ type serviceDetailLoadedMsg struct { type Model struct { theme styles.Theme scope log.Scope - db sqlite.DB + + api graphql.ServiceSet + ready bool summary domain.AccountSummary services []domain.ServiceStatus @@ -72,15 +73,16 @@ func New(theme styles.Theme, scope log.Scope) *Model { } } -// SetDB sets the database and starts polling. -func (m *Model) SetDB(db sqlite.DB) tea.Cmd { - m.db = db +// SetServices points the tab at the account-scoped services and starts polling. +func (m *Model) SetServices(services graphql.ServiceSet) tea.Cmd { + m.api = services + m.ready = true return m.poll() } -// Init starts polling. +// Init starts polling once the services are available. func (m *Model) Init() tea.Cmd { - if m.db == nil { + if !m.ready { return nil } return m.poll() @@ -95,7 +97,7 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd { if cmd, handled := tabpoll.UpdatePollCycle( msg, pollSource, - m.db != nil, + m.ready, &m.fetching, m.fetchData(), m.poll(), @@ -123,31 +125,31 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd { // fetchData returns a Cmd that queries service data off the event loop. func (m *Model) fetchData() tea.Cmd { - db := m.db + services := m.api scope := m.scope - return tabpoll.Fetch(dbTimeout, func(ctx context.Context) (fetchedData, error) { - summary, err := db.DatadogAccountStatuses().GetSummary(ctx) + return tabpoll.Fetch(fetchTimeout, func(ctx context.Context) (fetchedData, error) { + summary, err := services.Status.GetAccountSummary(ctx) if err != nil { scope.Error("get summary", "err", err) return fetchedData{}, err } - services, err := db.ServiceStatuses().ListEnabledServiceStatuses(ctx, maxServices) + statuses, err := services.Status.ListServiceStatuses(ctx) if err != nil { scope.Error("list service statuses", "err", err) - services = nil + statuses = nil } - return fetchedData{summary: summary, services: services}, nil + return fetchedData{summary: summary, services: statuses}, nil }) } // fetchDetail returns a Cmd that queries log event detail off the event loop. func (m *Model) fetchDetail(svc domain.ServiceStatus) tea.Cmd { - db := m.db + services := m.api scope := m.scope - return tabpoll.FetchDetail(dbTimeout, func(ctx context.Context) ([]domain.LogEventStatus, error) { - logEvents, err := db.LogEventStatuses().ListByService(ctx, svc.Name, 25) + return tabpoll.FetchDetail(fetchTimeout, func(ctx context.Context) ([]domain.LogEventStatus, error) { + logEvents, err := services.Status.ListServiceLogEvents(ctx, svc.ID) if err != nil { scope.Error("list log event statuses", "service", svc.Name, "err", err) return nil, err @@ -240,7 +242,7 @@ func (m *Model) ExpandedView(width, height int) string { if !m.hasData { return viewkit.RenderServicesEmptyState( m.theme, - m.db != nil, + m.ready, m.summary, "Ask Tero to explore your services and pick which ones to enable.", ) diff --git a/internal/app/statusbar/services/services_test.go b/internal/app/statusbar/services/services_test.go index 6ce11f80..0ad5c5bb 100644 --- a/internal/app/statusbar/services/services_test.go +++ b/internal/app/statusbar/services/services_test.go @@ -5,15 +5,14 @@ import ( "testing" "github.com/usetero/cli/internal/app/statusbar/tabpoll" + graphql "github.com/usetero/cli/internal/boundary/graphql" "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/sqlite/sqlitetest" "github.com/usetero/cli/internal/styles" ) func TestUpdatePollSourceFiltering(t *testing.T) { m := New(styles.NewTheme(true), logtest.NewScope(t)) - db := sqlitetest.OpenBareDB(t) - m.SetDB(db) + m.SetServices(graphql.ServiceSet{}) if cmd := m.Update(tabpoll.PollMsg{Source: "other"}); cmd != nil { t.Fatalf("expected foreign poll source to be ignored") @@ -26,8 +25,7 @@ func TestUpdatePollSourceFiltering(t *testing.T) { func TestUpdateDoesNotStartOverlappingFetch(t *testing.T) { m := New(styles.NewTheme(true), logtest.NewScope(t)) - db := sqlitetest.OpenBareDB(t) - m.SetDB(db) + m.SetServices(graphql.ServiceSet{}) if cmd := m.Update(tabpoll.PollMsg{Source: pollSource}); cmd == nil { t.Fatalf("expected poll to schedule fetch") diff --git a/internal/app/statusbar/statusbar.go b/internal/app/statusbar/statusbar.go index 063b15da..d304c013 100644 --- a/internal/app/statusbar/statusbar.go +++ b/internal/app/statusbar/statusbar.go @@ -2,56 +2,42 @@ package statusbar import ( - "time" - - "github.com/usetero/cli/internal/app/statusbar/compliance" - "github.com/usetero/cli/internal/app/statusbar/quality" "github.com/usetero/cli/internal/app/statusbar/services" - "github.com/usetero/cli/internal/app/statusbar/syncstatus" - "github.com/usetero/cli/internal/app/statusbar/waste" + "github.com/usetero/cli/internal/app/statusbar/surfaces" "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/powersync" "github.com/usetero/cli/internal/styles" ) const diag = "╱" -const workspaceCountTimeout = 2 * time.Second - -type workspaceCountLoadedMsg struct { - count int64 - err error -} // Tab indices for the drawer. const ( - TabWaste = 0 - TabQuality = 1 - TabCompliance = 2 - TabServices = 3 - TabSync = 4 - tabCount = 5 + TabIssues = 0 + TabChecks = 1 + TabServices = 2 + TabLogEvents = 3 + TabEdgeInstances = 4 + tabCount = 5 ) // Tab labels. -var tabLabels = [tabCount]string{"Waste", "Quality", "Compliance", "Services", "Sync"} +var tabLabels = [tabCount]string{"Issues", "Checks", "Services", "Log events", "Edge instances"} // Model renders the app status bar. type Model struct { - theme styles.Theme - scope log.Scope - env string - tabs []drawerTab - syncStatus *syncstatus.Model - servicesStatus *services.Model - wasteStatus *waste.Model - qualityStatus *quality.Model - complianceStatus *compliance.Model - width int + theme styles.Theme + scope log.Scope + env string + tabs []drawerTab + issuesStatus *surfaces.Model + checksStatus *surfaces.Model + servicesStatus *services.Model + logEventsStatus *surfaces.Model + edgeStatus *surfaces.Model + width int // Account context - org string - workspace string - workspaceCount int64 + org string // Conversation title string @@ -65,17 +51,18 @@ type Model struct { } // New creates a new statusbar. -func New(theme styles.Theme, scope log.Scope, syncer powersync.Syncer, host string, env string) *Model { +func New(theme styles.Theme, scope log.Scope, host string, env string) *Model { + _ = host scope = scope.Child("statusbar") m := &Model{ - theme: theme, - scope: scope, - env: env, - syncStatus: syncstatus.New(theme, scope, syncer, host), - servicesStatus: services.New(theme, scope), - wasteStatus: waste.New(theme, scope), - qualityStatus: quality.New(theme, scope), - complianceStatus: compliance.New(theme, scope), + theme: theme, + scope: scope, + env: env, + issuesStatus: surfaces.NewIssues(theme, scope), + checksStatus: surfaces.NewChecks(theme, scope), + servicesStatus: services.New(theme, scope), + logEventsStatus: surfaces.NewLogEvents(theme, scope), + edgeStatus: surfaces.NewEdgeInstances(theme, scope), } m.tabs = m.buildTabs() return m diff --git a/internal/app/statusbar/statusbar_data.go b/internal/app/statusbar/statusbar_data.go index 12ea09b4..ea7eae4a 100644 --- a/internal/app/statusbar/statusbar_data.go +++ b/internal/app/statusbar/statusbar_data.go @@ -1,26 +1,25 @@ package statusbar import ( - "context" - tea "charm.land/bubbletea/v2" + graphql "github.com/usetero/cli/internal/boundary/graphql" "github.com/usetero/cli/internal/core/bootstrap" - "github.com/usetero/cli/internal/sqlite" ) -// SetDB sets the database for status polling. -func (m *Model) SetDB(db sqlite.DB) tea.Cmd { - cmds := []tea.Cmd{m.fetchWorkspaceCount(db)} +// SetServices points the drawer tabs at the account-scoped control-plane +// services. Each tab polls its own GraphQL reads from here. +func (m *Model) SetServices(services graphql.ServiceSet) tea.Cmd { + cmds := make([]tea.Cmd, 0, len(m.tabs)) for _, tab := range m.tabs { - cmds = append(cmds, tab.SetDB(db)) + cmds = append(cmds, tab.SetServices(services)) } return tea.Batch(cmds...) } // Init initializes child models. func (m *Model) Init() tea.Cmd { - var cmds []tea.Cmd + cmds := make([]tea.Cmd, 0, len(m.tabs)) for _, tab := range m.tabs { cmds = append(cmds, tab.Init()) } @@ -31,7 +30,7 @@ func (m *Model) Init() tea.Cmd { func (m *Model) Update(msg tea.Msg) tea.Cmd { m.ingestStatusMessages(msg) - var cmds []tea.Cmd + cmds := make([]tea.Cmd, 0, len(m.tabs)) for _, tab := range m.tabs { cmds = append(cmds, tab.Update(msg)) } @@ -44,28 +43,6 @@ func (m *Model) ingestStatusMessages(msg tea.Msg) { m.org = msg.Org.Name case bootstrap.OrgCreated: m.org = msg.Org.Name - case bootstrap.WorkspaceSelected: - m.workspace = msg.Workspace.Name - case workspaceCountLoadedMsg: - if msg.err != nil { - m.scope.Error("scan workspace count", "err", msg.err) - break - } - m.workspaceCount = msg.count - } -} - -func (m *Model) fetchWorkspaceCount(db sqlite.DB) tea.Cmd { - return func() tea.Msg { - ctx, cancel := sqlite.WithTimeout(context.Background(), workspaceCountTimeout) - defer cancel() - - var count int64 - row := db.QueryRow(ctx, "SELECT COUNT(*) FROM workspaces") - if err := row.Scan(&count); err != nil { - return workspaceCountLoadedMsg{err: err} - } - return workspaceCountLoadedMsg{count: count} } } diff --git a/internal/app/statusbar/statusbar_test.go b/internal/app/statusbar/statusbar_test.go index 1a138c55..e1bbd0a8 100644 --- a/internal/app/statusbar/statusbar_test.go +++ b/internal/app/statusbar/statusbar_test.go @@ -1,60 +1,50 @@ package statusbar import ( - "context" "strings" "testing" "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/powersynctest" - "github.com/usetero/cli/internal/sqlite/sqlitetest" "github.com/usetero/cli/internal/styles" ) -func TestFetchWorkspaceCountAndUpdate(t *testing.T) { - db := sqlitetest.OpenBareDB(t) - ctx := context.Background() - - if _, err := db.Exec(ctx, "CREATE TABLE workspaces (id TEXT PRIMARY KEY)"); err != nil { - t.Fatalf("create workspaces table: %v", err) - } - if _, err := db.Exec(ctx, "INSERT INTO workspaces (id) VALUES (?), (?)", "w1", "w2"); err != nil { - t.Fatalf("insert workspaces: %v", err) - } - - m := New(styles.NewTheme(true), logtest.NewScope(t), powersynctest.NewMockSyncer(), "https://api.example.com", "dev") - msg := m.fetchWorkspaceCount(db)() - countMsg, ok := msg.(workspaceCountLoadedMsg) - if !ok { - t.Fatalf("expected workspaceCountLoadedMsg, got %T", msg) - } - if countMsg.err != nil { - t.Fatalf("unexpected fetch error: %v", countMsg.err) - } - if countMsg.count != 2 { - t.Fatalf("expected count=2, got %d", countMsg.count) - } +func TestRenderOrgContext(t *testing.T) { + m := New(styles.NewTheme(true), logtest.NewScope(t), "https://api.example.com", "dev") m.org = "Acme" - m.workspace = "Prod" - m.Update(countMsg) - view := m.renderOrgWorkspace() - if !strings.Contains(view, "Acme / Prod") { - t.Fatalf("expected org/workspace view, got %q", view) + if !strings.Contains(view, "Acme") { + t.Fatalf("expected org in view, got %q", view) } } -func TestFetchWorkspaceCountReturnsErrorWhenTableMissing(t *testing.T) { - db := sqlitetest.OpenBareDB(t) - m := New(styles.NewTheme(true), logtest.NewScope(t), powersynctest.NewMockSyncer(), "https://api.example.com", "dev") - - msg := m.fetchWorkspaceCount(db)() - countMsg, ok := msg.(workspaceCountLoadedMsg) - if !ok { - t.Fatalf("expected workspaceCountLoadedMsg, got %T", msg) - } - if countMsg.err == nil { - t.Fatalf("expected error when workspaces table is missing") +func TestBuildTabsMirrorsProductSurfaces(t *testing.T) { + m := New(styles.NewTheme(true), logtest.NewScope(t), "https://api.example.com", "dev") + + want := []struct { + group string + label string + }{ + {group: "Control Plane", label: "Issues"}, + {group: "Control Plane", label: "Checks"}, + {group: "Data Plane", label: "Services"}, + {group: "Data Plane", label: "Log events"}, + {group: "Data Plane", label: "Edge instances"}, + } + + if len(m.tabs) != len(want) { + t.Fatalf("tab count = %d, want %d", len(m.tabs), len(want)) + } + for i, tab := range m.tabs { + if tab.Label() != want[i].label { + t.Fatalf("tab %d label = %q, want %q", i, tab.Label(), want[i].label) + } + grouped, ok := tab.(groupedDrawerTab) + if !ok { + t.Fatalf("tab %d does not expose a group", i) + } + if grouped.GroupLabel() != want[i].group { + t.Fatalf("tab %d group = %q, want %q", i, grouped.GroupLabel(), want[i].group) + } } } diff --git a/internal/app/statusbar/statusbar_view.go b/internal/app/statusbar/statusbar_view.go index 045fbc7d..8d016e1b 100644 --- a/internal/app/statusbar/statusbar_view.go +++ b/internal/app/statusbar/statusbar_view.go @@ -32,28 +32,21 @@ func (m *Model) View() string { // 1. Brand + sync dot + org context (always shown) segments = append(segments, m.renderBrand()) - // 2. Services health (dot + service count + discovery) + // 2. Issues and services mirror the primary product surfaces without + // flooding the compact bar with every drawer tab. + issuesView := m.issuesStatus.CompactView() + if issuesView != "" { + segments = append(segments, issuesView) + } + servicesView := m.servicesStatus.CompactView() if servicesView != "" { segments = append(segments, servicesView) } - // 3. Waste status (pending count, estimated/observed savings) - wasteView := m.wasteStatus.CompactView() - if wasteView != "" { - segments = append(segments, wasteView) - } - - // 4. Quality status (field-level improvements) - qualityView := m.qualityStatus.CompactView() - if qualityView != "" { - segments = append(segments, qualityView) - } - - // 5. Compliance status (leaking/at-risk counts across PII, Secrets, PHI, Payment Data) - complianceView := m.complianceStatus.CompactView() - if complianceView != "" { - segments = append(segments, complianceView) + logEventsView := m.logEventsStatus.CompactView() + if logEventsView != "" { + segments = append(segments, logEventsView) } // Build right-aligned segment first so we know how much space is left. @@ -151,19 +144,34 @@ func (m *Model) renderTabBar(width int) string { colors := m.theme activeStyle := lipgloss.NewStyle().Foreground(colors.Accent).Background(colors.Bg).Bold(true) inactiveStyle := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) + groupStyle := lipgloss.NewStyle().Foreground(colors.TextSubtle).Background(colors.Bg).Bold(true) sepStyle := lipgloss.NewStyle().Foreground(colors.TextSubtle).Background(colors.Bg) - var tabs []string + var parts []string + lastGroup := "" for i, tab := range m.tabs { + group := "" + if grouped, ok := tab.(groupedDrawerTab); ok { + group = grouped.GroupLabel() + } + if group != "" && group != lastGroup { + parts = append(parts, groupStyle.Render(strings.ToUpper(group))) + lastGroup = group + } + label := tab.Label() if i == m.activeTab { - tabs = append(tabs, activeStyle.Render(label)) + parts = append(parts, activeStyle.Render(label)) } else { - tabs = append(tabs, inactiveStyle.Render(label)) + parts = append(parts, inactiveStyle.Render(label)) } } - return strings.Join(tabs, sepStyle.Render(" ")) + rendered := strings.Join(parts, sepStyle.Render(" ")) + if width > 0 && lipgloss.Width(rendered) > width { + return lipgloss.NewStyle().MaxWidth(width).Render(rendered) + } + return rendered } // renderDrawerHint renders the "ctrl+d open/close" hint. @@ -196,11 +204,6 @@ func (m *Model) renderBrand() string { brand += " " + envStyle.Render(strings.ToUpper(m.env)) } - syncView := m.syncStatus.CompactView() - if syncView != "" { - brand += " " + syncView - } - if m.org != "" { brand += " " + m.renderOrgWorkspace() } @@ -208,13 +211,10 @@ func (m *Model) renderBrand() string { return brand } -// renderOrgWorkspace renders org context. Includes workspace when multiple exist. +// renderOrgWorkspace renders the org context for the brand segment. func (m *Model) renderOrgWorkspace() string { colors := m.theme style := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) - if m.workspace != "" && m.workspaceCount > 1 { - return style.Render(m.org + " / " + m.workspace) - } return style.Render(m.org) } diff --git a/internal/app/statusbar/surfaces/surfaces.go b/internal/app/statusbar/surfaces/surfaces.go new file mode 100644 index 00000000..09444b31 --- /dev/null +++ b/internal/app/statusbar/surfaces/surfaces.go @@ -0,0 +1,423 @@ +// Package surfaces renders high-level product surfaces in the status drawer. +package surfaces + +import ( + "context" + "fmt" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/usetero/cli/internal/app/statusbar/tabpoll" + graphql "github.com/usetero/cli/internal/boundary/graphql" + "github.com/usetero/cli/internal/domain" + "github.com/usetero/cli/internal/format" + "github.com/usetero/cli/internal/log" + "github.com/usetero/cli/internal/styles" + "github.com/usetero/cli/internal/tea/components/table" +) + +const ( + pollInterval = 2 * time.Second + fetchTimeout = 2 * time.Second + + // edgeConnectedWindow is how recently an edge instance must have synced to + // count as connected. + edgeConnectedWindow = 10 * time.Minute +) + +type fetchFunc func(context.Context, graphql.ServiceSet) (Snapshot, error) + +// Metric is one line of supporting state for a product surface. +type Metric struct { + Label string + Value string + Tone string +} + +// Snapshot is the presentation state for one product surface. +type Snapshot struct { + Title string + Description string + Primary Metric + Metrics []Metric + Rows [][]string + Loaded bool +} + +// Model renders a non-interactive product surface tab. +type Model struct { + theme styles.Theme + scope log.Scope + + services graphql.ServiceSet + ready bool + + source string + fetch fetchFunc + + snapshot Snapshot + hasData bool + fetching bool + lastState string +} + +// NewIssues creates the issues surface. +func NewIssues(theme styles.Theme, scope log.Scope) *Model { + return newModel(theme, scope, "issues", fetchIssues) +} + +// NewChecks creates the checks surface. +func NewChecks(theme styles.Theme, scope log.Scope) *Model { + return newModel(theme, scope, "checks", fetchChecks) +} + +// NewLogEvents creates the log events surface. +func NewLogEvents(theme styles.Theme, scope log.Scope) *Model { + return newModel(theme, scope, "log-events", fetchLogEvents) +} + +// NewEdgeInstances creates the edge instances surface. +func NewEdgeInstances(theme styles.Theme, scope log.Scope) *Model { + return newModel(theme, scope, "edge-instances", fetchEdgeInstances) +} + +func newModel(theme styles.Theme, scope log.Scope, source string, fetch fetchFunc) *Model { + return &Model{ + theme: theme, + scope: scope.Child(source), + source: source, + fetch: fetch, + } +} + +// SetServices points the surface at the account-scoped services and starts polling. +func (m *Model) SetServices(services graphql.ServiceSet) tea.Cmd { + m.services = services + m.ready = true + return m.poll() +} + +// Init starts polling once the services are available. +func (m *Model) Init() tea.Cmd { + if !m.ready { + return nil + } + return m.poll() +} + +func (m *Model) poll() tea.Cmd { + return tabpoll.Tick(m.source, pollInterval) +} + +// Update handles polling messages. +func (m *Model) Update(msg tea.Msg) tea.Cmd { + if cmd, handled := tabpoll.UpdatePollCycle( + msg, + m.source, + m.ready, + &m.fetching, + m.fetchData(), + m.poll(), + func(snapshot Snapshot) { + key := snapshotKey(snapshot) + tabpoll.ApplyIfChanged(&m.lastState, key, nil, 0, func() { + m.snapshot = snapshot + m.hasData = snapshot.Loaded + }) + }, + ); handled { + return cmd + } + return nil +} + +func (m *Model) fetchData() tea.Cmd { + services := m.services + fetch := m.fetch + scope := m.scope + return tabpoll.Fetch(fetchTimeout, func(ctx context.Context) (Snapshot, error) { + snapshot, err := fetch(ctx, services) + if err != nil { + scope.Error("fetch surface", "err", err) + return Snapshot{}, err + } + return snapshot, nil + }) +} + +// HasData returns true when the tab has loaded a runtime snapshot. +func (m *Model) HasData() bool { + return m.hasData +} + +// CompactView renders the surface's compact status bar signal. +func (m *Model) CompactView() string { + if !m.hasData || m.snapshot.Primary.Value == "" || m.snapshot.Primary.Value == "0" { + return "" + } + + muted := lipgloss.NewStyle().Foreground(m.theme.TextMuted).Background(m.theme.Bg) + return muted.Render(m.snapshot.Primary.Value + " " + strings.ToLower(m.snapshot.Title)) +} + +// ExpandedView renders the surface drawer body. +func (m *Model) ExpandedView(width, _ int) string { + colors := m.theme + if !m.hasData { + return lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg).Render("Waiting for synced data...") + } + + titleStyle := lipgloss.NewStyle().Foreground(colors.Text).Background(colors.Bg).Bold(true) + muted := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) + + lines := []string{titleStyle.Render(m.snapshot.Title)} + if m.snapshot.Description != "" { + lines = append(lines, muted.Render(m.snapshot.Description)) + } + if len(m.snapshot.Metrics) > 0 { + lines = append(lines, "", m.renderMetrics()) + } + if len(m.snapshot.Rows) > 0 { + lines = append(lines, "", m.renderRows(width)) + } + return strings.Join(lines, "\n") +} + +func (m *Model) renderMetrics() string { + parts := make([]string, 0, len(m.snapshot.Metrics)+1) + if m.snapshot.Primary.Label != "" { + parts = append(parts, m.renderMetric(m.snapshot.Primary)) + } + for _, metric := range m.snapshot.Metrics { + parts = append(parts, m.renderMetric(metric)) + } + return strings.Join(parts, lipgloss.NewStyle().Foreground(m.theme.TextSubtle).Background(m.theme.Bg).Render(" ")) +} + +func (m *Model) renderMetric(metric Metric) string { + label := lipgloss.NewStyle().Foreground(m.theme.TextSubtle).Background(m.theme.Bg).Render(metric.Label) + valueStyle := lipgloss.NewStyle().Foreground(m.theme.TextMuted).Background(m.theme.Bg) + switch metric.Tone { + case "danger": + valueStyle = valueStyle.Foreground(m.theme.Error).Bold(true) + case "warning": + valueStyle = valueStyle.Foreground(m.theme.Warning).Bold(true) + case "success": + valueStyle = valueStyle.Foreground(m.theme.Success).Bold(true) + } + return valueStyle.Render(metric.Value) + " " + label +} + +func (m *Model) renderRows(width int) string { + tbl := table.New(m.theme, table.WithMaxValueWidth(36)) + tbl.Headers("Area", "State", "Signal") + tbl.SetWidth(width) + for _, row := range m.snapshot.Rows { + tbl.Row(row...) + } + return tbl.View() +} + +func fetchIssues(ctx context.Context, services graphql.ServiceSet) (Snapshot, error) { + summary, err := services.Issues.GetSummary(ctx) + if err != nil { + return Snapshot{}, err + } + return Snapshot{ + Title: "Issues", + Description: "Active issues awaiting operator judgment.", + Primary: Metric{Label: "open", Value: count(summary.Open), Tone: pendingTone(summary.Open)}, + Metrics: []Metric{ + {Label: "high", Value: count(summary.HighCount), Tone: highTone(summary.HighCount)}, + {Label: "medium", Value: count(summary.MediumCount), Tone: pendingTone(summary.MediumCount)}, + {Label: "low", Value: count(summary.LowCount)}, + }, + Rows: [][]string{ + {"High", count(summary.HighCount), "high-priority review queue"}, + {"Medium", count(summary.MediumCount), "normal review queue"}, + {"Low", count(summary.LowCount), "background cleanup"}, + }, + Loaded: true, + }, nil +} + +func fetchChecks(ctx context.Context, services graphql.ServiceSet) (Snapshot, error) { + catalog, err := services.Checks.List(ctx) + if err != nil { + return Snapshot{}, err + } + + var openFindings, activeIssues int64 + for _, check := range catalog.Checks { + openFindings += check.OpenFindingCount + activeIssues += check.ActiveIssueCount + } + + return Snapshot{ + Title: "Checks", + Description: "Product checks and their account-scoped posture.", + Primary: Metric{Label: "checks", Value: count(catalog.Total)}, + Metrics: []Metric{ + {Label: "cost", Value: count(catalog.DomainCount(domain.CheckDomainCost))}, + {Label: "compliance", Value: count(catalog.DomainCount(domain.CheckDomainCompliance))}, + {Label: "active issues", Value: count(activeIssues), Tone: pendingTone(activeIssues)}, + }, + Rows: [][]string{ + {"Cost", checkDomainSummary(catalog, domain.CheckDomainCost), "spend-reduction checks"}, + {"Compliance", checkDomainSummary(catalog, domain.CheckDomainCompliance), "data-protection checks"}, + {"Open findings", count(openFindings), "across all checks"}, + }, + Loaded: true, + }, nil +} + +func fetchLogEvents(ctx context.Context, services graphql.ServiceSet) (Snapshot, error) { + summary, err := services.Status.GetAccountSummary(ctx) + if err != nil { + return Snapshot{}, err + } + total := summary.EventCount + coverage := "not analyzed" + if total > 0 { + coverage = fmt.Sprintf("%d%% analyzed", int(float64(summary.AnalyzedCount)/float64(total)*100)) + } + volume := "waiting for volume" + if summary.TotalVolumePerHour != nil { + volume = format.Volume(*summary.TotalVolumePerHour) + " evt/hr" + } + + return Snapshot{ + Title: "Log events", + Description: "Discovered log-event catalog and analysis coverage.", + Primary: Metric{Label: "events", Value: count(total)}, + Metrics: []Metric{ + {Label: "analyzed", Value: count(summary.AnalyzedCount), Tone: analyzedTone(summary, total)}, + {Label: "volume", Value: volume}, + }, + Rows: [][]string{ + {"Catalog", count(total), "events discovered"}, + {"Analysis", count(summary.AnalyzedCount), coverage}, + {"Runtime", volume, "current observed throughput"}, + }, + Loaded: true, + }, nil +} + +func fetchEdgeInstances(ctx context.Context, services graphql.ServiceSet) (Snapshot, error) { + fleet, err := services.EdgeInstances.List(ctx) + if err != nil { + return Snapshot{}, err + } + connected := fleet.ConnectedCount(time.Now(), edgeConnectedWindow) + + rows := make([][]string, 0, len(fleet.Instances)) + for _, inst := range fleet.Instances { + state := "idle" + if inst.LastSyncAt.After(time.Now().Add(-edgeConnectedWindow)) { + state = "connected" + } + rows = append(rows, []string{inst.ServiceName, state, "last sync " + relativeTime(inst.LastSyncAt)}) + } + if len(rows) == 0 { + rows = [][]string{{"Runtime", "none", "no edge instances registered yet"}} + } + + return Snapshot{ + Title: "Edge instances", + Description: "Edge runtimes syncing policies from this account.", + Primary: Metric{Label: "instances", Value: count(fleet.Total)}, + Metrics: []Metric{ + {Label: "connected", Value: count(connected), Tone: connectedTone(connected, fleet.Total)}, + }, + Rows: rows, + Loaded: true, + }, nil +} + +func snapshotKey(snapshot Snapshot) string { + var b strings.Builder + b.WriteString(snapshot.Title) + b.WriteString(snapshot.Description) + b.WriteString(snapshot.Primary.Label) + b.WriteString(snapshot.Primary.Value) + for _, metric := range snapshot.Metrics { + b.WriteString(metric.Label) + b.WriteString(metric.Value) + b.WriteString(metric.Tone) + } + for _, row := range snapshot.Rows { + b.WriteString(strings.Join(row, "\x00")) + } + return b.String() +} + +func count(n int64) string { + return fmt.Sprintf("%d", n) +} + +// relativeTime renders a compact "Xm ago" style duration since t. +func relativeTime(t time.Time) string { + d := time.Since(t) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(d.Hours())) + default: + return fmt.Sprintf("%dd ago", int(d.Hours()/24)) + } +} + +func pendingTone(n int64) string { + if n > 0 { + return "warning" + } + return "success" +} + +func highTone(n int64) string { + if n > 0 { + return "danger" + } + return "success" +} + +func connectedTone(connected, total int64) string { + if total == 0 { + return "" + } + if connected == 0 { + return "danger" + } + if connected < total { + return "warning" + } + return "success" +} + +func analyzedTone(summary domain.AccountSummary, total int64) string { + if total == 0 { + return "" + } + if summary.AnalysisReady() { + return "success" + } + return "warning" +} + +func checkDomainSummary(catalog domain.CheckCatalog, d domain.CheckDomain) string { + var openFindings, activeIssues int64 + for _, check := range catalog.Checks { + if check.Domain != d { + continue + } + openFindings += check.OpenFindingCount + activeIssues += check.ActiveIssueCount + } + return fmt.Sprintf("%d checks, %d findings, %d issues", catalog.DomainCount(d), openFindings, activeIssues) +} diff --git a/internal/app/statusbar/surfaces/surfaces_test.go b/internal/app/statusbar/surfaces/surfaces_test.go new file mode 100644 index 00000000..3b538c99 --- /dev/null +++ b/internal/app/statusbar/surfaces/surfaces_test.go @@ -0,0 +1,115 @@ +package surfaces + +import ( + "strings" + "testing" + + "github.com/usetero/cli/internal/domain" + "github.com/usetero/cli/internal/log/logtest" + "github.com/usetero/cli/internal/styles" +) + +func TestCompactViewHidesEmptyAndZeroPrimary(t *testing.T) { + m := NewIssues(styles.NewTheme(true), logtest.NewScope(t)) + m.hasData = true + + cases := []struct { + name string + primary Metric + want string + }{ + {name: "no data loaded", primary: Metric{Value: "3"}, want: ""}, + {name: "zero value", primary: Metric{Value: "0"}, want: ""}, + {name: "empty value", primary: Metric{Value: ""}, want: ""}, + {name: "real value", primary: Metric{Value: "3"}, want: "issues"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m.hasData = tc.name != "no data loaded" + m.snapshot = Snapshot{Title: "Issues", Primary: tc.primary} + + got := m.CompactView() + if tc.want == "" { + if got != "" { + t.Fatalf("CompactView() = %q, want empty", got) + } + return + } + if !strings.Contains(got, tc.primary.Value) || !strings.Contains(got, tc.want) { + t.Fatalf("CompactView() = %q, want it to contain %q and %q", got, tc.primary.Value, tc.want) + } + }) + } +} + +func TestCheckDomainSummaryAggregatesByDomain(t *testing.T) { + catalog := domain.CheckCatalog{ + Checks: []domain.Check{ + {Domain: domain.CheckDomainCost, OpenFindingCount: 3, ActiveIssueCount: 1}, + {Domain: domain.CheckDomainCost, OpenFindingCount: 2, ActiveIssueCount: 0}, + {Domain: domain.CheckDomainCompliance, OpenFindingCount: 9, ActiveIssueCount: 4}, + }, + ByDomain: map[domain.CheckDomain]int64{ + domain.CheckDomainCost: 2, + domain.CheckDomainCompliance: 1, + }, + } + + got := checkDomainSummary(catalog, domain.CheckDomainCost) + if !strings.Contains(got, "2 checks") || !strings.Contains(got, "5 findings") || !strings.Contains(got, "1 issues") { + t.Fatalf("cost summary = %q, want 2 checks / 5 findings / 1 issues", got) + } +} + +func TestConnectedToneBranches(t *testing.T) { + if got := connectedTone(0, 0); got != "" { + t.Fatalf("expected no tone with zero fleet, got %q", got) + } + if got := connectedTone(0, 3); got != "danger" { + t.Fatalf("expected danger when none connected, got %q", got) + } + if got := connectedTone(1, 3); got != "warning" { + t.Fatalf("expected warning when partially connected, got %q", got) + } + if got := connectedTone(3, 3); got != "success" { + t.Fatalf("expected success when all connected, got %q", got) + } +} + +func TestAnalyzedToneBranches(t *testing.T) { + if got := analyzedTone(domain.AccountSummary{}, 0); got != "" { + t.Fatalf("expected no tone with zero total, got %q", got) + } + + ready := domain.AccountSummary{EventCount: 100, AnalyzedCount: 100} + if got := analyzedTone(ready, 100); got != "success" { + t.Fatalf("expected success tone when analysis ready, got %q", got) + } + + lagging := domain.AccountSummary{EventCount: 100, AnalyzedCount: 1} + if got := analyzedTone(lagging, 100); got != "warning" { + t.Fatalf("expected warning tone when analysis lagging, got %q", got) + } +} + +func TestSnapshotKeyDetectsContentChange(t *testing.T) { + base := Snapshot{ + Title: "Issues", + Primary: Metric{Label: "open", Value: "3"}, + Metrics: []Metric{{Label: "high", Value: "1", Tone: "danger"}}, + Rows: [][]string{{"Critical", "1", "needs review"}}, + } + + first := snapshotKey(base) + second := snapshotKey(base) + if first != second { + t.Fatal("snapshotKey must be stable for identical snapshots") + } + + changed := base + changed.Primary.Value = "4" + if snapshotKey(base) == snapshotKey(changed) { + t.Fatal("snapshotKey must change when a metric value changes") + } +} diff --git a/internal/app/statusbar/syncstatus/syncstatus.go b/internal/app/statusbar/syncstatus/syncstatus.go deleted file mode 100644 index 67426a18..00000000 --- a/internal/app/statusbar/syncstatus/syncstatus.go +++ /dev/null @@ -1,301 +0,0 @@ -// Package syncstatus renders sync connection status. -package syncstatus - -import ( - "context" - "fmt" - "image/color" - "net/url" - "strings" - "time" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - - appevents "github.com/usetero/cli/internal/app/events" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/powersync" - "github.com/usetero/cli/internal/sqlite" - "github.com/usetero/cli/internal/styles" -) - -const ( - pollInterval = 500 * time.Millisecond - pendingPollInterval = 2 * time.Second - dbTimeout = 2 * time.Second -) - -// syncPollTickMsg triggers a sync status check. -type syncPollTickMsg struct{} - -// pendingUploadsPollTickMsg triggers a pending-upload count refresh. -type pendingUploadsPollTickMsg struct{} - -// pendingUploadsLoadedMsg carries the result of an async pending-upload count. -type pendingUploadsLoadedMsg struct { - total int64 -} - -// Model renders sync connection status. -type Model struct { - theme styles.Theme - scope log.Scope - syncer powersync.Syncer - db sqlite.DB - host string - - // Cached state for change detection - lastState powersync.State - totalPending int64 - pendingFetch bool -} - -// New creates a new sync status model. -func New(theme styles.Theme, scope log.Scope, syncer powersync.Syncer, endpoint string) *Model { - host := endpoint - if u, err := url.Parse(endpoint); err == nil && u.Host != "" { - host = u.Host - } - - return &Model{ - theme: theme, - scope: scope.Child("syncstatus"), - syncer: syncer, - host: host, - } -} - -// SetDB sets the database for record count polling. -func (m *Model) SetDB(db sqlite.DB) tea.Cmd { - m.db = db - return nil -} - -// Init starts polling sync status. -func (m *Model) Init() tea.Cmd { - if m.syncer == nil { - return nil - } - return tea.Batch(m.poll(), m.pollPending()) -} - -func (m *Model) poll() tea.Cmd { - return tea.Tick(pollInterval, func(time.Time) tea.Msg { - return syncPollTickMsg{} - }) -} - -func (m *Model) pollPending() tea.Cmd { - return tea.Tick(pendingPollInterval, func(time.Time) tea.Msg { - return pendingUploadsPollTickMsg{} - }) -} - -// Update handles messages. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - switch msg := msg.(type) { - case syncPollTickMsg: - if m.syncer == nil { - return nil - } - - // syncer.State() is an atomic load — safe to call inline. - currentState := m.syncer.State() - stateChanged := m.stateChanged(currentState) - if stateChanged { - m.lastState = currentState - } - - cmds := []tea.Cmd{m.poll()} - if stateChanged { - cmds = append(cmds, func() tea.Msg { return appevents.SyncStateChanged{State: currentState} }) - - if errState, ok := currentState.(*powersync.Error); ok { - cmds = append(cmds, appevents.PublishErrorToastCmd("Sync error", errState.Err, true)) - } - } - - return tea.Batch(cmds...) - - case pendingUploadsPollTickMsg: - if m.db == nil { - return m.pollPending() - } - if m.pendingFetch { - return m.pollPending() - } - m.pendingFetch = true - return tea.Batch(m.pollPending(), m.fetchPending()) - - case pendingUploadsLoadedMsg: - m.pendingFetch = false - m.totalPending = msg.total - } - - return nil -} - -// fetchPending returns a Cmd that queries pending upload counts off the event loop. -func (m *Model) fetchPending() tea.Cmd { - if m.db == nil { - return nil - } - db := m.db - scope := m.scope - return func() tea.Msg { - ctx, cancel := sqlite.WithTimeout(context.Background(), dbTimeout) - defer cancel() - pending, err := db.PendingUploadCounts(ctx) - if err != nil { - scope.Error("pending upload counts", "err", err) - return pendingUploadsLoadedMsg{} - } - - var total int64 - for _, count := range pending { - total += count - } - return pendingUploadsLoadedMsg{total: total} - } -} - -// stateChanged returns true if the sync state has meaningfully changed. -func (m *Model) stateChanged(current powersync.State) bool { - if m.lastState == nil { - return current != nil - } - if current == nil { - return true - } - - switch last := m.lastState.(type) { - case *powersync.Disconnected: - _, ok := current.(*powersync.Disconnected) - return !ok - case *powersync.Connecting: - _, ok := current.(*powersync.Connecting) - return !ok - case *powersync.Syncing: - _, ok := current.(*powersync.Syncing) - return !ok - case *powersync.Ready: - _, ok := current.(*powersync.Ready) - return !ok - case *powersync.Reconnecting: - cur, ok := current.(*powersync.Reconnecting) - return !ok || cur.Degraded != last.Degraded - case *powersync.Error: - _, ok := current.(*powersync.Error) - return !ok - } - return true -} - -// HasData returns true when the syncer has reported at least one state update. -func (m *Model) HasData() bool { - return m.lastState != nil -} - -// CompactView renders the sync status for the statusbar: "● api.usetero.com" or "● syncing 45%" -func (m *Model) CompactView() string { - if m.lastState == nil { - return "" - } - - colors := m.theme - - switch state := m.lastState.(type) { - case *powersync.Disconnected: - return "" - - case *powersync.Connecting: - return dot(colors.Warning, colors.Bg) - - case *powersync.Syncing: - return dot(colors.Warning, colors.Bg) - - case *powersync.Ready: - return dot(colors.Success, colors.Bg) - - case *powersync.Reconnecting: - if state.Degraded { - return dot(colors.Error, colors.Bg) - } - return dot(colors.Warning, colors.Bg) - - case *powersync.Error: - return dot(colors.Error, colors.Bg) - - default: - return "" - } -} - -// ExpandedView renders the detailed sync status for the drawer. -func (m *Model) ExpandedView(width, _ int) string { - if m.lastState == nil { - return "" - } - - colors := m.theme - text := lipgloss.NewStyle().Foreground(colors.Text).Background(colors.Bg) - muted := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) - - var headline string - var description string - - switch state := m.lastState.(type) { - case *powersync.Disconnected: - headline = dot(colors.TextSubtle, colors.Bg) + " " + text.Render("Disconnected") - description = "Sync has not started yet." - - case *powersync.Connecting: - headline = dot(colors.Warning, colors.Bg) + " " + text.Render("Connecting...") - description = "Establishing connection to the control plane." - - case *powersync.Syncing: - if state.Progress != nil && state.Progress.Total > 0 { - pct := state.Progress.Downloaded * 100 / state.Progress.Total - headline = dot(colors.Warning, colors.Bg) + " " + text.Render(fmt.Sprintf("Syncing your data... %d%%", pct)) - description = fmt.Sprintf("%d / %d rows downloaded.", state.Progress.Downloaded, state.Progress.Total) - } else { - headline = dot(colors.Warning, colors.Bg) + " " + text.Render("Syncing your data...") - description = "Downloading from the control plane." - } - - case *powersync.Ready: - headline = dot(colors.Success, colors.Bg) + " " + text.Render("Connected") - description = "Your data is synced and up to date." - - case *powersync.Reconnecting: - if state.Degraded { - headline = dot(colors.Error, colors.Bg) + " " + text.Render("Connection issues") - description = "Multiple retries failed. Still trying to reconnect." - } else { - headline = dot(colors.Warning, colors.Bg) + " " + text.Render("Reconnecting...") - description = "Temporarily lost connection. Retrying automatically." - } - - case *powersync.Error: - errStyle := lipgloss.NewStyle().Foreground(colors.Error).Background(colors.Bg) - headline = dot(colors.Error, colors.Bg) + " " + errStyle.Render("Sync failed") - description = state.Err.Error() - } - - var lines []string - lines = append(lines, headline) - lines = append(lines, "") - lines = append(lines, muted.Render(description)) - - if m.totalPending > 0 { - lines = append(lines, "") - warn := lipgloss.NewStyle().Foreground(colors.Warning).Background(colors.Bg) - lines = append(lines, warn.Render(fmt.Sprintf("%d pending uploads", m.totalPending))) - } - - return strings.Join(lines, "\n") -} - -func dot(c color.Color, bg color.Color) string { - return lipgloss.NewStyle().Foreground(c).Background(bg).Render("●") -} diff --git a/internal/app/statusbar/syncstatus/syncstatus_test.go b/internal/app/statusbar/syncstatus/syncstatus_test.go deleted file mode 100644 index 4af7b0d5..00000000 --- a/internal/app/statusbar/syncstatus/syncstatus_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package syncstatus - -import ( - "testing" - - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/powersynctest" - "github.com/usetero/cli/internal/sqlite/sqlitetest" - "github.com/usetero/cli/internal/styles" -) - -func TestPendingPollDoesNotOverlapFetches(t *testing.T) { - m := New(styles.NewTheme(true), logtest.NewScope(t), powersynctest.NewMockSyncer(), "https://api.example.com") - db := sqlitetest.OpenBareDB(t) - m.SetDB(db) - - m.pendingFetch = true - if cmd := m.Update(pendingUploadsPollTickMsg{}); cmd == nil { - t.Fatalf("expected pending poll to keep polling even while fetch is in-flight") - } - if !m.pendingFetch { - t.Fatalf("expected in-flight flag to remain set") - } -} - -func TestPendingUploadsLoadedMsgClearsInFlightFlag(t *testing.T) { - m := New(styles.NewTheme(true), logtest.NewScope(t), powersynctest.NewMockSyncer(), "https://api.example.com") - m.pendingFetch = true - - m.Update(pendingUploadsLoadedMsg{total: 42}) - - if m.pendingFetch { - t.Fatalf("expected pending message to clear in-flight flag") - } - if m.totalPending != 42 { - t.Fatalf("expected pending total to update, got %d", m.totalPending) - } -} diff --git a/internal/app/statusbar/tabpoll/tabpoll.go b/internal/app/statusbar/tabpoll/tabpoll.go index 304f23d3..ef6be036 100644 --- a/internal/app/statusbar/tabpoll/tabpoll.go +++ b/internal/app/statusbar/tabpoll/tabpoll.go @@ -6,7 +6,6 @@ import ( tea "charm.land/bubbletea/v2" "github.com/usetero/cli/internal/app/statusbar/listdetail" - "github.com/usetero/cli/internal/sqlite" ) // PollMsg triggers a tab data refresh tick. @@ -30,7 +29,7 @@ func Tick(source string, interval time.Duration) tea.Cmd { // Fetch executes a typed data fetch with a timeout and returns DataMsg[T]. func Fetch[T any](timeout time.Duration, fetch func(ctx context.Context) (T, error)) tea.Cmd { return func() tea.Msg { - ctx, cancel := sqlite.WithTimeout(context.Background(), timeout) + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() data, err := fetch(ctx) return DataMsg[T]{Data: data, Err: err} @@ -40,7 +39,7 @@ func Fetch[T any](timeout time.Duration, fetch func(ctx context.Context) (T, err // FetchDetail executes a typed detail fetch with a timeout and maps result+error into a tea.Msg. func FetchDetail[T any](timeout time.Duration, fetch func(ctx context.Context) (T, error), mapMsg func(data T, err error) tea.Msg) tea.Cmd { return func() tea.Msg { - ctx, cancel := sqlite.WithTimeout(context.Background(), timeout) + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() data, err := fetch(ctx) return mapMsg(data, err) diff --git a/internal/app/statusbar/tabs.go b/internal/app/statusbar/tabs.go index fd5d5d28..2d52d58c 100644 --- a/internal/app/statusbar/tabs.go +++ b/internal/app/statusbar/tabs.go @@ -3,12 +3,13 @@ package statusbar import ( tea "charm.land/bubbletea/v2" - "github.com/usetero/cli/internal/sqlite" + graphql "github.com/usetero/cli/internal/boundary/graphql" ) // TabModel is the base contract every status bar tab model must satisfy. +// Tabs read from control-plane GraphQL services, not a local database. type TabModel interface { - SetDB(db sqlite.DB) tea.Cmd + SetServices(services graphql.ServiceSet) tea.Cmd Init() tea.Cmd Update(msg tea.Msg) tea.Cmd HasData() bool @@ -26,7 +27,7 @@ type InteractiveTabModel interface { type drawerTab interface { Label() string - SetDB(db sqlite.DB) tea.Cmd + SetServices(services graphql.ServiceSet) tea.Cmd Init() tea.Cmd Update(msg tea.Msg) tea.Cmd HasData() bool @@ -38,18 +39,25 @@ type drawerTab interface { HandleKeyPress(msg tea.KeyPressMsg) tea.Cmd } +type groupedDrawerTab interface { + GroupLabel() string +} + type tab struct { + group string label string model TabModel } -func newTab(label string, model TabModel) drawerTab { - return tab{label: label, model: model} +func newTab(group, label string, model TabModel) drawerTab { + return tab{group: group, label: label, model: model} } func (t tab) Label() string { return t.label } -func (t tab) SetDB(db sqlite.DB) tea.Cmd { return t.model.SetDB(db) } +func (t tab) GroupLabel() string { return t.group } + +func (t tab) SetServices(services graphql.ServiceSet) tea.Cmd { return t.model.SetServices(services) } func (t tab) Init() tea.Cmd { return t.model.Init() } @@ -90,10 +98,10 @@ func (t tab) HandleKeyPress(msg tea.KeyPressMsg) tea.Cmd { func (m *Model) buildTabs() []drawerTab { return []drawerTab{ - newTab(tabLabels[TabWaste], m.wasteStatus), - newTab(tabLabels[TabQuality], m.qualityStatus), - newTab(tabLabels[TabCompliance], m.complianceStatus), - newTab(tabLabels[TabServices], m.servicesStatus), - newTab(tabLabels[TabSync], m.syncStatus), + newTab("Control Plane", tabLabels[TabIssues], m.issuesStatus), + newTab("Control Plane", tabLabels[TabChecks], m.checksStatus), + newTab("Data Plane", tabLabels[TabServices], m.servicesStatus), + newTab("Data Plane", tabLabels[TabLogEvents], m.logEventsStatus), + newTab("Data Plane", tabLabels[TabEdgeInstances], m.edgeStatus), } } diff --git a/internal/app/statusbar/waste/detail.go b/internal/app/statusbar/waste/detail.go deleted file mode 100644 index 40e16da6..00000000 --- a/internal/app/statusbar/waste/detail.go +++ /dev/null @@ -1,138 +0,0 @@ -package waste - -import ( - "fmt" - "strings" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - - appevents "github.com/usetero/cli/internal/app/events" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/format" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/components/table" -) - -// detail renders the top pending policies for a single waste category. -type detail struct { - theme styles.Theme - category domain.PolicyCategoryStatus - policies []domain.WastePolicy - cursor int -} - -func (d *detail) Len() int { return len(d.policies) } -func (d *detail) Cursor() int { return d.cursor } -func (d *detail) SetCursor(v int) { d.cursor = v } - -// newDetail creates a detail view for the given category and pre-fetched policies. -func newDetail(theme styles.Theme, category domain.PolicyCategoryStatus, policies []domain.WastePolicy) *detail { - return &detail{ - theme: theme, - category: category, - policies: policies, - } -} - -// Prompt returns a tea.Cmd that emits a DrawerPromptRequested event for the selected policy. -func (d *detail) Prompt() tea.Cmd { - if len(d.policies) == 0 { - return nil - } - p := d.policies[d.cursor] - text := fmt.Sprintf( - "Pull up the %q policy for the %q log event in the %s service.", - d.category.Name(), p.LogEventName, p.ServiceName, - ) - return appevents.RequestDrawerPromptCmd(text) -} - -// View renders the detail: a header with category summary, then a policy table. -func (d *detail) View(width int) string { - var lines []string - lines = append(lines, d.renderHeader()) - if d.category.Principle != "" { - lines = append(lines, "") - muted := lipgloss.NewStyle().Foreground(d.theme.TextMuted).Background(d.theme.Bg) - lines = append(lines, muted.Render(d.category.Principle)) - } - lines = append(lines, "") - - if len(d.policies) == 0 { - muted := lipgloss.NewStyle().Foreground(d.theme.TextMuted).Background(d.theme.Bg) - lines = append(lines, muted.Render("No pending policies in this category.")) - } else { - lines = append(lines, d.renderTable(width)) - } - - return strings.Join(lines, "\n") -} - -// renderHeader renders the back hint + category name + summary. -func (d *detail) renderHeader() string { - colors := d.theme - muted := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) - text := lipgloss.NewStyle().Foreground(colors.Text).Background(colors.Bg) - sep := muted.Render(" · ") - - back := muted.Render("esc ◀") - name := text.Bold(true).Render(d.category.Name()) - - var parts []string - parts = append(parts, back+" "+name) - - if d.category.PendingCount > 0 { - warn := lipgloss.NewStyle().Foreground(colors.Warning).Background(colors.Bg) - parts = append(parts, warn.Render("●")+" "+warn.Render(fmt.Sprintf("%d pending", d.category.PendingCount))) - } - - if d.category.EstimatedCostPerHour != nil && *d.category.EstimatedCostPerHour > 0 { - parts = append(parts, muted.Render(format.YearlyCostPtr(d.category.EstimatedCostPerHour))) - } - - return strings.Join(parts, sep) -} - -// renderTable renders the per-policy table. -func (d *detail) renderTable(width int) string { - tbl := table.New(d.theme, table.WithMaxValueWidth(30)) - - showVolume := d.category.ReducesVolume() - if showVolume { - tbl.Headers("Log Event", "Service", "Volume", "Bytes", "Est. Impact") - } else { - tbl.Headers("Log Event", "Service", "Bytes", "Est. Impact") - } - tbl.SetWidth(width) - - accent := lipgloss.NewStyle().Foreground(d.theme.Accent).Background(d.theme.Bg) - dot := lipgloss.NewStyle().Foreground(d.theme.Warning).Background(d.theme.Bg).Render("●") - - for i, p := range d.policies { - name := p.LogEventName - if i == d.cursor { - name = accent.Render("▶ " + name) - } else { - name = dot + " " + name - } - - bytes := "—" - if p.BytesPerHour != nil { - bytes = format.Bytes(*p.BytesPerHour) + "/hr" - } - savings := format.YearlyCostPtr(p.EstimatedCostPerHour) - - if showVolume { - vol := "—" - if p.VolumePerHour != nil { - vol = format.Volume(*p.VolumePerHour) + " evt/hr" - } - tbl.Row(name, p.ServiceName, vol, bytes, savings) - } else { - tbl.Row(name, p.ServiceName, bytes, savings) - } - } - - return tbl.View() -} diff --git a/internal/app/statusbar/waste/waste.go b/internal/app/statusbar/waste/waste.go deleted file mode 100644 index 6eb2272e..00000000 --- a/internal/app/statusbar/waste/waste.go +++ /dev/null @@ -1,527 +0,0 @@ -// Package waste renders the waste indicator in the status bar. -package waste - -import ( - "context" - "encoding/json" - "fmt" - "math" - "strings" - "time" - - "charm.land/bubbles/v2/progress" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - - "github.com/usetero/cli/internal/app/statusbar/listdetail" - "github.com/usetero/cli/internal/app/statusbar/policytab" - "github.com/usetero/cli/internal/app/statusbar/tabpoll" - "github.com/usetero/cli/internal/app/statusbar/viewkit" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/format" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/sqlite" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/components/table" -) - -const ( - dbTimeout = 2 * time.Second - pollSource = "waste" -) - -type fetchedData struct { - summary domain.AccountSummary - categories []domain.PolicyCategoryStatus -} - -// wasteDetailLoadedMsg carries the result of an async detail fetch. -type wasteDetailLoadedMsg struct { - cat domain.PolicyCategoryStatus - policies []domain.WastePolicy - err error -} - -// Model renders the policy status: pending count, estimated savings, observed savings. -type Model struct { - theme styles.Theme - scope log.Scope - core policytab.Base - - summary domain.AccountSummary - categories []domain.PolicyCategoryStatus - - // Drawer navigation - detail *detail // non-nil when viewing a single category's policies -} - -// New creates a new policy status model. -func New(theme styles.Theme, scope log.Scope) *Model { - return &Model{ - theme: theme, - scope: scope.Child("waste"), - core: policytab.New(pollSource), - } -} - -// SetDB sets the database and starts polling. -func (m *Model) SetDB(db sqlite.DB) tea.Cmd { - return m.core.SetDB(db) -} - -// Init starts polling. -func (m *Model) Init() tea.Cmd { - return m.core.Init() -} - -// Update handles messages. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - if cmd, handled := policytab.UpdatePoll(&m.core, - msg, - m.fetchData(), - func(data fetchedData) { - key := m.buildStateKey(data.summary, data.categories) - m.core.ApplyIfChanged(key, len(data.categories), func() { - m.summary = data.summary - m.categories = data.categories - m.core.SetHasData(len(data.categories) > 0 || data.summary.PendingPolicyCount+data.summary.ApprovedPolicyCount+data.summary.DismissedPolicyCount > 0) - }) - }, - ); handled { - return cmd - } - - switch msg := msg.(type) { - case wasteDetailLoadedMsg: - if msg.err == nil { - m.detail = newDetail(m.theme, msg.cat, msg.policies) - } - - default: - _ = msg - } - - return nil -} - -// fetchData returns a Cmd that queries waste data off the event loop. -func (m *Model) fetchData() tea.Cmd { - db := m.core.DB() - scope := m.scope - return tabpoll.Fetch(dbTimeout, func(ctx context.Context) (fetchedData, error) { - summary, err := db.DatadogAccountStatuses().GetSummary(ctx) - if err != nil { - scope.Error("get summary", "err", err) - return fetchedData{}, err - } - - categories, err := db.LogEventPolicyCategoryStatuses().ListWasteCategoryStatuses(ctx) - if err != nil { - scope.Error("list waste category statuses", "err", err) - categories = nil - } - - return fetchedData{summary: summary, categories: categories}, nil - }) -} - -// fetchDetail returns a Cmd that queries category detail off the event loop. -func (m *Model) fetchDetail(cat domain.PolicyCategoryStatus) tea.Cmd { - db := m.core.DB() - scope := m.scope - return tabpoll.FetchDetail(dbTimeout, func(ctx context.Context) ([]domain.WastePolicy, error) { - policies, err := db.LogEventPolicyStatuses().ListTopPendingPoliciesByCategory(ctx, cat.Category, 25) - if err != nil { - scope.Error("list top pending policies", "category", cat.Category, "err", err) - return nil, err - } - return policies, nil - }, func(policies []domain.WastePolicy, err error) tea.Msg { - if err != nil { - return wasteDetailLoadedMsg{err: err} - } - return wasteDetailLoadedMsg{cat: cat, policies: policies} - }) -} - -// buildStateKey builds a string key for change detection. -func (m *Model) buildStateKey(s domain.AccountSummary, cats []domain.PolicyCategoryStatus) string { - key := wasteStateKey{ - Summary: wasteSummaryKey{ - ReadyForUse: s.ReadyForUse, - EventCount: s.EventCount, - AnalyzedCount: s.AnalyzedCount, - PendingPolicyCount: s.PendingPolicyCount, - ApprovedPolicyCount: s.ApprovedPolicyCount, - DismissedPolicyCount: s.DismissedPolicyCount, - EstimatedCostPerHour: s.EstimatedCostPerHour, - EstimatedCostPerHourB: s.EstimatedCostPerHourBytes, - EstimatedCostPerHourVol: s.EstimatedCostPerHourVolume, - EstimatedVolumePerHour: s.EstimatedVolumePerHour, - EstimatedBytesPerHour: s.EstimatedBytesPerHour, - ObservedCostBefore: s.ObservedCostBefore, - ObservedCostAfter: s.ObservedCostAfter, - ObservedVolumeBefore: s.ObservedVolumeBefore, - ObservedVolumeAfter: s.ObservedVolumeAfter, - TotalCostPerHour: s.TotalCostPerHour, - TotalVolumePerHour: s.TotalVolumePerHour, - TotalBytesPerHour: s.TotalBytesPerHour, - }, - Categories: make([]wasteCategoryKey, 0, len(cats)), - } - for _, c := range cats { - key.Categories = append(key.Categories, wasteCategoryKey{ - Category: string(c.Category), - PendingCount: c.PendingCount, - ApprovedCount: c.ApprovedCount, - DismissedCount: c.DismissedCount, - EstimatedVolumePerHour: c.EstimatedVolumePerHour, - EstimatedBytesPerHour: c.EstimatedBytesPerHour, - EstimatedCostPerHour: c.EstimatedCostPerHour, - EventsWithVolumes: c.EventsWithVolumes, - TotalEvents: c.TotalEvents, - }) - } - data, err := json.Marshal(key) - if err != nil { - return "" - } - return string(data) -} - -type wasteStateKey struct { - Summary wasteSummaryKey `json:"summary"` - Categories []wasteCategoryKey `json:"categories"` -} - -type wasteSummaryKey struct { - ReadyForUse bool `json:"ready_for_use"` - EventCount int64 `json:"event_count"` - AnalyzedCount int64 `json:"analyzed_count"` - PendingPolicyCount int64 `json:"pending_policy_count"` - ApprovedPolicyCount int64 `json:"approved_policy_count"` - DismissedPolicyCount int64 `json:"dismissed_policy_count"` - EstimatedCostPerHour *float64 `json:"estimated_cost_per_hour"` - EstimatedCostPerHourB *float64 `json:"estimated_cost_per_hour_bytes"` - EstimatedCostPerHourVol *float64 `json:"estimated_cost_per_hour_volume"` - EstimatedVolumePerHour *float64 `json:"estimated_volume_per_hour"` - EstimatedBytesPerHour *float64 `json:"estimated_bytes_per_hour"` - ObservedCostBefore *float64 `json:"observed_cost_before"` - ObservedCostAfter *float64 `json:"observed_cost_after"` - ObservedVolumeBefore *float64 `json:"observed_volume_before"` - ObservedVolumeAfter *float64 `json:"observed_volume_after"` - TotalCostPerHour *float64 `json:"total_cost_per_hour"` - TotalVolumePerHour *float64 `json:"total_volume_per_hour"` - TotalBytesPerHour *float64 `json:"total_bytes_per_hour"` -} - -type wasteCategoryKey struct { - Category string `json:"category"` - PendingCount int64 `json:"pending_count"` - ApprovedCount int64 `json:"approved_count"` - DismissedCount int64 `json:"dismissed_count"` - EstimatedVolumePerHour *float64 `json:"estimated_volume_per_hour"` - EstimatedBytesPerHour *float64 `json:"estimated_bytes_per_hour"` - EstimatedCostPerHour *float64 `json:"estimated_cost_per_hour"` - EventsWithVolumes int64 `json:"events_with_volumes"` - TotalEvents int64 `json:"total_events"` -} - -// HasData returns true when policy data has been loaded. -func (m *Model) HasData() bool { - return m.core.HasData() -} - -// HandleKeyPress handles keyboard navigation in the expanded drawer view. -func (m *Model) HandleKeyPress(msg tea.KeyPressMsg) tea.Cmd { - return m.navController().HandleKeyPress(msg) -} - -// InDetail returns true when the detail sub-view is active. -func (m *Model) InDetail() bool { - return m.detail != nil -} - -// CloseDetail exits the detail sub-view, returning to the category list. -func (m *Model) CloseDetail() { - m.detail = nil -} - -func (m *Model) navController() listdetail.Controller { - return m.core.NavController( - func() int { return len(m.categories) }, - func(index int) tea.Cmd { - cat := m.categories[index] - if cat.PendingCount == 0 { - return nil - } - return m.fetchDetail(cat) - }, - func() listdetail.Detail { return m.detail }, - func() { m.detail = nil }, - ) -} - -// CompactView renders the policy status for the statusbar. -func (m *Model) CompactView() string { - if !m.core.HasData() { - return "" - } - - s := m.summary - colors := m.theme - muted := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) - sep := muted.Render(" · ") - - var segments []string - - // Observed savings from approved policies (always shown — these are measured). - if saving := formatObservedSaving(s); saving != "" { - savingStyle := lipgloss.NewStyle().Foreground(colors.Success).Background(colors.Bg) - segments = append(segments, savingStyle.Render("saving "+saving)) - } - - if s.AnalysisReady() { - // Waste percentage with pending count. - if wp := wastePercent(s); wp > 0 { - dot := lipgloss.NewStyle().Foreground(colors.Warning).Background(colors.Bg).Render("●") - waste := fmt.Sprintf("%d%% waste", wp) - if s.PendingPolicyCount > 0 { - waste += fmt.Sprintf(" (%d)", s.PendingPolicyCount) - } - segments = append(segments, dot+" "+muted.Render(waste)) - } else if s.PendingPolicyCount > 0 { - dot := lipgloss.NewStyle().Foreground(colors.Warning).Background(colors.Bg).Render("●") - segments = append(segments, dot+" "+muted.Render(fmt.Sprintf("%d policies", s.PendingPolicyCount))) - } - } else if s.EventCount > 0 { - // Analysis still in progress — show progress instead of waste %. - segments = append(segments, muted.Render(fmt.Sprintf("%d/%d analyzed", s.AnalyzedCount, s.EventCount))) - } - - if len(segments) == 0 { - dot := lipgloss.NewStyle().Foreground(colors.Success).Background(colors.Bg).Render("●") - return dot + " " + muted.Render("healthy") - } - - return strings.Join(segments, sep) -} - -// wastePercent computes the estimated waste as a percentage of total bytes. -func wastePercent(s domain.AccountSummary) int { - if s.TotalBytesPerHour != nil && *s.TotalBytesPerHour > 0 && - s.EstimatedBytesPerHour != nil && *s.EstimatedBytesPerHour > 0 { - return int(math.Round(*s.EstimatedBytesPerHour / *s.TotalBytesPerHour * 100)) - } - return 0 -} - -// ExpandedView renders the detailed policy status for the drawer. -func (m *Model) ExpandedView(width, height int) string { - if !m.core.HasData() { - return viewkit.RenderPolicyEmptyState( - m.theme, - m.core.DB() != nil, - m.summary, - "Enable services to start detecting waste.", - "No waste detected. Your logs look clean.", - ) - } - - // Detail sub-view for a single category. - if m.detail != nil { - return m.detail.View(width) - } - - return viewkit.ComposeSummaryTableView( - m.theme, - m.renderWasteHeadline(), - m.renderCategoryTable(width), - m.cursorPrinciple(), - ) -} - -// renderWasteHeadline renders the waste summary: waste % and pending count. -func (m *Model) renderWasteHeadline() string { - s := m.summary - colors := m.theme - muted := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) - sep := muted.Render(" · ") - - var parts []string - - // Observed savings from approved policies (always shown — these are measured). - if saving := formatObservedSaving(s); saving != "" { - savingStyle := lipgloss.NewStyle().Foreground(colors.Success).Background(colors.Bg) - parts = append(parts, savingStyle.Render("saving "+saving)) - } - - // Pending count + waste %. Count first for consistency with other tabs. - if s.PendingPolicyCount > 0 { - dot := lipgloss.NewStyle().Foreground(colors.Warning).Background(colors.Bg).Render("●") - text := lipgloss.NewStyle().Foreground(colors.Text).Background(colors.Bg) - pending := dot + " " + text.Render(fmt.Sprintf("%d policies", s.PendingPolicyCount)) - if wp := wastePercent(s); wp > 0 { - pending += sep + text.Render(fmt.Sprintf("%d%% waste", wp)) - } - parts = append(parts, pending) - } else if wp := wastePercent(s); wp > 0 { - dot := lipgloss.NewStyle().Foreground(colors.Warning).Background(colors.Bg).Render("●") - text := lipgloss.NewStyle().Foreground(colors.Text).Background(colors.Bg) - parts = append(parts, dot+" "+text.Render(fmt.Sprintf("%d%% waste", wp))) - } - - // Analysis progress when not yet ready. - if s.EventCount > 0 && !s.AnalysisReady() { - pct := float64(s.AnalyzedCount) / float64(s.EventCount) - bar := m.analysisBar() - parts = append(parts, bar.ViewAs(pct)+" "+muted.Render(fmt.Sprintf("%d/%d analyzed", s.AnalyzedCount, s.EventCount))) - } - - if len(parts) == 0 { - return muted.Render("All policies reviewed") - } - - return strings.Join(parts, sep) -} - -// renderCategoryTable renders all waste categories in a single table with cursor highlighting. -func (m *Model) renderCategoryTable(width int) string { - if len(m.categories) == 0 { - return "" - } - - tbl := table.New(m.theme, table.WithMaxValueWidth(35)) - tbl.Headers("Category", "Pending", "Impact", "Approved") - tbl.SetWidth(width) - - warn := lipgloss.NewStyle().Foreground(m.theme.Warning).Background(m.theme.Bg) - ok := lipgloss.NewStyle().Foreground(m.theme.Success).Background(m.theme.Bg) - accent := lipgloss.NewStyle().Foreground(m.theme.Accent).Background(m.theme.Bg) - muted := lipgloss.NewStyle().Foreground(m.theme.TextMuted).Background(m.theme.Bg) - bar := m.discoveryBar() - - var totalCost float64 - if m.summary.EstimatedCostPerHour != nil { - totalCost = *m.summary.EstimatedCostPerHour - } - - // Find widest pending count for alignment. - maxPendingW := 1 - for _, c := range m.categories { - if w := len(format.Count(c.PendingCount)); w > maxPendingW { - maxPendingW = w - } - } - - for i, c := range m.categories { - dot := ok.Render("●") - if c.PendingCount > 0 { - dot = warn.Render("●") - } - - name := c.Name() - if i == m.core.Cursor() { - name = accent.Render("▶ " + name) - } else { - name = dot + " " + name - } - - // Clean categories: single checkmark row. - if c.PendingCount == 0 && c.ApprovedCount == 0 { - tbl.Row(name, ok.Render("✓"), "—", "—") - continue - } - - // Pending count with optional discovery progress bar. - pending := fmt.Sprintf("%-*s", maxPendingW, format.Count(c.PendingCount)) - if c.TotalEvents > 0 { - pct := int(c.EventsWithVolumes * 100 / c.TotalEvents) - if pct < 80 { - pending += " " + bar.ViewAs(float64(pct)/100) + " " + muted.Render(fmt.Sprintf("%d%%", pct)) - } - } - - tbl.Row( - name, - pending, - formatCategoryCost(c, totalCost, ok, muted), - format.Count(c.ApprovedCount), - ) - } - - return tbl.View() -} - -// analysisBar creates a small progress bar for inline use in the headline. -func (m *Model) analysisBar() progress.Model { - bar := progress.New( - progress.WithColors(m.theme.GradientStart, m.theme.GradientEnd), - progress.WithWidth(10), - progress.WithFillCharacters('█', '░'), - ) - bar.ShowPercentage = false - bar.EmptyColor = m.theme.TextMuted - return bar -} - -// discoveryBar creates a small progress bar for inline use in table cells. -func (m *Model) discoveryBar() progress.Model { - bar := progress.New( - progress.WithColors(m.theme.GradientStart, m.theme.GradientEnd), - progress.WithWidth(10), - progress.WithFillCharacters('█', '░'), - ) - bar.ShowPercentage = false - bar.EmptyColor = m.theme.TextMuted - return bar -} - -// formatCategoryCost returns estimated yearly cost for a category, with its -// share of total estimated waste. Shows dollar amount + percentage for -// categories ≥1% of total waste, just "<1%" for tiny categories. -func formatCategoryCost(c domain.PolicyCategoryStatus, totalCostPerHour float64, success, muted lipgloss.Style) string { - if c.EstimatedCostPerHour == nil || *c.EstimatedCostPerHour <= 0 { - return format.YearlyCostPtr(c.EstimatedCostPerHour) - } - - if totalCostPerHour > 0 { - pct := int(math.Round(*c.EstimatedCostPerHour / totalCostPerHour * 100)) - if pct <= 1 { - return muted.Render("≤1%") - } - cost := success.Render(format.YearlyCostPtr(c.EstimatedCostPerHour)) - if pct < 100 { - cost += " " + muted.Render(fmt.Sprintf("(%d%%)", pct)) - } - return cost - } - - return success.Render(format.YearlyCostPtr(c.EstimatedCostPerHour)) -} - -// cursorPrinciple returns the principle text for the currently selected category. -func (m *Model) cursorPrinciple() string { - if m.core.Cursor() < len(m.categories) { - return m.categories[m.core.Cursor()].Principle - } - return "" -} - -// formatObservedSaving returns the observed savings from approved policies. -func formatObservedSaving(s domain.AccountSummary) string { - if s.ObservedCostBefore != nil && s.ObservedCostAfter != nil { - diff := *s.ObservedCostBefore - *s.ObservedCostAfter - if diff > 0 { - return format.YearlyCost(diff) - } - return "" - } - if s.ObservedVolumeBefore != nil && s.ObservedVolumeAfter != nil { - diff := *s.ObservedVolumeBefore - *s.ObservedVolumeAfter - if diff > 0 { - return format.Volume(diff) + " evt/hr" - } - } - return "" -} diff --git a/internal/app/statusbar/waste/waste_test.go b/internal/app/statusbar/waste/waste_test.go deleted file mode 100644 index faf8340d..00000000 --- a/internal/app/statusbar/waste/waste_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package waste - -import ( - "testing" - - "github.com/usetero/cli/internal/app/statusbar/tabpoll" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/sqlite/sqlitetest" - "github.com/usetero/cli/internal/styles" -) - -func TestUpdatePollSourceFiltering(t *testing.T) { - m := New(styles.NewTheme(true), logtest.NewScope(t)) - db := sqlitetest.OpenBareDB(t) - m.SetDB(db) - - if cmd := m.Update(tabpoll.PollMsg{Source: "other"}); cmd != nil { - t.Fatalf("expected foreign poll source to be ignored") - } - - if cmd := m.Update(tabpoll.PollMsg{Source: pollSource}); cmd == nil { - t.Fatalf("expected own poll source to schedule fetch") - } -} diff --git a/internal/app/update_routing.go b/internal/app/update_routing.go index 227a2a4b..20e3623e 100644 --- a/internal/app/update_routing.go +++ b/internal/app/update_routing.go @@ -1,12 +1,9 @@ package app import ( - "strings" - "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" appevents "github.com/usetero/cli/internal/app/events" "github.com/usetero/cli/internal/tea/keymap" ) @@ -46,16 +43,6 @@ func (m *Model) handleInteractionMessage(msg tea.Msg) (tea.Cmd, bool) { } // Let downstream components observe mouse messages. return nil, false - case appevents.DrawerPromptRequested: - m.statusBar.CloseDrawer() - return func() tea.Msg { return msgs.UserSubmittedInput{Text: msg.Text} }, true - case msgs.UserSubmittedInput: - text := strings.TrimSpace(msg.Text) - if strings.EqualFold(text, "exit") || strings.EqualFold(text, "quit") { - m.quitDlg = newQuitDialog(m.theme) - return nil, true - } - return nil, false default: return nil, false } @@ -87,12 +74,6 @@ func (m *Model) handleKeyPress(msg tea.KeyPressMsg) (tea.Cmd, bool) { m.statusBar.CloseDrawer() return nil, true } - // Esc cancels active round first; only show dialog if nothing to cancel. - if m.chat != nil { - if cancelled, cmd := m.chat.CancelActiveRound(); cancelled { - return cmd, true - } - } m.quitDlg = newQuitDialog(m.theme) return nil, true } @@ -134,9 +115,9 @@ func (m *Model) updateChildren(msg tea.Msg) tea.Cmd { if m.onboarding != nil { cmds = append(cmds, m.onboarding.Update(msg)) } - case stateChat: - if m.chat != nil { - cmds = append(cmds, m.chat.Update(msg)) + case stateExplorer: + if m.explorer != nil { + cmds = append(cmds, m.explorer.Update(msg)) } } diff --git a/internal/app/view_overlay_test.go b/internal/app/view_overlay_test.go index f7243c30..838206c5 100644 --- a/internal/app/view_overlay_test.go +++ b/internal/app/view_overlay_test.go @@ -2,7 +2,6 @@ package app import ( "context" - "path/filepath" "strings" "testing" @@ -11,7 +10,6 @@ import ( "github.com/usetero/cli/internal/boundary/graphql/apitest" "github.com/usetero/cli/internal/config" "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/powersynctest" "github.com/usetero/cli/internal/preferences/preferencestest" "github.com/usetero/cli/internal/styles" ) @@ -77,7 +75,6 @@ func newViewTestModel(t *testing.T) *Model { userPrefs := preferencestest.NewMockUserPreferences() orgPrefs := preferencestest.NewMockOrgPreferences() authSvc := &authtest.MockAuth{} - syncer := powersynctest.NewMockSyncer() m := New( context.Background(), @@ -91,8 +88,6 @@ func newViewTestModel(t *testing.T) *Model { authSvc, userPrefs, orgPrefs, - testStorage{dbPath: filepath.Join(t.TempDir(), "view-test.sqlite")}, - syncer, scope, ) m.width = 120 diff --git a/internal/architecture/dependencies_test.go b/internal/architecture/dependencies_test.go index 42924d18..4f10286e 100644 --- a/internal/architecture/dependencies_test.go +++ b/internal/architecture/dependencies_test.go @@ -22,34 +22,15 @@ func TestDependencyBoundaries(t *testing.T) { } graphqlRoot := filepath.Join(root, "internal", "boundary", "graphql") - chatBoundaryRoot := filepath.Join(root, "internal", "boundary", "chat") - powersyncBoundaryRoot := filepath.Join(root, "internal", "boundary", "powersync") coreRoot := filepath.Join(root, "internal", "core") - chatRoot := filepath.Join(root, "internal", "app", "chat") assertNoForbiddenImports(t, graphqlRoot, []string{ "github.com/usetero/cli/internal/app/", }) - assertNoForbiddenImports(t, chatBoundaryRoot, []string{ - "github.com/usetero/cli/internal/app/", - }) - assertNoForbiddenImports(t, powersyncBoundaryRoot, []string{ - "github.com/usetero/cli/internal/app/", - }) assertNoForbiddenImports(t, coreRoot, []string{ "github.com/usetero/cli/internal/app/", "github.com/usetero/cli/internal/boundary/graphql/", }) - assertNoForbiddenImportsExcept(t, chatRoot, []string{ - "github.com/usetero/cli/internal/boundary/chat", - }, []string{ - filepath.Join("internal", "app", "chat", "messagelist", "messagelisttest"), - filepath.Join("internal", "app", "chat", "usecase"), - }) - assertOnlyAllowedChatBoundaryImports(t, chatRoot, []string{ - filepath.Join("internal", "app", "chat", "messagelist", "messagelisttest"), - filepath.Join("internal", "app", "chat", "usecase"), - }) } func findModuleRoot(start string) (string, error) { @@ -99,109 +80,3 @@ func assertNoForbiddenImports(t *testing.T, dir string, forbiddenPrefixes []stri t.Fatalf("walk %s: %v", dir, err) } } - -func assertNoForbiddenImportsExcept(t *testing.T, dir string, forbiddenPrefixes []string, allowedRelPaths []string) { - t.Helper() - - root, err := findModuleRoot(dir) - if err != nil { - t.Fatalf("find module root: %v", err) - } - - fs := token.NewFileSet() - err = filepath.WalkDir(dir, func(path string, d os.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if d.IsDir() { - return nil - } - if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { - return nil - } - - rel, err := filepath.Rel(root, path) - if err != nil { - return err - } - - f, err := parser.ParseFile(fs, path, nil, parser.ImportsOnly) - if err != nil { - return err - } - for _, imp := range f.Imports { - p := strings.Trim(imp.Path.Value, `"`) - for _, prefix := range forbiddenPrefixes { - if !strings.HasPrefix(p, prefix) { - continue - } - allowed := false - for _, allow := range allowedRelPaths { - if rel == allow || strings.HasPrefix(rel, allow+string(filepath.Separator)) { - allowed = true - break - } - } - if !allowed { - t.Errorf("%s imports forbidden package %s", path, p) - } - } - } - return nil - }) - if err != nil { - t.Fatalf("walk %s: %v", dir, err) - } -} - -func assertOnlyAllowedChatBoundaryImports(t *testing.T, dir string, allowedRelPaths []string) { - t.Helper() - - root, err := findModuleRoot(dir) - if err != nil { - t.Fatalf("find module root: %v", err) - } - - fs := token.NewFileSet() - err = filepath.WalkDir(dir, func(path string, d os.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if d.IsDir() { - return nil - } - if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { - return nil - } - - rel, err := filepath.Rel(root, path) - if err != nil { - return err - } - - f, err := parser.ParseFile(fs, path, nil, parser.ImportsOnly) - if err != nil { - return err - } - for _, imp := range f.Imports { - p := strings.Trim(imp.Path.Value, `"`) - if !strings.HasPrefix(p, "github.com/usetero/cli/internal/boundary/chat") { - continue - } - allowed := false - for _, allow := range allowedRelPaths { - if rel == allow || strings.HasPrefix(rel, allow+string(filepath.Separator)) { - allowed = true - break - } - } - if !allowed { - t.Errorf("%s imports boundary/chat outside allowed boundary", path) - } - } - return nil - }) - if err != nil { - t.Fatalf("walk %s: %v", dir, err) - } -} diff --git a/internal/boundary/chat/AGENTS.md b/internal/boundary/chat/AGENTS.md deleted file mode 100644 index 989786e3..00000000 --- a/internal/boundary/chat/AGENTS.md +++ /dev/null @@ -1,23 +0,0 @@ -# Chat Core - -Owns stream protocol semantics and snapshot reduction. - -## Rules - -1. Preserve event ordering guarantees (`seq` monotonic per turn). -2. Keep stream reduction logic pure and testable. -3. Expose terminal semantics explicitly (`completed`, `tool_use`, `aborted`, `failed`). -4. Do not leak transport quirks into app-layer orchestration. - -## Implementation Guidance - -1. Put transition policy in reducers. -2. Keep client streaming glue thin around reducers/snapshots. -3. Include turn/conversation identifiers in stream envelope fields where available. - -## Required Tests for Changes - -1. Reducer transition tests for new lifecycle paths. -2. Ordering/scoping regression tests. -3. Aborted/cancelled terminal behavior tests. - diff --git a/internal/boundary/chat/chattest/mock_client.go b/internal/boundary/chat/chattest/mock_client.go deleted file mode 100644 index 95286531..00000000 --- a/internal/boundary/chat/chattest/mock_client.go +++ /dev/null @@ -1,63 +0,0 @@ -package chattest - -import ( - "context" - - "github.com/usetero/cli/internal/boundary/chat" - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" -) - -// MockClient is a mock implementation of chat.Client for testing. -type MockClient struct { - StreamFunc func(ctx context.Context, req chat.Request, onMessage func(*domain.Message)) (*corechat.StreamResult, error) - StreamSnapshotsFunc func(ctx context.Context, req chat.Request, onSnapshot func(corechat.StreamSnapshot)) (*corechat.StreamResult, error) - SetAccountFunc func(accountID domain.AccountID) - WithAccountFunc func(accountID domain.AccountID) chat.Client -} - -var _ chat.Client = (*MockClient)(nil) - -func (m *MockClient) Stream(ctx context.Context, req chat.Request, onMessage func(*domain.Message)) (*corechat.StreamResult, error) { - if m.StreamFunc != nil { - return m.StreamFunc(ctx, req, onMessage) - } - if m.StreamSnapshotsFunc != nil { - return m.StreamSnapshots(ctx, req, func(s corechat.StreamSnapshot) { - if onMessage != nil { - onMessage(s.Message) - } - }) - } - return &corechat.StreamResult{}, nil -} - -func (m *MockClient) StreamSnapshots(ctx context.Context, req chat.Request, onSnapshot func(corechat.StreamSnapshot)) (*corechat.StreamResult, error) { - if m.StreamSnapshotsFunc != nil { - return m.StreamSnapshotsFunc(ctx, req, onSnapshot) - } - if m.StreamFunc != nil { - return m.StreamFunc(ctx, req, func(msg *domain.Message) { - if onSnapshot != nil { - onSnapshot(corechat.StreamSnapshot{Message: msg, Status: corechat.StreamStatusStreaming}) - } - }) - } - return &corechat.StreamResult{}, nil -} - -func (m *MockClient) SetAccountID(accountID domain.AccountID) { - if m.SetAccountFunc != nil { - m.SetAccountFunc(accountID) - } -} - -func (m *MockClient) WithAccountID(accountID domain.AccountID) chat.Client { - if m.WithAccountFunc != nil { - return m.WithAccountFunc(accountID) - } - if m.SetAccountFunc != nil { - m.SetAccountFunc(accountID) - } - return m -} diff --git a/internal/boundary/chat/client.go b/internal/boundary/chat/client.go deleted file mode 100644 index defc6287..00000000 --- a/internal/boundary/chat/client.go +++ /dev/null @@ -1,383 +0,0 @@ -// Package chat provides a client for the stateless Chat API. -// -// The Chat API is a pure function: f(messages, context) → stream of blocks. -// It doesn't read or write messages to any database. The client sends the -// full conversation history on every request and receives a streamed response. -// -// Message persistence is handled separately by the caller. -package chat - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strings" - "sync" - "time" - - "github.com/hashicorp/go-retryablehttp" - "github.com/usetero/cli/internal/auth" - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log" -) - -const ( - retryMax = 3 - retryWaitMin = 100 * time.Millisecond - retryWaitMax = 2 * time.Second - defaultStreamTimeout = 10 * time.Minute - maxErrorBodyPreview = 256 -) - -// HTTPDoer is the interface for making HTTP requests. -type HTTPDoer interface { - Do(req *http.Request) (*http.Response, error) -} - -// Client sends messages to the Chat API and streams responses. -type Client interface { - // StreamSnapshots sends the conversation to the Chat API and streams normalized snapshots. - // Each snapshot includes scoped progress metadata (conversation/turn/seq/status). - StreamSnapshots(ctx context.Context, req Request, onSnapshot func(corechat.StreamSnapshot)) (*corechat.StreamResult, error) - - // Stream sends the conversation to the Chat API and streams the response. - // The onMessage callback is called each time the message is updated with new content. - // The returned StreamResult contains the final message and any metadata. - Stream(ctx context.Context, req Request, onMessage func(*domain.Message)) (*corechat.StreamResult, error) - - // SetAccountID sets the account ID for requests. - SetAccountID(accountID domain.AccountID) - // WithAccountID returns a new client scoped to accountID. - WithAccountID(accountID domain.AccountID) Client -} - -// client is the concrete implementation of Client. -type client struct { - endpoint string - httpClient HTTPDoer - auth auth.Auth - accountID domain.AccountID - mu sync.RWMutex - scope log.Scope - globalTools []Tool -} - -// Ensure client implements Client. -var _ Client = (*client)(nil) - -// NewClient creates a new Chat API client. -// - globalTools are included in every request automatically -// - Retries transient errors (connection reset, 502/503/504) up to 3 times with backoff -// - Gets a fresh token via auth.GetAccessToken before each request -func NewClient(endpoint string, authService auth.Auth, scope log.Scope, globalTools []Tool) Client { - retryClient := retryablehttp.NewClient() - retryClient.RetryMax = retryMax - retryClient.RetryWaitMin = retryWaitMin - retryClient.RetryWaitMax = retryWaitMax - retryClient.Logger = nil - - return &client{ - endpoint: strings.TrimSuffix(endpoint, "/"), - httpClient: retryClient.StandardClient(), - auth: authService, - scope: scope.Child("chat"), - globalTools: globalTools, - } -} - -// NewClientWithHTTP creates a new Chat API client with a custom HTTP client (for testing). -func NewClientWithHTTP(endpoint string, authService auth.Auth, httpClient HTTPDoer, scope log.Scope, globalTools []Tool) Client { - return &client{ - endpoint: strings.TrimSuffix(endpoint, "/"), - httpClient: httpClient, - auth: authService, - scope: scope.Child("chat"), - globalTools: globalTools, - } -} - -// SetAccountID sets the account ID for requests. -func (c *client) SetAccountID(accountID domain.AccountID) { - c.mu.Lock() - defer c.mu.Unlock() - c.accountID = accountID -} - -// WithAccountID returns a copy scoped to the given account. -func (c *client) WithAccountID(accountID domain.AccountID) Client { - return &client{ - endpoint: c.endpoint, - httpClient: c.httpClient, - auth: c.auth, - accountID: accountID, - scope: c.scope, - globalTools: append([]Tool(nil), c.globalTools...), - } -} - -// Stream sends the conversation to the Chat API and streams the response. -// The onMessage callback is called each time the message is updated with new content. -// Global tools are automatically merged with any request-specific tools. -func (c *client) Stream(ctx context.Context, req Request, onMessage func(*domain.Message)) (*corechat.StreamResult, error) { - return c.StreamSnapshots(ctx, req, func(s corechat.StreamSnapshot) { - if onMessage != nil { - onMessage(s.Message) - } - }) -} - -// StreamSnapshots sends the conversation to the Chat API and streams normalized snapshots. -// Global tools are automatically merged with any request-specific tools. -func (c *client) StreamSnapshots(ctx context.Context, req Request, onSnapshot func(corechat.StreamSnapshot)) (*corechat.StreamResult, error) { - ctx, cancel := withDefaultTimeout(ctx, defaultStreamTimeout) - defer cancel() - - // Merge global tools with request-specific tools - allTools := append(append([]Tool(nil), c.globalTools...), req.Tools...) - if err := validateTools(allTools); err != nil { - return nil, fmt.Errorf("validate tools: %w", err) - } - req.Tools = allTools - - url := c.endpoint + "/api/chat/v2/messages" - accountID := c.accountIDSnapshot() - - c.scope.Debug("sending to chat API", - log.String("url", url), - log.String("conversation_id", req.ConversationID), - log.Int("message_count", len(req.Messages)), - log.Int("context_count", len(req.ContextEntities)), - log.Int("tool_count", len(req.Tools)), - ) - for _, summary := range summarizeRequestMessages(req.Messages) { - c.scope.Debug("chat request message", "summary", summary) - } - c.scope.Debug("chat request tool lineage", "summary", summarizeToolLineage(req.Messages)) - - // Get fresh token for this request - token, err := c.auth.GetAccessToken(ctx) - if err != nil { - c.scope.Error("failed to get access token", "error", err) - return nil, fmt.Errorf("get access token: %w", err) - } - - // Log token metadata for debugging auth issues - if claims, parseErr := parseTokenAudience(token); parseErr == nil { - c.scope.Debug("token acquired", - log.String("sub", claims.sub), - log.String("aud", strings.Join(claims.aud, ", ")), - log.String("org_id", claims.orgID), - log.Bool("expired", claims.expired), - ) - } - - wireReq, err := toWireRequest(req) - if err != nil { - return nil, fmt.Errorf("build wire request: %w", err) - } - - body, err := json.Marshal(wireReq) - if err != nil { - return nil, fmt.Errorf("marshal request: %w", err) - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) - if err != nil { - return nil, fmt.Errorf("create request: %w", err) - } - - c.setHeaders(httpReq, token, accountID) - - resp, err := c.httpClient.Do(httpReq) - if err != nil { - c.scope.Error("request failed", "error", err, "url", url) - return nil, fmt.Errorf("send request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - respBody, _ := io.ReadAll(resp.Body) - bodyPreview, bodyHash, bodyTruncated := summarizeErrorBody(respBody) - c.scope.Error("chat API returned error", - log.Int("status", resp.StatusCode), - log.String("url", url), - log.String("body_preview", bodyPreview), - log.String("body_sha256", bodyHash), - log.Int("body_bytes", len(respBody)), - log.Bool("body_truncated", bodyTruncated), - log.String("account_id", accountID.String()), - ) - return nil, fmt.Errorf("chat API error %d: %s", resp.StatusCode, bodyPreview) - } - - contentType := resp.Header.Get("Content-Type") - if !strings.HasPrefix(contentType, "text/event-stream") { - respBody, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("expected text/event-stream, got %s: %s", contentType, string(respBody)) - } - - // Build typed stream snapshots via deterministic core stream machine. - streamMachine := corechat.NewStreamMachine(req.ConversationID) - - err = readStream(resp.Body, func(data []byte, done bool) error { - var ( - snap *corechat.StreamSnapshot - err error - ) - if done { - snap, err = streamMachine.ConsumeDone() - } else { - snap, err = streamMachine.ConsumeData(data) - } - if err != nil { - return err - } - if onSnapshot != nil { - onSnapshot(*snap) - } - return nil - }) - if err != nil { - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - reason := err.Error() - if cause := context.Cause(ctx); cause != nil { - reason = cause.Error() - } - if onSnapshot != nil { - if snap := streamMachine.Abort(reason); snap != nil { - onSnapshot(*snap) - } - } - return &corechat.StreamResult{ - Message: streamMachine.Message(), - Metadata: streamMachine.Metadata(), - }, nil - } - return nil, err - } - - result := &corechat.StreamResult{ - Message: streamMachine.Message(), - } - result.Metadata = streamMachine.Metadata() - - return result, nil -} - -func (c *client) setHeaders(req *http.Request, token string, accountID domain.AccountID) { - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "text/event-stream") - req.Header.Set("Authorization", "Bearer "+token) - if accountID != "" { - req.Header.Set("X-Account-ID", accountID.String()) - } -} - -func (c *client) accountIDSnapshot() domain.AccountID { - c.mu.RLock() - defer c.mu.RUnlock() - return c.accountID -} - -func withDefaultTimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { - if _, ok := ctx.Deadline(); ok { - return ctx, func() {} - } - return context.WithTimeout(ctx, timeout) -} - -// tokenClaims holds the subset of JWT claims we log for debugging. -type tokenClaims struct { - sub string - aud []string - orgID string - expired bool -} - -// parseTokenAudience extracts key claims from a JWT without signature verification. -func parseTokenAudience(token string) (tokenClaims, error) { - parts := strings.SplitN(token, ".", 3) - if len(parts) != 3 { - return tokenClaims{}, fmt.Errorf("invalid JWT format") - } - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return tokenClaims{}, err - } - var raw struct { - Sub string `json:"sub"` - Aud []string `json:"aud"` - OrgID string `json:"org_id"` - Exp int64 `json:"exp"` - } - if err := json.Unmarshal(payload, &raw); err != nil { - return tokenClaims{}, err - } - return tokenClaims{ - sub: raw.Sub, - aud: raw.Aud, - orgID: raw.OrgID, - expired: raw.Exp > 0 && time.Now().Unix() > raw.Exp, - }, nil -} - -func summarizeRequestMessages(messages []domain.Message) []string { - out := make([]string, 0, len(messages)) - for _, msg := range messages { - blockKinds := make([]string, 0, len(msg.Content)) - for _, b := range msg.Content { - blockKinds = append(blockKinds, string(b.Type)) - } - out = append(out, fmt.Sprintf( - "id=%s role=%s stop_reason=%s blocks=%d kinds=%s", - msg.ID, - msg.Role, - msg.StopReason, - len(msg.Content), - strings.Join(blockKinds, ","), - )) - } - return out -} - -func summarizeToolLineage(messages []domain.Message) string { - toolUses := make([]string, 0) - toolResults := make([]string, 0) - for _, msg := range messages { - for _, b := range msg.Content { - if b.ToolUse != nil && b.ToolUse.ID != "" { - toolUses = append(toolUses, b.ToolUse.ID) - } - if b.ToolResult != nil && b.ToolResult.ToolUseID != "" { - toolResults = append(toolResults, b.ToolResult.ToolUseID) - } - } - } - return fmt.Sprintf("tool_use_ids=[%s] tool_result_ids=[%s]", strings.Join(toolUses, ","), strings.Join(toolResults, ",")) -} - -func summarizeErrorBody(body []byte) (preview, sha string, truncated bool) { - sum := sha256.Sum256(body) - sha = hex.EncodeToString(sum[:]) - - if len(body) == 0 { - return "(empty)", sha, false - } - text := strings.TrimSpace(string(body)) - text = strings.Join(strings.Fields(text), " ") - if text == "" { - return "(empty)", sha, false - } - if len(text) <= maxErrorBodyPreview { - return text, sha, false - } - return text[:maxErrorBodyPreview] + "...", sha, true -} diff --git a/internal/boundary/chat/client_test.go b/internal/boundary/chat/client_test.go deleted file mode 100644 index 1bd3ad0b..00000000 --- a/internal/boundary/chat/client_test.go +++ /dev/null @@ -1,832 +0,0 @@ -package chat_test - -import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "strings" - "testing" - - "github.com/usetero/cli/internal/auth/authtest" - "github.com/usetero/cli/internal/boundary/chat" - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log/logtest" -) - -type mockHTTPClient struct { - doFunc func(req *http.Request) (*http.Response, error) -} - -func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) { - return m.doFunc(req) -} - -type blockingReadCloser struct { - ctx context.Context -} - -func (b *blockingReadCloser) Read(_ []byte) (int, error) { - <-b.ctx.Done() - return 0, b.ctx.Err() -} - -func (b *blockingReadCloser) Close() error { return nil } - -const minimalValidStream = `data: {"chat_stream_version":"v2","type":"message_start","message_start":{"model":"claude-3","context_window":200000}} -data: {"chat_stream_version":"v2","type":"message_stop","message_stop":{"stop_reason":"end_turn","input_tokens":10,"output_tokens":2}} -data: [DONE] -` - -func validRequest() chat.Request { - return chat.Request{ - ConversationID: "00000000-0000-0000-0000-000000000001", - Messages: []domain.Message{{ - Role: domain.RoleUser, - Content: []domain.Block{{ - Type: domain.BlockTypeText, - Text: &domain.TextBlock{Content: "hello"}, - }}, - }}, - } -} - -func TestClient_Stream(t *testing.T) { - t.Parallel() - - t.Run("sends request with correct headers", func(t *testing.T) { - t.Parallel() - - var capturedReq *http.Request - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - capturedReq = req - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader(minimalValidStream)), - }, nil - }, - } - - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "test-token", nil - }, - } - - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - client.SetAccountID("acc-123") - - _, err := client.Stream(context.Background(), chat.Request{ - ConversationID: "00000000-0000-0000-0000-000000000001", - Messages: []domain.Message{{ - ID: "msg-1", - ConversationID: "00000000-0000-0000-0000-000000000001", - Role: domain.RoleUser, - Content: []domain.Block{{ - Index: 0, - Type: domain.BlockTypeText, - Text: &domain.TextBlock{Content: "hello"}, - }}, - }}, - }, func(msg *domain.Message) {}) - - if err != nil { - t.Fatalf("Stream() error = %v", err) - } - - if capturedReq.Header.Get("Authorization") != "Bearer test-token" { - t.Errorf("Authorization = %q, want %q", capturedReq.Header.Get("Authorization"), "Bearer test-token") - } - if capturedReq.Header.Get("X-Account-ID") != "acc-123" { - t.Errorf("X-Account-ID = %q, want %q", capturedReq.Header.Get("X-Account-ID"), "acc-123") - } - if capturedReq.Header.Get("Content-Type") != "application/json" { - t.Errorf("Content-Type = %q, want %q", capturedReq.Header.Get("Content-Type"), "application/json") - } - if capturedReq.Header.Get("Accept") != "text/event-stream" { - t.Errorf("Accept = %q, want %q", capturedReq.Header.Get("Accept"), "text/event-stream") - } - - body, err := io.ReadAll(capturedReq.Body) - if err != nil { - t.Fatalf("ReadAll(body) error = %v", err) - } - payload := string(body) - forbidden := []string{`"index"`, `"id"`, `"created_at"`} - for _, f := range forbidden { - if strings.Contains(payload, f) { - t.Fatalf("request payload unexpectedly contains %s: %s", f, payload) - } - } - if !strings.Contains(payload, `"chat_protocol_version":"v2"`) { - t.Fatalf("request payload missing chat_protocol_version=v2: %s", payload) - } - if !strings.Contains(payload, `"conversation_id":"00000000-0000-0000-0000-000000000001"`) { - t.Fatalf("request payload missing conversation_id: %s", payload) - } - }) - - t.Run("sends request to correct endpoint", func(t *testing.T) { - t.Parallel() - - var capturedReq *http.Request - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - capturedReq = req - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader(minimalValidStream)), - }, nil - }, - } - - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - - client := chat.NewClientWithHTTP("https://api.example.com/", mockAuth, httpClient, logtest.NewScope(t), nil) - - _, err := client.Stream(context.Background(), validRequest(), func(msg *domain.Message) {}) - if err != nil { - t.Fatalf("Stream() error = %v", err) - } - - want := "https://api.example.com/api/chat/v2/messages" - if capturedReq.URL.String() != want { - t.Errorf("URL = %q, want %q", capturedReq.URL.String(), want) - } - }) - - t.Run("returns error on auth failure", func(t *testing.T) { - t.Parallel() - - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - t.Fatal("HTTP client should not be called when auth fails") - return nil, nil - }, - } - - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "", errors.New("token expired") - }, - } - - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - _, err := client.Stream(context.Background(), validRequest(), func(msg *domain.Message) {}) - if err == nil { - t.Fatal("Stream() expected error, got nil") - } - if !strings.Contains(err.Error(), "access token") { - t.Errorf("error = %q, want to contain 'access token'", err.Error()) - } - }) - - t.Run("returns error on invalid outgoing tool definitions", func(t *testing.T) { - t.Parallel() - - httpCalled := false - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - httpCalled = true - return nil, errors.New("should not send request") - }, - } - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - _, err := client.Stream(context.Background(), chat.Request{ - Tools: []chat.Tool{{ - Name: "", - Description: "bad", - InputSchema: chat.NewObjectSchema(map[string]chat.Property{}, nil), - }}, - }, nil) - if err == nil || !strings.Contains(err.Error(), "validate tools") { - t.Fatalf("error = %v", err) - } - if httpCalled { - t.Fatal("HTTP request should not be sent for invalid tools") - } - }) - - t.Run("returns error on HTTP failure", func(t *testing.T) { - t.Parallel() - - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - return nil, errors.New("connection refused") - }, - } - - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - _, err := client.Stream(context.Background(), validRequest(), func(msg *domain.Message) {}) - if err == nil { - t.Fatal("Stream() expected error, got nil") - } - if !strings.Contains(err.Error(), "connection refused") { - t.Errorf("error = %q, want to contain 'connection refused'", err.Error()) - } - }) - - t.Run("returns error on non-200 status", func(t *testing.T) { - t.Parallel() - - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusUnauthorized, - Body: io.NopCloser(strings.NewReader("invalid token")), - }, nil - }, - } - - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - _, err := client.Stream(context.Background(), validRequest(), func(msg *domain.Message) {}) - if err == nil { - t.Fatal("Stream() expected error, got nil") - } - if !strings.Contains(err.Error(), "401") { - t.Errorf("error = %q, want to contain '401'", err.Error()) - } - }) - - t.Run("non-200 error body in returned error is truncated", func(t *testing.T) { - t.Parallel() - - longBody := "secret-token=abc123 " + strings.Repeat("x", 400) - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusBadRequest, - Body: io.NopCloser(strings.NewReader(longBody)), - }, nil - }, - } - - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - _, err := client.Stream(context.Background(), validRequest(), func(msg *domain.Message) {}) - if err == nil { - t.Fatal("Stream() expected error, got nil") - } - if !strings.Contains(err.Error(), "400") { - t.Fatalf("error = %q, want to contain 400", err.Error()) - } - if strings.Contains(err.Error(), strings.Repeat("x", 300)) { - t.Fatalf("error unexpectedly contains full response body: %q", err.Error()) - } - if !strings.Contains(err.Error(), "secret-token=abc123") { - t.Fatalf("error should include a short preview for debugging: %q", err.Error()) - } - }) - - t.Run("returns error on wrong content type", func(t *testing.T) { - t.Parallel() - - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"application/json"}}, - Body: io.NopCloser(strings.NewReader(`{"chat_stream_version":"v2","error": "something"}`)), - }, nil - }, - } - - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - _, err := client.Stream(context.Background(), validRequest(), func(msg *domain.Message) {}) - if err == nil { - t.Fatal("Stream() expected error, got nil") - } - if !strings.Contains(err.Error(), "text/event-stream") { - t.Errorf("error = %q, want to mention expected content type", err.Error()) - } - }) - - t.Run("builds message from stream and calls onMessage", func(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v2","type":"message_start","message_start":{"model":"claude-3","context_window":200000}} -data: {"chat_stream_version":"v2","type":"text_delta","text":{"content":"Hello"}} -data: {"chat_stream_version":"v2","type":"text_delta","text":{"content":" world"}} -data: {"chat_stream_version":"v2","type":"content_block_stop"} -data: {"chat_stream_version":"v2","type":"message_stop","message_stop":{"stop_reason":"end_turn","input_tokens":10,"output_tokens":2}} -data: [DONE] -` - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader(stream)), - }, nil - }, - } - - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - var messages []*domain.Message - _, err := client.Stream(context.Background(), validRequest(), func(msg *domain.Message) { - // Make a copy since the message is built incrementally - msgCopy := *msg - messages = append(messages, &msgCopy) - }) - - if err != nil { - t.Fatalf("Stream() error = %v", err) - } - - // Should have received multiple updates as the message was built - if len(messages) == 0 { - t.Fatal("expected at least one message callback") - } - - // Last message should have the complete content - lastMsg := messages[len(messages)-1] - if lastMsg.Model != "claude-3" { - t.Errorf("Model = %q, want %q", lastMsg.Model, "claude-3") - } - if lastMsg.StopReason != "end_turn" { - t.Errorf("StopReason = %q, want %q", lastMsg.StopReason, "end_turn") - } - if len(lastMsg.Content) != 1 { - t.Fatalf("Content length = %d, want 1", len(lastMsg.Content)) - } - if lastMsg.Content[0].Type != domain.BlockTypeText { - t.Errorf("Content[0].Type = %q, want %q", lastMsg.Content[0].Type, domain.BlockTypeText) - } - if lastMsg.Content[0].Text.Content != "Hello world" { - t.Errorf("Content[0].Text.Content = %q, want %q", lastMsg.Content[0].Text.Content, "Hello world") - } - }) - - t.Run("accumulates tool use with input deltas", func(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v2","type":"message_start","message_start":{"model":"claude-3","context_window":200000}} -data: {"chat_stream_version":"v2","type":"tool_use","tool_use":{"id":"tool-1","name":"query"}} -data: {"chat_stream_version":"v2","type":"tool_input_delta","tool_use_id":"tool-1","tool_input_delta":"{\"sql\":"} -data: {"chat_stream_version":"v2","type":"tool_input_delta","tool_use_id":"tool-1","tool_input_delta":"\"SELECT 1\"}"} -data: {"chat_stream_version":"v2","type":"content_block_stop","tool_use_id":"tool-1"} -data: {"chat_stream_version":"v2","type":"message_stop","message_stop":{"stop_reason":"tool_use","input_tokens":10,"output_tokens":2}} -data: [DONE] -` - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader(stream)), - }, nil - }, - } - - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - var lastMessage *domain.Message - _, err := client.Stream(context.Background(), validRequest(), func(msg *domain.Message) { - lastMessage = msg - }) - - if err != nil { - t.Fatalf("Stream() error = %v", err) - } - - if lastMessage == nil { - t.Fatal("expected message") - } - if len(lastMessage.Content) != 1 { - t.Fatalf("Content length = %d, want 1", len(lastMessage.Content)) - } - if lastMessage.Content[0].Type != domain.BlockTypeToolUse { - t.Errorf("Content[0].Type = %q, want %q", lastMessage.Content[0].Type, domain.BlockTypeToolUse) - } - if lastMessage.Content[0].ToolUse.ID != "tool-1" { - t.Errorf("ToolUse.ID = %q, want %q", lastMessage.Content[0].ToolUse.ID, "tool-1") - } - if lastMessage.Content[0].ToolUse.Name != "query" { - t.Errorf("ToolUse.Name = %q, want %q", lastMessage.Content[0].ToolUse.Name, "query") - } - expectedInput := `{"sql":"SELECT 1"}` - if string(lastMessage.Content[0].ToolUse.Input) != expectedInput { - t.Errorf("ToolUse.Input = %q, want %q", string(lastMessage.Content[0].ToolUse.Input), expectedInput) - } - }) - - t.Run("handles multiple interleaved tool calls", func(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v2","type":"message_start","message_start":{"model":"claude-3","context_window":200000}} -data: {"chat_stream_version":"v2","type":"tool_use","tool_use":{"id":"tool-a","name":"query"}} -data: {"chat_stream_version":"v2","type":"tool_use","tool_use":{"id":"tool-b","name":"query"}} -data: {"chat_stream_version":"v2","type":"tool_input_delta","tool_use_id":"tool-a","tool_input_delta":"{\"sql\":\"SELECT "} -data: {"chat_stream_version":"v2","type":"tool_input_delta","tool_use_id":"tool-b","tool_input_delta":"{\"sql\":\"SELECT "} -data: {"chat_stream_version":"v2","type":"tool_input_delta","tool_use_id":"tool-a","tool_input_delta":"1\"}"} -data: {"chat_stream_version":"v2","type":"tool_input_delta","tool_use_id":"tool-b","tool_input_delta":"2\"}"} -data: {"chat_stream_version":"v2","type":"content_block_stop","tool_use_id":"tool-b"} -data: {"chat_stream_version":"v2","type":"content_block_stop","tool_use_id":"tool-a"} -data: {"chat_stream_version":"v2","type":"message_stop","message_stop":{"stop_reason":"tool_use","input_tokens":10,"output_tokens":2}} -data: [DONE] -` - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader(stream)), - }, nil - }, - } - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - result, err := client.Stream(context.Background(), validRequest(), nil) - if err != nil { - t.Fatalf("Stream() error = %v", err) - } - if result == nil || result.Message == nil { - t.Fatal("expected stream result message") - } - if len(result.Message.Content) != 2 { - t.Fatalf("content blocks = %d, want 2", len(result.Message.Content)) - } - if string(result.Message.Content[0].ToolUse.Input) != `{"sql":"SELECT 1"}` { - t.Fatalf("tool-a input = %s", string(result.Message.Content[0].ToolUse.Input)) - } - if string(result.Message.Content[1].ToolUse.Input) != `{"sql":"SELECT 2"}` { - t.Fatalf("tool-b input = %s", string(result.Message.Content[1].ToolUse.Input)) - } - }) - - t.Run("fails on mid-stream error frame", func(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v2","type":"message_start","message_start":{"model":"claude-3","context_window":200000}} -data: {"chat_stream_version":"v2","error":"internal error"} -` - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader(stream)), - }, nil - }, - } - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - _, err := client.Stream(context.Background(), validRequest(), nil) - if err == nil || !strings.Contains(err.Error(), "server error: internal error") { - t.Fatalf("error = %v", err) - } - }) - - t.Run("WithAccountID remains stable during concurrent base account switches", func(t *testing.T) { - t.Parallel() - - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - if got := req.Header.Get("X-Account-ID"); got != "acc-scoped" { - return nil, fmt.Errorf("unexpected account header %q", got) - } - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader(minimalValidStream)), - }, nil - }, - } - - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - - base := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - base.SetAccountID("acc-base") - scoped := base.WithAccountID("acc-scoped") - - stop := make(chan struct{}) - done := make(chan struct{}) - go func() { - defer close(done) - for { - select { - case <-stop: - return - default: - base.SetAccountID("acc-a") - base.SetAccountID("acc-b") - } - } - }() - - for i := 0; i < 100; i++ { - if _, err := scoped.Stream(context.Background(), validRequest(), nil); err != nil { - close(stop) - <-done - t.Fatalf("scoped Stream() error at iter %d: %v", i, err) - } - } - - close(stop) - <-done - }) -} - -func TestClient_StreamSnapshots(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v2","conversation_id":"00000000-0000-0000-0000-000000000001","turn_id":"turn-1","seq":1,"type":"message_start","message_start":{"model":"claude-3","context_window":200000}} -data: {"chat_stream_version":"v2","conversation_id":"00000000-0000-0000-0000-000000000001","turn_id":"turn-1","seq":2,"type":"text_delta","text":{"content":"Hello"}} -data: {"chat_stream_version":"v2","conversation_id":"00000000-0000-0000-0000-000000000001","turn_id":"turn-1","seq":3,"type":"message_stop","message_stop":{"stop_reason":"end_turn","input_tokens":10,"output_tokens":2}} -data: [DONE] -` - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader(stream)), - }, nil - }, - } - - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - var snaps []corechat.StreamSnapshot - _, err := client.StreamSnapshots(context.Background(), validRequest(), func(s corechat.StreamSnapshot) { - snaps = append(snaps, s) - }) - if err != nil { - t.Fatalf("StreamSnapshots() error = %v", err) - } - - if len(snaps) == 0 { - t.Fatal("expected at least one snapshot") - } - - last := snaps[len(snaps)-1] - if !last.Done { - t.Fatal("last.Done = false, want true") - } - if last.Status != corechat.StreamStatusCompleted { - t.Fatalf("last.Status = %q, want %q", last.Status, corechat.StreamStatusCompleted) - } - if last.ConversationID != "00000000-0000-0000-0000-000000000001" { - t.Fatalf("last.ConversationID = %q, want 00000000-0000-0000-0000-000000000001", last.ConversationID) - } - if last.TurnID != "turn-1" { - t.Fatalf("last.TurnID = %q, want turn-1", last.TurnID) - } - if last.Seq != 3 { - t.Fatalf("last.Seq = %d, want 3", last.Seq) - } - if last.Metadata == nil { - t.Fatal("last.Metadata = nil, want non-nil") - } -} - -func TestClient_StreamSnapshots_CancelledContextEmitsAbortedSnapshot(t *testing.T) { - t.Parallel() - - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: &blockingReadCloser{ctx: req.Context()}, - }, nil - }, - } - - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - ctx, cancel := context.WithCancelCause(context.Background()) - cancel(errors.New("user_cancelled")) - - var snaps []corechat.StreamSnapshot - result, err := client.StreamSnapshots(ctx, validRequest(), func(s corechat.StreamSnapshot) { - snaps = append(snaps, s) - }) - if err != nil { - t.Fatalf("StreamSnapshots() error = %v", err) - } - if result == nil { - t.Fatal("expected non-nil result on canceled context") - } - if len(snaps) == 0 { - t.Fatal("expected aborted snapshot") - } - - last := snaps[len(snaps)-1] - if !last.Done { - t.Fatal("last.Done = false, want true") - } - if last.Status != corechat.StreamStatusAborted { - t.Fatalf("last.Status = %q, want %q", last.Status, corechat.StreamStatusAborted) - } - if last.AbortReason != "user_cancelled" { - t.Fatalf("last.AbortReason = %q, want user_cancelled", last.AbortReason) - } -} - -func TestClient_StreamSnapshots_RejectsNonMonotonicSeq(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v2","conversation_id":"00000000-0000-0000-0000-000000000001","turn_id":"turn-1","seq":1,"type":"message_start","message_start":{"model":"claude-3","context_window":200000}} -data: {"chat_stream_version":"v2","conversation_id":"00000000-0000-0000-0000-000000000001","turn_id":"turn-1","seq":2,"type":"text_delta","text":{"content":"b"}} -data: {"chat_stream_version":"v2","conversation_id":"00000000-0000-0000-0000-000000000001","turn_id":"turn-1","seq":2,"type":"message_stop","message_stop":{"stop_reason":"end_turn","input_tokens":10,"output_tokens":2}} -` - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader(stream)), - }, nil - }, - } - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - _, err := client.StreamSnapshots(context.Background(), validRequest(), nil) - if err == nil { - t.Fatal("expected error, got nil") - } - if !strings.Contains(err.Error(), "non-monotonic seq") { - t.Fatalf("error = %q, want non-monotonic seq", err.Error()) - } -} - -func TestClient_StreamSnapshots_RejectsTurnMismatch(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v2","conversation_id":"00000000-0000-0000-0000-000000000001","turn_id":"turn-1","seq":1,"type":"message_start","message_start":{"model":"claude-3","context_window":200000}} -data: {"chat_stream_version":"v2","conversation_id":"00000000-0000-0000-0000-000000000001","turn_id":"turn-2","seq":2,"type":"text_delta","text":{"content":"b"}} -` - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader(stream)), - }, nil - }, - } - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - _, err := client.StreamSnapshots(context.Background(), validRequest(), nil) - if err == nil { - t.Fatal("expected error, got nil") - } - if !strings.Contains(err.Error(), "turn_id mismatch") { - t.Fatalf("error = %q, want turn_id mismatch", err.Error()) - } -} - -func TestClient_StreamSnapshots_RejectsMalformedToolOrdering(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v2","conversation_id":"00000000-0000-0000-0000-000000000001","turn_id":"turn-1","seq":1,"type":"message_start","message_start":{"model":"claude-3","context_window":200000}} -data: {"chat_stream_version":"v2","conversation_id":"00000000-0000-0000-0000-000000000001","turn_id":"turn-1","seq":2,"type":"tool_input_delta","tool_use_id":"missing","tool_input_delta":"{}"} -` - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader(stream)), - }, nil - }, - } - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - _, err := client.StreamSnapshots(context.Background(), validRequest(), nil) - if err == nil { - t.Fatal("expected protocol error, got nil") - } - if !strings.Contains(err.Error(), "unknown tool_use_id") { - t.Fatalf("error = %q, want unknown tool_use_id", err.Error()) - } -} - -func TestClient_StreamSnapshots_RejectsDoneBeforeMessageStop(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v2","conversation_id":"00000000-0000-0000-0000-000000000001","turn_id":"turn-1","seq":1,"type":"message_start","message_start":{"model":"claude-3","context_window":200000}} -data: [DONE] -` - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader(stream)), - }, nil - }, - } - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - _, err := client.StreamSnapshots(context.Background(), validRequest(), nil) - if err == nil { - t.Fatal("expected protocol error, got nil") - } - if !strings.Contains(err.Error(), "before message_stop") { - t.Fatalf("error = %q, want before message_stop", err.Error()) - } -} diff --git a/internal/boundary/chat/protocol_mapping.go b/internal/boundary/chat/protocol_mapping.go deleted file mode 100644 index 8d398dd0..00000000 --- a/internal/boundary/chat/protocol_mapping.go +++ /dev/null @@ -1,207 +0,0 @@ -package chat - -import ( - "encoding/json" - "fmt" - - "github.com/usetero/cli/internal/boundary/chat/protocolv2" - "github.com/usetero/cli/internal/domain" -) - -func toWireRequest(req Request) (protocolv2.Request, error) { - messages := make([]protocolv2.Message, 0, len(req.Messages)) - for i, msg := range req.Messages { - wm, err := toWireMessage(msg) - if err != nil { - return protocolv2.Request{}, fmt.Errorf("messages[%d]: %w", i, err) - } - messages = append(messages, wm) - } - - wire := protocolv2.Request{ - ChatProtocolVersion: protocolv2.Version, - ConversationID: req.ConversationID, - Messages: messages, - ContextEntities: toWireContextEntities(req.ContextEntities), - Tools: toWireTools(req.Tools), - } - if err := protocolv2.Validate(wire); err != nil { - return protocolv2.Request{}, err - } - return wire, nil -} - -func toWireMessage(msg domain.Message) (protocolv2.Message, error) { - role, err := mapWireRole(msg.Role) - if err != nil { - return protocolv2.Message{}, err - } - stopReason, err := mapWireStopReason(msg.StopReason) - if err != nil { - return protocolv2.Message{}, err - } - - blocks := make([]protocolv2.Block, 0, len(msg.Content)) - for i, b := range msg.Content { - wb, err := toWireBlock(b) - if err != nil { - return protocolv2.Message{}, fmt.Errorf("content[%d]: %w", i, err) - } - blocks = append(blocks, wb) - } - - return protocolv2.Message{ - Role: role, - Content: blocks, - Model: msg.Model, - StopReason: stopReason, - }, nil -} - -func toWireBlock(b domain.Block) (protocolv2.Block, error) { - blockType, err := mapWireBlockType(b.Type) - if err != nil { - return protocolv2.Block{}, err - } - wb := protocolv2.Block{Type: blockType} - - switch b.Type { - case domain.BlockTypeText: - if b.Text == nil { - return protocolv2.Block{}, fmt.Errorf("text block missing payload") - } - wb.Text = &protocolv2.Text{Content: b.Text.Content} - case domain.BlockTypeThinking: - if b.Thinking == nil { - return protocolv2.Block{}, fmt.Errorf("thinking block missing payload") - } - wb.Thinking = &protocolv2.Thinking{Content: b.Thinking.Content} - case domain.BlockTypeToolUse: - if b.ToolUse == nil { - return protocolv2.Block{}, fmt.Errorf("tool_use block missing payload") - } - wb.ToolUse = &protocolv2.ToolUse{ID: b.ToolUse.ID, Name: b.ToolUse.Name, Input: b.ToolUse.Input} - case domain.BlockTypeToolResult: - if b.ToolResult == nil { - return protocolv2.Block{}, fmt.Errorf("tool_result block missing payload") - } - wb.ToolResult = &protocolv2.ToolResult{ - ToolUseID: b.ToolResult.ToolUseID, - IsError: boolPtr(b.ToolResult.IsError), - Error: b.ToolResult.Error, - Content: sanitizeToolResultContent(b.ToolResult.Content, b.ToolResult.IsError), - } - default: - return protocolv2.Block{}, fmt.Errorf("unsupported block type %q", b.Type) - } - - return wb, nil -} - -func toWireContextEntities(entities []domain.ContextEntity) []protocolv2.ContextEntity { - out := make([]protocolv2.ContextEntity, 0, len(entities)) - for _, entity := range entities { - out = append(out, protocolv2.ContextEntity{ - EntityType: protocolv2.ContextEntityType(entity.EntityType), - EntityID: entity.EntityID, - }) - } - return out -} - -func toWireTools(tools []Tool) []protocolv2.Tool { - out := make([]protocolv2.Tool, 0, len(tools)) - for _, tool := range tools { - out = append(out, protocolv2.Tool{ - Name: tool.Name, - Description: tool.Description, - InputSchema: schemaToMap(tool.InputSchema), - }) - } - return out -} - -func sanitizeToolResultContent(content map[string]any, isError bool) json.RawMessage { - if isError { - return nil - } - - if len(content) == 0 { - return json.RawMessage(`{}`) - } - - out := make(map[string]any, len(content)) - for k, v := range content { - switch k { - case "tool_use_id", "is_error", "error": - continue - default: - out[k] = v - } - } - if len(out) == 0 { - return json.RawMessage(`{}`) - } - encoded, err := json.Marshal(out) - if err != nil { - return json.RawMessage(`{}`) - } - return encoded -} - -func boolPtr(v bool) *bool { - return &v -} - -func schemaToMap(schema Schema) map[string]any { - encoded, err := json.Marshal(schema) - if err != nil { - return map[string]any{} - } - var out map[string]any - if err := json.Unmarshal(encoded, &out); err != nil { - return map[string]any{} - } - return out -} - -func mapWireRole(role domain.Role) (protocolv2.Role, error) { - switch role { - case domain.RoleUser: - return protocolv2.RoleUser, nil - case domain.RoleAssistant: - return protocolv2.RoleAssistant, nil - default: - return "", fmt.Errorf("unsupported role %q", role) - } -} - -func mapWireStopReason(reason string) (*protocolv2.StopReason, error) { - switch reason { - case "": - return nil, nil - case string(protocolv2.StopReasonEndTurn): - v := protocolv2.StopReasonEndTurn - return &v, nil - case string(protocolv2.StopReasonToolUse): - v := protocolv2.StopReasonToolUse - return &v, nil - default: - return nil, fmt.Errorf("unsupported stop_reason %q", reason) - } -} - -func mapWireBlockType(blockType domain.BlockType) (protocolv2.BlockType, error) { - switch blockType { - case domain.BlockTypeText: - return protocolv2.BlockTypeText, nil - case domain.BlockTypeThinking: - return protocolv2.BlockTypeThinking, nil - case domain.BlockTypeToolUse: - return protocolv2.BlockTypeToolUse, nil - case domain.BlockTypeToolResult: - return protocolv2.BlockTypeToolResult, nil - default: - return "", fmt.Errorf("unsupported block type %q", blockType) - } -} diff --git a/internal/boundary/chat/protocol_mapping_test.go b/internal/boundary/chat/protocol_mapping_test.go deleted file mode 100644 index c7bc9699..00000000 --- a/internal/boundary/chat/protocol_mapping_test.go +++ /dev/null @@ -1,268 +0,0 @@ -package chat - -import ( - "encoding/json" - "strings" - "testing" - "time" - - "github.com/usetero/cli/internal/domain" -) - -func TestToWireRequest_StripsInternalFields(t *testing.T) { - t.Parallel() - - req := Request{ - ConversationID: "00000000-0000-0000-0000-000000000001", - Messages: []domain.Message{{ - ID: "msg-1", - ConversationID: "00000000-0000-0000-0000-000000000001", - Role: domain.RoleUser, - CreatedAt: time.Now(), - Content: []domain.Block{{ - Index: 7, - Type: domain.BlockTypeText, - Text: &domain.TextBlock{Content: "hello"}, - }}, - }}, - Tools: []Tool{{ - Name: "query", - Description: "Run SQL", - InputSchema: NewObjectSchema(map[string]Property{"sql": {Type: "string"}}, []string{"sql"}), - }}, - } - - wireReq, err := toWireRequest(req) - if err != nil { - t.Fatalf("toWireRequest() error = %v", err) - } - data, err := json.Marshal(wireReq) - if err != nil { - t.Fatalf("json.Marshal(wireReq) error = %v", err) - } - payload := string(data) - - forbidden := []string{"\"id\"", "\"created_at\"", "\"index\""} - for _, key := range forbidden { - if strings.Contains(payload, key) { - t.Fatalf("payload contains forbidden field %s: %s", key, payload) - } - } - if !strings.Contains(payload, `"chat_protocol_version":"v2"`) { - t.Fatalf("payload missing chat_protocol_version=v2: %s", payload) - } - if !strings.Contains(payload, `"conversation_id":"00000000-0000-0000-0000-000000000001"`) { - t.Fatalf("payload missing conversation_id: %s", payload) - } - if !strings.Contains(payload, `"messages"`) || !strings.Contains(payload, `"content"`) { - t.Fatalf("payload missing expected fields: %s", payload) - } -} - -func TestToWireRequest_RejectsInvalidDomainBlock(t *testing.T) { - t.Parallel() - - _, err := toWireRequest(Request{ - ConversationID: "00000000-0000-0000-0000-000000000001", - Messages: []domain.Message{{ - Role: domain.RoleUser, - Content: []domain.Block{{ - Type: domain.BlockTypeText, - Text: nil, - }}, - }}, - }) - if err == nil { - t.Fatal("expected error, got nil") - } -} - -func TestToWireRequest_RejectsInvalidRole(t *testing.T) { - t.Parallel() - - _, err := toWireRequest(Request{ - ConversationID: "00000000-0000-0000-0000-000000000001", - Messages: []domain.Message{{ - Role: domain.Role("invalid"), - Content: []domain.Block{{ - Type: domain.BlockTypeText, - Text: &domain.TextBlock{Content: "hello"}, - }}, - }}, - }) - if err == nil { - t.Fatal("expected error, got nil") - } -} - -func TestToWireRequest_RejectsInvalidStopReason(t *testing.T) { - t.Parallel() - - _, err := toWireRequest(Request{ - ConversationID: "00000000-0000-0000-0000-000000000001", - Messages: []domain.Message{{ - Role: domain.RoleAssistant, - StopReason: "invalid", - Content: []domain.Block{{ - Type: domain.BlockTypeText, - Text: &domain.TextBlock{Content: "hello"}, - }}, - }}, - }) - if err == nil { - t.Fatal("expected error, got nil") - } -} - -func TestToWireRequest_SanitizesToolResultContent(t *testing.T) { - t.Parallel() - - wireReq, err := toWireRequest(Request{ - ConversationID: "00000000-0000-0000-0000-000000000001", - Messages: []domain.Message{ - { - Role: domain.RoleUser, - Content: []domain.Block{{ - Type: domain.BlockTypeText, - Text: &domain.TextBlock{Content: "run query"}, - }}, - }, - { - Role: domain.RoleAssistant, - Content: []domain.Block{{ - Type: domain.BlockTypeToolUse, - ToolUse: &domain.ToolUse{ - ID: "tool-1", - Name: "query", - Input: json.RawMessage(`{"sql":"select 1"}`), - }, - }}, - }, - { - Role: domain.RoleUser, - Content: []domain.Block{{ - Type: domain.BlockTypeToolResult, - ToolResult: &domain.ToolResult{ - ToolUseID: "tool-1", - Content: map[string]any{ - "tool_use_id": "tool-1", - "rows": []map[string]any{{"id": "svc-1"}}, - }, - }, - }}, - }, - }, - }) - if err != nil { - t.Fatalf("toWireRequest() error = %v", err) - } - if len(wireReq.Messages) != 3 || len(wireReq.Messages[2].Content) != 1 { - t.Fatalf("unexpected wire request shape: %#v", wireReq) - } - content := wireReq.Messages[2].Content[0].ToolResult.Content - var parsed map[string]any - if err := json.Unmarshal(content, &parsed); err != nil { - t.Fatalf("tool_result.content should be JSON object, got error: %v", err) - } - if _, ok := parsed["tool_use_id"]; ok { - t.Fatalf("tool_use_id leaked into tool_result.content: %#v", parsed) - } -} - -func TestToWireRequest_RejectsEmptyMessages(t *testing.T) { - t.Parallel() - - _, err := toWireRequest(Request{ - ConversationID: "00000000-0000-0000-0000-000000000001", - }) - if err == nil || !strings.Contains(err.Error(), `"messages" is required`) { - t.Fatalf("error = %v", err) - } -} - -func TestToWireRequest_RejectsMessageWithEmptyContent(t *testing.T) { - t.Parallel() - - _, err := toWireRequest(Request{ - ConversationID: "00000000-0000-0000-0000-000000000001", - Messages: []domain.Message{{ - Role: domain.RoleUser, - Content: nil, - }}, - }) - if err == nil || !strings.Contains(err.Error(), "content is required") { - t.Fatalf("error = %v", err) - } -} - -func TestToWireRequest_RejectsInvalidContextEntity(t *testing.T) { - t.Parallel() - - _, err := toWireRequest(Request{ - ConversationID: "00000000-0000-0000-0000-000000000001", - Messages: []domain.Message{{ - Role: domain.RoleUser, - Content: []domain.Block{{ - Type: domain.BlockTypeText, - Text: &domain.TextBlock{Content: "hello"}, - }}, - }}, - ContextEntities: []domain.ContextEntity{{ - EntityType: domain.ContextEntityType("policy"), - EntityID: "not-a-uuid", - }}, - }) - if err == nil || !strings.Contains(err.Error(), "invalid entity_type") { - t.Fatalf("error = %v", err) - } -} - -func TestToWireRequest_RejectsUnknownToolUseReference(t *testing.T) { - t.Parallel() - - _, err := toWireRequest(Request{ - ConversationID: "00000000-0000-0000-0000-000000000001", - Messages: []domain.Message{ - { - Role: domain.RoleUser, - Content: []domain.Block{{ - Type: domain.BlockTypeText, - Text: &domain.TextBlock{Content: "hello"}, - }}, - }, - { - Role: domain.RoleAssistant, - Content: []domain.Block{{ - Type: domain.BlockTypeToolUse, - ToolUse: &domain.ToolUse{ - ID: "tool-1", - Name: "query", - Input: json.RawMessage(`{"sql":"select 1"}`), - }, - }}, - }, - { - Role: domain.RoleUser, - Content: []domain.Block{ - { - Type: domain.BlockTypeToolResult, - ToolResult: &domain.ToolResult{ - ToolUseID: "tool-1", - Content: map[string]any{"rows": []any{}}, - }, - }, - { - Type: domain.BlockTypeToolResult, - ToolResult: &domain.ToolResult{ - ToolUseID: "toolu_missing", - Content: map[string]any{"rows": []any{}}, - }, - }, - }, - }, - }, - }) - if err == nil || !strings.Contains(err.Error(), `unknown tool_use_id "toolu_missing"`) { - t.Fatalf("error = %v", err) - } -} diff --git a/internal/boundary/chat/protocolv2/request.go b/internal/boundary/chat/protocolv2/request.go deleted file mode 100644 index b5bc6196..00000000 --- a/internal/boundary/chat/protocolv2/request.go +++ /dev/null @@ -1,325 +0,0 @@ -package protocolv2 - -import ( - "encoding/json" - "fmt" - - "github.com/google/uuid" -) - -const Version = "v2" - -type Role string - -const ( - RoleUser Role = "user" - RoleAssistant Role = "assistant" -) - -type StopReason string - -const ( - StopReasonEndTurn StopReason = "end_turn" - StopReasonToolUse StopReason = "tool_use" -) - -type BlockType string - -const ( - BlockTypeText BlockType = "text" - BlockTypeThinking BlockType = "thinking" - BlockTypeToolUse BlockType = "tool_use" - BlockTypeToolResult BlockType = "tool_result" -) - -type ContextEntityType string - -const ( - ContextEntityTypeService ContextEntityType = "service" - ContextEntityTypeLogEvent ContextEntityType = "log_event" -) - -type Request struct { - ChatProtocolVersion string `json:"chat_protocol_version"` - ConversationID string `json:"conversation_id"` - Messages []Message `json:"messages"` - ContextEntities []ContextEntity `json:"context_entities,omitempty"` - Tools []Tool `json:"tools,omitempty"` -} - -type Message struct { - Role Role `json:"role"` - Content []Block `json:"content"` - Model string `json:"model,omitempty"` - StopReason *StopReason `json:"stop_reason,omitempty"` -} - -type Block struct { - Type BlockType `json:"type"` - Text *Text `json:"text,omitempty"` - Thinking *Thinking `json:"thinking,omitempty"` - ToolUse *ToolUse `json:"tool_use,omitempty"` - ToolResult *ToolResult `json:"tool_result,omitempty"` -} - -type Text struct { - Content string `json:"content"` -} - -type Thinking struct { - Content string `json:"content"` -} - -type ToolUse struct { - ID string `json:"id"` - Name string `json:"name"` - Input json.RawMessage `json:"input"` -} - -type ToolResult struct { - ToolUseID string `json:"tool_use_id"` - IsError *bool `json:"is_error"` - Error string `json:"error,omitempty"` - Content json.RawMessage `json:"content,omitempty"` -} - -type ContextEntity struct { - EntityType ContextEntityType `json:"entity_type"` - EntityID string `json:"entity_id"` -} - -type Tool struct { - Name string `json:"name"` - Description string `json:"description"` - InputSchema map[string]any `json:"input_schema"` -} - -func Validate(req Request) error { - if req.ChatProtocolVersion == "" { - return fmt.Errorf(`"chat_protocol_version" is required`) - } - if req.ChatProtocolVersion != Version { - return fmt.Errorf(`"chat_protocol_version" must be %q`, Version) - } - if req.ConversationID == "" { - return fmt.Errorf(`"conversation_id" is required`) - } - if _, err := uuid.Parse(req.ConversationID); err != nil { - return fmt.Errorf(`"conversation_id" must be a valid UUID`) - } - if len(req.Messages) == 0 { - return fmt.Errorf(`"messages" is required`) - } - - for i, msg := range req.Messages { - if err := validateMessage(msg); err != nil { - return fmt.Errorf("messages[%d]: %w", i, err) - } - } - if err := validateMessageRoleOrder(req.Messages); err != nil { - return err - } - for i, entity := range req.ContextEntities { - if err := validateContextEntity(entity); err != nil { - return fmt.Errorf("context_entities[%d]: %w", i, err) - } - } - if err := validateToolReferences(req.Messages); err != nil { - return err - } - return nil -} - -func validateMessage(msg Message) error { - switch msg.Role { - case RoleUser, RoleAssistant: - default: - return fmt.Errorf("unsupported role %q", msg.Role) - } - if len(msg.Content) == 0 { - return fmt.Errorf("content is required") - } - if msg.StopReason != nil { - switch *msg.StopReason { - case StopReasonEndTurn, StopReasonToolUse: - default: - return fmt.Errorf("unsupported stop_reason %q", *msg.StopReason) - } - } - - for i, block := range msg.Content { - if err := validateBlock(block); err != nil { - return fmt.Errorf("content[%d]: %w", i, err) - } - } - return nil -} - -func validateBlock(block Block) error { - switch block.Type { - case BlockTypeText: - if block.Text == nil { - return fmt.Errorf("text block missing payload") - } - case BlockTypeThinking: - if block.Thinking == nil { - return fmt.Errorf("thinking block missing payload") - } - case BlockTypeToolUse: - if block.ToolUse == nil { - return fmt.Errorf("tool_use block missing payload") - } - if block.ToolUse.ID == "" || block.ToolUse.Name == "" { - return fmt.Errorf("tool_use missing id/name") - } - case BlockTypeToolResult: - if block.ToolResult == nil { - return fmt.Errorf("tool_result block missing payload") - } - if err := validateToolResult(*block.ToolResult); err != nil { - return err - } - default: - return fmt.Errorf("unsupported block type %q", block.Type) - } - return nil -} - -func validateToolResult(result ToolResult) error { - if result.ToolUseID == "" { - return fmt.Errorf("tool_result missing tool_use_id") - } - if result.IsError == nil { - return fmt.Errorf(`tool_result "is_error" is required`) - } - if *result.IsError { - if result.Error == "" { - return fmt.Errorf(`tool_result "error" is required when is_error is true`) - } - if len(result.Content) > 0 { - return fmt.Errorf(`tool_result "content" must be empty when is_error is true`) - } - return nil - } - - if len(result.Content) == 0 { - return fmt.Errorf(`tool_result "content" is required when is_error is false`) - } - var parsed map[string]any - if err := json.Unmarshal(result.Content, &parsed); err != nil { - return fmt.Errorf("tool_result content must be valid JSON object") - } - if _, ok := parsed["tool_use_id"]; ok { - return fmt.Errorf("tool_result.content must not contain tool_use_id") - } - return nil -} - -func validateContextEntity(entity ContextEntity) error { - if entity.EntityType == "" { - return fmt.Errorf(`"entity_type" is required`) - } - if entity.EntityType != ContextEntityTypeService && entity.EntityType != ContextEntityTypeLogEvent { - return fmt.Errorf(`invalid entity_type %q`, entity.EntityType) - } - if entity.EntityID == "" { - return fmt.Errorf(`"entity_id" is required`) - } - if _, err := uuid.Parse(entity.EntityID); err != nil { - return fmt.Errorf(`"entity_id" must be a valid UUID`) - } - return nil -} - -func validateToolReferences(messages []Message) error { - seen := make(map[string]struct{}) - resolved := make(map[string]struct{}) - for i, msg := range messages { - for j, block := range msg.Content { - switch block.Type { - case BlockTypeText, BlockTypeThinking: - // No cross-message linkage requirements. - case BlockTypeToolUse: - id := block.ToolUse.ID - if _, exists := seen[id]; exists { - return fmt.Errorf("messages[%d].content[%d]: duplicate tool_use id %q", i, j, id) - } - seen[id] = struct{}{} - case BlockTypeToolResult: - id := block.ToolResult.ToolUseID - if _, exists := seen[id]; !exists { - return fmt.Errorf("messages[%d].content[%d]: unknown tool_use_id %q", i, j, id) - } - resolved[id] = struct{}{} - } - } - } - for id := range seen { - if _, ok := resolved[id]; !ok { - return fmt.Errorf("tool_use %q is missing a matching tool_result", id) - } - } - return nil -} - -func validateMessageRoleOrder(messages []Message) error { - if len(messages) == 0 { - return nil - } - if messages[0].Role != RoleUser { - return fmt.Errorf("messages[0]: first message must be role=%q", RoleUser) - } - for i := 1; i < len(messages); i++ { - if messages[i].Role == messages[i-1].Role { - return fmt.Errorf("messages[%d]: role %q cannot repeat consecutively", i, messages[i].Role) - } - } - for i, msg := range messages { - toolUseIDs := toolUseIDsInMessage(msg) - if len(toolUseIDs) == 0 { - continue - } - if msg.Role != RoleAssistant { - return fmt.Errorf("messages[%d]: tool_use blocks are only allowed in assistant messages", i) - } - if i+1 >= len(messages) { - return fmt.Errorf("messages[%d]: tool_use requires an immediate following user tool_result message", i) - } - next := messages[i+1] - if next.Role != RoleUser { - return fmt.Errorf("messages[%d]: tool_use must be followed by role=%q", i+1, RoleUser) - } - nextResultIDs := toolResultIDsInMessage(next) - if len(nextResultIDs) == 0 { - return fmt.Errorf("messages[%d]: missing tool_result blocks for prior tool_use", i+1) - } - for _, id := range toolUseIDs { - if _, ok := nextResultIDs[id]; !ok { - return fmt.Errorf("messages[%d]: missing tool_result for tool_use_id %q", i+1, id) - } - } - } - return nil -} - -func toolUseIDsInMessage(msg Message) []string { - ids := make([]string, 0) - for _, block := range msg.Content { - if block.Type != BlockTypeToolUse || block.ToolUse == nil { - continue - } - ids = append(ids, block.ToolUse.ID) - } - return ids -} - -func toolResultIDsInMessage(msg Message) map[string]struct{} { - ids := make(map[string]struct{}) - for _, block := range msg.Content { - if block.Type != BlockTypeToolResult || block.ToolResult == nil { - continue - } - ids[block.ToolResult.ToolUseID] = struct{}{} - } - return ids -} diff --git a/internal/boundary/chat/protocolv2/request_test.go b/internal/boundary/chat/protocolv2/request_test.go deleted file mode 100644 index fa3708ba..00000000 --- a/internal/boundary/chat/protocolv2/request_test.go +++ /dev/null @@ -1,307 +0,0 @@ -package protocolv2 - -import ( - "encoding/json" - "strings" - "testing" -) - -func TestValidate(t *testing.T) { - t.Parallel() - - newValidRequest := func() Request { - ok := false - return Request{ - ChatProtocolVersion: Version, - ConversationID: "00000000-0000-0000-0000-000000000001", - Messages: []Message{ - { - Role: RoleUser, - Content: []Block{ - { - Type: BlockTypeText, - Text: &Text{Content: "run query"}, - }, - }, - }, - { - Role: RoleAssistant, - Content: []Block{ - { - Type: BlockTypeToolUse, - ToolUse: &ToolUse{ - ID: "toolu_1", - Name: "query", - Input: json.RawMessage(`{"sql":"select 1"}`), - }, - }, - }, - }, - { - Role: RoleUser, - Content: []Block{ - { - Type: BlockTypeToolResult, - ToolResult: &ToolResult{ - ToolUseID: "toolu_1", - IsError: &ok, - Content: json.RawMessage(`{"rows":[1]}`), - }, - }, - }, - }, - }, - ContextEntities: []ContextEntity{ - { - EntityType: ContextEntityTypeService, - EntityID: "00000000-0000-0000-0000-000000000002", - }, - }, - } - } - - t.Run("valid request passes", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - if err := Validate(req); err != nil { - t.Fatalf("Validate() error = %v", err) - } - }) - - t.Run("missing protocol version fails", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - req.ChatProtocolVersion = "" - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), `"chat_protocol_version" is required`) { - t.Fatalf("error = %v", err) - } - }) - - t.Run("unknown protocol version fails", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - req.ChatProtocolVersion = "v9" - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), `"chat_protocol_version" must be "v2"`) { - t.Fatalf("error = %v", err) - } - }) - - t.Run("invalid conversation id fails", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - req.ConversationID = "conv-1" - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), `"conversation_id" must be a valid UUID`) { - t.Fatalf("error = %v", err) - } - }) - - t.Run("empty messages fails", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - req.Messages = nil - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), `"messages" is required`) { - t.Fatalf("error = %v", err) - } - }) - - t.Run("empty message content fails", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - req.Messages[0].Content = nil - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), "content is required") { - t.Fatalf("error = %v", err) - } - }) - - t.Run("invalid role fails", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - req.Messages[0].Role = Role("invalid") - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), "unsupported role") { - t.Fatalf("error = %v", err) - } - }) - - t.Run("invalid stop reason fails", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - bad := StopReason("invalid") - req.Messages[0].StopReason = &bad - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), "unsupported stop_reason") { - t.Fatalf("error = %v", err) - } - }) - - t.Run("tool_result requires is_error", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - req.Messages[2].Content[0].ToolResult.IsError = nil - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), `"is_error" is required`) { - t.Fatalf("error = %v", err) - } - }) - - t.Run("tool_result error requires error text and empty content", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - yes := true - req.Messages[2].Content[0].ToolResult.IsError = &yes - req.Messages[2].Content[0].ToolResult.Content = json.RawMessage(`{"rows":[1]}`) - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), `"error" is required when is_error is true`) { - t.Fatalf("error = %v", err) - } - - req.Messages[2].Content[0].ToolResult.Error = "boom" - err = Validate(req) - if err == nil || !strings.Contains(err.Error(), `"content" must be empty when is_error is true`) { - t.Fatalf("error = %v", err) - } - }) - - t.Run("tool_result success requires content and forbids embedded tool_use_id", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - no := false - req.Messages[2].Content[0].ToolResult.IsError = &no - req.Messages[2].Content[0].ToolResult.Content = nil - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), `"content" is required when is_error is false`) { - t.Fatalf("error = %v", err) - } - - req.Messages[2].Content[0].ToolResult.Content = json.RawMessage(`{"tool_use_id":"toolu_1"}`) - err = Validate(req) - if err == nil || !strings.Contains(err.Error(), "must not contain tool_use_id") { - t.Fatalf("error = %v", err) - } - }) - - t.Run("invalid context entity type fails", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - req.ContextEntities[0].EntityType = ContextEntityType("policy") - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), "invalid entity_type") { - t.Fatalf("error = %v", err) - } - }) - - t.Run("invalid context entity id fails", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - req.ContextEntities[0].EntityID = "not-a-uuid" - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), `"entity_id" must be a valid UUID`) { - t.Fatalf("error = %v", err) - } - }) - - t.Run("unknown tool_use_id fails", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - req.Messages[2].Content = append(req.Messages[2].Content, Block{ - Type: BlockTypeToolResult, - ToolResult: &ToolResult{ - ToolUseID: "toolu_missing", - IsError: req.Messages[2].Content[0].ToolResult.IsError, - Content: json.RawMessage(`{"rows":[2]}`), - }, - }) - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), `unknown tool_use_id "toolu_missing"`) { - t.Fatalf("error = %v", err) - } - }) - - t.Run("duplicate tool_use id fails", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - req.Messages = append(req.Messages, Message{ - Role: RoleAssistant, - Content: []Block{{ - Type: BlockTypeToolUse, - ToolUse: &ToolUse{ - ID: "toolu_1", - Name: "query", - Input: json.RawMessage(`{"sql":"select 2"}`), - }, - }}, - }, Message{ - Role: RoleUser, - Content: []Block{{ - Type: BlockTypeToolResult, - ToolResult: &ToolResult{ - ToolUseID: "toolu_1", - IsError: req.Messages[2].Content[0].ToolResult.IsError, - Content: json.RawMessage(`{"rows":[9]}`), - }, - }}, - }) - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), `duplicate tool_use id "toolu_1"`) { - t.Fatalf("error = %v", err) - } - }) - - t.Run("tool_use must be followed by immediate user tool_result message", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - req.Messages = []Message{ - req.Messages[0], - req.Messages[1], - } - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), "tool_use requires an immediate following user tool_result message") { - t.Fatalf("error = %v", err) - } - }) - - t.Run("assistant turn between tool_use and tool_result fails ordering", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - req.Messages = []Message{ - req.Messages[0], - req.Messages[1], - { - Role: RoleAssistant, - Content: []Block{{ - Type: BlockTypeText, - Text: &Text{Content: "intermediate"}, - }}, - }, - req.Messages[2], - } - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), `role "assistant" cannot repeat consecutively`) { - t.Fatalf("error = %v", err) - } - }) - - t.Run("consecutive roles fail", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - req.Messages = []Message{ - req.Messages[0], - { - Role: RoleUser, - Content: []Block{{ - Type: BlockTypeText, - Text: &Text{Content: "again"}, - }}, - }, - } - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), `role "user" cannot repeat consecutively`) { - t.Fatalf("error = %v", err) - } - }) -} diff --git a/internal/boundary/chat/request.go b/internal/boundary/chat/request.go deleted file mode 100644 index f34c23da..00000000 --- a/internal/boundary/chat/request.go +++ /dev/null @@ -1,12 +0,0 @@ -package chat - -import "github.com/usetero/cli/internal/domain" - -// Request is the input to the Chat API. -// The client sends the full conversation history on every request. -type Request struct { - ConversationID string `json:"conversation_id"` - Messages []domain.Message `json:"messages"` - ContextEntities []domain.ContextEntity `json:"context_entities,omitempty"` - Tools []Tool `json:"tools"` -} diff --git a/internal/boundary/chat/stream.go b/internal/boundary/chat/stream.go deleted file mode 100644 index e9d6accd..00000000 --- a/internal/boundary/chat/stream.go +++ /dev/null @@ -1,45 +0,0 @@ -package chat - -import ( - "bufio" - "io" - "strings" -) - -const streamDone = "[DONE]" - -const ( - streamScannerInitialBuffer = 64 * 1024 - streamScannerMaxBuffer = 4 * 1024 * 1024 -) - -type streamDataHandler func(data []byte, done bool) error - -func readStream(r io.Reader, handler streamDataHandler) error { - scanner := bufio.NewScanner(r) - scanner.Buffer(make([]byte, streamScannerInitialBuffer), streamScannerMaxBuffer) - - for scanner.Scan() { - line := scanner.Text() - if line == "" || strings.HasPrefix(line, ":") { - continue - } - if !strings.HasPrefix(line, "data: ") { - continue - } - - data := strings.TrimPrefix(line, "data: ") - if data == streamDone { - if err := handler(nil, true); err != nil { - return err - } - continue - } - - if err := handler([]byte(data), false); err != nil { - return err - } - } - - return scanner.Err() -} diff --git a/internal/boundary/chat/stream_errors.go b/internal/boundary/chat/stream_errors.go deleted file mode 100644 index bbf7e91d..00000000 --- a/internal/boundary/chat/stream_errors.go +++ /dev/null @@ -1,93 +0,0 @@ -package chat - -import ( - "context" - "errors" - "regexp" - "strconv" - "strings" -) - -// StreamErrorClass is a normalized category for stream failures. -type StreamErrorClass string - -const ( - StreamErrorClassCancelled StreamErrorClass = "cancelled" - StreamErrorClassTimeout StreamErrorClass = "timeout" - StreamErrorClassProtocol StreamErrorClass = "protocol_error" - StreamErrorClassRequest StreamErrorClass = "request_error" - StreamErrorClassServer StreamErrorClass = "server_error" - StreamErrorClassUnknown StreamErrorClass = "unknown" -) - -var httpStatusCodePattern = regexp.MustCompile(`\b([1-5][0-9]{2})\b`) - -// ClassifyStreamError maps raw stream errors into stable operational buckets. -func ClassifyStreamError(err error) StreamErrorClass { - if err == nil { - return StreamErrorClassUnknown - } - if errors.Is(err, context.Canceled) { - return StreamErrorClassCancelled - } - if errors.Is(err, context.DeadlineExceeded) { - return StreamErrorClassTimeout - } - - msg := strings.ToLower(err.Error()) - switch { - case strings.Contains(msg, "user_cancelled"), - strings.Contains(msg, "context canceled"), - strings.Contains(msg, "canceled"): - return StreamErrorClassCancelled - case strings.Contains(msg, "deadline exceeded"), - strings.Contains(msg, "timeout"): - return StreamErrorClassTimeout - case strings.Contains(msg, "protocol error"), - strings.Contains(msg, "parse event"): - return StreamErrorClassProtocol - case strings.Contains(msg, "server error:"), - strings.Contains(msg, "chat api error"): - if status, ok := firstHTTPStatusCode(msg); ok { - switch { - case status >= 400 && status <= 499: - return StreamErrorClassRequest - case status >= 500 && status <= 599: - return StreamErrorClassServer - } - } - return StreamErrorClassServer - default: - return StreamErrorClassUnknown - } -} - -func firstHTTPStatusCode(msg string) (int, bool) { - matches := httpStatusCodePattern.FindStringSubmatch(msg) - if len(matches) < 2 { - return 0, false - } - status, err := strconv.Atoi(matches[1]) - if err != nil || status < 100 || status > 599 { - return 0, false - } - return status, true -} - -// UserFacingStreamError returns concise error copy suitable for toast UI. -func UserFacingStreamError(err error) string { - switch ClassifyStreamError(err) { - case StreamErrorClassCancelled: - return "Request was cancelled." - case StreamErrorClassTimeout: - return "The response timed out. Please try again." - case StreamErrorClassProtocol: - return "The chat service returned an unexpected stream format. Please retry." - case StreamErrorClassRequest: - return "The request was rejected by the chat service. Please retry." - case StreamErrorClassServer: - return "The chat service returned an internal error. Please try again." - default: - return "Something went wrong while streaming the response. Please try again." - } -} diff --git a/internal/boundary/chat/stream_errors_test.go b/internal/boundary/chat/stream_errors_test.go deleted file mode 100644 index 4afbb26d..00000000 --- a/internal/boundary/chat/stream_errors_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package chat - -import ( - "context" - "errors" - "testing" -) - -func TestClassifyStreamError(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - err error - want StreamErrorClass - }{ - {name: "context canceled", err: context.Canceled, want: StreamErrorClassCancelled}, - {name: "deadline exceeded", err: context.DeadlineExceeded, want: StreamErrorClassTimeout}, - {name: "user cancelled text", err: errors.New("user_cancelled"), want: StreamErrorClassCancelled}, - {name: "protocol", err: errors.New("protocol error: unknown event type"), want: StreamErrorClassProtocol}, - {name: "parse event", err: errors.New("parse event: bad json"), want: StreamErrorClassProtocol}, - {name: "server", err: errors.New("server error: internal error"), want: StreamErrorClassServer}, - {name: "server 400", err: errors.New("server error: stream: POST https://x: 400 Bad Request"), want: StreamErrorClassRequest}, - {name: "chat api", err: errors.New("chat API error 500"), want: StreamErrorClassServer}, - {name: "chat api 400", err: errors.New("chat API error 400: bad request"), want: StreamErrorClassRequest}, - {name: "unknown", err: errors.New("boom"), want: StreamErrorClassUnknown}, - {name: "nil", err: nil, want: StreamErrorClassUnknown}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - if got := ClassifyStreamError(tt.err); got != tt.want { - t.Fatalf("ClassifyStreamError() = %q, want %q", got, tt.want) - } - }) - } -} - -func TestUserFacingStreamError(t *testing.T) { - t.Parallel() - - if got := UserFacingStreamError(errors.New("chat API error 400: bad request")); got != "The request was rejected by the chat service. Please retry." { - t.Fatalf("400 message = %q", got) - } - if got := UserFacingStreamError(errors.New("chat API error 500: internal")); got != "The chat service returned an internal error. Please try again." { - t.Fatalf("500 message = %q", got) - } -} diff --git a/internal/boundary/chat/stream_test.go b/internal/boundary/chat/stream_test.go deleted file mode 100644 index 90526164..00000000 --- a/internal/boundary/chat/stream_test.go +++ /dev/null @@ -1,215 +0,0 @@ -package chat - -import ( - "errors" - "strings" - "testing" - - corechat "github.com/usetero/cli/internal/core/chat" -) - -func TestReadStream(t *testing.T) { - t.Parallel() - - t.Run("decodes happy-path v2 events", func(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v2","type":"message_start","message_start":{"model":"claude-3","context_window":200000}} -data: {"chat_stream_version":"v2","type":"text_delta","text":{"content":"Hello"}} -data: {"chat_stream_version":"v2","type":"message_stop","message_stop":{"stop_reason":"end_turn","input_tokens":1,"output_tokens":1}} -data: [DONE] -` - machine := corechat.NewStreamMachine("conv-1") - var snapshots []corechat.StreamSnapshot - err := readStream(strings.NewReader(stream), func(data []byte, done bool) error { - var ( - snap *corechat.StreamSnapshot - err error - ) - if done { - snap, err = machine.ConsumeDone() - } else { - snap, err = machine.ConsumeData(data) - } - if err != nil { - return err - } - snapshots = append(snapshots, *snap) - return nil - }) - if err != nil { - t.Fatalf("readStream() error = %v", err) - } - if len(snapshots) != 4 { - t.Fatalf("snapshots = %d, want 4", len(snapshots)) - } - if snapshots[0].Status != corechat.StreamStatusStreaming { - t.Fatalf("snapshot[0].Status = %q", snapshots[0].Status) - } - if snapshots[1].Status != corechat.StreamStatusStreaming { - t.Fatalf("snapshot[1].Status = %q", snapshots[1].Status) - } - if snapshots[2].Status != corechat.StreamStatusStreaming { - t.Fatalf("snapshot[2].Status = %q", snapshots[2].Status) - } - if !snapshots[3].Done { - t.Fatal("snapshot[3].Done = false, want true") - } - }) - - t.Run("stops when handler returns error for error frame", func(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v2","type":"message_start","message_start":{"model":"claude-3","context_window":200000}} -data: {"chat_stream_version":"v2","error":"internal error"} -data: [DONE] -` - calls := 0 - machine := corechat.NewStreamMachine("conv-1") - err := readStream(strings.NewReader(stream), func(data []byte, done bool) error { - calls++ - if done { - _, err := machine.ConsumeDone() - return err - } - _, err := machine.ConsumeData(data) - return err - }) - if err == nil { - t.Fatal("expected error, got nil") - } - if !strings.Contains(err.Error(), "server error: internal error") { - t.Fatalf("error = %q", err.Error()) - } - if calls != 2 { - t.Fatalf("handler calls = %d, want 2", calls) - } - }) - - t.Run("rejects unknown event types", func(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v2","type":"bogus"} -` - machine := corechat.NewStreamMachine("conv-1") - err := readStream(strings.NewReader(stream), func(data []byte, done bool) error { - if done { - _, err := machine.ConsumeDone() - return err - } - _, err := machine.ConsumeData(data) - return err - }) - if err == nil { - t.Fatal("expected error, got nil") - } - if !strings.Contains(err.Error(), "unknown event type") { - t.Fatalf("error = %q", err.Error()) - } - }) - - t.Run("rejects unknown fields", func(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v2","type":"text_delta","text":{"content":"x"},"unexpected":1} -` - machine := corechat.NewStreamMachine("conv-1") - err := readStream(strings.NewReader(stream), func(data []byte, done bool) error { - if done { - _, err := machine.ConsumeDone() - return err - } - _, err := machine.ConsumeData(data) - return err - }) - if err == nil { - t.Fatal("expected error, got nil") - } - if !strings.Contains(err.Error(), "unknown field") { - t.Fatalf("error = %q", err.Error()) - } - }) - - t.Run("rejects missing protocol version", func(t *testing.T) { - t.Parallel() - - stream := `data: {"type":"message_start","message_start":{"model":"claude-3","context_window":200000}} -` - machine := corechat.NewStreamMachine("conv-1") - err := readStream(strings.NewReader(stream), func(data []byte, done bool) error { - if done { - _, err := machine.ConsumeDone() - return err - } - _, err := machine.ConsumeData(data) - return err - }) - if err == nil || !strings.Contains(err.Error(), "chat_stream_version") { - t.Fatalf("error = %v", err) - } - }) - - t.Run("rejects mismatched protocol version", func(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v1","type":"message_start","message_start":{"model":"claude-3","context_window":200000}} -` - machine := corechat.NewStreamMachine("conv-1") - err := readStream(strings.NewReader(stream), func(data []byte, done bool) error { - if done { - _, err := machine.ConsumeDone() - return err - } - _, err := machine.ConsumeData(data) - return err - }) - if err == nil || !strings.Contains(err.Error(), "unsupported chat_stream_version") { - t.Fatalf("error = %v", err) - } - }) - - t.Run("rejects message_stop without token fields", func(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v2","type":"message_stop","message_stop":{"stop_reason":"end_turn"}} -` - machine := corechat.NewStreamMachine("conv-1") - err := readStream(strings.NewReader(stream), func(data []byte, done bool) error { - if done { - _, err := machine.ConsumeDone() - return err - } - _, err := machine.ConsumeData(data) - return err - }) - if err == nil || !strings.Contains(err.Error(), "message_stop missing required fields") { - t.Fatalf("error = %v", err) - } - }) - - t.Run("stops on handler error", func(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v2","type":"message_start","message_start":{"model":"claude-3","context_window":200000}} -data: {"chat_stream_version":"v2","type":"text_delta","text":{"content":"x"}} -` - handlerErr := errors.New("stop") - calls := 0 - machine := corechat.NewStreamMachine("conv-1") - err := readStream(strings.NewReader(stream), func(data []byte, done bool) error { - calls++ - if calls == 2 { - return handlerErr - } - if done { - _, err := machine.ConsumeDone() - return err - } - _, err := machine.ConsumeData(data) - return err - }) - if !errors.Is(err, handlerErr) { - t.Fatalf("error = %v, want %v", err, handlerErr) - } - }) -} diff --git a/internal/boundary/chat/tool.go b/internal/boundary/chat/tool.go deleted file mode 100644 index 0f8f3c5e..00000000 --- a/internal/boundary/chat/tool.go +++ /dev/null @@ -1,53 +0,0 @@ -package chat - -import "encoding/json" - -// Tool defines a tool the AI can call. -// This is the wire format sent to the Chat API. -type Tool struct { - Name string `json:"name"` - Description string `json:"description"` - InputSchema Schema `json:"input_schema"` -} - -// Schema defines the JSON Schema for tool input. -type Schema struct { - Type string `json:"type"` - Properties map[string]Property `json:"properties"` // Always required by Anthropic API - Required []string `json:"required,omitempty"` -} - -// MarshalJSON ensures Properties is never null (Anthropic API requires it). -func (s Schema) MarshalJSON() ([]byte, error) { - type schema Schema // avoid recursion - if s.Properties == nil { - s.Properties = map[string]Property{} - } - return json.Marshal(schema(s)) -} - -// NewObjectSchema creates an object schema with the given properties. -func NewObjectSchema(properties map[string]Property, required []string) Schema { - if properties == nil { - properties = map[string]Property{} - } - return Schema{ - Type: "object", - Properties: properties, - Required: required, - } -} - -// Property defines a single property in a JSON Schema. -type Property struct { - Type string `json:"type,omitempty"` - Description string `json:"description,omitempty"` - Enum []string `json:"enum,omitempty"` - Items *Items `json:"items,omitempty"` -} - -// Items defines the schema for array items. -type Items struct { - Type string `json:"type,omitempty"` - Items *Items `json:"items,omitempty"` -} diff --git a/internal/boundary/chat/tool_validation.go b/internal/boundary/chat/tool_validation.go deleted file mode 100644 index 5b68a321..00000000 --- a/internal/boundary/chat/tool_validation.go +++ /dev/null @@ -1,32 +0,0 @@ -package chat - -import "fmt" - -func validateTools(tools []Tool) error { - seen := make(map[string]struct{}, len(tools)) - for i, tool := range tools { - if tool.Name == "" { - return fmt.Errorf("tools[%d]: name is required", i) - } - if _, exists := seen[tool.Name]; exists { - return fmt.Errorf("tools[%d]: duplicate tool name %q", i, tool.Name) - } - seen[tool.Name] = struct{}{} - - if tool.Description == "" { - return fmt.Errorf("tools[%d]: description is required", i) - } - if tool.InputSchema.Type != "object" { - return fmt.Errorf("tools[%d]: input_schema.type must be object", i) - } - if tool.InputSchema.Properties == nil { - return fmt.Errorf("tools[%d]: input_schema.properties is required", i) - } - for key := range tool.InputSchema.Properties { - if key == "" { - return fmt.Errorf("tools[%d]: property name must not be empty", i) - } - } - } - return nil -} diff --git a/internal/boundary/chat/tool_validation_test.go b/internal/boundary/chat/tool_validation_test.go deleted file mode 100644 index f27d2e54..00000000 --- a/internal/boundary/chat/tool_validation_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package chat - -import "testing" - -func TestValidateTools(t *testing.T) { - t.Parallel() - - valid := []Tool{{ - Name: "query", - Description: "Run SQL", - InputSchema: NewObjectSchema(map[string]Property{"sql": {Type: "string"}}, []string{"sql"}), - }} - if err := validateTools(valid); err != nil { - t.Fatalf("validateTools(valid) error = %v", err) - } - - tests := []struct { - name string - tools []Tool - }{ - {name: "missing name", tools: []Tool{{Description: "d", InputSchema: NewObjectSchema(map[string]Property{}, nil)}}}, - {name: "duplicate name", tools: []Tool{{Name: "q", Description: "d", InputSchema: NewObjectSchema(map[string]Property{}, nil)}, {Name: "q", Description: "d2", InputSchema: NewObjectSchema(map[string]Property{}, nil)}}}, - {name: "missing description", tools: []Tool{{Name: "q", InputSchema: NewObjectSchema(map[string]Property{}, nil)}}}, - {name: "wrong schema type", tools: []Tool{{Name: "q", Description: "d", InputSchema: Schema{Type: "string", Properties: map[string]Property{}}}}}, - {name: "nil properties", tools: []Tool{{Name: "q", Description: "d", InputSchema: Schema{Type: "object"}}}}, - {name: "empty property name", tools: []Tool{{Name: "q", Description: "d", InputSchema: NewObjectSchema(map[string]Property{"": {Type: "string"}}, nil)}}}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - if err := validateTools(tt.tools); err == nil { - t.Fatalf("validateTools(%s): expected error", tt.name) - } - }) - } -} diff --git a/internal/boundary/graphql/account_service.go b/internal/boundary/graphql/account_service.go index 20b178a8..d85cd99d 100644 --- a/internal/boundary/graphql/account_service.go +++ b/internal/boundary/graphql/account_service.go @@ -87,8 +87,7 @@ func (s *AccountService) Get(ctx context.Context, accountID domain.AccountID) (* // Create creates a new account with the given client-provided ID. func (s *AccountService) Create(ctx context.Context, input CreateAccountInput) (*domain.Account, error) { s.scope.Debug("creating account via API", "id", input.ID.String(), "organizationID", input.OrganizationID, "name", input.Name) - genInput := gen.CreateAccountInput{ - Id: ptr(input.ID.String()), + genInput := gen.AccountCreateInput{ OrganizationID: input.OrganizationID.String(), Name: input.Name, } diff --git a/internal/boundary/graphql/account_service_test.go b/internal/boundary/graphql/account_service_test.go index e3c41933..e9b4e7d2 100644 --- a/internal/boundary/graphql/account_service_test.go +++ b/internal/boundary/graphql/account_service_test.go @@ -20,9 +20,9 @@ func TestAccountService_List(t *testing.T) { ListAccountsFunc: func(ctx context.Context, orgID string) (*gen.ListAccountsResponse, error) { return &gen.ListAccountsResponse{ Accounts: gen.ListAccountsAccountsAccountConnection{ - Edges: []*gen.ListAccountsAccountsAccountConnectionEdgesAccountEdge{ - {Node: &gen.ListAccountsAccountsAccountConnectionEdgesAccountEdgeNodeAccount{Id: "acc-1", Name: "Production"}}, - {Node: &gen.ListAccountsAccountsAccountConnectionEdgesAccountEdgeNodeAccount{Id: "acc-2", Name: "Staging"}}, + Edges: []gen.ListAccountsAccountsAccountConnectionEdgesAccountEdge{ + {Node: gen.ListAccountsAccountsAccountConnectionEdgesAccountEdgeNodeAccount{Id: "acc-1", Name: "Production"}}, + {Node: gen.ListAccountsAccountsAccountConnectionEdgesAccountEdgeNodeAccount{Id: "acc-2", Name: "Staging"}}, }, }, }, nil @@ -52,7 +52,7 @@ func TestAccountService_List(t *testing.T) { ListAccountsFunc: func(ctx context.Context, orgID string) (*gen.ListAccountsResponse, error) { return &gen.ListAccountsResponse{ Accounts: gen.ListAccountsAccountsAccountConnection{ - Edges: []*gen.ListAccountsAccountsAccountConnectionEdgesAccountEdge{}, + Edges: []gen.ListAccountsAccountsAccountConnectionEdgesAccountEdge{}, }, }, nil }, @@ -93,9 +93,9 @@ func TestAccountService_Create(t *testing.T) { t.Parallel() t.Run("creates account and returns domain model", func(t *testing.T) { t.Parallel() - var capturedInput gen.CreateAccountInput + var capturedInput gen.AccountCreateInput mockClient := &apitest.MockClient{ - CreateAccountFunc: func(ctx context.Context, input gen.CreateAccountInput) (*gen.CreateAccountResponse, error) { + CreateAccountFunc: func(ctx context.Context, input gen.AccountCreateInput) (*gen.CreateAccountResponse, error) { capturedInput = input return &gen.CreateAccountResponse{ CreateAccount: gen.CreateAccountCreateAccount{ @@ -127,7 +127,7 @@ func TestAccountService_Create(t *testing.T) { t.Run("propagates client errors", func(t *testing.T) { t.Parallel() mockClient := &apitest.MockClient{ - CreateAccountFunc: func(ctx context.Context, input gen.CreateAccountInput) (*gen.CreateAccountResponse, error) { + CreateAccountFunc: func(ctx context.Context, input gen.AccountCreateInput) (*gen.CreateAccountResponse, error) { return nil, errors.New("validation error") }, } diff --git a/internal/boundary/graphql/apitest/factories.go b/internal/boundary/graphql/apitest/factories.go index 42699e43..cfbed1ef 100644 --- a/internal/boundary/graphql/apitest/factories.go +++ b/internal/boundary/graphql/apitest/factories.go @@ -29,16 +29,3 @@ func NewAccount(opts ...func(*domain.Account)) domain.Account { } return acc } - -// NewWorkspace creates a test workspace with sensible defaults. -// Use functional options to override specific fields. -func NewWorkspace(opts ...func(*domain.Workspace)) domain.Workspace { - ws := domain.Workspace{ - ID: domain.NewWorkspaceID(), - Name: "Test Workspace", - } - for _, opt := range opts { - opt(&ws) - } - return ws -} diff --git a/internal/boundary/graphql/apitest/mock_client.go b/internal/boundary/graphql/apitest/mock_client.go index 80f7f0bb..78db89fc 100644 --- a/internal/boundary/graphql/apitest/mock_client.go +++ b/internal/boundary/graphql/apitest/mock_client.go @@ -14,22 +14,22 @@ type MockClient struct { WithAccountIDFunc func(accountID domain.AccountID) graphql.Client RawQueryFunc func(ctx context.Context, query string, variables map[string]interface{}) (map[string]interface{}, error) ListOrganizationsFunc func(ctx context.Context) (*gen.ListOrganizationsResponse, error) - CreateOrganizationAndBootstrapFunc func(ctx context.Context, input gen.CreateOrganizationInput) (*gen.CreateOrganizationAndBootstrapResponse, error) + CreateOrganizationAndBootstrapFunc func(ctx context.Context, input gen.OrganizationCreateInput) (*gen.CreateOrganizationAndBootstrapResponse, error) ListAccountsFunc func(ctx context.Context, organizationID string) (*gen.ListAccountsResponse, error) - CreateAccountFunc func(ctx context.Context, input gen.CreateAccountInput) (*gen.CreateAccountResponse, error) + CreateAccountFunc func(ctx context.Context, input gen.AccountCreateInput) (*gen.CreateAccountResponse, error) GetAccountFunc func(ctx context.Context, accountID string) (*gen.GetAccountResponse, error) ValidateDatadogApiKeyFunc func(ctx context.Context, input gen.ValidateDatadogApiKeyInput) (*gen.ValidateDatadogApiKeyResponse, error) - CreateDatadogAccountWithCredentialsFunc func(ctx context.Context, input gen.CreateDatadogAccountWithCredentialsInput) (*gen.CreateDatadogAccountWithCredentialsResponse, error) + CreateDatadogAccountWithCredentialsFunc func(ctx context.Context, input gen.DatadogAccountCreateInput) (*gen.CreateDatadogAccountWithCredentialsResponse, error) GetDatadogAccountStatusFunc func(ctx context.Context, id string) (*gen.GetDatadogAccountStatusResponse, error) - ListWorkspacesFunc func(ctx context.Context, accountID string) (*gen.ListWorkspacesResponse, error) - CreateConversationFunc func(ctx context.Context, input gen.CreateConversationInput) (*gen.CreateConversationResponse, error) - UpdateConversationFunc func(ctx context.Context, id string, input gen.UpdateConversationInput) (*gen.UpdateConversationResponse, error) - DeleteConversationFunc func(ctx context.Context, id string) (*gen.DeleteConversationResponse, error) - CreateMessageFunc func(ctx context.Context, input gen.CreateMessageInput) (*gen.CreateMessageResponse, error) EnableServiceFunc func(ctx context.Context, serviceID string) (*gen.EnableServiceResponse, error) DisableServiceFunc func(ctx context.Context, serviceID string) (*gen.DisableServiceResponse, error) - ApproveLogEventPolicyFunc func(ctx context.Context, policyID string) (*gen.ApproveLogEventPolicyResponse, error) - DismissLogEventPolicyFunc func(ctx context.Context, policyID string) (*gen.DismissLogEventPolicyResponse, error) + GetIssueSummaryFunc func(ctx context.Context) (*gen.GetIssueSummaryResponse, error) + ListIssuesFunc func(ctx context.Context, first int) (*gen.ListIssuesResponse, error) + ListChecksFunc func(ctx context.Context) (*gen.ListChecksResponse, error) + ListEdgeInstancesFunc func(ctx context.Context) (*gen.ListEdgeInstancesResponse, error) + GetAccountStatusSummaryFunc func(ctx context.Context) (*gen.GetAccountStatusSummaryResponse, error) + ListServiceStatusesFunc func(ctx context.Context, first int) (*gen.ListServiceStatusesResponse, error) + ListServiceLogEventsFunc func(ctx context.Context, serviceID string, first int) (*gen.ListServiceLogEventsResponse, error) } // NewMockClient creates a MockClient with sensible defaults. @@ -67,7 +67,7 @@ func (m *MockClient) ListOrganizations(ctx context.Context) (*gen.ListOrganizati return nil, nil } -func (m *MockClient) CreateOrganizationAndBootstrap(ctx context.Context, input gen.CreateOrganizationInput) (*gen.CreateOrganizationAndBootstrapResponse, error) { +func (m *MockClient) CreateOrganizationAndBootstrap(ctx context.Context, input gen.OrganizationCreateInput) (*gen.CreateOrganizationAndBootstrapResponse, error) { if m.CreateOrganizationAndBootstrapFunc != nil { return m.CreateOrganizationAndBootstrapFunc(ctx, input) } @@ -81,7 +81,7 @@ func (m *MockClient) ListAccounts(ctx context.Context, organizationID string) (* return nil, nil } -func (m *MockClient) CreateAccount(ctx context.Context, input gen.CreateAccountInput) (*gen.CreateAccountResponse, error) { +func (m *MockClient) CreateAccount(ctx context.Context, input gen.AccountCreateInput) (*gen.CreateAccountResponse, error) { if m.CreateAccountFunc != nil { return m.CreateAccountFunc(ctx, input) } @@ -102,7 +102,7 @@ func (m *MockClient) ValidateDatadogApiKey(ctx context.Context, input gen.Valida return nil, nil } -func (m *MockClient) CreateDatadogAccountWithCredentials(ctx context.Context, input gen.CreateDatadogAccountWithCredentialsInput) (*gen.CreateDatadogAccountWithCredentialsResponse, error) { +func (m *MockClient) CreateDatadogAccountWithCredentials(ctx context.Context, input gen.DatadogAccountCreateInput) (*gen.CreateDatadogAccountWithCredentialsResponse, error) { if m.CreateDatadogAccountWithCredentialsFunc != nil { return m.CreateDatadogAccountWithCredentialsFunc(ctx, input) } @@ -116,65 +116,65 @@ func (m *MockClient) GetDatadogAccountStatus(ctx context.Context, id string) (*g return nil, nil } -func (m *MockClient) ListWorkspaces(ctx context.Context, accountID string) (*gen.ListWorkspacesResponse, error) { - if m.ListWorkspacesFunc != nil { - return m.ListWorkspacesFunc(ctx, accountID) +func (m *MockClient) EnableService(ctx context.Context, serviceID string) (*gen.EnableServiceResponse, error) { + if m.EnableServiceFunc != nil { + return m.EnableServiceFunc(ctx, serviceID) } return nil, nil } -func (m *MockClient) CreateConversation(ctx context.Context, input gen.CreateConversationInput) (*gen.CreateConversationResponse, error) { - if m.CreateConversationFunc != nil { - return m.CreateConversationFunc(ctx, input) +func (m *MockClient) DisableService(ctx context.Context, serviceID string) (*gen.DisableServiceResponse, error) { + if m.DisableServiceFunc != nil { + return m.DisableServiceFunc(ctx, serviceID) } return nil, nil } -func (m *MockClient) UpdateConversation(ctx context.Context, id string, input gen.UpdateConversationInput) (*gen.UpdateConversationResponse, error) { - if m.UpdateConversationFunc != nil { - return m.UpdateConversationFunc(ctx, id, input) +func (m *MockClient) GetIssueSummary(ctx context.Context) (*gen.GetIssueSummaryResponse, error) { + if m.GetIssueSummaryFunc != nil { + return m.GetIssueSummaryFunc(ctx) } return nil, nil } -func (m *MockClient) DeleteConversation(ctx context.Context, id string) (*gen.DeleteConversationResponse, error) { - if m.DeleteConversationFunc != nil { - return m.DeleteConversationFunc(ctx, id) +func (m *MockClient) ListIssues(ctx context.Context, first int) (*gen.ListIssuesResponse, error) { + if m.ListIssuesFunc != nil { + return m.ListIssuesFunc(ctx, first) } return nil, nil } -func (m *MockClient) CreateMessage(ctx context.Context, input gen.CreateMessageInput) (*gen.CreateMessageResponse, error) { - if m.CreateMessageFunc != nil { - return m.CreateMessageFunc(ctx, input) +func (m *MockClient) ListChecks(ctx context.Context) (*gen.ListChecksResponse, error) { + if m.ListChecksFunc != nil { + return m.ListChecksFunc(ctx) } return nil, nil } -func (m *MockClient) EnableService(ctx context.Context, serviceID string) (*gen.EnableServiceResponse, error) { - if m.EnableServiceFunc != nil { - return m.EnableServiceFunc(ctx, serviceID) +func (m *MockClient) ListEdgeInstances(ctx context.Context) (*gen.ListEdgeInstancesResponse, error) { + if m.ListEdgeInstancesFunc != nil { + return m.ListEdgeInstancesFunc(ctx) } return nil, nil } -func (m *MockClient) DisableService(ctx context.Context, serviceID string) (*gen.DisableServiceResponse, error) { - if m.DisableServiceFunc != nil { - return m.DisableServiceFunc(ctx, serviceID) +func (m *MockClient) GetAccountStatusSummary(ctx context.Context) (*gen.GetAccountStatusSummaryResponse, error) { + if m.GetAccountStatusSummaryFunc != nil { + return m.GetAccountStatusSummaryFunc(ctx) } return nil, nil } -func (m *MockClient) ApproveLogEventPolicy(ctx context.Context, policyID string) (*gen.ApproveLogEventPolicyResponse, error) { - if m.ApproveLogEventPolicyFunc != nil { - return m.ApproveLogEventPolicyFunc(ctx, policyID) +func (m *MockClient) ListServiceStatuses(ctx context.Context, first int) (*gen.ListServiceStatusesResponse, error) { + if m.ListServiceStatusesFunc != nil { + return m.ListServiceStatusesFunc(ctx, first) } return nil, nil } -func (m *MockClient) DismissLogEventPolicy(ctx context.Context, policyID string) (*gen.DismissLogEventPolicyResponse, error) { - if m.DismissLogEventPolicyFunc != nil { - return m.DismissLogEventPolicyFunc(ctx, policyID) +func (m *MockClient) ListServiceLogEvents(ctx context.Context, serviceID string, first int) (*gen.ListServiceLogEventsResponse, error) { + if m.ListServiceLogEventsFunc != nil { + return m.ListServiceLogEventsFunc(ctx, serviceID, first) } return nil, nil } diff --git a/internal/boundary/graphql/apitest/mock_conversations.go b/internal/boundary/graphql/apitest/mock_conversations.go deleted file mode 100644 index fc080459..00000000 --- a/internal/boundary/graphql/apitest/mock_conversations.go +++ /dev/null @@ -1,41 +0,0 @@ -package apitest - -import ( - "context" - - graphql "github.com/usetero/cli/internal/boundary/graphql" - "github.com/usetero/cli/internal/domain" -) - -// MockConversations implements graphql.Conversations for testing. -type MockConversations struct { - CreateFunc func(ctx context.Context, input graphql.CreateConversationInput) (*domain.Conversation, error) - UpdateFunc func(ctx context.Context, id domain.ConversationID, input graphql.UpdateConversationInput) (*domain.Conversation, error) - DeleteFunc func(ctx context.Context, id domain.ConversationID) error -} - -// NewMockConversations creates a MockConversations with sensible defaults. -func NewMockConversations() *MockConversations { - return &MockConversations{} -} - -func (m *MockConversations) Create(ctx context.Context, input graphql.CreateConversationInput) (*domain.Conversation, error) { - if m.CreateFunc != nil { - return m.CreateFunc(ctx, input) - } - return nil, nil -} - -func (m *MockConversations) Update(ctx context.Context, id domain.ConversationID, input graphql.UpdateConversationInput) (*domain.Conversation, error) { - if m.UpdateFunc != nil { - return m.UpdateFunc(ctx, id, input) - } - return nil, nil -} - -func (m *MockConversations) Delete(ctx context.Context, id domain.ConversationID) error { - if m.DeleteFunc != nil { - return m.DeleteFunc(ctx, id) - } - return nil -} diff --git a/internal/boundary/graphql/apitest/mock_messages.go b/internal/boundary/graphql/apitest/mock_messages.go deleted file mode 100644 index 3a6185a0..00000000 --- a/internal/boundary/graphql/apitest/mock_messages.go +++ /dev/null @@ -1,27 +0,0 @@ -package apitest - -import ( - "context" - - graphql "github.com/usetero/cli/internal/boundary/graphql" - "github.com/usetero/cli/internal/domain" -) - -// MockMessages is a mock implementation of graphql.Messages. -type MockMessages struct { - CreateMessageFunc func(ctx context.Context, msg *domain.Message) error -} - -var _ graphql.Messages = (*MockMessages)(nil) - -// NewMockMessages creates a MockMessages with sensible defaults. -func NewMockMessages() *MockMessages { - return &MockMessages{} -} - -func (m *MockMessages) CreateMessage(ctx context.Context, msg *domain.Message) error { - if m.CreateMessageFunc != nil { - return m.CreateMessageFunc(ctx, msg) - } - return nil -} diff --git a/internal/boundary/graphql/apitest/mock_services.go b/internal/boundary/graphql/apitest/mock_services.go index a7281517..9fdd85f9 100644 --- a/internal/boundary/graphql/apitest/mock_services.go +++ b/internal/boundary/graphql/apitest/mock_services.go @@ -7,7 +7,6 @@ import graphql "github.com/usetero/cli/internal/boundary/graphql" func NewMockServiceSet( organizations *MockOrganizations, accounts *MockAccounts, - workspaces *MockWorkspaces, datadogAccounts *MockDatadogAccounts, ) graphql.ServiceSet { services := graphql.ServiceSet{} @@ -18,9 +17,6 @@ func NewMockServiceSet( if accounts != nil { services.Accounts = accounts } - if workspaces != nil { - services.Workspaces = workspaces - } if datadogAccounts != nil { services.DatadogAccounts = datadogAccounts } diff --git a/internal/boundary/graphql/apitest/mock_workspaces.go b/internal/boundary/graphql/apitest/mock_workspaces.go deleted file mode 100644 index 24857dc9..00000000 --- a/internal/boundary/graphql/apitest/mock_workspaces.go +++ /dev/null @@ -1,28 +0,0 @@ -package apitest - -import ( - "context" - - "github.com/usetero/cli/internal/domain" -) - -// MockWorkspaces implements graphql.Workspaces for testing. -type MockWorkspaces struct { - ListFunc func(ctx context.Context, accountID domain.AccountID) ([]domain.Workspace, error) -} - -// NewMockWorkspaces creates a MockWorkspaces with sensible defaults. -func NewMockWorkspaces() *MockWorkspaces { - return &MockWorkspaces{ - ListFunc: func(ctx context.Context, accountID domain.AccountID) ([]domain.Workspace, error) { - return []domain.Workspace{}, nil - }, - } -} - -func (m *MockWorkspaces) List(ctx context.Context, accountID domain.AccountID) ([]domain.Workspace, error) { - if m.ListFunc != nil { - return m.ListFunc(ctx, accountID) - } - return nil, nil -} diff --git a/internal/boundary/graphql/check_service.go b/internal/boundary/graphql/check_service.go new file mode 100644 index 00000000..28dd5216 --- /dev/null +++ b/internal/boundary/graphql/check_service.go @@ -0,0 +1,79 @@ +package graphql + +import ( + "context" + + "github.com/usetero/cli/internal/boundary/graphql/gen" + "github.com/usetero/cli/internal/domain" + "github.com/usetero/cli/internal/log" +) + +// Checks provides access to the code-defined product check catalog. +type Checks interface { + List(ctx context.Context) (domain.CheckCatalog, error) +} + +// CheckService reads product checks and their account-scoped posture from the +// control plane. +type CheckService struct { + client Client + scope log.Scope +} + +var _ Checks = (*CheckService)(nil) + +// NewCheckService creates a new check service. +func NewCheckService(client Client, scope log.Scope) *CheckService { + return &CheckService{ + client: client, + scope: scope.Child("checks"), + } +} + +// List fetches all product checks with their posture and the server-computed +// per-domain counts. +func (s *CheckService) List(ctx context.Context) (domain.CheckCatalog, error) { + s.scope.Debug("fetching checks from API") + resp, err := s.client.ListChecks(ctx) + if err != nil { + s.scope.Error("failed to fetch checks", "error", err) + return domain.CheckCatalog{}, err + } + + catalog := domain.CheckCatalog{ + Total: int64(resp.Checks.TotalCount), + Checks: make([]domain.Check, 0, len(resp.Checks.Edges)), + ByDomain: make(map[domain.CheckDomain]int64), + } + for _, edge := range resp.Checks.Edges { + node := edge.Node + catalog.Checks = append(catalog.Checks, domain.Check{ + ID: node.Id, + Name: node.Name, + Domain: checkDomain(node.Domain), + OpenFindingCount: int64(node.Posture.OpenFindingCount), + PendingFindingCount: int64(node.Posture.PendingFindingCount), + EscalatedFindingCount: int64(node.Posture.EscalatedFindingCount), + ActiveIssueCount: int64(node.Posture.ActiveIssueCount), + AffectedServiceCount: int64(node.Posture.AffectedServiceCount), + CurrentCostPerHour: node.Posture.Current.TotalUsdPerHour, + }) + } + for _, bucket := range resp.Checks.Facets.Domains.Buckets { + catalog.ByDomain[checkDomain(bucket.Value)] = int64(bucket.Count) + } + + s.scope.Debug("fetched checks", log.Int("count", len(catalog.Checks))) + return catalog, nil +} + +func checkDomain(d gen.FindingCheckDomain) domain.CheckDomain { + switch d { + case gen.FindingCheckDomainCost: + return domain.CheckDomainCost + case gen.FindingCheckDomainCompliance: + return domain.CheckDomainCompliance + default: + return domain.CheckDomain(d) + } +} diff --git a/internal/boundary/graphql/check_service_test.go b/internal/boundary/graphql/check_service_test.go new file mode 100644 index 00000000..580c580c --- /dev/null +++ b/internal/boundary/graphql/check_service_test.go @@ -0,0 +1,95 @@ +package graphql_test + +import ( + "context" + "errors" + "testing" + + graphql "github.com/usetero/cli/internal/boundary/graphql" + "github.com/usetero/cli/internal/boundary/graphql/apitest" + "github.com/usetero/cli/internal/boundary/graphql/gen" + "github.com/usetero/cli/internal/domain" + "github.com/usetero/cli/internal/log/logtest" +) + +func TestCheckService_List(t *testing.T) { + t.Parallel() + t.Run("maps checks, posture, and domain facets", func(t *testing.T) { + t.Parallel() + cost := 12.5 + mockClient := &apitest.MockClient{ + ListChecksFunc: func(ctx context.Context) (*gen.ListChecksResponse, error) { + return &gen.ListChecksResponse{ + Checks: gen.ListChecksChecksCheckConnection{ + TotalCount: 2, + Edges: []gen.ListChecksChecksCheckConnectionEdgesCheckEdge{ + {Node: gen.ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheck{ + Id: "chk-1", + Name: "Debug noise", + Domain: gen.FindingCheckDomainCost, + Posture: gen.ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture{ + OpenFindingCount: 5, + PendingFindingCount: 3, + ActiveIssueCount: 2, + AffectedServiceCount: 4, + Current: gen.ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPostureCurrentStatusMeasurementTotals{ + TotalUsdPerHour: &cost, + }, + }, + }}, + {Node: gen.ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheck{ + Id: "chk-2", + Name: "PII exposure", + Domain: gen.FindingCheckDomainCompliance, + }}, + }, + Facets: gen.ListChecksChecksCheckConnectionFacetsCheckFacets{ + Domains: gen.ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacet{ + Buckets: []gen.ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacetBucketsCheckDomainFacetBucket{ + {Value: gen.FindingCheckDomainCost, Count: 1}, + {Value: gen.FindingCheckDomainCompliance, Count: 1}, + }, + }, + }, + }, + }, nil + }, + } + + svc := graphql.NewCheckService(mockClient, logtest.NewScope(t)) + catalog, err := svc.List(context.Background()) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if catalog.Total != 2 || len(catalog.Checks) != 2 { + t.Fatalf("catalog = %+v, want total=2 and 2 checks", catalog) + } + first := catalog.Checks[0] + if first.ID != "chk-1" || first.Domain != domain.CheckDomainCost || first.OpenFindingCount != 5 { + t.Errorf("first check = %+v", first) + } + if first.CurrentCostPerHour == nil || *first.CurrentCostPerHour != 12.5 { + t.Errorf("first check cost = %v, want 12.5", first.CurrentCostPerHour) + } + if catalog.DomainCount(domain.CheckDomainCost) != 1 || catalog.DomainCount(domain.CheckDomainCompliance) != 1 { + t.Errorf("domain counts = %+v", catalog.ByDomain) + } + }) + + t.Run("propagates client errors", func(t *testing.T) { + t.Parallel() + mockClient := &apitest.MockClient{ + ListChecksFunc: func(ctx context.Context) (*gen.ListChecksResponse, error) { + return nil, errors.New("network error") + }, + } + + svc := graphql.NewCheckService(mockClient, logtest.NewScope(t)) + _, err := svc.List(context.Background()) + + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} diff --git a/internal/boundary/graphql/client.go b/internal/boundary/graphql/client.go index 4c1646fc..bcdaa714 100644 --- a/internal/boundary/graphql/client.go +++ b/internal/boundary/graphql/client.go @@ -33,36 +33,32 @@ type Client interface { // Organization operations ListOrganizations(ctx context.Context) (*gen.ListOrganizationsResponse, error) - CreateOrganizationAndBootstrap(ctx context.Context, input gen.CreateOrganizationInput) (*gen.CreateOrganizationAndBootstrapResponse, error) + CreateOrganizationAndBootstrap(ctx context.Context, input gen.OrganizationCreateInput) (*gen.CreateOrganizationAndBootstrapResponse, error) // Account operations ListAccounts(ctx context.Context, organizationID string) (*gen.ListAccountsResponse, error) - CreateAccount(ctx context.Context, input gen.CreateAccountInput) (*gen.CreateAccountResponse, error) + CreateAccount(ctx context.Context, input gen.AccountCreateInput) (*gen.CreateAccountResponse, error) GetAccount(ctx context.Context, accountID string) (*gen.GetAccountResponse, error) // Datadog operations ValidateDatadogApiKey(ctx context.Context, input gen.ValidateDatadogApiKeyInput) (*gen.ValidateDatadogApiKeyResponse, error) - CreateDatadogAccountWithCredentials(ctx context.Context, input gen.CreateDatadogAccountWithCredentialsInput) (*gen.CreateDatadogAccountWithCredentialsResponse, error) + CreateDatadogAccountWithCredentials(ctx context.Context, input gen.DatadogAccountCreateInput) (*gen.CreateDatadogAccountWithCredentialsResponse, error) GetDatadogAccountStatus(ctx context.Context, id string) (*gen.GetDatadogAccountStatusResponse, error) - // Workspace operations - ListWorkspaces(ctx context.Context, accountID string) (*gen.ListWorkspacesResponse, error) - - // Conversation operations - CreateConversation(ctx context.Context, input gen.CreateConversationInput) (*gen.CreateConversationResponse, error) - UpdateConversation(ctx context.Context, id string, input gen.UpdateConversationInput) (*gen.UpdateConversationResponse, error) - DeleteConversation(ctx context.Context, id string) (*gen.DeleteConversationResponse, error) - - // Message operations - CreateMessage(ctx context.Context, input gen.CreateMessageInput) (*gen.CreateMessageResponse, error) - // Service operations EnableService(ctx context.Context, serviceID string) (*gen.EnableServiceResponse, error) DisableService(ctx context.Context, serviceID string) (*gen.DisableServiceResponse, error) - // Policy operations - ApproveLogEventPolicy(ctx context.Context, policyID string) (*gen.ApproveLogEventPolicyResponse, error) - DismissLogEventPolicy(ctx context.Context, policyID string) (*gen.DismissLogEventPolicyResponse, error) + // Product surface reads + GetIssueSummary(ctx context.Context) (*gen.GetIssueSummaryResponse, error) + ListIssues(ctx context.Context, first int) (*gen.ListIssuesResponse, error) + ListChecks(ctx context.Context) (*gen.ListChecksResponse, error) + ListEdgeInstances(ctx context.Context) (*gen.ListEdgeInstancesResponse, error) + + // Data-plane status reads + GetAccountStatusSummary(ctx context.Context) (*gen.GetAccountStatusSummaryResponse, error) + ListServiceStatuses(ctx context.Context, first int) (*gen.ListServiceStatusesResponse, error) + ListServiceLogEvents(ctx context.Context, serviceID string, first int) (*gen.ListServiceLogEventsResponse, error) } // client is the concrete implementation of Client. @@ -199,7 +195,7 @@ func (c *client) ListOrganizations(ctx context.Context) (*gen.ListOrganizationsR return gen.ListOrganizations(ctx, gql) } -func (c *client) CreateOrganizationAndBootstrap(ctx context.Context, input gen.CreateOrganizationInput) (*gen.CreateOrganizationAndBootstrapResponse, error) { +func (c *client) CreateOrganizationAndBootstrap(ctx context.Context, input gen.OrganizationCreateInput) (*gen.CreateOrganizationAndBootstrapResponse, error) { gql, err := c.gql(ctx) if err != nil { return nil, err @@ -217,7 +213,7 @@ func (c *client) ListAccounts(ctx context.Context, organizationID string) (*gen. return gen.ListAccounts(ctx, gql, organizationID) } -func (c *client) CreateAccount(ctx context.Context, input gen.CreateAccountInput) (*gen.CreateAccountResponse, error) { +func (c *client) CreateAccount(ctx context.Context, input gen.AccountCreateInput) (*gen.CreateAccountResponse, error) { gql, err := c.gql(ctx) if err != nil { return nil, err @@ -244,7 +240,7 @@ func (c *client) ValidateDatadogApiKey(ctx context.Context, input gen.ValidateDa return gen.ValidateDatadogApiKey(ctx, gql, input) } -func (c *client) CreateDatadogAccountWithCredentials(ctx context.Context, input gen.CreateDatadogAccountWithCredentialsInput) (*gen.CreateDatadogAccountWithCredentialsResponse, error) { +func (c *client) CreateDatadogAccountWithCredentials(ctx context.Context, input gen.DatadogAccountCreateInput) (*gen.CreateDatadogAccountWithCredentialsResponse, error) { gql, err := c.gql(ctx) if err != nil { return nil, err @@ -260,84 +256,80 @@ func (c *client) GetDatadogAccountStatus(ctx context.Context, id string) (*gen.G return gen.GetDatadogAccountStatus(ctx, gql, id) } -// Workspace operations +// Service operations -func (c *client) ListWorkspaces(ctx context.Context, accountID string) (*gen.ListWorkspacesResponse, error) { +func (c *client) EnableService(ctx context.Context, serviceID string) (*gen.EnableServiceResponse, error) { gql, err := c.gql(ctx) if err != nil { return nil, err } - return gen.ListWorkspaces(ctx, gql, accountID) + return gen.EnableService(ctx, gql, serviceID) } -// Conversation operations - -func (c *client) CreateConversation(ctx context.Context, input gen.CreateConversationInput) (*gen.CreateConversationResponse, error) { +func (c *client) DisableService(ctx context.Context, serviceID string) (*gen.DisableServiceResponse, error) { gql, err := c.gql(ctx) if err != nil { return nil, err } - return gen.CreateConversation(ctx, gql, input) + return gen.DisableService(ctx, gql, serviceID) } -func (c *client) UpdateConversation(ctx context.Context, id string, input gen.UpdateConversationInput) (*gen.UpdateConversationResponse, error) { +// Product surface reads + +func (c *client) GetIssueSummary(ctx context.Context) (*gen.GetIssueSummaryResponse, error) { gql, err := c.gql(ctx) if err != nil { return nil, err } - return gen.UpdateConversation(ctx, gql, id, input) + return gen.GetIssueSummary(ctx, gql) } -func (c *client) DeleteConversation(ctx context.Context, id string) (*gen.DeleteConversationResponse, error) { +func (c *client) ListIssues(ctx context.Context, first int) (*gen.ListIssuesResponse, error) { gql, err := c.gql(ctx) if err != nil { return nil, err } - return gen.DeleteConversation(ctx, gql, id) + return gen.ListIssues(ctx, gql, first) } -// Message operations - -func (c *client) CreateMessage(ctx context.Context, input gen.CreateMessageInput) (*gen.CreateMessageResponse, error) { +func (c *client) ListChecks(ctx context.Context) (*gen.ListChecksResponse, error) { gql, err := c.gql(ctx) if err != nil { return nil, err } - return gen.CreateMessage(ctx, gql, input) + return gen.ListChecks(ctx, gql) } -// Service operations - -func (c *client) EnableService(ctx context.Context, serviceID string) (*gen.EnableServiceResponse, error) { +func (c *client) ListEdgeInstances(ctx context.Context) (*gen.ListEdgeInstancesResponse, error) { gql, err := c.gql(ctx) if err != nil { return nil, err } - return gen.EnableService(ctx, gql, serviceID) + return gen.ListEdgeInstances(ctx, gql) } -func (c *client) DisableService(ctx context.Context, serviceID string) (*gen.DisableServiceResponse, error) { +// Data-plane status reads + +func (c *client) GetAccountStatusSummary(ctx context.Context) (*gen.GetAccountStatusSummaryResponse, error) { gql, err := c.gql(ctx) if err != nil { return nil, err } - return gen.DisableService(ctx, gql, serviceID) + return gen.GetAccountStatusSummary(ctx, gql) } -// Policy operations - -func (c *client) ApproveLogEventPolicy(ctx context.Context, policyID string) (*gen.ApproveLogEventPolicyResponse, error) { +func (c *client) ListServiceStatuses(ctx context.Context, first int) (*gen.ListServiceStatusesResponse, error) { gql, err := c.gql(ctx) if err != nil { return nil, err } - return gen.ApproveLogEventPolicy(ctx, gql, policyID) + return gen.ListServiceStatuses(ctx, gql, first) } -func (c *client) DismissLogEventPolicy(ctx context.Context, policyID string) (*gen.DismissLogEventPolicyResponse, error) { +func (c *client) ListServiceLogEvents(ctx context.Context, serviceID string, first int) (*gen.ListServiceLogEventsResponse, error) { gql, err := c.gql(ctx) if err != nil { return nil, err } - return gen.DismissLogEventPolicy(ctx, gql, policyID) + return gen.ListServiceLogEvents(ctx, gql, serviceID, first) } diff --git a/internal/boundary/graphql/conversation_service.go b/internal/boundary/graphql/conversation_service.go deleted file mode 100644 index 678ded93..00000000 --- a/internal/boundary/graphql/conversation_service.go +++ /dev/null @@ -1,127 +0,0 @@ -package graphql - -import ( - "context" - "errors" - "fmt" - - "github.com/google/uuid" - "github.com/usetero/cli/internal/boundary/graphql/gen" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log" -) - -// CreateConversationInput contains the fields for creating a conversation. -type CreateConversationInput struct { - ID uuid.UUID - WorkspaceID domain.WorkspaceID - Title string -} - -// UpdateConversationInput contains the fields that can be updated on a conversation. -// Fields are pointers — nil means "don't change", non-nil means "set to this value". -type UpdateConversationInput struct { - Title *string -} - -// Conversations provides access to conversations. -type Conversations interface { - Create(ctx context.Context, input CreateConversationInput) (*domain.Conversation, error) - Update(ctx context.Context, id domain.ConversationID, input UpdateConversationInput) (*domain.Conversation, error) - Delete(ctx context.Context, id domain.ConversationID) error -} - -// ConversationService handles conversation-related API operations. -type ConversationService struct { - client Client - scope log.Scope -} - -// Ensure ConversationService implements Conversations. -var _ Conversations = (*ConversationService)(nil) - -// NewConversationService creates a new conversation service. -func NewConversationService(client Client, scope log.Scope) *ConversationService { - return &ConversationService{ - client: client, - scope: scope.Child("conversations"), - } -} - -// Create creates a new conversation with the given client-provided ID. -func (s *ConversationService) Create(ctx context.Context, input CreateConversationInput) (*domain.Conversation, error) { - s.scope.Debug("creating conversation via API", "id", input.ID.String(), "workspaceID", input.WorkspaceID.String(), "title", input.Title) - - genInput := gen.CreateConversationInput{ - Id: ptr(input.ID.String()), - WorkspaceID: input.WorkspaceID.String(), - Title: ptr(input.Title), - } - - resp, err := s.client.CreateConversation(ctx, genInput) - if err != nil { - s.scope.Error("failed to create conversation", "error", err) - if classified := classifyError(err); classified != nil { - return nil, fmt.Errorf("create conversation %s: %w", input.ID, classified) - } - return nil, err - } - - conversation := &domain.Conversation{ - ID: domain.ConversationID(resp.CreateConversation.Id), - WorkspaceID: input.WorkspaceID, - Title: input.Title, - } - - s.scope.Debug("created conversation via API", "id", conversation.ID) - return conversation, nil -} - -// Update updates a conversation. -func (s *ConversationService) Update(ctx context.Context, id domain.ConversationID, input UpdateConversationInput) (*domain.Conversation, error) { - s.scope.Debug("updating conversation via API", "id", id.String()) - - genInput := gen.UpdateConversationInput{} - if input.Title != nil { - if *input.Title == "" { - genInput.ClearTitle = ptr(true) - } else { - genInput.Title = input.Title - } - } - - resp, err := s.client.UpdateConversation(ctx, id.String(), genInput) - if err != nil { - s.scope.Error("failed to update conversation", "error", err, "id", id) - if classified := classifyError(err); classified != nil { - return nil, fmt.Errorf("update conversation %s: %w", id, classified) - } - return nil, err - } - - conversation := &domain.Conversation{ - ID: domain.ConversationID(resp.UpdateConversation.Id), - Title: deref(resp.UpdateConversation.Title), - } - - s.scope.Debug("updated conversation via API", "id", conversation.ID) - return conversation, nil -} - -// Delete deletes a conversation. -// Returns ErrNotFound (via errors.Is) if the conversation does not exist. -func (s *ConversationService) Delete(ctx context.Context, id domain.ConversationID) error { - s.scope.Debug("deleting conversation via API", "id", id.String()) - - _, err := s.client.DeleteConversation(ctx, id.String()) - if err != nil { - s.scope.Error("failed to delete conversation", "error", err, "id", id) - if classified := classifyError(err); classified != nil { - return errors.Join(fmt.Errorf("delete conversation %s", id), classified) - } - return err - } - - s.scope.Debug("deleted conversation via API", "id", id.String()) - return nil -} diff --git a/internal/boundary/graphql/datadog_account_service.go b/internal/boundary/graphql/datadog_account_service.go index ab90c05a..e9d51f8a 100644 --- a/internal/boundary/graphql/datadog_account_service.go +++ b/internal/boundary/graphql/datadog_account_service.go @@ -167,14 +167,11 @@ func (s *DatadogAccountService) ValidateAPIKey(ctx context.Context, input Valida // The control plane validates the credentials before creating the account. func (s *DatadogAccountService) CreateAccount(ctx context.Context, input CreateDatadogAccountInput) (*DatadogAccount, error) { s.scope.Debug("creating datadog account with credentials via API", "id", input.ID.String(), "accountID", input.AccountID, "site", input.Site) - genInput := gen.CreateDatadogAccountWithCredentialsInput{ - Attributes: gen.CreateDatadogAccountInput{ - Id: ptr(input.ID.String()), - AccountID: input.AccountID.String(), - Name: input.Name, - Site: gen.DatadogAccountSite(input.Site), - }, - Credentials: gen.CreateDatadogCredentialsInput{ + genInput := gen.DatadogAccountCreateInput{ + AccountID: input.AccountID.String(), + Name: input.Name, + Site: gen.DatadogAccountSite(input.Site), + Credentials: gen.DatadogAccountCredentialsInput{ ApiKey: input.APIKey, AppKey: input.AppKey, }, @@ -217,18 +214,20 @@ func (s *DatadogAccountService) GetStatus(ctx context.Context, datadogAccountID } result := &DatadogAccountStatus{ - Health: DatadogAccountHealth(statusNode.Health), - ReadyForUse: statusNode.ReadyForUse, - ServiceCount: statusNode.LogServiceCount, - ActiveServices: statusNode.LogActiveServices, - OkServices: statusNode.OkServices, - DisabledServices: statusNode.DisabledServices, - InactiveServices: statusNode.InactiveServices, - EventCount: statusNode.LogEventCount, - AnalyzedCount: statusNode.LogEventAnalyzedCount, - PendingPolicyCount: statusNode.PolicyPendingCount, - ApprovedPolicyCount: statusNode.PolicyApprovedCount, - DismissedPolicyCount: statusNode.PolicyDismissedCount, + Health: DatadogAccountHealth(statusNode.Health), + ReadyForUse: statusNode.Readiness.ReadyForUse, + ServiceCount: statusNode.Coverage.LogServiceCount, + ActiveServices: statusNode.Coverage.LogActiveServices, + OkServices: statusNode.Coverage.OkServices, + DisabledServices: statusNode.Coverage.DisabledServices, + InactiveServices: statusNode.Coverage.InactiveServices, + EventCount: statusNode.Coverage.LogEventCount, + AnalyzedCount: statusNode.Coverage.LogEventAnalyzedCount, + // Policy counts moved to the Issue model and are no longer on the + // Datadog account status. Wired via the issues query in a later step. + PendingPolicyCount: 0, + ApprovedPolicyCount: 0, + DismissedPolicyCount: 0, } s.scope.Debug("fetched datadog account status", diff --git a/internal/boundary/graphql/datadog_account_service_test.go b/internal/boundary/graphql/datadog_account_service_test.go index f33d1f1b..1d3d1835 100644 --- a/internal/boundary/graphql/datadog_account_service_test.go +++ b/internal/boundary/graphql/datadog_account_service_test.go @@ -20,9 +20,9 @@ func TestDatadogAccountService_HasAccount(t *testing.T) { GetAccountFunc: func(ctx context.Context, accountID string) (*gen.GetAccountResponse, error) { return &gen.GetAccountResponse{ Accounts: gen.GetAccountAccountsAccountConnection{ - Edges: []*gen.GetAccountAccountsAccountConnectionEdgesAccountEdge{ + Edges: []gen.GetAccountAccountsAccountConnectionEdgesAccountEdge{ { - Node: &gen.GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccount{ + Node: gen.GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccount{ Id: "acc-1", DatadogAccount: &gen.GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccountDatadogAccount{ Id: "dd-123", @@ -54,9 +54,9 @@ func TestDatadogAccountService_HasAccount(t *testing.T) { GetAccountFunc: func(ctx context.Context, accountID string) (*gen.GetAccountResponse, error) { return &gen.GetAccountResponse{ Accounts: gen.GetAccountAccountsAccountConnection{ - Edges: []*gen.GetAccountAccountsAccountConnectionEdgesAccountEdge{ + Edges: []gen.GetAccountAccountsAccountConnectionEdgesAccountEdge{ { - Node: &gen.GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccount{ + Node: gen.GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccount{ Id: "acc-1", // Empty DatadogAccount - nil pointer DatadogAccount: nil, @@ -85,7 +85,7 @@ func TestDatadogAccountService_HasAccount(t *testing.T) { GetAccountFunc: func(ctx context.Context, accountID string) (*gen.GetAccountResponse, error) { return &gen.GetAccountResponse{ Accounts: gen.GetAccountAccountsAccountConnection{ - Edges: []*gen.GetAccountAccountsAccountConnectionEdgesAccountEdge{}, + Edges: []gen.GetAccountAccountsAccountConnectionEdgesAccountEdge{}, }, }, nil }, @@ -111,9 +111,9 @@ func TestDatadogAccountService_GetAccount(t *testing.T) { GetAccountFunc: func(ctx context.Context, accountID string) (*gen.GetAccountResponse, error) { return &gen.GetAccountResponse{ Accounts: gen.GetAccountAccountsAccountConnection{ - Edges: []*gen.GetAccountAccountsAccountConnectionEdgesAccountEdge{ + Edges: []gen.GetAccountAccountsAccountConnectionEdgesAccountEdge{ { - Node: &gen.GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccount{ + Node: gen.GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccount{ Id: "acc-1", DatadogAccount: &gen.GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccountDatadogAccount{ Id: "dd-123", @@ -154,9 +154,9 @@ func TestDatadogAccountService_GetAccount(t *testing.T) { GetAccountFunc: func(ctx context.Context, accountID string) (*gen.GetAccountResponse, error) { return &gen.GetAccountResponse{ Accounts: gen.GetAccountAccountsAccountConnection{ - Edges: []*gen.GetAccountAccountsAccountConnectionEdgesAccountEdge{ + Edges: []gen.GetAccountAccountsAccountConnectionEdgesAccountEdge{ { - Node: &gen.GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccount{ + Node: gen.GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccount{ Id: "acc-1", DatadogAccount: nil, }, @@ -287,21 +287,24 @@ func TestDatadogAccountService_GetStatus(t *testing.T) { GetDatadogAccountStatusFunc: func(ctx context.Context, id string) (*gen.GetDatadogAccountStatusResponse, error) { return &gen.GetDatadogAccountStatusResponse{ DatadogAccounts: gen.GetDatadogAccountStatusDatadogAccountsDatadogAccountConnection{ - Edges: []*gen.GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge{ + Edges: []gen.GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge{ { - Node: &gen.GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount{ + Node: gen.GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount{ Id: "dd-123", - Status: &gen.GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache{ - Health: gen.DatadogAccountStatusCacheHealthOk, - ReadyForUse: true, - LogServiceCount: 10, - LogActiveServices: 8, - OkServices: 7, - DisabledServices: 1, - InactiveServices: 1, - LogEventCount: 200, - LogEventAnalyzedCount: 180, - PolicyPendingCount: 12, + Status: &gen.GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus{ + Health: gen.StatusHealthOk, + Readiness: gen.GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusReadiness{ + ReadyForUse: true, + }, + Coverage: gen.GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage{ + LogServiceCount: 10, + LogActiveServices: 8, + OkServices: 7, + DisabledServices: 1, + InactiveServices: 1, + LogEventCount: 200, + LogEventAnalyzedCount: 180, + }, }, }, }, @@ -335,8 +338,9 @@ func TestDatadogAccountService_GetStatus(t *testing.T) { if status.AnalyzedCount != 180 { t.Errorf("AnalyzedCount = %d, want 180", status.AnalyzedCount) } - if status.PendingPolicyCount != 12 { - t.Errorf("PendingPolicyCount = %d, want 12", status.PendingPolicyCount) + // Policy counts moved to the Issue model; status no longer carries them. + if status.PendingPolicyCount != 0 { + t.Errorf("PendingPolicyCount = %d, want 0", status.PendingPolicyCount) } }) @@ -346,7 +350,7 @@ func TestDatadogAccountService_GetStatus(t *testing.T) { GetDatadogAccountStatusFunc: func(ctx context.Context, id string) (*gen.GetDatadogAccountStatusResponse, error) { return &gen.GetDatadogAccountStatusResponse{ DatadogAccounts: gen.GetDatadogAccountStatusDatadogAccountsDatadogAccountConnection{ - Edges: []*gen.GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge{}, + Edges: []gen.GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge{}, }, }, nil }, diff --git a/internal/boundary/graphql/edge_instance_service.go b/internal/boundary/graphql/edge_instance_service.go new file mode 100644 index 00000000..898dbcbf --- /dev/null +++ b/internal/boundary/graphql/edge_instance_service.go @@ -0,0 +1,62 @@ +package graphql + +import ( + "context" + + "github.com/usetero/cli/internal/domain" + "github.com/usetero/cli/internal/log" +) + +// EdgeInstances provides access to the account's edge runtime fleet. +type EdgeInstances interface { + List(ctx context.Context) (domain.EdgeFleet, error) +} + +// EdgeInstanceService reads edge instances from the control plane. +type EdgeInstanceService struct { + client Client + scope log.Scope +} + +var _ EdgeInstances = (*EdgeInstanceService)(nil) + +// NewEdgeInstanceService creates a new edge instance service. +func NewEdgeInstanceService(client Client, scope log.Scope) *EdgeInstanceService { + return &EdgeInstanceService{ + client: client, + scope: scope.Child("edge-instances"), + } +} + +// List fetches the edge instance fleet for the active account. The total is +// server-reported; recency/connectivity is derived by callers from LastSyncAt. +func (s *EdgeInstanceService) List(ctx context.Context) (domain.EdgeFleet, error) { + s.scope.Debug("fetching edge instances from API") + resp, err := s.client.ListEdgeInstances(ctx) + if err != nil { + s.scope.Error("failed to fetch edge instances", "error", err) + return domain.EdgeFleet{}, err + } + + fleet := domain.EdgeFleet{ + Total: int64(resp.EdgeInstances.TotalCount), + Instances: make([]domain.EdgeInstance, 0, len(resp.EdgeInstances.Edges)), + } + for _, edge := range resp.EdgeInstances.Edges { + node := edge.Node + namespace := "" + if node.ServiceNamespace != nil { + namespace = *node.ServiceNamespace + } + fleet.Instances = append(fleet.Instances, domain.EdgeInstance{ + ID: node.Id, + InstanceID: node.InstanceID, + ServiceName: node.ServiceName, + ServiceNamespace: namespace, + LastSyncAt: node.LastSyncAt, + }) + } + + s.scope.Debug("fetched edge instances", log.Int("count", len(fleet.Instances))) + return fleet, nil +} diff --git a/internal/boundary/graphql/edge_instance_service_test.go b/internal/boundary/graphql/edge_instance_service_test.go new file mode 100644 index 00000000..49328101 --- /dev/null +++ b/internal/boundary/graphql/edge_instance_service_test.go @@ -0,0 +1,81 @@ +package graphql_test + +import ( + "context" + "errors" + "testing" + "time" + + graphql "github.com/usetero/cli/internal/boundary/graphql" + "github.com/usetero/cli/internal/boundary/graphql/apitest" + "github.com/usetero/cli/internal/boundary/graphql/gen" + "github.com/usetero/cli/internal/log/logtest" +) + +func TestEdgeInstanceService_List(t *testing.T) { + t.Parallel() + t.Run("maps fleet total and instances", func(t *testing.T) { + t.Parallel() + ns := "payments" + now := time.Now() + mockClient := &apitest.MockClient{ + ListEdgeInstancesFunc: func(ctx context.Context) (*gen.ListEdgeInstancesResponse, error) { + return &gen.ListEdgeInstancesResponse{ + EdgeInstances: gen.ListEdgeInstancesEdgeInstancesEdgeInstanceConnection{ + TotalCount: 2, + Edges: []gen.ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdge{ + {Node: gen.ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance{ + Id: "edge-1", + InstanceID: "inst-1", + ServiceName: "checkout", + ServiceNamespace: &ns, + LastSyncAt: now, + }}, + {Node: gen.ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance{ + Id: "edge-2", + InstanceID: "inst-2", + ServiceName: "billing", + LastSyncAt: now.Add(-time.Hour), + }}, + }, + }, + }, nil + }, + } + + svc := graphql.NewEdgeInstanceService(mockClient, logtest.NewScope(t)) + fleet, err := svc.List(context.Background()) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if fleet.Total != 2 || len(fleet.Instances) != 2 { + t.Fatalf("fleet = %+v, want total=2 and 2 instances", fleet) + } + if fleet.Instances[0].ServiceNamespace != "payments" { + t.Errorf("namespace = %q, want payments", fleet.Instances[0].ServiceNamespace) + } + if fleet.Instances[1].ServiceNamespace != "" { + t.Errorf("nil namespace should map to empty string, got %q", fleet.Instances[1].ServiceNamespace) + } + if got := fleet.ConnectedCount(now, 30*time.Minute); got != 1 { + t.Errorf("ConnectedCount = %d, want 1", got) + } + }) + + t.Run("propagates client errors", func(t *testing.T) { + t.Parallel() + mockClient := &apitest.MockClient{ + ListEdgeInstancesFunc: func(ctx context.Context) (*gen.ListEdgeInstancesResponse, error) { + return nil, errors.New("network error") + }, + } + + svc := graphql.NewEdgeInstanceService(mockClient, logtest.NewScope(t)) + _, err := svc.List(context.Background()) + + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} diff --git a/internal/boundary/graphql/gen/generated.go b/internal/boundary/graphql/gen/generated.go index 6ea4871a..857a020c 100644 --- a/internal/boundary/graphql/gen/generated.go +++ b/internal/boundary/graphql/gen/generated.go @@ -4,96 +4,29 @@ package gen import ( "context" - "encoding/json" - "fmt" "time" "github.com/Khan/genqlient/graphql" ) -// ApproveLogEventPolicyApproveLogEventPolicy includes the requested fields of the GraphQL type LogEventPolicy. -type ApproveLogEventPolicyApproveLogEventPolicy struct { - // Unique identifier - Id string `json:"id"` - // When this policy was approved by a user - ApprovedAt *time.Time `json:"approvedAt"` - // User ID who approved this policy - ApprovedBy *string `json:"approvedBy"` - // When this policy was dismissed by a user - DismissedAt *time.Time `json:"dismissedAt"` - // User ID who dismissed this policy - DismissedBy *string `json:"dismissedBy"` -} - -// GetId returns ApproveLogEventPolicyApproveLogEventPolicy.Id, and is useful for accessing the field via an interface. -func (v *ApproveLogEventPolicyApproveLogEventPolicy) GetId() string { return v.Id } - -// GetApprovedAt returns ApproveLogEventPolicyApproveLogEventPolicy.ApprovedAt, and is useful for accessing the field via an interface. -func (v *ApproveLogEventPolicyApproveLogEventPolicy) GetApprovedAt() *time.Time { return v.ApprovedAt } - -// GetApprovedBy returns ApproveLogEventPolicyApproveLogEventPolicy.ApprovedBy, and is useful for accessing the field via an interface. -func (v *ApproveLogEventPolicyApproveLogEventPolicy) GetApprovedBy() *string { return v.ApprovedBy } - -// GetDismissedAt returns ApproveLogEventPolicyApproveLogEventPolicy.DismissedAt, and is useful for accessing the field via an interface. -func (v *ApproveLogEventPolicyApproveLogEventPolicy) GetDismissedAt() *time.Time { - return v.DismissedAt -} - -// GetDismissedBy returns ApproveLogEventPolicyApproveLogEventPolicy.DismissedBy, and is useful for accessing the field via an interface. -func (v *ApproveLogEventPolicyApproveLogEventPolicy) GetDismissedBy() *string { return v.DismissedBy } - -// ApproveLogEventPolicyResponse is returned by ApproveLogEventPolicy on success. -type ApproveLogEventPolicyResponse struct { - // Approve a log event policy, enabling it for enforcement. - // Clears any previous dismissal. - ApproveLogEventPolicy ApproveLogEventPolicyApproveLogEventPolicy `json:"approveLogEventPolicy"` -} - -// GetApproveLogEventPolicy returns ApproveLogEventPolicyResponse.ApproveLogEventPolicy, and is useful for accessing the field via an interface. -func (v *ApproveLogEventPolicyResponse) GetApproveLogEventPolicy() ApproveLogEventPolicyApproveLogEventPolicy { - return v.ApproveLogEventPolicy -} - -// A content block in a message. Exactly one of the typed fields should be set. -type ContentBlockInput struct { - Type ContentBlockType `json:"type"` - Text *TextBlockInput `json:"text"` - Thinking *ThinkingBlockInput `json:"thinking"` - ToolUse *ToolUseInput `json:"toolUse"` - ToolResult *ToolResultInput `json:"toolResult"` +// Account creation input. +type AccountCreateInput struct { + // Parent organization this account belongs to + OrganizationID string `json:"organizationID"` + // Human-readable name within the organization + Name string `json:"name"` + // Short account key used in display IDs. Auto-set from name when omitted. + DisplayKey *string `json:"displayKey"` } -// GetType returns ContentBlockInput.Type, and is useful for accessing the field via an interface. -func (v *ContentBlockInput) GetType() ContentBlockType { return v.Type } - -// GetText returns ContentBlockInput.Text, and is useful for accessing the field via an interface. -func (v *ContentBlockInput) GetText() *TextBlockInput { return v.Text } - -// GetThinking returns ContentBlockInput.Thinking, and is useful for accessing the field via an interface. -func (v *ContentBlockInput) GetThinking() *ThinkingBlockInput { return v.Thinking } - -// GetToolUse returns ContentBlockInput.ToolUse, and is useful for accessing the field via an interface. -func (v *ContentBlockInput) GetToolUse() *ToolUseInput { return v.ToolUse } +// GetOrganizationID returns AccountCreateInput.OrganizationID, and is useful for accessing the field via an interface. +func (v *AccountCreateInput) GetOrganizationID() string { return v.OrganizationID } -// GetToolResult returns ContentBlockInput.ToolResult, and is useful for accessing the field via an interface. -func (v *ContentBlockInput) GetToolResult() *ToolResultInput { return v.ToolResult } - -// The type of content block. -type ContentBlockType string - -const ( - ContentBlockTypeText ContentBlockType = "text" - ContentBlockTypeThinking ContentBlockType = "thinking" - ContentBlockTypeToolUse ContentBlockType = "tool_use" - ContentBlockTypeToolResult ContentBlockType = "tool_result" -) +// GetName returns AccountCreateInput.Name, and is useful for accessing the field via an interface. +func (v *AccountCreateInput) GetName() string { return v.Name } -var AllContentBlockType = []ContentBlockType{ - ContentBlockTypeText, - ContentBlockTypeThinking, - ContentBlockTypeToolUse, - ContentBlockTypeToolResult, -} +// GetDisplayKey returns AccountCreateInput.DisplayKey, and is useful for accessing the field via an interface. +func (v *AccountCreateInput) GetDisplayKey() *string { return v.DisplayKey } // CreateAccountCreateAccount includes the requested fields of the GraphQL type Account. type CreateAccountCreateAccount struct { @@ -114,146 +47,24 @@ func (v *CreateAccountCreateAccount) GetName() string { return v.Name } // GetCreatedAt returns CreateAccountCreateAccount.CreatedAt, and is useful for accessing the field via an interface. func (v *CreateAccountCreateAccount) GetCreatedAt() time.Time { return v.CreatedAt } -// CreateAccountInput is used for create Account object. -// Input was generated by ent. -type CreateAccountInput struct { - // Human-readable name within the organization - Name string `json:"name"` - OrganizationID string `json:"organizationID"` - DatadogAccountID *string `json:"datadogAccountID"` - // Optional client-provided UUID for offline-first sync. - // If provided and a record with this ID exists, returns the existing record. - Id *string `json:"id"` -} - -// GetName returns CreateAccountInput.Name, and is useful for accessing the field via an interface. -func (v *CreateAccountInput) GetName() string { return v.Name } - -// GetOrganizationID returns CreateAccountInput.OrganizationID, and is useful for accessing the field via an interface. -func (v *CreateAccountInput) GetOrganizationID() string { return v.OrganizationID } - -// GetDatadogAccountID returns CreateAccountInput.DatadogAccountID, and is useful for accessing the field via an interface. -func (v *CreateAccountInput) GetDatadogAccountID() *string { return v.DatadogAccountID } - -// GetId returns CreateAccountInput.Id, and is useful for accessing the field via an interface. -func (v *CreateAccountInput) GetId() *string { return v.Id } - // CreateAccountResponse is returned by CreateAccount on success. type CreateAccountResponse struct { + // Create account. CreateAccount CreateAccountCreateAccount `json:"createAccount"` } // GetCreateAccount returns CreateAccountResponse.CreateAccount, and is useful for accessing the field via an interface. func (v *CreateAccountResponse) GetCreateAccount() CreateAccountCreateAccount { return v.CreateAccount } -// CreateConversationCreateConversation includes the requested fields of the GraphQL type Conversation. -type CreateConversationCreateConversation struct { - // Unique identifier - Id string `json:"id"` - // AI-generated title, set after first exchange - Title *string `json:"title"` - // When the conversation was created - CreatedAt time.Time `json:"createdAt"` - // When the conversation was last updated - UpdatedAt time.Time `json:"updatedAt"` -} - -// GetId returns CreateConversationCreateConversation.Id, and is useful for accessing the field via an interface. -func (v *CreateConversationCreateConversation) GetId() string { return v.Id } - -// GetTitle returns CreateConversationCreateConversation.Title, and is useful for accessing the field via an interface. -func (v *CreateConversationCreateConversation) GetTitle() *string { return v.Title } - -// GetCreatedAt returns CreateConversationCreateConversation.CreatedAt, and is useful for accessing the field via an interface. -func (v *CreateConversationCreateConversation) GetCreatedAt() time.Time { return v.CreatedAt } - -// GetUpdatedAt returns CreateConversationCreateConversation.UpdatedAt, and is useful for accessing the field via an interface. -func (v *CreateConversationCreateConversation) GetUpdatedAt() time.Time { return v.UpdatedAt } - -// CreateConversationInput is used for create Conversation object. -// Input was generated by ent. -type CreateConversationInput struct { - // AI-generated title, set after first exchange - Title *string `json:"title"` - WorkspaceID string `json:"workspaceID"` - ViewID *string `json:"viewID"` - // Optional client-provided UUID for offline-first sync. - // If provided and a conversation with this ID exists, returns the existing record. - Id *string `json:"id"` -} - -// GetTitle returns CreateConversationInput.Title, and is useful for accessing the field via an interface. -func (v *CreateConversationInput) GetTitle() *string { return v.Title } - -// GetWorkspaceID returns CreateConversationInput.WorkspaceID, and is useful for accessing the field via an interface. -func (v *CreateConversationInput) GetWorkspaceID() string { return v.WorkspaceID } - -// GetViewID returns CreateConversationInput.ViewID, and is useful for accessing the field via an interface. -func (v *CreateConversationInput) GetViewID() *string { return v.ViewID } - -// GetId returns CreateConversationInput.Id, and is useful for accessing the field via an interface. -func (v *CreateConversationInput) GetId() *string { return v.Id } - -// CreateConversationResponse is returned by CreateConversation on success. -type CreateConversationResponse struct { - // Create a new conversation in a workspace. - // The conversation is owned by the authenticated user. - CreateConversation CreateConversationCreateConversation `json:"createConversation"` -} - -// GetCreateConversation returns CreateConversationResponse.CreateConversation, and is useful for accessing the field via an interface. -func (v *CreateConversationResponse) GetCreateConversation() CreateConversationCreateConversation { - return v.CreateConversation -} - -// CreateDatadogAccountInput is used for create DatadogAccount object. -// Input was generated by ent. -type CreateDatadogAccountInput struct { - // Display name for this Datadog account - Name string `json:"name"` - // Datadog regional site. US1: datadoghq.com, US3: us3.datadoghq.com, US5: - // us5.datadoghq.com, EU1: datadoghq.eu, US1_FED: ddog-gov.com, AP1: - // ap1.datadoghq.com, AP2: ap2.datadoghq.com. - Site DatadogAccountSite `json:"site"` - // Cost per GB of log data ingested (USD). NULL = using Datadog's published rate - // ($0.10/GB). Set to override with actual contract rate. - CostPerGBIngested *float64 `json:"costPerGBIngested"` - // Fraction of the API rate limit to consume (0.0-1.0). NULL = default (0.8 = - // 80%). Leaves headroom for the customer's own API usage. - RateLimitUtilization *float64 `json:"rateLimitUtilization"` - AccountID string `json:"accountID"` - // Optional client-provided UUID for offline-first sync. - // If provided and a record with this ID exists, returns the existing record. - Id *string `json:"id"` -} - -// GetName returns CreateDatadogAccountInput.Name, and is useful for accessing the field via an interface. -func (v *CreateDatadogAccountInput) GetName() string { return v.Name } - -// GetSite returns CreateDatadogAccountInput.Site, and is useful for accessing the field via an interface. -func (v *CreateDatadogAccountInput) GetSite() DatadogAccountSite { return v.Site } - -// GetCostPerGBIngested returns CreateDatadogAccountInput.CostPerGBIngested, and is useful for accessing the field via an interface. -func (v *CreateDatadogAccountInput) GetCostPerGBIngested() *float64 { return v.CostPerGBIngested } - -// GetRateLimitUtilization returns CreateDatadogAccountInput.RateLimitUtilization, and is useful for accessing the field via an interface. -func (v *CreateDatadogAccountInput) GetRateLimitUtilization() *float64 { return v.RateLimitUtilization } - -// GetAccountID returns CreateDatadogAccountInput.AccountID, and is useful for accessing the field via an interface. -func (v *CreateDatadogAccountInput) GetAccountID() string { return v.AccountID } - -// GetId returns CreateDatadogAccountInput.Id, and is useful for accessing the field via an interface. -func (v *CreateDatadogAccountInput) GetId() *string { return v.Id } - // CreateDatadogAccountWithCredentialsCreateDatadogAccount includes the requested fields of the GraphQL type DatadogAccount. type CreateDatadogAccountWithCredentialsCreateDatadogAccount struct { // Unique identifier of the Datadog configuration Id string `json:"id"` // Display name for this Datadog account Name string `json:"name"` - // Datadog regional site. US1: datadoghq.com, US3: us3.datadoghq.com, US5: - // us5.datadoghq.com, EU1: datadoghq.eu, US1_FED: ddog-gov.com, AP1: - // ap1.datadoghq.com, AP2: ap2.datadoghq.com. + // Datadog regional site. Values: US1 = datadoghq.com.; US3 = us3.datadoghq.com.; + // US5 = us5.datadoghq.com.; EU1 = datadoghq.eu.; US1_FED = ddog-gov.com.; AP1 = + // ap1.datadoghq.com.; AP2 = ap2.datadoghq.com. Site DatadogAccountSite `json:"site"` // When the Datadog account was created CreatedAt time.Time `json:"createdAt"` @@ -282,23 +93,9 @@ func (v *CreateDatadogAccountWithCredentialsCreateDatadogAccount) GetUpdatedAt() return v.UpdatedAt } -type CreateDatadogAccountWithCredentialsInput struct { - Attributes CreateDatadogAccountInput `json:"attributes"` - Credentials CreateDatadogCredentialsInput `json:"credentials"` -} - -// GetAttributes returns CreateDatadogAccountWithCredentialsInput.Attributes, and is useful for accessing the field via an interface. -func (v *CreateDatadogAccountWithCredentialsInput) GetAttributes() CreateDatadogAccountInput { - return v.Attributes -} - -// GetCredentials returns CreateDatadogAccountWithCredentialsInput.Credentials, and is useful for accessing the field via an interface. -func (v *CreateDatadogAccountWithCredentialsInput) GetCredentials() CreateDatadogCredentialsInput { - return v.Credentials -} - // CreateDatadogAccountWithCredentialsResponse is returned by CreateDatadogAccountWithCredentials on success. type CreateDatadogAccountWithCredentialsResponse struct { + // Create datadogaccount. CreateDatadogAccount CreateDatadogAccountWithCredentialsCreateDatadogAccount `json:"createDatadogAccount"` } @@ -307,97 +104,10 @@ func (v *CreateDatadogAccountWithCredentialsResponse) GetCreateDatadogAccount() return v.CreateDatadogAccount } -type CreateDatadogCredentialsInput struct { - ApiKey string `json:"apiKey"` - AppKey string `json:"appKey"` -} - -// GetApiKey returns CreateDatadogCredentialsInput.ApiKey, and is useful for accessing the field via an interface. -func (v *CreateDatadogCredentialsInput) GetApiKey() string { return v.ApiKey } - -// GetAppKey returns CreateDatadogCredentialsInput.AppKey, and is useful for accessing the field via an interface. -func (v *CreateDatadogCredentialsInput) GetAppKey() string { return v.AppKey } - -// CreateMessageCreateMessage includes the requested fields of the GraphQL type Message. -type CreateMessageCreateMessage struct { - // Unique identifier - Id string `json:"id"` - // Who sent this message. user: human-originated, assistant: AI-originated. - Role MessageRole `json:"role"` - // AI model that produced this message. Null for user messages. - Model *string `json:"model"` - // Why the assistant stopped generating. end_turn: completed response, tool_use: - // paused to call a tool. Null for user messages. - StopReason *MessageStopReason `json:"stopReason"` - // When the message was created - CreatedAt time.Time `json:"createdAt"` -} - -// GetId returns CreateMessageCreateMessage.Id, and is useful for accessing the field via an interface. -func (v *CreateMessageCreateMessage) GetId() string { return v.Id } - -// GetRole returns CreateMessageCreateMessage.Role, and is useful for accessing the field via an interface. -func (v *CreateMessageCreateMessage) GetRole() MessageRole { return v.Role } - -// GetModel returns CreateMessageCreateMessage.Model, and is useful for accessing the field via an interface. -func (v *CreateMessageCreateMessage) GetModel() *string { return v.Model } - -// GetStopReason returns CreateMessageCreateMessage.StopReason, and is useful for accessing the field via an interface. -func (v *CreateMessageCreateMessage) GetStopReason() *MessageStopReason { return v.StopReason } - -// GetCreatedAt returns CreateMessageCreateMessage.CreatedAt, and is useful for accessing the field via an interface. -func (v *CreateMessageCreateMessage) GetCreatedAt() time.Time { return v.CreatedAt } - -// CreateMessageInput is used for create Message object. -// Input was generated by ent. -type CreateMessageInput struct { - // Who sent this message. user: human-originated, assistant: AI-originated. - Role MessageRole `json:"role"` - // Why the assistant stopped generating. end_turn: completed response, tool_use: - // paused to call a tool. Null for user messages. - StopReason *MessageStopReason `json:"stopReason"` - // AI model that produced this message. Null for user messages. - Model *string `json:"model"` - ConversationID string `json:"conversationID"` - // Optional client-provided UUID for offline-first sync. - // If provided and a message with this ID exists, returns the existing record. - Id *string `json:"id"` - // Array of typed content blocks. - Content []ContentBlockInput `json:"content"` -} - -// GetRole returns CreateMessageInput.Role, and is useful for accessing the field via an interface. -func (v *CreateMessageInput) GetRole() MessageRole { return v.Role } - -// GetStopReason returns CreateMessageInput.StopReason, and is useful for accessing the field via an interface. -func (v *CreateMessageInput) GetStopReason() *MessageStopReason { return v.StopReason } - -// GetModel returns CreateMessageInput.Model, and is useful for accessing the field via an interface. -func (v *CreateMessageInput) GetModel() *string { return v.Model } - -// GetConversationID returns CreateMessageInput.ConversationID, and is useful for accessing the field via an interface. -func (v *CreateMessageInput) GetConversationID() string { return v.ConversationID } - -// GetId returns CreateMessageInput.Id, and is useful for accessing the field via an interface. -func (v *CreateMessageInput) GetId() *string { return v.Id } - -// GetContent returns CreateMessageInput.Content, and is useful for accessing the field via an interface. -func (v *CreateMessageInput) GetContent() []ContentBlockInput { return v.Content } - -// CreateMessageResponse is returned by CreateMessage on success. -type CreateMessageResponse struct { - // Create a new message in a conversation. - CreateMessage CreateMessageCreateMessage `json:"createMessage"` -} - -// GetCreateMessage returns CreateMessageResponse.CreateMessage, and is useful for accessing the field via an interface. -func (v *CreateMessageResponse) GetCreateMessage() CreateMessageCreateMessage { return v.CreateMessage } - // CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResult includes the requested fields of the GraphQL type OrganizationBootstrapResult. type CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResult struct { Organization CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResultOrganization `json:"organization"` Account CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResultAccount `json:"account"` - Workspace CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResultWorkspace `json:"workspace"` } // GetOrganization returns CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResult.Organization, and is useful for accessing the field via an interface. @@ -410,11 +120,6 @@ func (v *CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizatio return v.Account } -// GetWorkspace returns CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResult.Workspace, and is useful for accessing the field via an interface. -func (v *CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResult) GetWorkspace() CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResultWorkspace { - return v.Workspace -} - // CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResultAccount includes the requested fields of the GraphQL type Account. type CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResultAccount struct { // Unique identifier of the account @@ -465,26 +170,9 @@ func (v *CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizatio return v.WorkosOrganizationID } -// CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResultWorkspace includes the requested fields of the GraphQL type Workspace. -type CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResultWorkspace struct { - // Unique identifier of the workspace - Id string `json:"id"` - // Human-readable name within the account - Name string `json:"name"` -} - -// GetId returns CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResultWorkspace.Id, and is useful for accessing the field via an interface. -func (v *CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResultWorkspace) GetId() string { - return v.Id -} - -// GetName returns CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResultWorkspace.Name, and is useful for accessing the field via an interface. -func (v *CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResultWorkspace) GetName() string { - return v.Name -} - // CreateOrganizationAndBootstrapResponse is returned by CreateOrganizationAndBootstrap on success. type CreateOrganizationAndBootstrapResponse struct { + // Create a new organization and its default account using the additive runtime. CreateOrganizationAndBootstrap CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResult `json:"createOrganizationAndBootstrap"` } @@ -493,23 +181,46 @@ func (v *CreateOrganizationAndBootstrapResponse) GetCreateOrganizationAndBootstr return v.CreateOrganizationAndBootstrap } -// CreateOrganizationInput is used for create Organization object. -// Input was generated by ent. -type CreateOrganizationInput struct { - // Human-readable name, unique across the system +// DatadogAccount creation input. +type DatadogAccountCreateInput struct { + // Parent account this configuration belongs to + AccountID string `json:"accountID"` + // Display name for this Datadog account Name string `json:"name"` - // Optional client-provided UUID for offline-first sync. - // If provided and a record with this ID exists, returns the existing record. - Id *string `json:"id"` + // Datadog regional site. Values: US1 = datadoghq.com.; US3 = us3.datadoghq.com.; + // US5 = us5.datadoghq.com.; EU1 = datadoghq.eu.; US1_FED = ddog-gov.com.; AP1 = + // ap1.datadoghq.com.; AP2 = ap2.datadoghq.com. + Site DatadogAccountSite `json:"site"` + // Datadog API credentials. Stored in the secret store, not Postgres. + Credentials DatadogAccountCredentialsInput `json:"credentials"` +} + +// GetAccountID returns DatadogAccountCreateInput.AccountID, and is useful for accessing the field via an interface. +func (v *DatadogAccountCreateInput) GetAccountID() string { return v.AccountID } + +// GetName returns DatadogAccountCreateInput.Name, and is useful for accessing the field via an interface. +func (v *DatadogAccountCreateInput) GetName() string { return v.Name } + +// GetSite returns DatadogAccountCreateInput.Site, and is useful for accessing the field via an interface. +func (v *DatadogAccountCreateInput) GetSite() DatadogAccountSite { return v.Site } + +// GetCredentials returns DatadogAccountCreateInput.Credentials, and is useful for accessing the field via an interface. +func (v *DatadogAccountCreateInput) GetCredentials() DatadogAccountCredentialsInput { + return v.Credentials +} + +// Datadog credentials for account creation. +type DatadogAccountCredentialsInput struct { + ApiKey string `json:"apiKey"` + AppKey string `json:"appKey"` } -// GetName returns CreateOrganizationInput.Name, and is useful for accessing the field via an interface. -func (v *CreateOrganizationInput) GetName() string { return v.Name } +// GetApiKey returns DatadogAccountCredentialsInput.ApiKey, and is useful for accessing the field via an interface. +func (v *DatadogAccountCredentialsInput) GetApiKey() string { return v.ApiKey } -// GetId returns CreateOrganizationInput.Id, and is useful for accessing the field via an interface. -func (v *CreateOrganizationInput) GetId() *string { return v.Id } +// GetAppKey returns DatadogAccountCredentialsInput.AppKey, and is useful for accessing the field via an interface. +func (v *DatadogAccountCredentialsInput) GetAppKey() string { return v.AppKey } -// DatadogAccountSite is enum for the field site type DatadogAccountSite string const ( @@ -532,44 +243,19 @@ var AllDatadogAccountSite = []DatadogAccountSite{ DatadogAccountSiteAp2, } -// DatadogAccountStatusCacheHealth is enum for the field health -type DatadogAccountStatusCacheHealth string - -const ( - DatadogAccountStatusCacheHealthDisabled DatadogAccountStatusCacheHealth = "DISABLED" - DatadogAccountStatusCacheHealthInactive DatadogAccountStatusCacheHealth = "INACTIVE" - DatadogAccountStatusCacheHealthError DatadogAccountStatusCacheHealth = "ERROR" - DatadogAccountStatusCacheHealthOk DatadogAccountStatusCacheHealth = "OK" -) - -var AllDatadogAccountStatusCacheHealth = []DatadogAccountStatusCacheHealth{ - DatadogAccountStatusCacheHealthDisabled, - DatadogAccountStatusCacheHealthInactive, - DatadogAccountStatusCacheHealthError, - DatadogAccountStatusCacheHealthOk, -} - -// DeleteConversationResponse is returned by DeleteConversation on success. -type DeleteConversationResponse struct { - // Delete a conversation and all its messages. - DeleteConversation bool `json:"deleteConversation"` -} - -// GetDeleteConversation returns DeleteConversationResponse.DeleteConversation, and is useful for accessing the field via an interface. -func (v *DeleteConversationResponse) GetDeleteConversation() bool { return v.DeleteConversation } - // DisableServiceResponse is returned by DisableService on success. type DisableServiceResponse struct { - UpdateService DisableServiceUpdateService `json:"updateService"` + // Set service enabled state. + SetServiceEnabled DisableServiceSetServiceEnabledService `json:"setServiceEnabled"` } -// GetUpdateService returns DisableServiceResponse.UpdateService, and is useful for accessing the field via an interface. -func (v *DisableServiceResponse) GetUpdateService() DisableServiceUpdateService { - return v.UpdateService +// GetSetServiceEnabled returns DisableServiceResponse.SetServiceEnabled, and is useful for accessing the field via an interface. +func (v *DisableServiceResponse) GetSetServiceEnabled() DisableServiceSetServiceEnabledService { + return v.SetServiceEnabled } -// DisableServiceUpdateService includes the requested fields of the GraphQL type Service. -type DisableServiceUpdateService struct { +// DisableServiceSetServiceEnabledService includes the requested fields of the GraphQL type Service. +type DisableServiceSetServiceEnabledService struct { // Unique identifier of the service Id string `json:"id"` // Service identifier in telemetry (e.g., 'checkout-service') @@ -578,68 +264,28 @@ type DisableServiceUpdateService struct { Enabled bool `json:"enabled"` } -// GetId returns DisableServiceUpdateService.Id, and is useful for accessing the field via an interface. -func (v *DisableServiceUpdateService) GetId() string { return v.Id } - -// GetName returns DisableServiceUpdateService.Name, and is useful for accessing the field via an interface. -func (v *DisableServiceUpdateService) GetName() string { return v.Name } - -// GetEnabled returns DisableServiceUpdateService.Enabled, and is useful for accessing the field via an interface. -func (v *DisableServiceUpdateService) GetEnabled() bool { return v.Enabled } - -// DismissLogEventPolicyDismissLogEventPolicy includes the requested fields of the GraphQL type LogEventPolicy. -type DismissLogEventPolicyDismissLogEventPolicy struct { - // Unique identifier - Id string `json:"id"` - // When this policy was dismissed by a user - DismissedAt *time.Time `json:"dismissedAt"` - // User ID who dismissed this policy - DismissedBy *string `json:"dismissedBy"` - // When this policy was approved by a user - ApprovedAt *time.Time `json:"approvedAt"` - // User ID who approved this policy - ApprovedBy *string `json:"approvedBy"` -} - -// GetId returns DismissLogEventPolicyDismissLogEventPolicy.Id, and is useful for accessing the field via an interface. -func (v *DismissLogEventPolicyDismissLogEventPolicy) GetId() string { return v.Id } - -// GetDismissedAt returns DismissLogEventPolicyDismissLogEventPolicy.DismissedAt, and is useful for accessing the field via an interface. -func (v *DismissLogEventPolicyDismissLogEventPolicy) GetDismissedAt() *time.Time { - return v.DismissedAt -} - -// GetDismissedBy returns DismissLogEventPolicyDismissLogEventPolicy.DismissedBy, and is useful for accessing the field via an interface. -func (v *DismissLogEventPolicyDismissLogEventPolicy) GetDismissedBy() *string { return v.DismissedBy } - -// GetApprovedAt returns DismissLogEventPolicyDismissLogEventPolicy.ApprovedAt, and is useful for accessing the field via an interface. -func (v *DismissLogEventPolicyDismissLogEventPolicy) GetApprovedAt() *time.Time { return v.ApprovedAt } - -// GetApprovedBy returns DismissLogEventPolicyDismissLogEventPolicy.ApprovedBy, and is useful for accessing the field via an interface. -func (v *DismissLogEventPolicyDismissLogEventPolicy) GetApprovedBy() *string { return v.ApprovedBy } +// GetId returns DisableServiceSetServiceEnabledService.Id, and is useful for accessing the field via an interface. +func (v *DisableServiceSetServiceEnabledService) GetId() string { return v.Id } -// DismissLogEventPolicyResponse is returned by DismissLogEventPolicy on success. -type DismissLogEventPolicyResponse struct { - // Dismiss a log event policy, hiding it from pending review. - // Clears any previous approval. - DismissLogEventPolicy DismissLogEventPolicyDismissLogEventPolicy `json:"dismissLogEventPolicy"` -} +// GetName returns DisableServiceSetServiceEnabledService.Name, and is useful for accessing the field via an interface. +func (v *DisableServiceSetServiceEnabledService) GetName() string { return v.Name } -// GetDismissLogEventPolicy returns DismissLogEventPolicyResponse.DismissLogEventPolicy, and is useful for accessing the field via an interface. -func (v *DismissLogEventPolicyResponse) GetDismissLogEventPolicy() DismissLogEventPolicyDismissLogEventPolicy { - return v.DismissLogEventPolicy -} +// GetEnabled returns DisableServiceSetServiceEnabledService.Enabled, and is useful for accessing the field via an interface. +func (v *DisableServiceSetServiceEnabledService) GetEnabled() bool { return v.Enabled } // EnableServiceResponse is returned by EnableService on success. type EnableServiceResponse struct { - UpdateService EnableServiceUpdateService `json:"updateService"` + // Set service enabled state. + SetServiceEnabled EnableServiceSetServiceEnabledService `json:"setServiceEnabled"` } -// GetUpdateService returns EnableServiceResponse.UpdateService, and is useful for accessing the field via an interface. -func (v *EnableServiceResponse) GetUpdateService() EnableServiceUpdateService { return v.UpdateService } +// GetSetServiceEnabled returns EnableServiceResponse.SetServiceEnabled, and is useful for accessing the field via an interface. +func (v *EnableServiceResponse) GetSetServiceEnabled() EnableServiceSetServiceEnabledService { + return v.SetServiceEnabled +} -// EnableServiceUpdateService includes the requested fields of the GraphQL type Service. -type EnableServiceUpdateService struct { +// EnableServiceSetServiceEnabledService includes the requested fields of the GraphQL type Service. +type EnableServiceSetServiceEnabledService struct { // Unique identifier of the service Id string `json:"id"` // Service identifier in telemetry (e.g., 'checkout-service') @@ -648,40 +294,44 @@ type EnableServiceUpdateService struct { Enabled bool `json:"enabled"` } -// GetId returns EnableServiceUpdateService.Id, and is useful for accessing the field via an interface. -func (v *EnableServiceUpdateService) GetId() string { return v.Id } +// GetId returns EnableServiceSetServiceEnabledService.Id, and is useful for accessing the field via an interface. +func (v *EnableServiceSetServiceEnabledService) GetId() string { return v.Id } + +// GetName returns EnableServiceSetServiceEnabledService.Name, and is useful for accessing the field via an interface. +func (v *EnableServiceSetServiceEnabledService) GetName() string { return v.Name } -// GetName returns EnableServiceUpdateService.Name, and is useful for accessing the field via an interface. -func (v *EnableServiceUpdateService) GetName() string { return v.Name } +// GetEnabled returns EnableServiceSetServiceEnabledService.Enabled, and is useful for accessing the field via an interface. +func (v *EnableServiceSetServiceEnabledService) GetEnabled() bool { return v.Enabled } + +type FindingCheckDomain string + +const ( + FindingCheckDomainCost FindingCheckDomain = "cost" + FindingCheckDomainCompliance FindingCheckDomain = "compliance" +) -// GetEnabled returns EnableServiceUpdateService.Enabled, and is useful for accessing the field via an interface. -func (v *EnableServiceUpdateService) GetEnabled() bool { return v.Enabled } +var AllFindingCheckDomain = []FindingCheckDomain{ + FindingCheckDomainCost, + FindingCheckDomainCompliance, +} // GetAccountAccountsAccountConnection includes the requested fields of the GraphQL type AccountConnection. -// The GraphQL type's documentation follows. -// -// A connection to a list of items. type GetAccountAccountsAccountConnection struct { - // A list of edges. - Edges []*GetAccountAccountsAccountConnectionEdgesAccountEdge `json:"edges"` + Edges []GetAccountAccountsAccountConnectionEdgesAccountEdge `json:"edges"` } // GetEdges returns GetAccountAccountsAccountConnection.Edges, and is useful for accessing the field via an interface. -func (v *GetAccountAccountsAccountConnection) GetEdges() []*GetAccountAccountsAccountConnectionEdgesAccountEdge { +func (v *GetAccountAccountsAccountConnection) GetEdges() []GetAccountAccountsAccountConnectionEdgesAccountEdge { return v.Edges } // GetAccountAccountsAccountConnectionEdgesAccountEdge includes the requested fields of the GraphQL type AccountEdge. -// The GraphQL type's documentation follows. -// -// An edge in a connection. type GetAccountAccountsAccountConnectionEdgesAccountEdge struct { - // The item at the end of the edge. - Node *GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccount `json:"node"` + Node GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccount `json:"node"` } // GetNode returns GetAccountAccountsAccountConnectionEdgesAccountEdge.Node, and is useful for accessing the field via an interface. -func (v *GetAccountAccountsAccountConnectionEdgesAccountEdge) GetNode() *GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccount { +func (v *GetAccountAccountsAccountConnectionEdgesAccountEdge) GetNode() GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccount { return v.Node } @@ -707,9 +357,9 @@ type GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccountDatadogAccoun Id string `json:"id"` // Display name for this Datadog account Name string `json:"name"` - // Datadog regional site. US1: datadoghq.com, US3: us3.datadoghq.com, US5: - // us5.datadoghq.com, EU1: datadoghq.eu, US1_FED: ddog-gov.com, AP1: - // ap1.datadoghq.com, AP2: ap2.datadoghq.com. + // Datadog regional site. Values: US1 = datadoghq.com.; US3 = us3.datadoghq.com.; + // US5 = us5.datadoghq.com.; EU1 = datadoghq.eu.; US1_FED = ddog-gov.com.; AP1 = + // ap1.datadoghq.com.; AP2 = ap2.datadoghq.com. Site DatadogAccountSite `json:"site"` } @@ -730,860 +380,508 @@ func (v *GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccountDatadogAc // GetAccountResponse is returned by GetAccount on success. type GetAccountResponse struct { - // Query accounts. Accounts belong to an organization and contain services and workspaces. + // Query Accounts records in your account. Accounts GetAccountAccountsAccountConnection `json:"accounts"` } // GetAccounts returns GetAccountResponse.Accounts, and is useful for accessing the field via an interface. func (v *GetAccountResponse) GetAccounts() GetAccountAccountsAccountConnection { return v.Accounts } -// GetDatadogAccountStatusDatadogAccountsDatadogAccountConnection includes the requested fields of the GraphQL type DatadogAccountConnection. -// The GraphQL type's documentation follows. -// -// A connection to a list of items. -type GetDatadogAccountStatusDatadogAccountsDatadogAccountConnection struct { - // A list of edges. - Edges []*GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge `json:"edges"` +// GetAccountStatusSummaryDatadogAccountsDatadogAccountConnection includes the requested fields of the GraphQL type DatadogAccountConnection. +type GetAccountStatusSummaryDatadogAccountsDatadogAccountConnection struct { + Edges []GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge `json:"edges"` } -// GetEdges returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnection.Edges, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnection) GetEdges() []*GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge { +// GetEdges returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnection.Edges, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnection) GetEdges() []GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge { return v.Edges } -// GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge includes the requested fields of the GraphQL type DatadogAccountEdge. -// The GraphQL type's documentation follows. -// -// An edge in a connection. -type GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge struct { - // The item at the end of the edge. - Node *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount `json:"node"` +// GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge includes the requested fields of the GraphQL type DatadogAccountEdge. +type GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge struct { + Node GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount `json:"node"` } -// GetNode returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge.Node, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge) GetNode() *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount { +// GetNode returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge.Node, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge) GetNode() GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount { return v.Node } -// GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount includes the requested fields of the GraphQL type DatadogAccount. -type GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount struct { - // Unique identifier of the Datadog configuration - Id string `json:"id"` - // Status of this Datadog account in the discovery pipeline. +// GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount includes the requested fields of the GraphQL type DatadogAccount. +type GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount struct { + // Status of this Datadog account in the catalog pipeline. // Derived from the status of all services discovered from this account. - // Returns null if cache has not been populated yet. - Status *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache `json:"status"` + // Returns null if the status view has no row yet. + Status *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus `json:"status"` } -// GetId returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount.Id, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount) GetId() string { - return v.Id +// GetStatus returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount.Status, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount) GetStatus() *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus { + return v.Status } -// GetStatus returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount.Status, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount) GetStatus() *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache { - return v.Status +// GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus includes the requested fields of the GraphQL type DatadogAccountStatus. +type GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus struct { + Health StatusHealth `json:"health"` + Readiness GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusReadiness `json:"readiness"` + Coverage GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage `json:"coverage"` + Current GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatus `json:"current"` } -// GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache includes the requested fields of the GraphQL type DatadogAccountStatusCache. -type GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache struct { - // Overall health of the Datadog account. DISABLED (integration turned off), INACTIVE (no data received), OK (healthy). - Health DatadogAccountStatusCacheHealth `json:"health"` - ReadyForUse bool `json:"readyForUse"` - LogEventCount int `json:"logEventCount"` - LogEventAnalyzedCount int `json:"logEventAnalyzedCount"` - LogServiceCount int `json:"logServiceCount"` - LogActiveServices int `json:"logActiveServices"` - DisabledServices int `json:"disabledServices"` - InactiveServices int `json:"inactiveServices"` - OkServices int `json:"okServices"` - PolicyPendingCount int `json:"policyPendingCount"` - PolicyApprovedCount int `json:"policyApprovedCount"` - PolicyDismissedCount int `json:"policyDismissedCount"` - ServiceVolumePerHour *float64 `json:"serviceVolumePerHour"` - ServiceCostPerHourVolumeUsd *float64 `json:"serviceCostPerHourVolumeUsd"` - LogEventVolumePerHour *float64 `json:"logEventVolumePerHour"` - LogEventBytesPerHour *float64 `json:"logEventBytesPerHour"` - LogEventCostPerHourBytesUsd *float64 `json:"logEventCostPerHourBytesUsd"` - LogEventCostPerHourVolumeUsd *float64 `json:"logEventCostPerHourVolumeUsd"` - LogEventCostPerHourUsd *float64 `json:"logEventCostPerHourUsd"` - EstimatedVolumeReductionPerHour *float64 `json:"estimatedVolumeReductionPerHour"` - EstimatedBytesReductionPerHour *float64 `json:"estimatedBytesReductionPerHour"` - EstimatedCostReductionPerHourBytesUsd *float64 `json:"estimatedCostReductionPerHourBytesUsd"` - EstimatedCostReductionPerHourVolumeUsd *float64 `json:"estimatedCostReductionPerHourVolumeUsd"` - EstimatedCostReductionPerHourUsd *float64 `json:"estimatedCostReductionPerHourUsd"` - ObservedVolumePerHourBefore *float64 `json:"observedVolumePerHourBefore"` - ObservedVolumePerHourAfter *float64 `json:"observedVolumePerHourAfter"` - ObservedBytesPerHourBefore *float64 `json:"observedBytesPerHourBefore"` - ObservedBytesPerHourAfter *float64 `json:"observedBytesPerHourAfter"` - ObservedCostPerHourBeforeBytesUsd *float64 `json:"observedCostPerHourBeforeBytesUsd"` - ObservedCostPerHourBeforeVolumeUsd *float64 `json:"observedCostPerHourBeforeVolumeUsd"` - ObservedCostPerHourBeforeUsd *float64 `json:"observedCostPerHourBeforeUsd"` - ObservedCostPerHourAfterBytesUsd *float64 `json:"observedCostPerHourAfterBytesUsd"` - ObservedCostPerHourAfterVolumeUsd *float64 `json:"observedCostPerHourAfterVolumeUsd"` - ObservedCostPerHourAfterUsd *float64 `json:"observedCostPerHourAfterUsd"` -} - -// GetHealth returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.Health, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetHealth() DatadogAccountStatusCacheHealth { +// GetHealth returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus.Health, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus) GetHealth() StatusHealth { return v.Health } -// GetReadyForUse returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.ReadyForUse, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetReadyForUse() bool { - return v.ReadyForUse +// GetReadiness returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus.Readiness, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus) GetReadiness() GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusReadiness { + return v.Readiness +} + +// GetCoverage returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus.Coverage, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus) GetCoverage() GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage { + return v.Coverage +} + +// GetCurrent returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus.Current, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus) GetCurrent() GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatus { + return v.Current +} + +// GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage includes the requested fields of the GraphQL type DatadogAccountStatusCoverage. +type GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage struct { + LogEventCount int `json:"logEventCount"` + LogEventAnalyzedCount int `json:"logEventAnalyzedCount"` + LogServiceCount int `json:"logServiceCount"` + LogActiveServices int `json:"logActiveServices"` + DisabledServices int `json:"disabledServices"` + InactiveServices int `json:"inactiveServices"` + OkServices int `json:"okServices"` } -// GetLogEventCount returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.LogEventCount, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetLogEventCount() int { +// GetLogEventCount returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage.LogEventCount, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage) GetLogEventCount() int { return v.LogEventCount } -// GetLogEventAnalyzedCount returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.LogEventAnalyzedCount, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetLogEventAnalyzedCount() int { +// GetLogEventAnalyzedCount returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage.LogEventAnalyzedCount, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage) GetLogEventAnalyzedCount() int { return v.LogEventAnalyzedCount } -// GetLogServiceCount returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.LogServiceCount, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetLogServiceCount() int { +// GetLogServiceCount returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage.LogServiceCount, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage) GetLogServiceCount() int { return v.LogServiceCount } -// GetLogActiveServices returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.LogActiveServices, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetLogActiveServices() int { +// GetLogActiveServices returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage.LogActiveServices, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage) GetLogActiveServices() int { return v.LogActiveServices } -// GetDisabledServices returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.DisabledServices, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetDisabledServices() int { +// GetDisabledServices returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage.DisabledServices, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage) GetDisabledServices() int { return v.DisabledServices } -// GetInactiveServices returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.InactiveServices, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetInactiveServices() int { +// GetInactiveServices returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage.InactiveServices, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage) GetInactiveServices() int { return v.InactiveServices } -// GetOkServices returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.OkServices, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetOkServices() int { +// GetOkServices returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage.OkServices, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage) GetOkServices() int { return v.OkServices } -// GetPolicyPendingCount returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.PolicyPendingCount, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetPolicyPendingCount() int { - return v.PolicyPendingCount +// GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatus includes the requested fields of the GraphQL type DatadogAccountCurrentStatus. +type GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatus struct { + Services GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusServicesStatusServiceTotals `json:"services"` + Totals GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusTotalsStatusMeasurementTotals `json:"totals"` } -// GetPolicyApprovedCount returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.PolicyApprovedCount, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetPolicyApprovedCount() int { - return v.PolicyApprovedCount +// GetServices returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatus.Services, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatus) GetServices() GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusServicesStatusServiceTotals { + return v.Services } -// GetPolicyDismissedCount returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.PolicyDismissedCount, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetPolicyDismissedCount() int { - return v.PolicyDismissedCount +// GetTotals returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatus.Totals, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatus) GetTotals() GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusTotalsStatusMeasurementTotals { + return v.Totals } -// GetServiceVolumePerHour returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.ServiceVolumePerHour, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetServiceVolumePerHour() *float64 { - return v.ServiceVolumePerHour +// GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusServicesStatusServiceTotals includes the requested fields of the GraphQL type StatusServiceTotals. +type GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusServicesStatusServiceTotals struct { + EventsPerHour *float64 `json:"eventsPerHour"` + VolumeUsdPerHour *float64 `json:"volumeUsdPerHour"` } -// GetServiceCostPerHourVolumeUsd returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.ServiceCostPerHourVolumeUsd, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetServiceCostPerHourVolumeUsd() *float64 { - return v.ServiceCostPerHourVolumeUsd +// GetEventsPerHour returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusServicesStatusServiceTotals.EventsPerHour, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusServicesStatusServiceTotals) GetEventsPerHour() *float64 { + return v.EventsPerHour } -// GetLogEventVolumePerHour returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.LogEventVolumePerHour, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetLogEventVolumePerHour() *float64 { - return v.LogEventVolumePerHour +// GetVolumeUsdPerHour returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusServicesStatusServiceTotals.VolumeUsdPerHour, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusServicesStatusServiceTotals) GetVolumeUsdPerHour() *float64 { + return v.VolumeUsdPerHour } -// GetLogEventBytesPerHour returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.LogEventBytesPerHour, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetLogEventBytesPerHour() *float64 { - return v.LogEventBytesPerHour +// GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusTotalsStatusMeasurementTotals includes the requested fields of the GraphQL type StatusMeasurementTotals. +type GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusTotalsStatusMeasurementTotals struct { + EventsPerHour *float64 `json:"eventsPerHour"` + BytesPerHour *float64 `json:"bytesPerHour"` + TotalUsdPerHour *float64 `json:"totalUsdPerHour"` + VolumeUsdPerHour *float64 `json:"volumeUsdPerHour"` } -// GetLogEventCostPerHourBytesUsd returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.LogEventCostPerHourBytesUsd, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetLogEventCostPerHourBytesUsd() *float64 { - return v.LogEventCostPerHourBytesUsd +// GetEventsPerHour returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusTotalsStatusMeasurementTotals.EventsPerHour, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusTotalsStatusMeasurementTotals) GetEventsPerHour() *float64 { + return v.EventsPerHour } -// GetLogEventCostPerHourVolumeUsd returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.LogEventCostPerHourVolumeUsd, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetLogEventCostPerHourVolumeUsd() *float64 { - return v.LogEventCostPerHourVolumeUsd +// GetBytesPerHour returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusTotalsStatusMeasurementTotals.BytesPerHour, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusTotalsStatusMeasurementTotals) GetBytesPerHour() *float64 { + return v.BytesPerHour } -// GetLogEventCostPerHourUsd returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.LogEventCostPerHourUsd, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetLogEventCostPerHourUsd() *float64 { - return v.LogEventCostPerHourUsd +// GetTotalUsdPerHour returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusTotalsStatusMeasurementTotals.TotalUsdPerHour, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusTotalsStatusMeasurementTotals) GetTotalUsdPerHour() *float64 { + return v.TotalUsdPerHour } -// GetEstimatedVolumeReductionPerHour returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.EstimatedVolumeReductionPerHour, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetEstimatedVolumeReductionPerHour() *float64 { - return v.EstimatedVolumeReductionPerHour +// GetVolumeUsdPerHour returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusTotalsStatusMeasurementTotals.VolumeUsdPerHour, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusTotalsStatusMeasurementTotals) GetVolumeUsdPerHour() *float64 { + return v.VolumeUsdPerHour } -// GetEstimatedBytesReductionPerHour returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.EstimatedBytesReductionPerHour, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetEstimatedBytesReductionPerHour() *float64 { - return v.EstimatedBytesReductionPerHour +// GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusReadiness includes the requested fields of the GraphQL type StatusReadiness. +type GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusReadiness struct { + ReadyForUse bool `json:"readyForUse"` } -// GetEstimatedCostReductionPerHourBytesUsd returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.EstimatedCostReductionPerHourBytesUsd, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetEstimatedCostReductionPerHourBytesUsd() *float64 { - return v.EstimatedCostReductionPerHourBytesUsd +// GetReadyForUse returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusReadiness.ReadyForUse, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusReadiness) GetReadyForUse() bool { + return v.ReadyForUse } -// GetEstimatedCostReductionPerHourVolumeUsd returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.EstimatedCostReductionPerHourVolumeUsd, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetEstimatedCostReductionPerHourVolumeUsd() *float64 { - return v.EstimatedCostReductionPerHourVolumeUsd +// GetAccountStatusSummaryResponse is returned by GetAccountStatusSummary on success. +type GetAccountStatusSummaryResponse struct { + // Query DatadogAccounts records in your account. + DatadogAccounts GetAccountStatusSummaryDatadogAccountsDatadogAccountConnection `json:"datadogAccounts"` } -// GetEstimatedCostReductionPerHourUsd returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.EstimatedCostReductionPerHourUsd, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetEstimatedCostReductionPerHourUsd() *float64 { - return v.EstimatedCostReductionPerHourUsd +// GetDatadogAccounts returns GetAccountStatusSummaryResponse.DatadogAccounts, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryResponse) GetDatadogAccounts() GetAccountStatusSummaryDatadogAccountsDatadogAccountConnection { + return v.DatadogAccounts } -// GetObservedVolumePerHourBefore returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.ObservedVolumePerHourBefore, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetObservedVolumePerHourBefore() *float64 { - return v.ObservedVolumePerHourBefore +// GetDatadogAccountStatusDatadogAccountsDatadogAccountConnection includes the requested fields of the GraphQL type DatadogAccountConnection. +type GetDatadogAccountStatusDatadogAccountsDatadogAccountConnection struct { + Edges []GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge `json:"edges"` } -// GetObservedVolumePerHourAfter returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.ObservedVolumePerHourAfter, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetObservedVolumePerHourAfter() *float64 { - return v.ObservedVolumePerHourAfter +// GetEdges returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnection.Edges, and is useful for accessing the field via an interface. +func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnection) GetEdges() []GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge { + return v.Edges } -// GetObservedBytesPerHourBefore returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.ObservedBytesPerHourBefore, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetObservedBytesPerHourBefore() *float64 { - return v.ObservedBytesPerHourBefore +// GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge includes the requested fields of the GraphQL type DatadogAccountEdge. +type GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge struct { + Node GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount `json:"node"` } -// GetObservedBytesPerHourAfter returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.ObservedBytesPerHourAfter, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetObservedBytesPerHourAfter() *float64 { - return v.ObservedBytesPerHourAfter +// GetNode returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge.Node, and is useful for accessing the field via an interface. +func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge) GetNode() GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount { + return v.Node } -// GetObservedCostPerHourBeforeBytesUsd returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.ObservedCostPerHourBeforeBytesUsd, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetObservedCostPerHourBeforeBytesUsd() *float64 { - return v.ObservedCostPerHourBeforeBytesUsd +// GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount includes the requested fields of the GraphQL type DatadogAccount. +type GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount struct { + // Unique identifier of the Datadog configuration + Id string `json:"id"` + // Status of this Datadog account in the catalog pipeline. + // Derived from the status of all services discovered from this account. + // Returns null if the status view has no row yet. + Status *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus `json:"status"` } -// GetObservedCostPerHourBeforeVolumeUsd returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.ObservedCostPerHourBeforeVolumeUsd, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetObservedCostPerHourBeforeVolumeUsd() *float64 { - return v.ObservedCostPerHourBeforeVolumeUsd +// GetId returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount.Id, and is useful for accessing the field via an interface. +func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount) GetId() string { + return v.Id } -// GetObservedCostPerHourBeforeUsd returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.ObservedCostPerHourBeforeUsd, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetObservedCostPerHourBeforeUsd() *float64 { - return v.ObservedCostPerHourBeforeUsd +// GetStatus returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount.Status, and is useful for accessing the field via an interface. +func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount) GetStatus() *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus { + return v.Status } -// GetObservedCostPerHourAfterBytesUsd returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.ObservedCostPerHourAfterBytesUsd, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetObservedCostPerHourAfterBytesUsd() *float64 { - return v.ObservedCostPerHourAfterBytesUsd +// GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus includes the requested fields of the GraphQL type DatadogAccountStatus. +type GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus struct { + Health StatusHealth `json:"health"` + Readiness GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusReadiness `json:"readiness"` + Coverage GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage `json:"coverage"` } -// GetObservedCostPerHourAfterVolumeUsd returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.ObservedCostPerHourAfterVolumeUsd, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetObservedCostPerHourAfterVolumeUsd() *float64 { - return v.ObservedCostPerHourAfterVolumeUsd +// GetHealth returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus.Health, and is useful for accessing the field via an interface. +func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus) GetHealth() StatusHealth { + return v.Health } -// GetObservedCostPerHourAfterUsd returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.ObservedCostPerHourAfterUsd, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetObservedCostPerHourAfterUsd() *float64 { - return v.ObservedCostPerHourAfterUsd +// GetReadiness returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus.Readiness, and is useful for accessing the field via an interface. +func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus) GetReadiness() GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusReadiness { + return v.Readiness } -// GetDatadogAccountStatusResponse is returned by GetDatadogAccountStatus on success. -type GetDatadogAccountStatusResponse struct { - // Query connected Datadog accounts. - DatadogAccounts GetDatadogAccountStatusDatadogAccountsDatadogAccountConnection `json:"datadogAccounts"` +// GetCoverage returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus.Coverage, and is useful for accessing the field via an interface. +func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus) GetCoverage() GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage { + return v.Coverage } -// GetDatadogAccounts returns GetDatadogAccountStatusResponse.DatadogAccounts, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusResponse) GetDatadogAccounts() GetDatadogAccountStatusDatadogAccountsDatadogAccountConnection { - return v.DatadogAccounts +// GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage includes the requested fields of the GraphQL type DatadogAccountStatusCoverage. +type GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage struct { + LogEventCount int `json:"logEventCount"` + LogEventAnalyzedCount int `json:"logEventAnalyzedCount"` + LogServiceCount int `json:"logServiceCount"` + LogActiveServices int `json:"logActiveServices"` + DisabledServices int `json:"disabledServices"` + InactiveServices int `json:"inactiveServices"` + OkServices int `json:"okServices"` } -// GetServiceByNameResponse is returned by GetServiceByName on success. -type GetServiceByNameResponse struct { - // Query services in your system. - Services GetServiceByNameServicesServiceConnection `json:"services"` +// GetLogEventCount returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage.LogEventCount, and is useful for accessing the field via an interface. +func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage) GetLogEventCount() int { + return v.LogEventCount } -// GetServices returns GetServiceByNameResponse.Services, and is useful for accessing the field via an interface. -func (v *GetServiceByNameResponse) GetServices() GetServiceByNameServicesServiceConnection { - return v.Services +// GetLogEventAnalyzedCount returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage.LogEventAnalyzedCount, and is useful for accessing the field via an interface. +func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage) GetLogEventAnalyzedCount() int { + return v.LogEventAnalyzedCount } -// GetServiceByNameServicesServiceConnection includes the requested fields of the GraphQL type ServiceConnection. -// The GraphQL type's documentation follows. -// -// A connection to a list of items. -type GetServiceByNameServicesServiceConnection struct { - // A list of edges. - Edges []*GetServiceByNameServicesServiceConnectionEdgesServiceEdge `json:"edges"` +// GetLogServiceCount returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage.LogServiceCount, and is useful for accessing the field via an interface. +func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage) GetLogServiceCount() int { + return v.LogServiceCount } -// GetEdges returns GetServiceByNameServicesServiceConnection.Edges, and is useful for accessing the field via an interface. -func (v *GetServiceByNameServicesServiceConnection) GetEdges() []*GetServiceByNameServicesServiceConnectionEdgesServiceEdge { - return v.Edges +// GetLogActiveServices returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage.LogActiveServices, and is useful for accessing the field via an interface. +func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage) GetLogActiveServices() int { + return v.LogActiveServices } -// GetServiceByNameServicesServiceConnectionEdgesServiceEdge includes the requested fields of the GraphQL type ServiceEdge. -// The GraphQL type's documentation follows. -// -// An edge in a connection. -type GetServiceByNameServicesServiceConnectionEdgesServiceEdge struct { - // The item at the end of the edge. - Node *GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService `json:"node"` +// GetDisabledServices returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage.DisabledServices, and is useful for accessing the field via an interface. +func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage) GetDisabledServices() int { + return v.DisabledServices } -// GetNode returns GetServiceByNameServicesServiceConnectionEdgesServiceEdge.Node, and is useful for accessing the field via an interface. -func (v *GetServiceByNameServicesServiceConnectionEdgesServiceEdge) GetNode() *GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService { - return v.Node +// GetInactiveServices returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage.InactiveServices, and is useful for accessing the field via an interface. +func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage) GetInactiveServices() int { + return v.InactiveServices } -// GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService includes the requested fields of the GraphQL type Service. -type GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService struct { - // Unique identifier of the service - Id string `json:"id"` - // Service identifier in telemetry (e.g., 'checkout-service') - Name string `json:"name"` - // AI-generated description of what this service does and its telemetry characteristics - Description string `json:"description"` - // Whether log analysis and policy generation is active for this service - Enabled bool `json:"enabled"` - // When the service was created - CreatedAt time.Time `json:"createdAt"` - // When the service was last updated - UpdatedAt time.Time `json:"updatedAt"` +// GetOkServices returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage.OkServices, and is useful for accessing the field via an interface. +func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage) GetOkServices() int { + return v.OkServices } -// GetId returns GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService.Id, and is useful for accessing the field via an interface. -func (v *GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService) GetId() string { - return v.Id +// GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusReadiness includes the requested fields of the GraphQL type StatusReadiness. +type GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusReadiness struct { + ReadyForUse bool `json:"readyForUse"` } -// GetName returns GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService.Name, and is useful for accessing the field via an interface. -func (v *GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService) GetName() string { - return v.Name +// GetReadyForUse returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusReadiness.ReadyForUse, and is useful for accessing the field via an interface. +func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusReadiness) GetReadyForUse() bool { + return v.ReadyForUse } -// GetDescription returns GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService.Description, and is useful for accessing the field via an interface. -func (v *GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService) GetDescription() string { - return v.Description -} - -// GetEnabled returns GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService.Enabled, and is useful for accessing the field via an interface. -func (v *GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService) GetEnabled() bool { - return v.Enabled +// GetDatadogAccountStatusResponse is returned by GetDatadogAccountStatus on success. +type GetDatadogAccountStatusResponse struct { + // Query DatadogAccounts records in your account. + DatadogAccounts GetDatadogAccountStatusDatadogAccountsDatadogAccountConnection `json:"datadogAccounts"` } -// GetCreatedAt returns GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService.CreatedAt, and is useful for accessing the field via an interface. -func (v *GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService) GetCreatedAt() time.Time { - return v.CreatedAt +// GetDatadogAccounts returns GetDatadogAccountStatusResponse.DatadogAccounts, and is useful for accessing the field via an interface. +func (v *GetDatadogAccountStatusResponse) GetDatadogAccounts() GetDatadogAccountStatusDatadogAccountsDatadogAccountConnection { + return v.DatadogAccounts } -// GetUpdatedAt returns GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService.UpdatedAt, and is useful for accessing the field via an interface. -func (v *GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService) GetUpdatedAt() time.Time { - return v.UpdatedAt +// GetIssueSummaryIssuesIssueConnection includes the requested fields of the GraphQL type IssueConnection. +type GetIssueSummaryIssuesIssueConnection struct { + TotalCount int `json:"totalCount"` + Summary GetIssueSummaryIssuesIssueConnectionSummaryIssueSummary `json:"summary"` + Facets GetIssueSummaryIssuesIssueConnectionFacetsIssueFacets `json:"facets"` } -// GetServiceNode includes the requested fields of the GraphQL interface Node. -// -// GetServiceNode is implemented by the following types: -// GetServiceNodeAccount -// GetServiceNodeConversation -// GetServiceNodeConversationContext -// GetServiceNodeDatadogAccount -// GetServiceNodeDatadogAccountStatusCache -// GetServiceNodeDatadogLogIndex -// GetServiceNodeEdgeApiKey -// GetServiceNodeEdgeInstance -// GetServiceNodeLogEvent -// GetServiceNodeLogEventField -// GetServiceNodeLogEventPolicy -// GetServiceNodeLogEventPolicyCategoryStatusCache -// GetServiceNodeLogEventPolicyStatusCache -// GetServiceNodeLogEventStatusCache -// GetServiceNodeLogSample -// GetServiceNodeMessage -// GetServiceNodeOrganization -// GetServiceNodeService -// GetServiceNodeServiceStatusCache -// GetServiceNodeTeam -// GetServiceNodeView -// GetServiceNodeViewFavorite -// GetServiceNodeWorkspace -// The GraphQL type's documentation follows. -// -// An object with an ID. -// Follows the [Relay Global Object Identification Specification](https://relay.dev/graphql/objectidentification.htm) -type GetServiceNode interface { - implementsGraphQLInterfaceGetServiceNode() - // GetTypename returns the receiver's concrete GraphQL type-name (see interface doc for possible values). - GetTypename() *string -} - -func (v *GetServiceNodeAccount) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeConversation) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeConversationContext) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeDatadogAccount) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeDatadogAccountStatusCache) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeDatadogLogIndex) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeEdgeApiKey) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeEdgeInstance) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeLogEvent) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeLogEventField) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeLogEventPolicy) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeLogEventPolicyCategoryStatusCache) implementsGraphQLInterfaceGetServiceNode() { -} -func (v *GetServiceNodeLogEventPolicyStatusCache) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeLogEventStatusCache) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeLogSample) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeMessage) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeOrganization) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeService) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeServiceStatusCache) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeTeam) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeView) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeViewFavorite) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeWorkspace) implementsGraphQLInterfaceGetServiceNode() {} - -func __unmarshalGetServiceNode(b []byte, v *GetServiceNode) error { - if string(b) == "null" { - return nil - } +// GetTotalCount returns GetIssueSummaryIssuesIssueConnection.TotalCount, and is useful for accessing the field via an interface. +func (v *GetIssueSummaryIssuesIssueConnection) GetTotalCount() int { return v.TotalCount } - var tn struct { - TypeName string `json:"__typename"` - } - err := json.Unmarshal(b, &tn) - if err != nil { - return err - } - - switch tn.TypeName { - case "Account": - *v = new(GetServiceNodeAccount) - return json.Unmarshal(b, *v) - case "Conversation": - *v = new(GetServiceNodeConversation) - return json.Unmarshal(b, *v) - case "ConversationContext": - *v = new(GetServiceNodeConversationContext) - return json.Unmarshal(b, *v) - case "DatadogAccount": - *v = new(GetServiceNodeDatadogAccount) - return json.Unmarshal(b, *v) - case "DatadogAccountStatusCache": - *v = new(GetServiceNodeDatadogAccountStatusCache) - return json.Unmarshal(b, *v) - case "DatadogLogIndex": - *v = new(GetServiceNodeDatadogLogIndex) - return json.Unmarshal(b, *v) - case "EdgeApiKey": - *v = new(GetServiceNodeEdgeApiKey) - return json.Unmarshal(b, *v) - case "EdgeInstance": - *v = new(GetServiceNodeEdgeInstance) - return json.Unmarshal(b, *v) - case "LogEvent": - *v = new(GetServiceNodeLogEvent) - return json.Unmarshal(b, *v) - case "LogEventField": - *v = new(GetServiceNodeLogEventField) - return json.Unmarshal(b, *v) - case "LogEventPolicy": - *v = new(GetServiceNodeLogEventPolicy) - return json.Unmarshal(b, *v) - case "LogEventPolicyCategoryStatusCache": - *v = new(GetServiceNodeLogEventPolicyCategoryStatusCache) - return json.Unmarshal(b, *v) - case "LogEventPolicyStatusCache": - *v = new(GetServiceNodeLogEventPolicyStatusCache) - return json.Unmarshal(b, *v) - case "LogEventStatusCache": - *v = new(GetServiceNodeLogEventStatusCache) - return json.Unmarshal(b, *v) - case "LogSample": - *v = new(GetServiceNodeLogSample) - return json.Unmarshal(b, *v) - case "Message": - *v = new(GetServiceNodeMessage) - return json.Unmarshal(b, *v) - case "Organization": - *v = new(GetServiceNodeOrganization) - return json.Unmarshal(b, *v) - case "Service": - *v = new(GetServiceNodeService) - return json.Unmarshal(b, *v) - case "ServiceStatusCache": - *v = new(GetServiceNodeServiceStatusCache) - return json.Unmarshal(b, *v) - case "Team": - *v = new(GetServiceNodeTeam) - return json.Unmarshal(b, *v) - case "View": - *v = new(GetServiceNodeView) - return json.Unmarshal(b, *v) - case "ViewFavorite": - *v = new(GetServiceNodeViewFavorite) - return json.Unmarshal(b, *v) - case "Workspace": - *v = new(GetServiceNodeWorkspace) - return json.Unmarshal(b, *v) - case "": - return fmt.Errorf( - "response was missing Node.__typename") - default: - return fmt.Errorf( - `unexpected concrete type for GetServiceNode: "%v"`, tn.TypeName) - } +// GetSummary returns GetIssueSummaryIssuesIssueConnection.Summary, and is useful for accessing the field via an interface. +func (v *GetIssueSummaryIssuesIssueConnection) GetSummary() GetIssueSummaryIssuesIssueConnectionSummaryIssueSummary { + return v.Summary } -func __marshalGetServiceNode(v *GetServiceNode) ([]byte, error) { - - var typename string - switch v := (*v).(type) { - case *GetServiceNodeAccount: - typename = "Account" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeAccount - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeConversation: - typename = "Conversation" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeConversation - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeConversationContext: - typename = "ConversationContext" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeConversationContext - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeDatadogAccount: - typename = "DatadogAccount" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeDatadogAccount - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeDatadogAccountStatusCache: - typename = "DatadogAccountStatusCache" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeDatadogAccountStatusCache - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeDatadogLogIndex: - typename = "DatadogLogIndex" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeDatadogLogIndex - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeEdgeApiKey: - typename = "EdgeApiKey" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeEdgeApiKey - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeEdgeInstance: - typename = "EdgeInstance" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeEdgeInstance - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeLogEvent: - typename = "LogEvent" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeLogEvent - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeLogEventField: - typename = "LogEventField" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeLogEventField - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeLogEventPolicy: - typename = "LogEventPolicy" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeLogEventPolicy - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeLogEventPolicyCategoryStatusCache: - typename = "LogEventPolicyCategoryStatusCache" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeLogEventPolicyCategoryStatusCache - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeLogEventPolicyStatusCache: - typename = "LogEventPolicyStatusCache" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeLogEventPolicyStatusCache - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeLogEventStatusCache: - typename = "LogEventStatusCache" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeLogEventStatusCache - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeLogSample: - typename = "LogSample" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeLogSample - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeMessage: - typename = "Message" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeMessage - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeOrganization: - typename = "Organization" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeOrganization - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeService: - typename = "Service" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeService - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeServiceStatusCache: - typename = "ServiceStatusCache" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeServiceStatusCache - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeTeam: - typename = "Team" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeTeam - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeView: - typename = "View" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeView - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeViewFavorite: - typename = "ViewFavorite" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeViewFavorite - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeWorkspace: - typename = "Workspace" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeWorkspace - }{typename, v} - return json.Marshal(result) - case nil: - return []byte("null"), nil - default: - return nil, fmt.Errorf( - `unexpected concrete type for GetServiceNode: "%T"`, v) - } +// GetFacets returns GetIssueSummaryIssuesIssueConnection.Facets, and is useful for accessing the field via an interface. +func (v *GetIssueSummaryIssuesIssueConnection) GetFacets() GetIssueSummaryIssuesIssueConnectionFacetsIssueFacets { + return v.Facets } -// GetServiceNodeAccount includes the requested fields of the GraphQL type Account. -type GetServiceNodeAccount struct { - Typename *string `json:"__typename"` +// GetIssueSummaryIssuesIssueConnectionFacetsIssueFacets includes the requested fields of the GraphQL type IssueFacets. +type GetIssueSummaryIssuesIssueConnectionFacetsIssueFacets struct { + Priorities GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacet `json:"priorities"` } -// GetTypename returns GetServiceNodeAccount.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeAccount) GetTypename() *string { return v.Typename } - -// GetServiceNodeConversation includes the requested fields of the GraphQL type Conversation. -type GetServiceNodeConversation struct { - Typename *string `json:"__typename"` +// GetPriorities returns GetIssueSummaryIssuesIssueConnectionFacetsIssueFacets.Priorities, and is useful for accessing the field via an interface. +func (v *GetIssueSummaryIssuesIssueConnectionFacetsIssueFacets) GetPriorities() GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacet { + return v.Priorities } -// GetTypename returns GetServiceNodeConversation.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeConversation) GetTypename() *string { return v.Typename } - -// GetServiceNodeConversationContext includes the requested fields of the GraphQL type ConversationContext. -type GetServiceNodeConversationContext struct { - Typename *string `json:"__typename"` +// GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacet includes the requested fields of the GraphQL type IssuePriorityFacet. +type GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacet struct { + Buckets []GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacetBucketsIssuePriorityFacetBucket `json:"buckets"` } -// GetTypename returns GetServiceNodeConversationContext.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeConversationContext) GetTypename() *string { return v.Typename } - -// GetServiceNodeDatadogAccount includes the requested fields of the GraphQL type DatadogAccount. -type GetServiceNodeDatadogAccount struct { - Typename *string `json:"__typename"` +// GetBuckets returns GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacet.Buckets, and is useful for accessing the field via an interface. +func (v *GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacet) GetBuckets() []GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacetBucketsIssuePriorityFacetBucket { + return v.Buckets } -// GetTypename returns GetServiceNodeDatadogAccount.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeDatadogAccount) GetTypename() *string { return v.Typename } - -// GetServiceNodeDatadogAccountStatusCache includes the requested fields of the GraphQL type DatadogAccountStatusCache. -type GetServiceNodeDatadogAccountStatusCache struct { - Typename *string `json:"__typename"` +// GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacetBucketsIssuePriorityFacetBucket includes the requested fields of the GraphQL type IssuePriorityFacetBucket. +type GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacetBucketsIssuePriorityFacetBucket struct { + Value IssuePriority `json:"value"` + Count int `json:"count"` } -// GetTypename returns GetServiceNodeDatadogAccountStatusCache.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeDatadogAccountStatusCache) GetTypename() *string { return v.Typename } - -// GetServiceNodeDatadogLogIndex includes the requested fields of the GraphQL type DatadogLogIndex. -type GetServiceNodeDatadogLogIndex struct { - Typename *string `json:"__typename"` +// GetValue returns GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacetBucketsIssuePriorityFacetBucket.Value, and is useful for accessing the field via an interface. +func (v *GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacetBucketsIssuePriorityFacetBucket) GetValue() IssuePriority { + return v.Value } -// GetTypename returns GetServiceNodeDatadogLogIndex.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeDatadogLogIndex) GetTypename() *string { return v.Typename } +// GetCount returns GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacetBucketsIssuePriorityFacetBucket.Count, and is useful for accessing the field via an interface. +func (v *GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacetBucketsIssuePriorityFacetBucket) GetCount() int { + return v.Count +} -// GetServiceNodeEdgeApiKey includes the requested fields of the GraphQL type EdgeApiKey. -type GetServiceNodeEdgeApiKey struct { - Typename *string `json:"__typename"` +// GetIssueSummaryIssuesIssueConnectionSummaryIssueSummary includes the requested fields of the GraphQL type IssueSummary. +type GetIssueSummaryIssuesIssueConnectionSummaryIssueSummary struct { + Count int `json:"count"` } -// GetTypename returns GetServiceNodeEdgeApiKey.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeEdgeApiKey) GetTypename() *string { return v.Typename } +// GetCount returns GetIssueSummaryIssuesIssueConnectionSummaryIssueSummary.Count, and is useful for accessing the field via an interface. +func (v *GetIssueSummaryIssuesIssueConnectionSummaryIssueSummary) GetCount() int { return v.Count } -// GetServiceNodeEdgeInstance includes the requested fields of the GraphQL type EdgeInstance. -type GetServiceNodeEdgeInstance struct { - Typename *string `json:"__typename"` +// GetIssueSummaryResponse is returned by GetIssueSummary on success. +type GetIssueSummaryResponse struct { + // Query Issues records in your account. + Issues GetIssueSummaryIssuesIssueConnection `json:"issues"` } -// GetTypename returns GetServiceNodeEdgeInstance.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeEdgeInstance) GetTypename() *string { return v.Typename } +// GetIssues returns GetIssueSummaryResponse.Issues, and is useful for accessing the field via an interface. +func (v *GetIssueSummaryResponse) GetIssues() GetIssueSummaryIssuesIssueConnection { return v.Issues } -// GetServiceNodeLogEvent includes the requested fields of the GraphQL type LogEvent. -type GetServiceNodeLogEvent struct { - Typename *string `json:"__typename"` +// GetServiceByNameResponse is returned by GetServiceByName on success. +type GetServiceByNameResponse struct { + // Query Services records in your account. + Services GetServiceByNameServicesServiceConnection `json:"services"` } -// GetTypename returns GetServiceNodeLogEvent.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeLogEvent) GetTypename() *string { return v.Typename } +// GetServices returns GetServiceByNameResponse.Services, and is useful for accessing the field via an interface. +func (v *GetServiceByNameResponse) GetServices() GetServiceByNameServicesServiceConnection { + return v.Services +} -// GetServiceNodeLogEventField includes the requested fields of the GraphQL type LogEventField. -type GetServiceNodeLogEventField struct { - Typename *string `json:"__typename"` +// GetServiceByNameServicesServiceConnection includes the requested fields of the GraphQL type ServiceConnection. +type GetServiceByNameServicesServiceConnection struct { + Edges []GetServiceByNameServicesServiceConnectionEdgesServiceEdge `json:"edges"` } -// GetTypename returns GetServiceNodeLogEventField.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeLogEventField) GetTypename() *string { return v.Typename } +// GetEdges returns GetServiceByNameServicesServiceConnection.Edges, and is useful for accessing the field via an interface. +func (v *GetServiceByNameServicesServiceConnection) GetEdges() []GetServiceByNameServicesServiceConnectionEdgesServiceEdge { + return v.Edges +} -// GetServiceNodeLogEventPolicy includes the requested fields of the GraphQL type LogEventPolicy. -type GetServiceNodeLogEventPolicy struct { - Typename *string `json:"__typename"` +// GetServiceByNameServicesServiceConnectionEdgesServiceEdge includes the requested fields of the GraphQL type ServiceEdge. +type GetServiceByNameServicesServiceConnectionEdgesServiceEdge struct { + Node GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService `json:"node"` } -// GetTypename returns GetServiceNodeLogEventPolicy.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeLogEventPolicy) GetTypename() *string { return v.Typename } +// GetNode returns GetServiceByNameServicesServiceConnectionEdgesServiceEdge.Node, and is useful for accessing the field via an interface. +func (v *GetServiceByNameServicesServiceConnectionEdgesServiceEdge) GetNode() GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService { + return v.Node +} -// GetServiceNodeLogEventPolicyCategoryStatusCache includes the requested fields of the GraphQL type LogEventPolicyCategoryStatusCache. -type GetServiceNodeLogEventPolicyCategoryStatusCache struct { - Typename *string `json:"__typename"` +// GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService includes the requested fields of the GraphQL type Service. +type GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService struct { + // Unique identifier of the service + Id string `json:"id"` + // Service identifier in telemetry (e.g., 'checkout-service') + Name string `json:"name"` + // Whether log analysis and policy generation is active for this service + Enabled bool `json:"enabled"` + // When the service was created + CreatedAt time.Time `json:"createdAt"` + // When the service was last updated + UpdatedAt time.Time `json:"updatedAt"` } -// GetTypename returns GetServiceNodeLogEventPolicyCategoryStatusCache.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeLogEventPolicyCategoryStatusCache) GetTypename() *string { return v.Typename } +// GetId returns GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService.Id, and is useful for accessing the field via an interface. +func (v *GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService) GetId() string { + return v.Id +} -// GetServiceNodeLogEventPolicyStatusCache includes the requested fields of the GraphQL type LogEventPolicyStatusCache. -type GetServiceNodeLogEventPolicyStatusCache struct { - Typename *string `json:"__typename"` +// GetName returns GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService.Name, and is useful for accessing the field via an interface. +func (v *GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService) GetName() string { + return v.Name } -// GetTypename returns GetServiceNodeLogEventPolicyStatusCache.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeLogEventPolicyStatusCache) GetTypename() *string { return v.Typename } +// GetEnabled returns GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService.Enabled, and is useful for accessing the field via an interface. +func (v *GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService) GetEnabled() bool { + return v.Enabled +} -// GetServiceNodeLogEventStatusCache includes the requested fields of the GraphQL type LogEventStatusCache. -type GetServiceNodeLogEventStatusCache struct { - Typename *string `json:"__typename"` +// GetCreatedAt returns GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService.CreatedAt, and is useful for accessing the field via an interface. +func (v *GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService) GetCreatedAt() time.Time { + return v.CreatedAt } -// GetTypename returns GetServiceNodeLogEventStatusCache.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeLogEventStatusCache) GetTypename() *string { return v.Typename } +// GetUpdatedAt returns GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService.UpdatedAt, and is useful for accessing the field via an interface. +func (v *GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService) GetUpdatedAt() time.Time { + return v.UpdatedAt +} -// GetServiceNodeLogSample includes the requested fields of the GraphQL type LogSample. -type GetServiceNodeLogSample struct { - Typename *string `json:"__typename"` +// GetServiceResponse is returned by GetService on success. +type GetServiceResponse struct { + // Query Services records in your account. + Services GetServiceServicesServiceConnection `json:"services"` } -// GetTypename returns GetServiceNodeLogSample.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeLogSample) GetTypename() *string { return v.Typename } +// GetServices returns GetServiceResponse.Services, and is useful for accessing the field via an interface. +func (v *GetServiceResponse) GetServices() GetServiceServicesServiceConnection { return v.Services } -// GetServiceNodeMessage includes the requested fields of the GraphQL type Message. -type GetServiceNodeMessage struct { - Typename *string `json:"__typename"` +// GetServiceServicesServiceConnection includes the requested fields of the GraphQL type ServiceConnection. +type GetServiceServicesServiceConnection struct { + Edges []GetServiceServicesServiceConnectionEdgesServiceEdge `json:"edges"` } -// GetTypename returns GetServiceNodeMessage.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeMessage) GetTypename() *string { return v.Typename } +// GetEdges returns GetServiceServicesServiceConnection.Edges, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnection) GetEdges() []GetServiceServicesServiceConnectionEdgesServiceEdge { + return v.Edges +} -// GetServiceNodeOrganization includes the requested fields of the GraphQL type Organization. -type GetServiceNodeOrganization struct { - Typename *string `json:"__typename"` +// GetServiceServicesServiceConnectionEdgesServiceEdge includes the requested fields of the GraphQL type ServiceEdge. +type GetServiceServicesServiceConnectionEdgesServiceEdge struct { + Node GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService `json:"node"` } -// GetTypename returns GetServiceNodeOrganization.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeOrganization) GetTypename() *string { return v.Typename } +// GetNode returns GetServiceServicesServiceConnectionEdgesServiceEdge.Node, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnectionEdgesServiceEdge) GetNode() GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService { + return v.Node +} -// GetServiceNodeService includes the requested fields of the GraphQL type Service. -type GetServiceNodeService struct { - Typename *string `json:"__typename"` +// GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService includes the requested fields of the GraphQL type Service. +type GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService struct { // Unique identifier of the service Id string `json:"id"` // Service identifier in telemetry (e.g., 'checkout-service') Name string `json:"name"` - // AI-generated description of what this service does and its telemetry characteristics - Description string `json:"description"` // Whether log analysis and policy generation is active for this service Enabled bool `json:"enabled"` // When the service was created @@ -1591,206 +889,129 @@ type GetServiceNodeService struct { // When the service was last updated UpdatedAt time.Time `json:"updatedAt"` // Account this service belongs to - Account GetServiceNodeServiceAccount `json:"account"` + Account GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceAccount `json:"account"` // Log event types produced by this service - LogEvents []GetServiceNodeServiceLogEventsLogEvent `json:"logEvents"` + LogEvents GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnection `json:"logEvents"` } -// GetTypename returns GetServiceNodeService.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeService) GetTypename() *string { return v.Typename } +// GetId returns GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService.Id, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService) GetId() string { return v.Id } -// GetId returns GetServiceNodeService.Id, and is useful for accessing the field via an interface. -func (v *GetServiceNodeService) GetId() string { return v.Id } - -// GetName returns GetServiceNodeService.Name, and is useful for accessing the field via an interface. -func (v *GetServiceNodeService) GetName() string { return v.Name } - -// GetDescription returns GetServiceNodeService.Description, and is useful for accessing the field via an interface. -func (v *GetServiceNodeService) GetDescription() string { return v.Description } +// GetName returns GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService.Name, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService) GetName() string { + return v.Name +} -// GetEnabled returns GetServiceNodeService.Enabled, and is useful for accessing the field via an interface. -func (v *GetServiceNodeService) GetEnabled() bool { return v.Enabled } +// GetEnabled returns GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService.Enabled, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService) GetEnabled() bool { + return v.Enabled +} -// GetCreatedAt returns GetServiceNodeService.CreatedAt, and is useful for accessing the field via an interface. -func (v *GetServiceNodeService) GetCreatedAt() time.Time { return v.CreatedAt } +// GetCreatedAt returns GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService.CreatedAt, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService) GetCreatedAt() time.Time { + return v.CreatedAt +} -// GetUpdatedAt returns GetServiceNodeService.UpdatedAt, and is useful for accessing the field via an interface. -func (v *GetServiceNodeService) GetUpdatedAt() time.Time { return v.UpdatedAt } +// GetUpdatedAt returns GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService.UpdatedAt, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService) GetUpdatedAt() time.Time { + return v.UpdatedAt +} -// GetAccount returns GetServiceNodeService.Account, and is useful for accessing the field via an interface. -func (v *GetServiceNodeService) GetAccount() GetServiceNodeServiceAccount { return v.Account } +// GetAccount returns GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService.Account, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService) GetAccount() GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceAccount { + return v.Account +} -// GetLogEvents returns GetServiceNodeService.LogEvents, and is useful for accessing the field via an interface. -func (v *GetServiceNodeService) GetLogEvents() []GetServiceNodeServiceLogEventsLogEvent { +// GetLogEvents returns GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService.LogEvents, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService) GetLogEvents() GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnection { return v.LogEvents } -// GetServiceNodeServiceAccount includes the requested fields of the GraphQL type Account. -type GetServiceNodeServiceAccount struct { +// GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceAccount includes the requested fields of the GraphQL type Account. +type GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceAccount struct { // Unique identifier of the account Id string `json:"id"` // Human-readable name within the organization Name string `json:"name"` } -// GetId returns GetServiceNodeServiceAccount.Id, and is useful for accessing the field via an interface. -func (v *GetServiceNodeServiceAccount) GetId() string { return v.Id } - -// GetName returns GetServiceNodeServiceAccount.Name, and is useful for accessing the field via an interface. -func (v *GetServiceNodeServiceAccount) GetName() string { return v.Name } - -// GetServiceNodeServiceLogEventsLogEvent includes the requested fields of the GraphQL type LogEvent. -type GetServiceNodeServiceLogEventsLogEvent struct { - // Unique identifier of the log event - Id string `json:"id"` - // Snake_case identifier unique per service, e.g. nginx_access_log - Name string `json:"name"` - // What the event is and what data instances carry. Helps engineers decide whether to look here. - Description string `json:"description"` - // When the log event was created - CreatedAt time.Time `json:"createdAt"` +// GetId returns GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceAccount.Id, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceAccount) GetId() string { + return v.Id } -// GetId returns GetServiceNodeServiceLogEventsLogEvent.Id, and is useful for accessing the field via an interface. -func (v *GetServiceNodeServiceLogEventsLogEvent) GetId() string { return v.Id } - -// GetName returns GetServiceNodeServiceLogEventsLogEvent.Name, and is useful for accessing the field via an interface. -func (v *GetServiceNodeServiceLogEventsLogEvent) GetName() string { return v.Name } - -// GetDescription returns GetServiceNodeServiceLogEventsLogEvent.Description, and is useful for accessing the field via an interface. -func (v *GetServiceNodeServiceLogEventsLogEvent) GetDescription() string { return v.Description } - -// GetCreatedAt returns GetServiceNodeServiceLogEventsLogEvent.CreatedAt, and is useful for accessing the field via an interface. -func (v *GetServiceNodeServiceLogEventsLogEvent) GetCreatedAt() time.Time { return v.CreatedAt } - -// GetServiceNodeServiceStatusCache includes the requested fields of the GraphQL type ServiceStatusCache. -type GetServiceNodeServiceStatusCache struct { - Typename *string `json:"__typename"` +// GetName returns GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceAccount.Name, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceAccount) GetName() string { + return v.Name } -// GetTypename returns GetServiceNodeServiceStatusCache.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeServiceStatusCache) GetTypename() *string { return v.Typename } - -// GetServiceNodeTeam includes the requested fields of the GraphQL type Team. -type GetServiceNodeTeam struct { - Typename *string `json:"__typename"` +// GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnection includes the requested fields of the GraphQL type LogEventConnection. +type GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnection struct { + Edges []GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdge `json:"edges"` } -// GetTypename returns GetServiceNodeTeam.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeTeam) GetTypename() *string { return v.Typename } - -// GetServiceNodeView includes the requested fields of the GraphQL type View. -type GetServiceNodeView struct { - Typename *string `json:"__typename"` +// GetEdges returns GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnection.Edges, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnection) GetEdges() []GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdge { + return v.Edges } -// GetTypename returns GetServiceNodeView.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeView) GetTypename() *string { return v.Typename } - -// GetServiceNodeViewFavorite includes the requested fields of the GraphQL type ViewFavorite. -type GetServiceNodeViewFavorite struct { - Typename *string `json:"__typename"` +// GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdge includes the requested fields of the GraphQL type LogEventEdge. +type GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdge struct { + Node GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent `json:"node"` } -// GetTypename returns GetServiceNodeViewFavorite.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeViewFavorite) GetTypename() *string { return v.Typename } - -// GetServiceNodeWorkspace includes the requested fields of the GraphQL type Workspace. -type GetServiceNodeWorkspace struct { - Typename *string `json:"__typename"` +// GetNode returns GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdge.Node, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdge) GetNode() GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent { + return v.Node } -// GetTypename returns GetServiceNodeWorkspace.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeWorkspace) GetTypename() *string { return v.Typename } - -// GetServiceResponse is returned by GetService on success. -type GetServiceResponse struct { - // Fetches an object given its ID. - Node *GetServiceNode `json:"-"` +// GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent includes the requested fields of the GraphQL type LogEvent. +type GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent struct { + // Unique identifier of the log event + Id string `json:"id"` + // Snake_case identifier unique per service, e.g. nginx_access_log + Name string `json:"name"` + // When the log event was created + CreatedAt time.Time `json:"createdAt"` } -// GetNode returns GetServiceResponse.Node, and is useful for accessing the field via an interface. -func (v *GetServiceResponse) GetNode() *GetServiceNode { return v.Node } - -func (v *GetServiceResponse) UnmarshalJSON(b []byte) error { - - if string(b) == "null" { - return nil - } - - var firstPass struct { - *GetServiceResponse - Node json.RawMessage `json:"node"` - graphql.NoUnmarshalJSON - } - firstPass.GetServiceResponse = v - - err := json.Unmarshal(b, &firstPass) - if err != nil { - return err - } - - { - dst := &v.Node - src := firstPass.Node - if len(src) != 0 && string(src) != "null" { - *dst = new(GetServiceNode) - err = __unmarshalGetServiceNode( - src, *dst) - if err != nil { - return fmt.Errorf( - "unable to unmarshal GetServiceResponse.Node: %w", err) - } - } - } - return nil +// GetId returns GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent.Id, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent) GetId() string { + return v.Id } -type __premarshalGetServiceResponse struct { - Node json.RawMessage `json:"node"` +// GetName returns GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent.Name, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent) GetName() string { + return v.Name } -func (v *GetServiceResponse) MarshalJSON() ([]byte, error) { - premarshaled, err := v.__premarshalJSON() - if err != nil { - return nil, err - } - return json.Marshal(premarshaled) +// GetCreatedAt returns GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent.CreatedAt, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent) GetCreatedAt() time.Time { + return v.CreatedAt } -func (v *GetServiceResponse) __premarshalJSON() (*__premarshalGetServiceResponse, error) { - var retval __premarshalGetServiceResponse +type IssuePriority string - { +const ( + IssuePriorityLow IssuePriority = "low" + IssuePriorityMedium IssuePriority = "medium" + IssuePriorityHigh IssuePriority = "high" +) - dst := &retval.Node - src := v.Node - if src != nil { - var err error - *dst, err = __marshalGetServiceNode( - src) - if err != nil { - return nil, fmt.Errorf( - "unable to marshal GetServiceResponse.Node: %w", err) - } - } - } - return &retval, nil +var AllIssuePriority = []IssuePriority{ + IssuePriorityLow, + IssuePriorityMedium, + IssuePriorityHigh, } // ListAccountsAccountsAccountConnection includes the requested fields of the GraphQL type AccountConnection. -// The GraphQL type's documentation follows. -// -// A connection to a list of items. type ListAccountsAccountsAccountConnection struct { - // A list of edges. - Edges []*ListAccountsAccountsAccountConnectionEdgesAccountEdge `json:"edges"` - // Identifies the total count of items in the connection. - TotalCount int `json:"totalCount"` + Edges []ListAccountsAccountsAccountConnectionEdgesAccountEdge `json:"edges"` + TotalCount int `json:"totalCount"` } // GetEdges returns ListAccountsAccountsAccountConnection.Edges, and is useful for accessing the field via an interface. -func (v *ListAccountsAccountsAccountConnection) GetEdges() []*ListAccountsAccountsAccountConnectionEdgesAccountEdge { +func (v *ListAccountsAccountsAccountConnection) GetEdges() []ListAccountsAccountsAccountConnectionEdgesAccountEdge { return v.Edges } @@ -1798,16 +1019,12 @@ func (v *ListAccountsAccountsAccountConnection) GetEdges() []*ListAccountsAccoun func (v *ListAccountsAccountsAccountConnection) GetTotalCount() int { return v.TotalCount } // ListAccountsAccountsAccountConnectionEdgesAccountEdge includes the requested fields of the GraphQL type AccountEdge. -// The GraphQL type's documentation follows. -// -// An edge in a connection. type ListAccountsAccountsAccountConnectionEdgesAccountEdge struct { - // The item at the end of the edge. - Node *ListAccountsAccountsAccountConnectionEdgesAccountEdgeNodeAccount `json:"node"` + Node ListAccountsAccountsAccountConnectionEdgesAccountEdgeNodeAccount `json:"node"` } // GetNode returns ListAccountsAccountsAccountConnectionEdgesAccountEdge.Node, and is useful for accessing the field via an interface. -func (v *ListAccountsAccountsAccountConnectionEdgesAccountEdge) GetNode() *ListAccountsAccountsAccountConnectionEdgesAccountEdgeNodeAccount { +func (v *ListAccountsAccountsAccountConnectionEdgesAccountEdge) GetNode() ListAccountsAccountsAccountConnectionEdgesAccountEdgeNodeAccount { return v.Node } @@ -1838,26 +1055,332 @@ func (v *ListAccountsAccountsAccountConnectionEdgesAccountEdgeNodeAccount) GetCr // ListAccountsResponse is returned by ListAccounts on success. type ListAccountsResponse struct { - // Query accounts. Accounts belong to an organization and contain services and workspaces. + // Query Accounts records in your account. Accounts ListAccountsAccountsAccountConnection `json:"accounts"` } // GetAccounts returns ListAccountsResponse.Accounts, and is useful for accessing the field via an interface. func (v *ListAccountsResponse) GetAccounts() ListAccountsAccountsAccountConnection { return v.Accounts } -// ListOrganizationsOrganizationsOrganizationConnection includes the requested fields of the GraphQL type OrganizationConnection. +// ListChecksChecksCheckConnection includes the requested fields of the GraphQL type CheckConnection. +type ListChecksChecksCheckConnection struct { + TotalCount int `json:"totalCount"` + Edges []ListChecksChecksCheckConnectionEdgesCheckEdge `json:"edges"` + Facets ListChecksChecksCheckConnectionFacetsCheckFacets `json:"facets"` +} + +// GetTotalCount returns ListChecksChecksCheckConnection.TotalCount, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnection) GetTotalCount() int { return v.TotalCount } + +// GetEdges returns ListChecksChecksCheckConnection.Edges, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnection) GetEdges() []ListChecksChecksCheckConnectionEdgesCheckEdge { + return v.Edges +} + +// GetFacets returns ListChecksChecksCheckConnection.Facets, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnection) GetFacets() ListChecksChecksCheckConnectionFacetsCheckFacets { + return v.Facets +} + +// ListChecksChecksCheckConnectionEdgesCheckEdge includes the requested fields of the GraphQL type CheckEdge. +type ListChecksChecksCheckConnectionEdgesCheckEdge struct { + Node ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheck `json:"node"` +} + +// GetNode returns ListChecksChecksCheckConnectionEdgesCheckEdge.Node, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionEdgesCheckEdge) GetNode() ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheck { + return v.Node +} + +// ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheck includes the requested fields of the GraphQL type Check. +// The GraphQL type's documentation follows. +// +// One code-defined standing product question and its account posture. +type ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheck struct { + Id string `json:"id"` + Name string `json:"name"` + Domain FindingCheckDomain `json:"domain"` + Posture ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture `json:"posture"` +} + +// GetId returns ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheck.Id, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheck) GetId() string { return v.Id } + +// GetName returns ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheck.Name, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheck) GetName() string { return v.Name } + +// GetDomain returns ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheck.Domain, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheck) GetDomain() FindingCheckDomain { + return v.Domain +} + +// GetPosture returns ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheck.Posture, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheck) GetPosture() ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture { + return v.Posture +} + +// ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture includes the requested fields of the GraphQL type CheckPosture. // The GraphQL type's documentation follows. // -// A connection to a list of items. +// Account-scoped posture for one check, computed from findings and issues. +type ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture struct { + OpenFindingCount int `json:"openFindingCount"` + PendingFindingCount int `json:"pendingFindingCount"` + EscalatedFindingCount int `json:"escalatedFindingCount"` + ActiveIssueCount int `json:"activeIssueCount"` + AffectedServiceCount int `json:"affectedServiceCount"` + Current ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPostureCurrentStatusMeasurementTotals `json:"current"` +} + +// GetOpenFindingCount returns ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture.OpenFindingCount, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture) GetOpenFindingCount() int { + return v.OpenFindingCount +} + +// GetPendingFindingCount returns ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture.PendingFindingCount, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture) GetPendingFindingCount() int { + return v.PendingFindingCount +} + +// GetEscalatedFindingCount returns ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture.EscalatedFindingCount, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture) GetEscalatedFindingCount() int { + return v.EscalatedFindingCount +} + +// GetActiveIssueCount returns ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture.ActiveIssueCount, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture) GetActiveIssueCount() int { + return v.ActiveIssueCount +} + +// GetAffectedServiceCount returns ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture.AffectedServiceCount, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture) GetAffectedServiceCount() int { + return v.AffectedServiceCount +} + +// GetCurrent returns ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture.Current, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture) GetCurrent() ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPostureCurrentStatusMeasurementTotals { + return v.Current +} + +// ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPostureCurrentStatusMeasurementTotals includes the requested fields of the GraphQL type StatusMeasurementTotals. +type ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPostureCurrentStatusMeasurementTotals struct { + TotalUsdPerHour *float64 `json:"totalUsdPerHour"` +} + +// GetTotalUsdPerHour returns ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPostureCurrentStatusMeasurementTotals.TotalUsdPerHour, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPostureCurrentStatusMeasurementTotals) GetTotalUsdPerHour() *float64 { + return v.TotalUsdPerHour +} + +// ListChecksChecksCheckConnectionFacetsCheckFacets includes the requested fields of the GraphQL type CheckFacets. +type ListChecksChecksCheckConnectionFacetsCheckFacets struct { + Domains ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacet `json:"domains"` +} + +// GetDomains returns ListChecksChecksCheckConnectionFacetsCheckFacets.Domains, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionFacetsCheckFacets) GetDomains() ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacet { + return v.Domains +} + +// ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacet includes the requested fields of the GraphQL type CheckDomainFacet. +type ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacet struct { + Buckets []ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacetBucketsCheckDomainFacetBucket `json:"buckets"` +} + +// GetBuckets returns ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacet.Buckets, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacet) GetBuckets() []ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacetBucketsCheckDomainFacetBucket { + return v.Buckets +} + +// ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacetBucketsCheckDomainFacetBucket includes the requested fields of the GraphQL type CheckDomainFacetBucket. +type ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacetBucketsCheckDomainFacetBucket struct { + Value FindingCheckDomain `json:"value"` + Count int `json:"count"` +} + +// GetValue returns ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacetBucketsCheckDomainFacetBucket.Value, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacetBucketsCheckDomainFacetBucket) GetValue() FindingCheckDomain { + return v.Value +} + +// GetCount returns ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacetBucketsCheckDomainFacetBucket.Count, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacetBucketsCheckDomainFacetBucket) GetCount() int { + return v.Count +} + +// ListChecksResponse is returned by ListChecks on success. +type ListChecksResponse struct { + // Code-defined product checks with account-scoped posture measurements. + Checks ListChecksChecksCheckConnection `json:"checks"` +} + +// GetChecks returns ListChecksResponse.Checks, and is useful for accessing the field via an interface. +func (v *ListChecksResponse) GetChecks() ListChecksChecksCheckConnection { return v.Checks } + +// ListEdgeInstancesEdgeInstancesEdgeInstanceConnection includes the requested fields of the GraphQL type EdgeInstanceConnection. +type ListEdgeInstancesEdgeInstancesEdgeInstanceConnection struct { + TotalCount int `json:"totalCount"` + Edges []ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdge `json:"edges"` +} + +// GetTotalCount returns ListEdgeInstancesEdgeInstancesEdgeInstanceConnection.TotalCount, and is useful for accessing the field via an interface. +func (v *ListEdgeInstancesEdgeInstancesEdgeInstanceConnection) GetTotalCount() int { + return v.TotalCount +} + +// GetEdges returns ListEdgeInstancesEdgeInstancesEdgeInstanceConnection.Edges, and is useful for accessing the field via an interface. +func (v *ListEdgeInstancesEdgeInstancesEdgeInstanceConnection) GetEdges() []ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdge { + return v.Edges +} + +// ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdge includes the requested fields of the GraphQL type EdgeInstanceEdge. +type ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdge struct { + Node ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance `json:"node"` +} + +// GetNode returns ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdge.Node, and is useful for accessing the field via an interface. +func (v *ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdge) GetNode() ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance { + return v.Node +} + +// ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance includes the requested fields of the GraphQL type EdgeInstance. +type ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance struct { + // Unique identifier of this edge instance + Id string `json:"id"` + // The service.instance.id resource attribute identifying this instance + InstanceID string `json:"instanceID"` + // The service.name resource attribute + ServiceName string `json:"serviceName"` + // The service.namespace resource attribute + ServiceNamespace *string `json:"serviceNamespace"` + // When this edge instance last synced + LastSyncAt time.Time `json:"lastSyncAt"` +} + +// GetId returns ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance.Id, and is useful for accessing the field via an interface. +func (v *ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance) GetId() string { + return v.Id +} + +// GetInstanceID returns ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance.InstanceID, and is useful for accessing the field via an interface. +func (v *ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance) GetInstanceID() string { + return v.InstanceID +} + +// GetServiceName returns ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance.ServiceName, and is useful for accessing the field via an interface. +func (v *ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance) GetServiceName() string { + return v.ServiceName +} + +// GetServiceNamespace returns ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance.ServiceNamespace, and is useful for accessing the field via an interface. +func (v *ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance) GetServiceNamespace() *string { + return v.ServiceNamespace +} + +// GetLastSyncAt returns ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance.LastSyncAt, and is useful for accessing the field via an interface. +func (v *ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance) GetLastSyncAt() time.Time { + return v.LastSyncAt +} + +// ListEdgeInstancesResponse is returned by ListEdgeInstances on success. +type ListEdgeInstancesResponse struct { + // Query EdgeInstances records in your account. + EdgeInstances ListEdgeInstancesEdgeInstancesEdgeInstanceConnection `json:"edgeInstances"` +} + +// GetEdgeInstances returns ListEdgeInstancesResponse.EdgeInstances, and is useful for accessing the field via an interface. +func (v *ListEdgeInstancesResponse) GetEdgeInstances() ListEdgeInstancesEdgeInstancesEdgeInstanceConnection { + return v.EdgeInstances +} + +// ListIssuesIssuesIssueConnection includes the requested fields of the GraphQL type IssueConnection. +type ListIssuesIssuesIssueConnection struct { + TotalCount int `json:"totalCount"` + Edges []ListIssuesIssuesIssueConnectionEdgesIssueEdge `json:"edges"` +} + +// GetTotalCount returns ListIssuesIssuesIssueConnection.TotalCount, and is useful for accessing the field via an interface. +func (v *ListIssuesIssuesIssueConnection) GetTotalCount() int { return v.TotalCount } + +// GetEdges returns ListIssuesIssuesIssueConnection.Edges, and is useful for accessing the field via an interface. +func (v *ListIssuesIssuesIssueConnection) GetEdges() []ListIssuesIssuesIssueConnectionEdgesIssueEdge { + return v.Edges +} + +// ListIssuesIssuesIssueConnectionEdgesIssueEdge includes the requested fields of the GraphQL type IssueEdge. +type ListIssuesIssuesIssueConnectionEdgesIssueEdge struct { + Node ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue `json:"node"` +} + +// GetNode returns ListIssuesIssuesIssueConnectionEdgesIssueEdge.Node, and is useful for accessing the field via an interface. +func (v *ListIssuesIssuesIssueConnectionEdgesIssueEdge) GetNode() ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue { + return v.Node +} + +// ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue includes the requested fields of the GraphQL type Issue. +type ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue struct { + // Unique identifier + Id string `json:"id"` + // Stable account-local issue identifier for product links and support workflows. + DisplayID string `json:"displayID"` + // Short user-facing title for this issue. + Title string `json:"title"` + // How much attention a kept finding deserves. Values: low = Legitimate finding, + // but low urgency or prominence.; medium = Legitimate finding with clear but not + // top-tier urgency.; high = Legitimate finding that deserves strong user attention. + Priority IssuePriority `json:"priority"` + Service *ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssueService `json:"service"` +} + +// GetId returns ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue.Id, and is useful for accessing the field via an interface. +func (v *ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue) GetId() string { return v.Id } + +// GetDisplayID returns ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue.DisplayID, and is useful for accessing the field via an interface. +func (v *ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue) GetDisplayID() string { + return v.DisplayID +} + +// GetTitle returns ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue.Title, and is useful for accessing the field via an interface. +func (v *ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue) GetTitle() string { return v.Title } + +// GetPriority returns ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue.Priority, and is useful for accessing the field via an interface. +func (v *ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue) GetPriority() IssuePriority { + return v.Priority +} + +// GetService returns ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue.Service, and is useful for accessing the field via an interface. +func (v *ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue) GetService() *ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssueService { + return v.Service +} + +// ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssueService includes the requested fields of the GraphQL type Service. +type ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssueService struct { + // Service identifier in telemetry (e.g., 'checkout-service') + Name string `json:"name"` +} + +// GetName returns ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssueService.Name, and is useful for accessing the field via an interface. +func (v *ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssueService) GetName() string { + return v.Name +} + +// ListIssuesResponse is returned by ListIssues on success. +type ListIssuesResponse struct { + // Query Issues records in your account. + Issues ListIssuesIssuesIssueConnection `json:"issues"` +} + +// GetIssues returns ListIssuesResponse.Issues, and is useful for accessing the field via an interface. +func (v *ListIssuesResponse) GetIssues() ListIssuesIssuesIssueConnection { return v.Issues } + +// ListOrganizationsOrganizationsOrganizationConnection includes the requested fields of the GraphQL type OrganizationConnection. type ListOrganizationsOrganizationsOrganizationConnection struct { - // A list of edges. - Edges []*ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdge `json:"edges"` - // Identifies the total count of items in the connection. - TotalCount int `json:"totalCount"` + Edges []ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdge `json:"edges"` + TotalCount int `json:"totalCount"` } // GetEdges returns ListOrganizationsOrganizationsOrganizationConnection.Edges, and is useful for accessing the field via an interface. -func (v *ListOrganizationsOrganizationsOrganizationConnection) GetEdges() []*ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdge { +func (v *ListOrganizationsOrganizationsOrganizationConnection) GetEdges() []ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdge { return v.Edges } @@ -1867,16 +1390,12 @@ func (v *ListOrganizationsOrganizationsOrganizationConnection) GetTotalCount() i } // ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdge includes the requested fields of the GraphQL type OrganizationEdge. -// The GraphQL type's documentation follows. -// -// An edge in a connection. type ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdge struct { - // The item at the end of the edge. - Node *ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdgeNodeOrganization `json:"node"` + Node ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdgeNodeOrganization `json:"node"` } // GetNode returns ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdge.Node, and is useful for accessing the field via an interface. -func (v *ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdge) GetNode() *ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdgeNodeOrganization { +func (v *ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdge) GetNode() ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdgeNodeOrganization { return v.Node } @@ -1914,7 +1433,7 @@ func (v *ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEd // ListOrganizationsResponse is returned by ListOrganizations on success. type ListOrganizationsResponse struct { - // Query organizations. An organization is the top-level container that holds accounts. + // Query Organizations records in your account. Organizations ListOrganizationsOrganizationsOrganizationConnection `json:"organizations"` } @@ -1923,307 +1442,342 @@ func (v *ListOrganizationsResponse) GetOrganizations() ListOrganizationsOrganiza return v.Organizations } -// ListServicesResponse is returned by ListServices on success. -type ListServicesResponse struct { - // Query services in your system. - Services ListServicesServicesServiceConnection `json:"services"` +// ListServiceLogEventsLogEventsLogEventConnection includes the requested fields of the GraphQL type LogEventConnection. +type ListServiceLogEventsLogEventsLogEventConnection struct { + Edges []ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdge `json:"edges"` } -// GetServices returns ListServicesResponse.Services, and is useful for accessing the field via an interface. -func (v *ListServicesResponse) GetServices() ListServicesServicesServiceConnection { return v.Services } +// GetEdges returns ListServiceLogEventsLogEventsLogEventConnection.Edges, and is useful for accessing the field via an interface. +func (v *ListServiceLogEventsLogEventsLogEventConnection) GetEdges() []ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdge { + return v.Edges +} -// ListServicesServicesServiceConnection includes the requested fields of the GraphQL type ServiceConnection. -// The GraphQL type's documentation follows. -// -// A connection to a list of items. -type ListServicesServicesServiceConnection struct { - // A list of edges. - Edges []*ListServicesServicesServiceConnectionEdgesServiceEdge `json:"edges"` - // Identifies the total count of items in the connection. - TotalCount int `json:"totalCount"` +// ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdge includes the requested fields of the GraphQL type LogEventEdge. +type ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdge struct { + Node ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent `json:"node"` } -// GetEdges returns ListServicesServicesServiceConnection.Edges, and is useful for accessing the field via an interface. -func (v *ListServicesServicesServiceConnection) GetEdges() []*ListServicesServicesServiceConnectionEdgesServiceEdge { - return v.Edges +// GetNode returns ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdge.Node, and is useful for accessing the field via an interface. +func (v *ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdge) GetNode() ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent { + return v.Node } -// GetTotalCount returns ListServicesServicesServiceConnection.TotalCount, and is useful for accessing the field via an interface. -func (v *ListServicesServicesServiceConnection) GetTotalCount() int { return v.TotalCount } +// ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent includes the requested fields of the GraphQL type LogEvent. +type ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent struct { + // Snake_case identifier unique per service, e.g. nginx_access_log + Name string `json:"name"` + // Human-readable title for the log event, suitable for product surfaces. + DisplayName *string `json:"displayName"` + // Status of this log event. + // Shows where the log event is in the preparation pipeline. + // Returns null if the status view has no row yet. + Status *ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatus `json:"status"` +} -// ListServicesServicesServiceConnectionEdgesServiceEdge includes the requested fields of the GraphQL type ServiceEdge. -// The GraphQL type's documentation follows. -// -// An edge in a connection. -type ListServicesServicesServiceConnectionEdgesServiceEdge struct { - // The item at the end of the edge. - Node *ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService `json:"node"` +// GetName returns ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent.Name, and is useful for accessing the field via an interface. +func (v *ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent) GetName() string { + return v.Name } -// GetNode returns ListServicesServicesServiceConnectionEdgesServiceEdge.Node, and is useful for accessing the field via an interface. -func (v *ListServicesServicesServiceConnectionEdgesServiceEdge) GetNode() *ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService { - return v.Node +// GetDisplayName returns ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent.DisplayName, and is useful for accessing the field via an interface. +func (v *ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent) GetDisplayName() *string { + return v.DisplayName } -// ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService includes the requested fields of the GraphQL type Service. -type ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService struct { - // Unique identifier of the service - Id string `json:"id"` - // Service identifier in telemetry (e.g., 'checkout-service') - Name string `json:"name"` - // AI-generated description of what this service does and its telemetry characteristics - Description string `json:"description"` - // Whether log analysis and policy generation is active for this service - Enabled bool `json:"enabled"` - // When the service was created - CreatedAt time.Time `json:"createdAt"` - // When the service was last updated - UpdatedAt time.Time `json:"updatedAt"` +// GetStatus returns ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent.Status, and is useful for accessing the field via an interface. +func (v *ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent) GetStatus() *ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatus { + return v.Status } -// GetId returns ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService.Id, and is useful for accessing the field via an interface. -func (v *ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService) GetId() string { - return v.Id +// ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatus includes the requested fields of the GraphQL type LogEventStatus. +type ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatus struct { + Current ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatusCurrentStatusMeasurementTotals `json:"current"` } -// GetName returns ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService.Name, and is useful for accessing the field via an interface. -func (v *ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService) GetName() string { - return v.Name +// GetCurrent returns ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatus.Current, and is useful for accessing the field via an interface. +func (v *ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatus) GetCurrent() ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatusCurrentStatusMeasurementTotals { + return v.Current } -// GetDescription returns ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService.Description, and is useful for accessing the field via an interface. -func (v *ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService) GetDescription() string { - return v.Description +// ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatusCurrentStatusMeasurementTotals includes the requested fields of the GraphQL type StatusMeasurementTotals. +type ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatusCurrentStatusMeasurementTotals struct { + EventsPerHour *float64 `json:"eventsPerHour"` + BytesPerHour *float64 `json:"bytesPerHour"` + TotalUsdPerHour *float64 `json:"totalUsdPerHour"` } -// GetEnabled returns ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService.Enabled, and is useful for accessing the field via an interface. -func (v *ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService) GetEnabled() bool { - return v.Enabled +// GetEventsPerHour returns ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatusCurrentStatusMeasurementTotals.EventsPerHour, and is useful for accessing the field via an interface. +func (v *ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatusCurrentStatusMeasurementTotals) GetEventsPerHour() *float64 { + return v.EventsPerHour } -// GetCreatedAt returns ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService.CreatedAt, and is useful for accessing the field via an interface. -func (v *ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService) GetCreatedAt() time.Time { - return v.CreatedAt +// GetBytesPerHour returns ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatusCurrentStatusMeasurementTotals.BytesPerHour, and is useful for accessing the field via an interface. +func (v *ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatusCurrentStatusMeasurementTotals) GetBytesPerHour() *float64 { + return v.BytesPerHour } -// GetUpdatedAt returns ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService.UpdatedAt, and is useful for accessing the field via an interface. -func (v *ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService) GetUpdatedAt() time.Time { - return v.UpdatedAt +// GetTotalUsdPerHour returns ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatusCurrentStatusMeasurementTotals.TotalUsdPerHour, and is useful for accessing the field via an interface. +func (v *ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatusCurrentStatusMeasurementTotals) GetTotalUsdPerHour() *float64 { + return v.TotalUsdPerHour } -// ListWorkspacesResponse is returned by ListWorkspaces on success. -type ListWorkspacesResponse struct { - // Query workspaces. Workspaces are used to analyze and classify telemetry. - Workspaces ListWorkspacesWorkspacesWorkspaceConnection `json:"workspaces"` +// ListServiceLogEventsResponse is returned by ListServiceLogEvents on success. +type ListServiceLogEventsResponse struct { + // Query LogEvents records in your account. + LogEvents ListServiceLogEventsLogEventsLogEventConnection `json:"logEvents"` } -// GetWorkspaces returns ListWorkspacesResponse.Workspaces, and is useful for accessing the field via an interface. -func (v *ListWorkspacesResponse) GetWorkspaces() ListWorkspacesWorkspacesWorkspaceConnection { - return v.Workspaces +// GetLogEvents returns ListServiceLogEventsResponse.LogEvents, and is useful for accessing the field via an interface. +func (v *ListServiceLogEventsResponse) GetLogEvents() ListServiceLogEventsLogEventsLogEventConnection { + return v.LogEvents } -// ListWorkspacesWorkspacesWorkspaceConnection includes the requested fields of the GraphQL type WorkspaceConnection. -// The GraphQL type's documentation follows. -// -// A connection to a list of items. -type ListWorkspacesWorkspacesWorkspaceConnection struct { - // A list of edges. - Edges []*ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdge `json:"edges"` - // Identifies the total count of items in the connection. - TotalCount int `json:"totalCount"` +// ListServiceStatusesResponse is returned by ListServiceStatuses on success. +type ListServiceStatusesResponse struct { + // Query Services records in your account. + Services ListServiceStatusesServicesServiceConnection `json:"services"` } -// GetEdges returns ListWorkspacesWorkspacesWorkspaceConnection.Edges, and is useful for accessing the field via an interface. -func (v *ListWorkspacesWorkspacesWorkspaceConnection) GetEdges() []*ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdge { - return v.Edges +// GetServices returns ListServiceStatusesResponse.Services, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesResponse) GetServices() ListServiceStatusesServicesServiceConnection { + return v.Services } -// GetTotalCount returns ListWorkspacesWorkspacesWorkspaceConnection.TotalCount, and is useful for accessing the field via an interface. -func (v *ListWorkspacesWorkspacesWorkspaceConnection) GetTotalCount() int { return v.TotalCount } +// ListServiceStatusesServicesServiceConnection includes the requested fields of the GraphQL type ServiceConnection. +type ListServiceStatusesServicesServiceConnection struct { + Edges []ListServiceStatusesServicesServiceConnectionEdgesServiceEdge `json:"edges"` +} -// ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdge includes the requested fields of the GraphQL type WorkspaceEdge. -// The GraphQL type's documentation follows. -// -// An edge in a connection. -type ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdge struct { - // The item at the end of the edge. - Node *ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdgeNodeWorkspace `json:"node"` +// GetEdges returns ListServiceStatusesServicesServiceConnection.Edges, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnection) GetEdges() []ListServiceStatusesServicesServiceConnectionEdgesServiceEdge { + return v.Edges +} + +// ListServiceStatusesServicesServiceConnectionEdgesServiceEdge includes the requested fields of the GraphQL type ServiceEdge. +type ListServiceStatusesServicesServiceConnectionEdgesServiceEdge struct { + Node ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeService `json:"node"` } -// GetNode returns ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdge.Node, and is useful for accessing the field via an interface. -func (v *ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdge) GetNode() *ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdgeNodeWorkspace { +// GetNode returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdge.Node, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdge) GetNode() ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeService { return v.Node } -// ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdgeNodeWorkspace includes the requested fields of the GraphQL type Workspace. -type ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdgeNodeWorkspace struct { - // Unique identifier of the workspace +// ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeService includes the requested fields of the GraphQL type Service. +type ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeService struct { + // Unique identifier of the service Id string `json:"id"` - // Human-readable name within the account + // Service identifier in telemetry (e.g., 'checkout-service') Name string `json:"name"` - // Primary purpose determining evaluation strategy. observability: performance - // and reliability, security: threat detection, compliance: regulatory requirements. - Purpose WorkspacePurpose `json:"purpose"` - // When the workspace was created - CreatedAt time.Time `json:"createdAt"` + // Narrow status surface for the services list. + // Drops issue-projection rollups (preview / effective / savings) and + // per-service indexing cost from the full status to keep the list query cheap. + // Use status for the service detail page. + // Returns null if no baseline or volume data is available yet. + StatusSummary *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummary `json:"statusSummary"` } -// GetId returns ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdgeNodeWorkspace.Id, and is useful for accessing the field via an interface. -func (v *ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdgeNodeWorkspace) GetId() string { +// GetId returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeService.Id, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeService) GetId() string { return v.Id } -// GetName returns ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdgeNodeWorkspace.Name, and is useful for accessing the field via an interface. -func (v *ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdgeNodeWorkspace) GetName() string { +// GetName returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeService.Name, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeService) GetName() string { return v.Name } -// GetPurpose returns ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdgeNodeWorkspace.Purpose, and is useful for accessing the field via an interface. -func (v *ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdgeNodeWorkspace) GetPurpose() WorkspacePurpose { - return v.Purpose +// GetStatusSummary returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeService.StatusSummary, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeService) GetStatusSummary() *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummary { + return v.StatusSummary } -// GetCreatedAt returns ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdgeNodeWorkspace.CreatedAt, and is useful for accessing the field via an interface. -func (v *ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdgeNodeWorkspace) GetCreatedAt() time.Time { - return v.CreatedAt +// ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummary includes the requested fields of the GraphQL type ServiceStatusSummary. +type ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummary struct { + Health StatusHealth `json:"health"` + LogEventCount int `json:"logEventCount"` + LogEventAnalyzedCount int `json:"logEventAnalyzedCount"` + Current ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrent `json:"current"` } -// MessageRole is enum for the field role -type MessageRole string +// GetHealth returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummary.Health, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummary) GetHealth() StatusHealth { + return v.Health +} -const ( - MessageRoleUser MessageRole = "user" - MessageRoleAssistant MessageRole = "assistant" -) +// GetLogEventCount returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummary.LogEventCount, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummary) GetLogEventCount() int { + return v.LogEventCount +} -var AllMessageRole = []MessageRole{ - MessageRoleUser, - MessageRoleAssistant, +// GetLogEventAnalyzedCount returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummary.LogEventAnalyzedCount, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummary) GetLogEventAnalyzedCount() int { + return v.LogEventAnalyzedCount } -// MessageStopReason is enum for the field stop_reason -type MessageStopReason string +// GetCurrent returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummary.Current, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummary) GetCurrent() ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrent { + return v.Current +} -const ( - MessageStopReasonEndTurn MessageStopReason = "end_turn" - MessageStopReasonToolUse MessageStopReason = "tool_use" -) +// ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrent includes the requested fields of the GraphQL type ServiceStatusSummaryCurrent. +type ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrent struct { + EventsPerHour *float64 `json:"eventsPerHour"` + BytesPerHour *float64 `json:"bytesPerHour"` + TotalUsdPerHour *float64 `json:"totalUsdPerHour"` + Severity ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrentSeverityStatusSeverityTotals `json:"severity"` +} + +// GetEventsPerHour returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrent.EventsPerHour, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrent) GetEventsPerHour() *float64 { + return v.EventsPerHour +} -var AllMessageStopReason = []MessageStopReason{ - MessageStopReasonEndTurn, - MessageStopReasonToolUse, +// GetBytesPerHour returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrent.BytesPerHour, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrent) GetBytesPerHour() *float64 { + return v.BytesPerHour } -// Text content from the user or assistant. -type TextBlockInput struct { - Content string `json:"content"` +// GetTotalUsdPerHour returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrent.TotalUsdPerHour, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrent) GetTotalUsdPerHour() *float64 { + return v.TotalUsdPerHour } -// GetContent returns TextBlockInput.Content, and is useful for accessing the field via an interface. -func (v *TextBlockInput) GetContent() string { return v.Content } +// GetSeverity returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrent.Severity, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrent) GetSeverity() ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrentSeverityStatusSeverityTotals { + return v.Severity +} -// The AI's internal reasoning (extended thinking). -type ThinkingBlockInput struct { - Content string `json:"content"` +// ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrentSeverityStatusSeverityTotals includes the requested fields of the GraphQL type StatusSeverityTotals. +type ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrentSeverityStatusSeverityTotals struct { + DebugEventsPerHour *float64 `json:"debugEventsPerHour"` + InfoEventsPerHour *float64 `json:"infoEventsPerHour"` + WarnEventsPerHour *float64 `json:"warnEventsPerHour"` + ErrorEventsPerHour *float64 `json:"errorEventsPerHour"` + OtherEventsPerHour *float64 `json:"otherEventsPerHour"` } -// GetContent returns ThinkingBlockInput.Content, and is useful for accessing the field via an interface. -func (v *ThinkingBlockInput) GetContent() string { return v.Content } +// GetDebugEventsPerHour returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrentSeverityStatusSeverityTotals.DebugEventsPerHour, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrentSeverityStatusSeverityTotals) GetDebugEventsPerHour() *float64 { + return v.DebugEventsPerHour +} -// The result of a tool call. -type ToolResultInput struct { - // The ID of the tool call this result is for. - ToolUseId string `json:"toolUseId"` - // Whether the tool execution resulted in an error. - IsError bool `json:"isError"` - // Human-readable error message when isError is true. - Error *string `json:"error"` - // Structured result data (JSON object). - Content *map[string]any `json:"content"` +// GetInfoEventsPerHour returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrentSeverityStatusSeverityTotals.InfoEventsPerHour, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrentSeverityStatusSeverityTotals) GetInfoEventsPerHour() *float64 { + return v.InfoEventsPerHour } -// GetToolUseId returns ToolResultInput.ToolUseId, and is useful for accessing the field via an interface. -func (v *ToolResultInput) GetToolUseId() string { return v.ToolUseId } +// GetWarnEventsPerHour returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrentSeverityStatusSeverityTotals.WarnEventsPerHour, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrentSeverityStatusSeverityTotals) GetWarnEventsPerHour() *float64 { + return v.WarnEventsPerHour +} -// GetIsError returns ToolResultInput.IsError, and is useful for accessing the field via an interface. -func (v *ToolResultInput) GetIsError() bool { return v.IsError } +// GetErrorEventsPerHour returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrentSeverityStatusSeverityTotals.ErrorEventsPerHour, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrentSeverityStatusSeverityTotals) GetErrorEventsPerHour() *float64 { + return v.ErrorEventsPerHour +} -// GetError returns ToolResultInput.Error, and is useful for accessing the field via an interface. -func (v *ToolResultInput) GetError() *string { return v.Error } +// GetOtherEventsPerHour returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrentSeverityStatusSeverityTotals.OtherEventsPerHour, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrentSeverityStatusSeverityTotals) GetOtherEventsPerHour() *float64 { + return v.OtherEventsPerHour +} -// GetContent returns ToolResultInput.Content, and is useful for accessing the field via an interface. -func (v *ToolResultInput) GetContent() *map[string]any { return v.Content } +// ListServicesResponse is returned by ListServices on success. +type ListServicesResponse struct { + // Query Services records in your account. + Services ListServicesServicesServiceConnection `json:"services"` +} -// A tool call from the assistant. -type ToolUseInput struct { - // Unique identifier for this tool call. - Id string `json:"id"` - // The name of the tool being called. - Name string `json:"name"` - // The input parameters for the tool (JSON object). - Input map[string]any `json:"input"` +// GetServices returns ListServicesResponse.Services, and is useful for accessing the field via an interface. +func (v *ListServicesResponse) GetServices() ListServicesServicesServiceConnection { return v.Services } + +// ListServicesServicesServiceConnection includes the requested fields of the GraphQL type ServiceConnection. +type ListServicesServicesServiceConnection struct { + Edges []ListServicesServicesServiceConnectionEdgesServiceEdge `json:"edges"` + TotalCount int `json:"totalCount"` } -// GetId returns ToolUseInput.Id, and is useful for accessing the field via an interface. -func (v *ToolUseInput) GetId() string { return v.Id } +// GetEdges returns ListServicesServicesServiceConnection.Edges, and is useful for accessing the field via an interface. +func (v *ListServicesServicesServiceConnection) GetEdges() []ListServicesServicesServiceConnectionEdgesServiceEdge { + return v.Edges +} -// GetName returns ToolUseInput.Name, and is useful for accessing the field via an interface. -func (v *ToolUseInput) GetName() string { return v.Name } +// GetTotalCount returns ListServicesServicesServiceConnection.TotalCount, and is useful for accessing the field via an interface. +func (v *ListServicesServicesServiceConnection) GetTotalCount() int { return v.TotalCount } -// GetInput returns ToolUseInput.Input, and is useful for accessing the field via an interface. -func (v *ToolUseInput) GetInput() map[string]any { return v.Input } +// ListServicesServicesServiceConnectionEdgesServiceEdge includes the requested fields of the GraphQL type ServiceEdge. +type ListServicesServicesServiceConnectionEdgesServiceEdge struct { + Node ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService `json:"node"` +} -// UpdateConversationInput is used for update Conversation object. -// Input was generated by ent. -type UpdateConversationInput struct { - // AI-generated title, set after first exchange - Title *string `json:"title"` - ClearTitle *bool `json:"clearTitle"` - ViewID *string `json:"viewID"` - ClearView *bool `json:"clearView"` +// GetNode returns ListServicesServicesServiceConnectionEdgesServiceEdge.Node, and is useful for accessing the field via an interface. +func (v *ListServicesServicesServiceConnectionEdgesServiceEdge) GetNode() ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService { + return v.Node } -// GetTitle returns UpdateConversationInput.Title, and is useful for accessing the field via an interface. -func (v *UpdateConversationInput) GetTitle() *string { return v.Title } +// ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService includes the requested fields of the GraphQL type Service. +type ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService struct { + // Unique identifier of the service + Id string `json:"id"` + // Service identifier in telemetry (e.g., 'checkout-service') + Name string `json:"name"` + // Whether log analysis and policy generation is active for this service + Enabled bool `json:"enabled"` + // When the service was created + CreatedAt time.Time `json:"createdAt"` + // When the service was last updated + UpdatedAt time.Time `json:"updatedAt"` +} -// GetClearTitle returns UpdateConversationInput.ClearTitle, and is useful for accessing the field via an interface. -func (v *UpdateConversationInput) GetClearTitle() *bool { return v.ClearTitle } +// GetId returns ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService.Id, and is useful for accessing the field via an interface. +func (v *ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService) GetId() string { + return v.Id +} -// GetViewID returns UpdateConversationInput.ViewID, and is useful for accessing the field via an interface. -func (v *UpdateConversationInput) GetViewID() *string { return v.ViewID } +// GetName returns ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService.Name, and is useful for accessing the field via an interface. +func (v *ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService) GetName() string { + return v.Name +} -// GetClearView returns UpdateConversationInput.ClearView, and is useful for accessing the field via an interface. -func (v *UpdateConversationInput) GetClearView() *bool { return v.ClearView } +// GetEnabled returns ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService.Enabled, and is useful for accessing the field via an interface. +func (v *ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService) GetEnabled() bool { + return v.Enabled +} -// UpdateConversationResponse is returned by UpdateConversation on success. -type UpdateConversationResponse struct { - // Update a conversation (e.g., set title). - UpdateConversation UpdateConversationUpdateConversation `json:"updateConversation"` +// GetCreatedAt returns ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService.CreatedAt, and is useful for accessing the field via an interface. +func (v *ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService) GetCreatedAt() time.Time { + return v.CreatedAt } -// GetUpdateConversation returns UpdateConversationResponse.UpdateConversation, and is useful for accessing the field via an interface. -func (v *UpdateConversationResponse) GetUpdateConversation() UpdateConversationUpdateConversation { - return v.UpdateConversation +// GetUpdatedAt returns ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService.UpdatedAt, and is useful for accessing the field via an interface. +func (v *ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService) GetUpdatedAt() time.Time { + return v.UpdatedAt } -// UpdateConversationUpdateConversation includes the requested fields of the GraphQL type Conversation. -type UpdateConversationUpdateConversation struct { - // Unique identifier - Id string `json:"id"` - // AI-generated title, set after first exchange - Title *string `json:"title"` - // When the conversation was last updated - UpdatedAt time.Time `json:"updatedAt"` +// Organization creation input. +type OrganizationCreateInput struct { + // Human-readable name, unique across the system + Name string `json:"name"` } -// GetId returns UpdateConversationUpdateConversation.Id, and is useful for accessing the field via an interface. -func (v *UpdateConversationUpdateConversation) GetId() string { return v.Id } +// GetName returns OrganizationCreateInput.Name, and is useful for accessing the field via an interface. +func (v *OrganizationCreateInput) GetName() string { return v.Name } + +type StatusHealth string -// GetTitle returns UpdateConversationUpdateConversation.Title, and is useful for accessing the field via an interface. -func (v *UpdateConversationUpdateConversation) GetTitle() *string { return v.Title } +const ( + StatusHealthDisabled StatusHealth = "DISABLED" + StatusHealthInactive StatusHealth = "INACTIVE" + StatusHealthError StatusHealth = "ERROR" + StatusHealthOk StatusHealth = "OK" +) -// GetUpdatedAt returns UpdateConversationUpdateConversation.UpdatedAt, and is useful for accessing the field via an interface. -func (v *UpdateConversationUpdateConversation) GetUpdatedAt() time.Time { return v.UpdatedAt } +var AllStatusHealth = []StatusHealth{ + StatusHealthDisabled, + StatusHealthInactive, + StatusHealthError, + StatusHealthOk, +} type ValidateDatadogApiKeyInput struct { ApiKey string `json:"apiKey"` @@ -2238,6 +1792,7 @@ func (v *ValidateDatadogApiKeyInput) GetSite() DatadogAccountSite { return v.Sit // ValidateDatadogApiKeyResponse is returned by ValidateDatadogApiKey on success. type ValidateDatadogApiKeyResponse struct { + // Validate a Datadog API key against a Datadog site. ValidateDatadogApiKey ValidateDatadogApiKeyValidateDatadogApiKeyValidateDatadogApiKeyResult `json:"validateDatadogApiKey"` } @@ -2262,78 +1817,31 @@ func (v *ValidateDatadogApiKeyValidateDatadogApiKeyValidateDatadogApiKeyResult) return v.Error } -// WorkspacePurpose is enum for the field purpose -type WorkspacePurpose string - -const ( - WorkspacePurposeObservability WorkspacePurpose = "observability" - WorkspacePurposeSecurity WorkspacePurpose = "security" - WorkspacePurposeCompliance WorkspacePurpose = "compliance" -) - -var AllWorkspacePurpose = []WorkspacePurpose{ - WorkspacePurposeObservability, - WorkspacePurposeSecurity, - WorkspacePurposeCompliance, -} - -// __ApproveLogEventPolicyInput is used internally by genqlient -type __ApproveLogEventPolicyInput struct { - Id string `json:"id"` -} - -// GetId returns __ApproveLogEventPolicyInput.Id, and is useful for accessing the field via an interface. -func (v *__ApproveLogEventPolicyInput) GetId() string { return v.Id } - // __CreateAccountInput is used internally by genqlient type __CreateAccountInput struct { - Input CreateAccountInput `json:"input"` + Input AccountCreateInput `json:"input"` } // GetInput returns __CreateAccountInput.Input, and is useful for accessing the field via an interface. -func (v *__CreateAccountInput) GetInput() CreateAccountInput { return v.Input } - -// __CreateConversationInput is used internally by genqlient -type __CreateConversationInput struct { - Input CreateConversationInput `json:"input"` -} - -// GetInput returns __CreateConversationInput.Input, and is useful for accessing the field via an interface. -func (v *__CreateConversationInput) GetInput() CreateConversationInput { return v.Input } +func (v *__CreateAccountInput) GetInput() AccountCreateInput { return v.Input } // __CreateDatadogAccountWithCredentialsInput is used internally by genqlient type __CreateDatadogAccountWithCredentialsInput struct { - Input CreateDatadogAccountWithCredentialsInput `json:"input"` + Input DatadogAccountCreateInput `json:"input"` } // GetInput returns __CreateDatadogAccountWithCredentialsInput.Input, and is useful for accessing the field via an interface. -func (v *__CreateDatadogAccountWithCredentialsInput) GetInput() CreateDatadogAccountWithCredentialsInput { +func (v *__CreateDatadogAccountWithCredentialsInput) GetInput() DatadogAccountCreateInput { return v.Input } -// __CreateMessageInput is used internally by genqlient -type __CreateMessageInput struct { - Input CreateMessageInput `json:"input"` -} - -// GetInput returns __CreateMessageInput.Input, and is useful for accessing the field via an interface. -func (v *__CreateMessageInput) GetInput() CreateMessageInput { return v.Input } - // __CreateOrganizationAndBootstrapInput is used internally by genqlient type __CreateOrganizationAndBootstrapInput struct { - Input CreateOrganizationInput `json:"input"` + Input OrganizationCreateInput `json:"input"` } // GetInput returns __CreateOrganizationAndBootstrapInput.Input, and is useful for accessing the field via an interface. -func (v *__CreateOrganizationAndBootstrapInput) GetInput() CreateOrganizationInput { return v.Input } - -// __DeleteConversationInput is used internally by genqlient -type __DeleteConversationInput struct { - Id string `json:"id"` -} - -// GetId returns __DeleteConversationInput.Id, and is useful for accessing the field via an interface. -func (v *__DeleteConversationInput) GetId() string { return v.Id } +func (v *__CreateOrganizationAndBootstrapInput) GetInput() OrganizationCreateInput { return v.Input } // __DisableServiceInput is used internally by genqlient type __DisableServiceInput struct { @@ -2343,14 +1851,6 @@ type __DisableServiceInput struct { // GetServiceId returns __DisableServiceInput.ServiceId, and is useful for accessing the field via an interface. func (v *__DisableServiceInput) GetServiceId() string { return v.ServiceId } -// __DismissLogEventPolicyInput is used internally by genqlient -type __DismissLogEventPolicyInput struct { - Id string `json:"id"` -} - -// GetId returns __DismissLogEventPolicyInput.Id, and is useful for accessing the field via an interface. -func (v *__DismissLogEventPolicyInput) GetId() string { return v.Id } - // __EnableServiceInput is used internally by genqlient type __EnableServiceInput struct { ServiceId string `json:"serviceId"` @@ -2399,25 +1899,33 @@ type __ListAccountsInput struct { // GetOrganizationID returns __ListAccountsInput.OrganizationID, and is useful for accessing the field via an interface. func (v *__ListAccountsInput) GetOrganizationID() string { return v.OrganizationID } -// __ListWorkspacesInput is used internally by genqlient -type __ListWorkspacesInput struct { - AccountID string `json:"accountID"` +// __ListIssuesInput is used internally by genqlient +type __ListIssuesInput struct { + First int `json:"first"` } -// GetAccountID returns __ListWorkspacesInput.AccountID, and is useful for accessing the field via an interface. -func (v *__ListWorkspacesInput) GetAccountID() string { return v.AccountID } +// GetFirst returns __ListIssuesInput.First, and is useful for accessing the field via an interface. +func (v *__ListIssuesInput) GetFirst() int { return v.First } -// __UpdateConversationInput is used internally by genqlient -type __UpdateConversationInput struct { - Id string `json:"id"` - Input UpdateConversationInput `json:"input"` +// __ListServiceLogEventsInput is used internally by genqlient +type __ListServiceLogEventsInput struct { + ServiceID string `json:"serviceID"` + First int `json:"first"` } -// GetId returns __UpdateConversationInput.Id, and is useful for accessing the field via an interface. -func (v *__UpdateConversationInput) GetId() string { return v.Id } +// GetServiceID returns __ListServiceLogEventsInput.ServiceID, and is useful for accessing the field via an interface. +func (v *__ListServiceLogEventsInput) GetServiceID() string { return v.ServiceID } -// GetInput returns __UpdateConversationInput.Input, and is useful for accessing the field via an interface. -func (v *__UpdateConversationInput) GetInput() UpdateConversationInput { return v.Input } +// GetFirst returns __ListServiceLogEventsInput.First, and is useful for accessing the field via an interface. +func (v *__ListServiceLogEventsInput) GetFirst() int { return v.First } + +// __ListServiceStatusesInput is used internally by genqlient +type __ListServiceStatusesInput struct { + First int `json:"first"` +} + +// GetFirst returns __ListServiceStatusesInput.First, and is useful for accessing the field via an interface. +func (v *__ListServiceStatusesInput) GetFirst() int { return v.First } // __ValidateDatadogApiKeyInput is used internally by genqlient type __ValidateDatadogApiKeyInput struct { @@ -2427,48 +1935,9 @@ type __ValidateDatadogApiKeyInput struct { // GetInput returns __ValidateDatadogApiKeyInput.Input, and is useful for accessing the field via an interface. func (v *__ValidateDatadogApiKeyInput) GetInput() ValidateDatadogApiKeyInput { return v.Input } -// The mutation executed by ApproveLogEventPolicy. -const ApproveLogEventPolicy_Operation = ` -mutation ApproveLogEventPolicy ($id: ID!) { - approveLogEventPolicy(id: $id) { - id - approvedAt - approvedBy - dismissedAt - dismissedBy - } -} -` - -// Mutation to approve a log event policy for enforcement -func ApproveLogEventPolicy( - ctx_ context.Context, - client_ graphql.Client, - id string, -) (data_ *ApproveLogEventPolicyResponse, err_ error) { - req_ := &graphql.Request{ - OpName: "ApproveLogEventPolicy", - Query: ApproveLogEventPolicy_Operation, - Variables: &__ApproveLogEventPolicyInput{ - Id: id, - }, - } - - data_ = &ApproveLogEventPolicyResponse{} - resp_ := &graphql.Response{Data: data_} - - err_ = client_.MakeRequest( - ctx_, - req_, - resp_, - ) - - return data_, err_ -} - // The mutation executed by CreateAccount. const CreateAccount_Operation = ` -mutation CreateAccount ($input: CreateAccountInput!) { +mutation CreateAccount ($input: AccountCreateInput!) { createAccount(input: $input) { id name @@ -2480,7 +1949,7 @@ mutation CreateAccount ($input: CreateAccountInput!) { func CreateAccount( ctx_ context.Context, client_ graphql.Client, - input CreateAccountInput, + input AccountCreateInput, ) (data_ *CreateAccountResponse, err_ error) { req_ := &graphql.Request{ OpName: "CreateAccount", @@ -2502,46 +1971,9 @@ func CreateAccount( return data_, err_ } -// The mutation executed by CreateConversation. -const CreateConversation_Operation = ` -mutation CreateConversation ($input: CreateConversationInput!) { - createConversation(input: $input) { - id - title - createdAt - updatedAt - } -} -` - -func CreateConversation( - ctx_ context.Context, - client_ graphql.Client, - input CreateConversationInput, -) (data_ *CreateConversationResponse, err_ error) { - req_ := &graphql.Request{ - OpName: "CreateConversation", - Query: CreateConversation_Operation, - Variables: &__CreateConversationInput{ - Input: input, - }, - } - - data_ = &CreateConversationResponse{} - resp_ := &graphql.Response{Data: data_} - - err_ = client_.MakeRequest( - ctx_, - req_, - resp_, - ) - - return data_, err_ -} - // The mutation executed by CreateDatadogAccountWithCredentials. const CreateDatadogAccountWithCredentials_Operation = ` -mutation CreateDatadogAccountWithCredentials ($input: CreateDatadogAccountWithCredentialsInput!) { +mutation CreateDatadogAccountWithCredentials ($input: DatadogAccountCreateInput!) { createDatadogAccount(input: $input) { id name @@ -2555,55 +1987,17 @@ mutation CreateDatadogAccountWithCredentials ($input: CreateDatadogAccountWithCr func CreateDatadogAccountWithCredentials( ctx_ context.Context, client_ graphql.Client, - input CreateDatadogAccountWithCredentialsInput, + input DatadogAccountCreateInput, ) (data_ *CreateDatadogAccountWithCredentialsResponse, err_ error) { req_ := &graphql.Request{ - OpName: "CreateDatadogAccountWithCredentials", - Query: CreateDatadogAccountWithCredentials_Operation, - Variables: &__CreateDatadogAccountWithCredentialsInput{ - Input: input, - }, - } - - data_ = &CreateDatadogAccountWithCredentialsResponse{} - resp_ := &graphql.Response{Data: data_} - - err_ = client_.MakeRequest( - ctx_, - req_, - resp_, - ) - - return data_, err_ -} - -// The mutation executed by CreateMessage. -const CreateMessage_Operation = ` -mutation CreateMessage ($input: CreateMessageInput!) { - createMessage(input: $input) { - id - role - model - stopReason - createdAt - } -} -` - -func CreateMessage( - ctx_ context.Context, - client_ graphql.Client, - input CreateMessageInput, -) (data_ *CreateMessageResponse, err_ error) { - req_ := &graphql.Request{ - OpName: "CreateMessage", - Query: CreateMessage_Operation, - Variables: &__CreateMessageInput{ + OpName: "CreateDatadogAccountWithCredentials", + Query: CreateDatadogAccountWithCredentials_Operation, + Variables: &__CreateDatadogAccountWithCredentialsInput{ Input: input, }, } - data_ = &CreateMessageResponse{} + data_ = &CreateDatadogAccountWithCredentialsResponse{} resp_ := &graphql.Response{Data: data_} err_ = client_.MakeRequest( @@ -2617,7 +2011,7 @@ func CreateMessage( // The mutation executed by CreateOrganizationAndBootstrap. const CreateOrganizationAndBootstrap_Operation = ` -mutation CreateOrganizationAndBootstrap ($input: CreateOrganizationInput!) { +mutation CreateOrganizationAndBootstrap ($input: OrganizationCreateInput!) { createOrganizationAndBootstrap(input: $input) { organization { id @@ -2629,10 +2023,6 @@ mutation CreateOrganizationAndBootstrap ($input: CreateOrganizationInput!) { id name } - workspace { - id - name - } } } ` @@ -2640,7 +2030,7 @@ mutation CreateOrganizationAndBootstrap ($input: CreateOrganizationInput!) { func CreateOrganizationAndBootstrap( ctx_ context.Context, client_ graphql.Client, - input CreateOrganizationInput, + input OrganizationCreateInput, ) (data_ *CreateOrganizationAndBootstrapResponse, err_ error) { req_ := &graphql.Request{ OpName: "CreateOrganizationAndBootstrap", @@ -2662,42 +2052,10 @@ func CreateOrganizationAndBootstrap( return data_, err_ } -// The mutation executed by DeleteConversation. -const DeleteConversation_Operation = ` -mutation DeleteConversation ($id: ID!) { - deleteConversation(id: $id) -} -` - -func DeleteConversation( - ctx_ context.Context, - client_ graphql.Client, - id string, -) (data_ *DeleteConversationResponse, err_ error) { - req_ := &graphql.Request{ - OpName: "DeleteConversation", - Query: DeleteConversation_Operation, - Variables: &__DeleteConversationInput{ - Id: id, - }, - } - - data_ = &DeleteConversationResponse{} - resp_ := &graphql.Response{Data: data_} - - err_ = client_.MakeRequest( - ctx_, - req_, - resp_, - ) - - return data_, err_ -} - // The mutation executed by DisableService. const DisableService_Operation = ` mutation DisableService ($serviceId: ID!) { - updateService(id: $serviceId, input: {enabled:false}) { + setServiceEnabled(id: $serviceId, enabled: false) { id name enabled @@ -2731,49 +2089,10 @@ func DisableService( return data_, err_ } -// The mutation executed by DismissLogEventPolicy. -const DismissLogEventPolicy_Operation = ` -mutation DismissLogEventPolicy ($id: ID!) { - dismissLogEventPolicy(id: $id) { - id - dismissedAt - dismissedBy - approvedAt - approvedBy - } -} -` - -// Mutation to dismiss a log event policy from pending review -func DismissLogEventPolicy( - ctx_ context.Context, - client_ graphql.Client, - id string, -) (data_ *DismissLogEventPolicyResponse, err_ error) { - req_ := &graphql.Request{ - OpName: "DismissLogEventPolicy", - Query: DismissLogEventPolicy_Operation, - Variables: &__DismissLogEventPolicyInput{ - Id: id, - }, - } - - data_ = &DismissLogEventPolicyResponse{} - resp_ := &graphql.Response{Data: data_} - - err_ = client_.MakeRequest( - ctx_, - req_, - resp_, - ) - - return data_, err_ -} - // The mutation executed by EnableService. const EnableService_Operation = ` mutation EnableService ($serviceId: ID!) { - updateService(id: $serviceId, input: {enabled:true}) { + setServiceEnabled(id: $serviceId, enabled: true) { id name enabled @@ -2850,6 +2169,71 @@ func GetAccount( return data_, err_ } +// The query executed by GetAccountStatusSummary. +const GetAccountStatusSummary_Operation = ` +query GetAccountStatusSummary { + datadogAccounts(first: 1) { + edges { + node { + status { + health + readiness { + readyForUse + } + coverage { + logEventCount + logEventAnalyzedCount + logServiceCount + logActiveServices + disabledServices + inactiveServices + okServices + } + current { + services { + eventsPerHour + volumeUsdPerHour + } + totals { + eventsPerHour + bytesPerHour + totalUsdPerHour + volumeUsdPerHour + } + } + } + } + } + } +} +` + +// Account-level status summary for the data-plane surfaces. The control plane +// computes health, service counts, coverage, and throughput totals; the CLI +// reads them as-is rather than aggregating service rows locally. The active +// account is scoped via the request header, so the single datadog account for +// the account is taken as the first edge. +func GetAccountStatusSummary( + ctx_ context.Context, + client_ graphql.Client, +) (data_ *GetAccountStatusSummaryResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "GetAccountStatusSummary", + Query: GetAccountStatusSummary_Operation, + } + + data_ = &GetAccountStatusSummaryResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The query executed by GetDatadogAccountStatus. const GetDatadogAccountStatus_Operation = ` query GetDatadogAccountStatus ($id: ID!) { @@ -2859,39 +2243,18 @@ query GetDatadogAccountStatus ($id: ID!) { id status { health - readyForUse - logEventCount - logEventAnalyzedCount - logServiceCount - logActiveServices - disabledServices - inactiveServices - okServices - policyPendingCount - policyApprovedCount - policyDismissedCount - serviceVolumePerHour - serviceCostPerHourVolumeUsd - logEventVolumePerHour - logEventBytesPerHour - logEventCostPerHourBytesUsd - logEventCostPerHourVolumeUsd - logEventCostPerHourUsd - estimatedVolumeReductionPerHour - estimatedBytesReductionPerHour - estimatedCostReductionPerHourBytesUsd - estimatedCostReductionPerHourVolumeUsd - estimatedCostReductionPerHourUsd - observedVolumePerHourBefore - observedVolumePerHourAfter - observedBytesPerHourBefore - observedBytesPerHourAfter - observedCostPerHourBeforeBytesUsd - observedCostPerHourBeforeVolumeUsd - observedCostPerHourBeforeUsd - observedCostPerHourAfterBytesUsd - observedCostPerHourAfterVolumeUsd - observedCostPerHourAfterUsd + readiness { + readyForUse + } + coverage { + logEventCount + logEventAnalyzedCount + logServiceCount + logActiveServices + disabledServices + inactiveServices + okServices + } } } } @@ -2924,27 +2287,74 @@ func GetDatadogAccountStatus( return data_, err_ } +// The query executed by GetIssueSummary. +const GetIssueSummary_Operation = ` +query GetIssueSummary { + issues(where: {closedAtIsNil:true,ignoredAtIsNil:true}) { + totalCount + summary { + count + } + facets { + priorities { + buckets { + value + count + } + } + } + } +} +` + +// Active issues with server-computed priority breakdown. An issue is active +// while both closedAt and ignoredAt are nil; the control plane computes the +// summary count and priority facet buckets so the CLI never aggregates locally. +func GetIssueSummary( + ctx_ context.Context, + client_ graphql.Client, +) (data_ *GetIssueSummaryResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "GetIssueSummary", + Query: GetIssueSummary_Operation, + } + + data_ = &GetIssueSummaryResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The query executed by GetService. const GetService_Operation = ` query GetService ($id: ID!) { - node(id: $id) { - __typename - ... on Service { - id - name - description - enabled - createdAt - updatedAt - account { - id - name - } - logEvents { + services(where: {id:$id}, first: 1) { + edges { + node { id name - description + enabled createdAt + updatedAt + account { + id + name + } + logEvents { + edges { + node { + id + name + createdAt + } + } + } } } } @@ -2985,7 +2395,6 @@ query GetServiceByName ($name: String!) { node { id name - description enabled createdAt updatedAt @@ -3062,6 +2471,151 @@ func ListAccounts( return data_, err_ } +// The query executed by ListChecks. +const ListChecks_Operation = ` +query ListChecks { + checks { + totalCount + edges { + node { + id + name + domain + posture { + openFindingCount + pendingFindingCount + escalatedFindingCount + activeIssueCount + affectedServiceCount + current { + totalUsdPerHour + } + } + } + } + facets { + domains { + buckets { + value + count + } + } + } + } +} +` + +// Code-defined product checks with account-scoped posture. Posture counts and +// cost totals are computed server-side from findings and issues; the domain +// facet groups checks by lane (cost / compliance). +func ListChecks( + ctx_ context.Context, + client_ graphql.Client, +) (data_ *ListChecksResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "ListChecks", + Query: ListChecks_Operation, + } + + data_ = &ListChecksResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by ListEdgeInstances. +const ListEdgeInstances_Operation = ` +query ListEdgeInstances { + edgeInstances(orderBy: {field:LAST_SYNC_AT,direction:DESC}) { + totalCount + edges { + node { + id + instanceID + serviceName + serviceNamespace + lastSyncAt + } + } + } +} +` + +// Edge instances registered for the active account. totalCount is the fleet +// size; lastSyncAt lets the surface report recency without local aggregation. +func ListEdgeInstances( + ctx_ context.Context, + client_ graphql.Client, +) (data_ *ListEdgeInstancesResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "ListEdgeInstances", + Query: ListEdgeInstances_Operation, + } + + data_ = &ListEdgeInstancesResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by ListIssues. +const ListIssues_Operation = ` +query ListIssues ($first: Int!) { + issues(first: $first, where: {closedAtIsNil:true,ignoredAtIsNil:true}, orderBy: {field:PRIORITY,direction:DESC}) { + totalCount + edges { + node { + id + displayID + title + priority + service { + name + } + } + } + } +} +` + +// Individual active issues with detail, for the issues command and read tool. +func ListIssues( + ctx_ context.Context, + client_ graphql.Client, + first int, +) (data_ *ListIssuesResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "ListIssues", + Query: ListIssues_Operation, + Variables: &__ListIssuesInput{ + First: first, + }, + } + + data_ = &ListIssuesResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The query executed by ListOrganizations. const ListOrganizations_Operation = ` query ListOrganizations { @@ -3100,36 +2654,45 @@ func ListOrganizations( return data_, err_ } -// The query executed by ListServices. -const ListServices_Operation = ` -query ListServices { - services(first: 100) { +// The query executed by ListServiceLogEvents. +const ListServiceLogEvents_Operation = ` +query ListServiceLogEvents ($serviceID: ID!, $first: Int!) { + logEvents(first: $first, where: {serviceID:$serviceID}) { edges { node { - id name - description - enabled - createdAt - updatedAt + displayName + status { + current { + eventsPerHour + bytesPerHour + totalUsdPerHour + } + } } } - totalCount } } ` -// Query to list all services with basic information -func ListServices( +// Log events for one service, with current throughput/cost, for the service +// detail drill-down. +func ListServiceLogEvents( ctx_ context.Context, client_ graphql.Client, -) (data_ *ListServicesResponse, err_ error) { + serviceID string, + first int, +) (data_ *ListServiceLogEventsResponse, err_ error) { req_ := &graphql.Request{ - OpName: "ListServices", - Query: ListServices_Operation, + OpName: "ListServiceLogEvents", + Query: ListServiceLogEvents_Operation, + Variables: &__ListServiceLogEventsInput{ + ServiceID: serviceID, + First: first, + }, } - data_ = &ListServicesResponse{} + data_ = &ListServiceLogEventsResponse{} resp_ := &graphql.Response{Data: data_} err_ = client_.MakeRequest( @@ -3141,37 +2704,54 @@ func ListServices( return data_, err_ } -// The query executed by ListWorkspaces. -const ListWorkspaces_Operation = ` -query ListWorkspaces ($accountID: ID!) { - workspaces(where: {accountID:$accountID}) { +// The query executed by ListServiceStatuses. +const ListServiceStatuses_Operation = ` +query ListServiceStatuses ($first: Int!) { + services(first: $first, where: {enabled:true}) { edges { node { id name - purpose - createdAt + statusSummary { + health + logEventCount + logEventAnalyzedCount + current { + eventsPerHour + bytesPerHour + totalUsdPerHour + severity { + debugEventsPerHour + infoEventsPerHour + warnEventsPerHour + errorEventsPerHour + otherEventsPerHour + } + } + } } } - totalCount } } ` -func ListWorkspaces( +// Enabled services with the narrow per-service status used by the services list. +// statusSummary is the control plane's list-optimized projection (health, event +// counts, current throughput and severity mix). +func ListServiceStatuses( ctx_ context.Context, client_ graphql.Client, - accountID string, -) (data_ *ListWorkspacesResponse, err_ error) { + first int, +) (data_ *ListServiceStatusesResponse, err_ error) { req_ := &graphql.Request{ - OpName: "ListWorkspaces", - Query: ListWorkspaces_Operation, - Variables: &__ListWorkspacesInput{ - AccountID: accountID, + OpName: "ListServiceStatuses", + Query: ListServiceStatuses_Operation, + Variables: &__ListServiceStatusesInput{ + First: first, }, } - data_ = &ListWorkspacesResponse{} + data_ = &ListServiceStatusesResponse{} resp_ := &graphql.Response{Data: data_} err_ = client_.MakeRequest( @@ -3183,33 +2763,35 @@ func ListWorkspaces( return data_, err_ } -// The mutation executed by UpdateConversation. -const UpdateConversation_Operation = ` -mutation UpdateConversation ($id: ID!, $input: UpdateConversationInput!) { - updateConversation(id: $id, input: $input) { - id - title - updatedAt +// The query executed by ListServices. +const ListServices_Operation = ` +query ListServices { + services(first: 100) { + edges { + node { + id + name + enabled + createdAt + updatedAt + } + } + totalCount } } ` -func UpdateConversation( +// Query to list all services with basic information +func ListServices( ctx_ context.Context, client_ graphql.Client, - id string, - input UpdateConversationInput, -) (data_ *UpdateConversationResponse, err_ error) { +) (data_ *ListServicesResponse, err_ error) { req_ := &graphql.Request{ - OpName: "UpdateConversation", - Query: UpdateConversation_Operation, - Variables: &__UpdateConversationInput{ - Id: id, - Input: input, - }, + OpName: "ListServices", + Query: ListServices_Operation, } - data_ = &UpdateConversationResponse{} + data_ = &ListServicesResponse{} resp_ := &graphql.Response{Data: data_} err_ = client_.MakeRequest( diff --git a/internal/boundary/graphql/gen/queries/accounts.graphql b/internal/boundary/graphql/gen/queries/accounts.graphql index 4eabe120..3be5bd12 100644 --- a/internal/boundary/graphql/gen/queries/accounts.graphql +++ b/internal/boundary/graphql/gen/queries/accounts.graphql @@ -11,7 +11,7 @@ query ListAccounts($organizationID: ID!) { } } -mutation CreateAccount($input: CreateAccountInput!) { +mutation CreateAccount($input: AccountCreateInput!) { createAccount(input: $input) { id name diff --git a/internal/boundary/graphql/gen/queries/checks.graphql b/internal/boundary/graphql/gen/queries/checks.graphql new file mode 100644 index 00000000..80025c08 --- /dev/null +++ b/internal/boundary/graphql/gen/queries/checks.graphql @@ -0,0 +1,33 @@ +# Code-defined product checks with account-scoped posture. Posture counts and +# cost totals are computed server-side from findings and issues; the domain +# facet groups checks by lane (cost / compliance). +query ListChecks { + checks { + totalCount + edges { + node { + id + name + domain + posture { + openFindingCount + pendingFindingCount + escalatedFindingCount + activeIssueCount + affectedServiceCount + current { + totalUsdPerHour + } + } + } + } + facets { + domains { + buckets { + value + count + } + } + } + } +} diff --git a/internal/boundary/graphql/gen/queries/conversations.graphql b/internal/boundary/graphql/gen/queries/conversations.graphql deleted file mode 100644 index ec257f27..00000000 --- a/internal/boundary/graphql/gen/queries/conversations.graphql +++ /dev/null @@ -1,20 +0,0 @@ -mutation CreateConversation($input: CreateConversationInput!) { - createConversation(input: $input) { - id - title - createdAt - updatedAt - } -} - -mutation UpdateConversation($id: ID!, $input: UpdateConversationInput!) { - updateConversation(id: $id, input: $input) { - id - title - updatedAt - } -} - -mutation DeleteConversation($id: ID!) { - deleteConversation(id: $id) -} diff --git a/internal/boundary/graphql/gen/queries/datadog_accounts.graphql b/internal/boundary/graphql/gen/queries/datadog_accounts.graphql index 9a732fac..1d1078a9 100644 --- a/internal/boundary/graphql/gen/queries/datadog_accounts.graphql +++ b/internal/boundary/graphql/gen/queries/datadog_accounts.graphql @@ -14,7 +14,7 @@ query GetAccount($id: ID!) { } mutation CreateDatadogAccountWithCredentials( - $input: CreateDatadogAccountWithCredentialsInput! + $input: DatadogAccountCreateInput! ) { createDatadogAccount(input: $input) { id @@ -39,39 +39,18 @@ query GetDatadogAccountStatus($id: ID!) { id status { health - readyForUse - logEventCount - logEventAnalyzedCount - logServiceCount - logActiveServices - disabledServices - inactiveServices - okServices - policyPendingCount - policyApprovedCount - policyDismissedCount - serviceVolumePerHour - serviceCostPerHourVolumeUsd - logEventVolumePerHour - logEventBytesPerHour - logEventCostPerHourBytesUsd - logEventCostPerHourVolumeUsd - logEventCostPerHourUsd - estimatedVolumeReductionPerHour - estimatedBytesReductionPerHour - estimatedCostReductionPerHourBytesUsd - estimatedCostReductionPerHourVolumeUsd - estimatedCostReductionPerHourUsd - observedVolumePerHourBefore - observedVolumePerHourAfter - observedBytesPerHourBefore - observedBytesPerHourAfter - observedCostPerHourBeforeBytesUsd - observedCostPerHourBeforeVolumeUsd - observedCostPerHourBeforeUsd - observedCostPerHourAfterBytesUsd - observedCostPerHourAfterVolumeUsd - observedCostPerHourAfterUsd + readiness { + readyForUse + } + coverage { + logEventCount + logEventAnalyzedCount + logServiceCount + logActiveServices + disabledServices + inactiveServices + okServices + } } } } diff --git a/internal/boundary/graphql/gen/queries/edge_instances.graphql b/internal/boundary/graphql/gen/queries/edge_instances.graphql new file mode 100644 index 00000000..034f24d7 --- /dev/null +++ b/internal/boundary/graphql/gen/queries/edge_instances.graphql @@ -0,0 +1,16 @@ +# Edge instances registered for the active account. totalCount is the fleet +# size; lastSyncAt lets the surface report recency without local aggregation. +query ListEdgeInstances { + edgeInstances(orderBy: { field: LAST_SYNC_AT, direction: DESC }) { + totalCount + edges { + node { + id + instanceID + serviceName + serviceNamespace + lastSyncAt + } + } + } +} diff --git a/internal/boundary/graphql/gen/queries/issues.graphql b/internal/boundary/graphql/gen/queries/issues.graphql new file mode 100644 index 00000000..227146b0 --- /dev/null +++ b/internal/boundary/graphql/gen/queries/issues.graphql @@ -0,0 +1,41 @@ +# Active issues with server-computed priority breakdown. An issue is active +# while both closedAt and ignoredAt are nil; the control plane computes the +# summary count and priority facet buckets so the CLI never aggregates locally. +query GetIssueSummary { + issues(where: { closedAtIsNil: true, ignoredAtIsNil: true }) { + totalCount + summary { + count + } + facets { + priorities { + buckets { + value + count + } + } + } + } +} + +# Individual active issues with detail, for the issues command and read tool. +query ListIssues($first: Int!) { + issues( + first: $first + where: { closedAtIsNil: true, ignoredAtIsNil: true } + orderBy: { field: PRIORITY, direction: DESC } + ) { + totalCount + edges { + node { + id + displayID + title + priority + service { + name + } + } + } + } +} diff --git a/internal/boundary/graphql/gen/queries/log_event_policies.graphql b/internal/boundary/graphql/gen/queries/log_event_policies.graphql deleted file mode 100644 index 942289aa..00000000 --- a/internal/boundary/graphql/gen/queries/log_event_policies.graphql +++ /dev/null @@ -1,21 +0,0 @@ -# Mutation to approve a log event policy for enforcement -mutation ApproveLogEventPolicy($id: ID!) { - approveLogEventPolicy(id: $id) { - id - approvedAt - approvedBy - dismissedAt - dismissedBy - } -} - -# Mutation to dismiss a log event policy from pending review -mutation DismissLogEventPolicy($id: ID!) { - dismissLogEventPolicy(id: $id) { - id - dismissedAt - dismissedBy - approvedAt - approvedBy - } -} diff --git a/internal/boundary/graphql/gen/queries/messages.graphql b/internal/boundary/graphql/gen/queries/messages.graphql deleted file mode 100644 index c0ad0b80..00000000 --- a/internal/boundary/graphql/gen/queries/messages.graphql +++ /dev/null @@ -1,9 +0,0 @@ -mutation CreateMessage($input: CreateMessageInput!) { - createMessage(input: $input) { - id - role - model - stopReason - createdAt - } -} diff --git a/internal/boundary/graphql/gen/queries/organizations.graphql b/internal/boundary/graphql/gen/queries/organizations.graphql index 19296d56..d287632e 100644 --- a/internal/boundary/graphql/gen/queries/organizations.graphql +++ b/internal/boundary/graphql/gen/queries/organizations.graphql @@ -12,7 +12,7 @@ query ListOrganizations { } } -mutation CreateOrganizationAndBootstrap($input: CreateOrganizationInput!) { +mutation CreateOrganizationAndBootstrap($input: OrganizationCreateInput!) { createOrganizationAndBootstrap(input: $input) { organization { id @@ -24,9 +24,5 @@ mutation CreateOrganizationAndBootstrap($input: CreateOrganizationInput!) { id name } - workspace { - id - name - } } } diff --git a/internal/boundary/graphql/gen/queries/services.graphql b/internal/boundary/graphql/gen/queries/services.graphql index 86d30e1e..bfb1e56a 100644 --- a/internal/boundary/graphql/gen/queries/services.graphql +++ b/internal/boundary/graphql/gen/queries/services.graphql @@ -5,7 +5,6 @@ query ListServices { node { id name - description enabled createdAt updatedAt @@ -17,23 +16,27 @@ query ListServices { # Query to get detailed information about a specific service by ID query GetService($id: ID!) { - node(id: $id) { - ... on Service { - id - name - description - enabled - createdAt - updatedAt - account { - id - name - } - logEvents { + services(where: { id: $id }, first: 1) { + edges { + node { id name - description + enabled createdAt + updatedAt + account { + id + name + } + logEvents { + edges { + node { + id + name + createdAt + } + } + } } } } @@ -46,7 +49,6 @@ query GetServiceByName($name: String!) { node { id name - description enabled createdAt updatedAt @@ -57,7 +59,7 @@ query GetServiceByName($name: String!) { # Mutation to enable a service for analysis mutation EnableService($serviceId: ID!) { - updateService(id: $serviceId, input: { enabled: true }) { + setServiceEnabled(id: $serviceId, enabled: true) { id name enabled @@ -66,7 +68,7 @@ mutation EnableService($serviceId: ID!) { # Mutation to disable a service mutation DisableService($serviceId: ID!) { - updateService(id: $serviceId, input: { enabled: false }) { + setServiceEnabled(id: $serviceId, enabled: false) { id name enabled diff --git a/internal/boundary/graphql/gen/queries/status.graphql b/internal/boundary/graphql/gen/queries/status.graphql new file mode 100644 index 00000000..655994c2 --- /dev/null +++ b/internal/boundary/graphql/gen/queries/status.graphql @@ -0,0 +1,91 @@ +# Account-level status summary for the data-plane surfaces. The control plane +# computes health, service counts, coverage, and throughput totals; the CLI +# reads them as-is rather than aggregating service rows locally. The active +# account is scoped via the request header, so the single datadog account for +# the account is taken as the first edge. +query GetAccountStatusSummary { + datadogAccounts(first: 1) { + edges { + node { + status { + health + readiness { + readyForUse + } + coverage { + logEventCount + logEventAnalyzedCount + logServiceCount + logActiveServices + disabledServices + inactiveServices + okServices + } + current { + services { + eventsPerHour + volumeUsdPerHour + } + totals { + eventsPerHour + bytesPerHour + totalUsdPerHour + volumeUsdPerHour + } + } + } + } + } + } +} + +# Enabled services with the narrow per-service status used by the services list. +# statusSummary is the control plane's list-optimized projection (health, event +# counts, current throughput and severity mix). +query ListServiceStatuses($first: Int!) { + services(first: $first, where: { enabled: true }) { + edges { + node { + id + name + statusSummary { + health + logEventCount + logEventAnalyzedCount + current { + eventsPerHour + bytesPerHour + totalUsdPerHour + severity { + debugEventsPerHour + infoEventsPerHour + warnEventsPerHour + errorEventsPerHour + otherEventsPerHour + } + } + } + } + } + } +} + +# Log events for one service, with current throughput/cost, for the service +# detail drill-down. +query ListServiceLogEvents($serviceID: ID!, $first: Int!) { + logEvents(first: $first, where: { serviceID: $serviceID }) { + edges { + node { + name + displayName + status { + current { + eventsPerHour + bytesPerHour + totalUsdPerHour + } + } + } + } + } +} diff --git a/internal/boundary/graphql/gen/queries/workspaces.graphql b/internal/boundary/graphql/gen/queries/workspaces.graphql deleted file mode 100644 index ed326f52..00000000 --- a/internal/boundary/graphql/gen/queries/workspaces.graphql +++ /dev/null @@ -1,13 +0,0 @@ -query ListWorkspaces($accountID: ID!) { - workspaces(where: { accountID: $accountID }) { - edges { - node { - id - name - purpose - createdAt - } - } - totalCount - } -} diff --git a/internal/boundary/graphql/gen/schema.graphql b/internal/boundary/graphql/gen/schema.graphql index 809b1854..be415946 100644 --- a/internal/boundary/graphql/gen/schema.graphql +++ b/internal/boundary/graphql/gen/schema.graphql @@ -24,25 +24,20 @@ directive @specifiedBy( url: String! ) on SCALAR -type Account implements Node { +type Account { """Unique identifier of the account""" id: ID! """Parent organization this account belongs to""" organizationID: ID! - """ - Denormalized for PowerSync. Auto-set via trigger from organization.workos_organization_id. - """ - workosOrganizationID: String! - """Human-readable name within the organization""" name: String! """ - Multiplier applied to volume data via trigger. 1 = real data, >1 = scaled for demos. + Short account key used in display IDs. Auto-set from name when omitted. """ - demoScaleFactor: Int! + displayKey: String """When the account was created""" createdAt: Time! @@ -53,777 +48,905 @@ type Account implements Node { """Organization this account belongs to""" organization: Organization! - """Services that produce telemetry""" - services: [Service!] - - """Purpose-aligned workspaces for telemetry evaluation""" - workspaces: [Workspace!] - """Datadog integration configuration""" datadogAccount: DatadogAccount + """Services that produce telemetry""" + services(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: ServiceOrder, where: ServiceWhereInput): ServiceConnection! + """Edge instances that sync policies from this account""" - edgeInstances: [EdgeInstance!] + edgeInstances(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: EdgeInstanceOrder, where: EdgeInstanceWhereInput): EdgeInstanceConnection! """API keys for edge instance authentication""" - edgeAPIKeys: [EdgeApiKey!] + edgeAPIKeys(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: EdgeApiKeyOrder, where: EdgeApiKeyWhereInput): EdgeApiKeyConnection! } -"""A connection to a list of items.""" type AccountConnection { - """A list of edges.""" - edges: [AccountEdge] - - """Information to aid in pagination.""" + edges: [AccountEdge!]! pageInfo: PageInfo! - - """Identifies the total count of items in the connection.""" totalCount: Int! } -"""An edge in a connection.""" -type AccountEdge { - """The item at the end of the edge.""" - node: Account +"""Account creation input.""" +input AccountCreateInput { + """Parent organization this account belongs to""" + organizationID: ID! + + """Human-readable name within the organization""" + name: String! + + """ + Short account key used in display IDs. Auto-set from name when omitted. + """ + displayKey: String +} - """A cursor for use in pagination.""" +type AccountEdge { + node: Account! cursor: Cursor! } -"""Ordering options for Account connections""" input AccountOrder { - """The ordering direction.""" direction: OrderDirection! = ASC - - """The field by which to order Accounts.""" field: AccountOrderField! } -"""Properties by which Account connections can be ordered.""" enum AccountOrderField { + ID NAME + DISPLAY_KEY CREATED_AT UPDATED_AT } -""" -AccountWhereInput is used for filtering Account objects. -Input was generated by ent. -""" +"""Account update input.""" +input AccountUpdateInput { + """Human-readable name within the organization""" + name: String + + """Clear displayKey.""" + clearDisplayKey: Boolean + + """ + Short account key used in display IDs. Auto-set from name when omitted. + """ + displayKey: String +} + input AccountWhereInput { not: AccountWhereInput and: [AccountWhereInput!] or: [AccountWhereInput!] - """id field predicates""" + """"organization" edge predicates.""" + organization: OrganizationWhereInput + + """Whether the "organization" edge has at least one related row.""" + hasOrganization: Boolean + + """"datadog_account" edge predicates.""" + datadogAccount: DatadogAccountWhereInput + + """Whether the "datadog_account" edge has at least one related row.""" + hasDatadogAccount: Boolean + + """"services" edge predicates.""" + services: ServiceWhereInput + + """Whether the "services" edge has at least one related row.""" + hasServices: Boolean + + """"edge_instances" edge predicates.""" + edgeInstances: EdgeInstanceWhereInput + + """Whether the "edge_instances" edge has at least one related row.""" + hasEdgeInstances: Boolean + + """"edge_api_keys" edge predicates.""" + edgeAPIKeys: EdgeApiKeyWhereInput + + """Whether the "edge_api_keys" edge has at least one related row.""" + hasEdgeAPIKeys: Boolean + + """"id" field predicates.""" id: ID + + """"id" field predicates.""" idNEQ: ID + + """"id" field predicates.""" idIn: [ID!] + + """"id" field predicates.""" idNotIn: [ID!] + + """"id" field predicates.""" idGT: ID + + """"id" field predicates.""" idGTE: ID + + """"id" field predicates.""" idLT: ID + + """"id" field predicates.""" idLTE: ID - """organization_id field predicates""" + """"organization_id" field predicates.""" organizationID: ID + + """"organization_id" field predicates.""" organizationIDNEQ: ID + + """"organization_id" field predicates.""" organizationIDIn: [ID!] + + """"organization_id" field predicates.""" organizationIDNotIn: [ID!] - """workos_organization_id field predicates""" - workosOrganizationID: String - workosOrganizationIDNEQ: String - workosOrganizationIDIn: [String!] - workosOrganizationIDNotIn: [String!] - workosOrganizationIDGT: String - workosOrganizationIDGTE: String - workosOrganizationIDLT: String - workosOrganizationIDLTE: String - workosOrganizationIDContains: String - workosOrganizationIDHasPrefix: String - workosOrganizationIDHasSuffix: String - workosOrganizationIDEqualFold: String - workosOrganizationIDContainsFold: String - - """name field predicates""" + """"name" field predicates.""" name: String + + """"name" field predicates.""" nameNEQ: String + + """"name" field predicates.""" nameIn: [String!] + + """"name" field predicates.""" nameNotIn: [String!] + + """"name" field predicates.""" nameGT: String + + """"name" field predicates.""" nameGTE: String + + """"name" field predicates.""" nameLT: String + + """"name" field predicates.""" nameLTE: String + + """"name" field predicates.""" nameContains: String + + """"name" field predicates.""" nameHasPrefix: String + + """"name" field predicates.""" nameHasSuffix: String + + """"name" field predicates.""" nameEqualFold: String + + """"name" field predicates.""" nameContainsFold: String - """demo_scale_factor field predicates""" - demoScaleFactor: Int - demoScaleFactorNEQ: Int - demoScaleFactorIn: [Int!] - demoScaleFactorNotIn: [Int!] - demoScaleFactorGT: Int - demoScaleFactorGTE: Int - demoScaleFactorLT: Int - demoScaleFactorLTE: Int - - """created_at field predicates""" + """"display_key" field predicates.""" + displayKey: String + + """"display_key" field predicates.""" + displayKeyNEQ: String + + """"display_key" field predicates.""" + displayKeyIn: [String!] + + """"display_key" field predicates.""" + displayKeyNotIn: [String!] + + """"display_key" field predicates.""" + displayKeyGT: String + + """"display_key" field predicates.""" + displayKeyGTE: String + + """"display_key" field predicates.""" + displayKeyLT: String + + """"display_key" field predicates.""" + displayKeyLTE: String + + """"display_key" field predicates.""" + displayKeyContains: String + + """"display_key" field predicates.""" + displayKeyHasPrefix: String + + """"display_key" field predicates.""" + displayKeyHasSuffix: String + + """"display_key" field predicates.""" + displayKeyIsNil: Boolean + + """"display_key" field predicates.""" + displayKeyNotNil: Boolean + + """"display_key" field predicates.""" + displayKeyEqualFold: String + + """"display_key" field predicates.""" + displayKeyContainsFold: String + + """"created_at" field predicates.""" createdAt: Time + + """"created_at" field predicates.""" createdAtNEQ: Time + + """"created_at" field predicates.""" createdAtIn: [Time!] + + """"created_at" field predicates.""" createdAtNotIn: [Time!] + + """"created_at" field predicates.""" createdAtGT: Time + + """"created_at" field predicates.""" createdAtGTE: Time + + """"created_at" field predicates.""" createdAtLT: Time + + """"created_at" field predicates.""" createdAtLTE: Time - """updated_at field predicates""" + """"updated_at" field predicates.""" updatedAt: Time + + """"updated_at" field predicates.""" updatedAtNEQ: Time + + """"updated_at" field predicates.""" updatedAtIn: [Time!] + + """"updated_at" field predicates.""" updatedAtNotIn: [Time!] + + """"updated_at" field predicates.""" updatedAtGT: Time + + """"updated_at" field predicates.""" updatedAtGTE: Time + + """"updated_at" field predicates.""" updatedAtLT: Time + + """"updated_at" field predicates.""" updatedAtLTE: Time +} - """organization edge predicates""" - hasOrganization: Boolean - hasOrganizationWith: [OrganizationWhereInput!] +""" +Captures a clearly exposed separate peer materially involved in a recurring log event. +""" +type AttributionProfile { + """How the durable peer label is known, when a label is clear.""" + peerLabel: AttributionProfilePeerLabel - """services edge predicates""" - hasServices: Boolean - hasServicesWith: [ServiceWhereInput!] + """The coarse operational role of the peer.""" + peerRole: PeerRole - """workspaces edge predicates""" - hasWorkspaces: Boolean - hasWorkspacesWith: [WorkspaceWhereInput!] + """ + Sparse lower_snake_case operational kind for the peer when directly clear and useful. + """ + peerKind: String - """datadog_account edge predicates""" - hasDatadogAccount: Boolean - hasDatadogAccountWith: [DatadogAccountWhereInput!] + """The coarse side of the peer relative to the current service.""" + boundarySide: BoundarySide +} - """edge_instances edge predicates""" - hasEdgeInstances: Boolean - hasEdgeInstancesWith: [EdgeInstanceWhereInput!] +"""How the durable peer label is known, when a label is clear.""" +type AttributionProfilePeerLabel { + """ + A normalized durable peer-family label when the event family is intrinsically about one stable peer. + """ + exact: String - """edge_api_keys edge predicates""" - hasEdgeAPIKeys: Boolean - hasEdgeAPIKeysWith: [EdgeApiKeyWhereInput!] + """ + The observed field path whose whole value is the peer label when the label may vary by record. + """ + path: AttributionProfilePeerLabelPath } """ -A content block in a message. Exactly one of the typed fields is set based on type. +The observed field path whose whole value is the peer label when the label may vary by record. """ -type ContentBlock { - type: ContentBlockType! - text: TextBlock - thinking: ThinkingBlock - toolUse: ToolUse - toolResult: ToolResult +type AttributionProfilePeerLabelPath { + """The exact observed field path.""" + path: [String!]! + + """ + The grouping path for sibling fields that should be reasoned about together. + """ + pathFamily: [String!]! } """ -A content block in a message. Exactly one of the typed fields should be set. +Captures a clearly exposed separate peer materially involved in a recurring log event. """ -input ContentBlockInput { - type: ContentBlockType! - text: TextBlockInput - thinking: ThinkingBlockInput - toolUse: ToolUseInput - toolResult: ToolResultInput -} +input AttributionProfileWhereInput { + """Negated predicates.""" + not: AttributionProfileWhereInput -"""The type of content block.""" -enum ContentBlockType { - text - thinking - tool_use - tool_result -} + """Predicates that must all match.""" + and: [AttributionProfileWhereInput!] -type Conversation implements Node { - """Unique identifier""" - id: ID! + """Predicates where at least one must match.""" + or: [AttributionProfileWhereInput!] + + """Whether the attribution_profile JSONB value is present.""" + present: Boolean """ - Denormalized for tenant isolation. Auto-set via trigger from workspace.account_id. + A normalized durable peer-family label when the event family is intrinsically about one stable peer. """ - accountID: UUID! + peerLabelExact: String - """Workspace this conversation belongs to""" - workspaceID: ID! + """ + A normalized durable peer-family label when the event family is intrinsically about one stable peer. + """ + peerLabelExactNEQ: String - """If set, this conversation is for iterating on a specific view""" - viewID: ID + """ + A normalized durable peer-family label when the event family is intrinsically about one stable peer. + """ + peerLabelExactIn: [String!] - """WorkOS user ID who owns this conversation""" - userID: String! + """ + A normalized durable peer-family label when the event family is intrinsically about one stable peer. + """ + peerLabelExactNotIn: [String!] - """AI-generated title, set after first exchange""" - title: String + """ + A normalized durable peer-family label when the event family is intrinsically about one stable peer. + """ + peerLabelExactContains: String - """When the conversation was created""" - createdAt: Time! + """ + A normalized durable peer-family label when the event family is intrinsically about one stable peer. + """ + peerLabelExactHasPrefix: String - """When the conversation was last updated""" - updatedAt: Time! + """ + A normalized durable peer-family label when the event family is intrinsically about one stable peer. + """ + peerLabelExactHasSuffix: String + + """ + A normalized durable peer-family label when the event family is intrinsically about one stable peer. + """ + peerLabelExactEqualFold: String - """Workspace this conversation belongs to""" - workspace: Workspace! + """ + A normalized durable peer-family label when the event family is intrinsically about one stable peer. + """ + peerLabelExactContainsFold: String - """Messages in this conversation""" - messages: [Message!] + """ + A normalized durable peer-family label when the event family is intrinsically about one stable peer. + """ + hasPeerLabelExact: Boolean - """Active entity context set""" - conversationContexts: [ConversationContext!] + """The coarse operational role of the peer.""" + peerRole: PeerRole - """Views created in this conversation""" - views: [View!] + """The coarse operational role of the peer.""" + peerRoleNEQ: PeerRole - """View being iterated on, if this is a view iteration conversation""" - view: View -} + """The coarse operational role of the peer.""" + peerRoleIn: [PeerRole!] -"""A connection to a list of items.""" -type ConversationConnection { - """A list of edges.""" - edges: [ConversationEdge] + """The coarse operational role of the peer.""" + peerRoleNotIn: [PeerRole!] - """Information to aid in pagination.""" - pageInfo: PageInfo! + """ + Sparse lower_snake_case operational kind for the peer when directly clear and useful. + """ + peerKind: String - """Identifies the total count of items in the connection.""" - totalCount: Int! -} + """ + Sparse lower_snake_case operational kind for the peer when directly clear and useful. + """ + peerKindNEQ: String -type ConversationContext implements Node { - """Unique identifier""" - id: ID! + """ + Sparse lower_snake_case operational kind for the peer when directly clear and useful. + """ + peerKindIn: [String!] """ - Denormalized for tenant isolation. Auto-set via trigger from conversation.account_id. + Sparse lower_snake_case operational kind for the peer when directly clear and useful. """ - accountID: UUID! + peerKindNotIn: [String!] + + """ + Sparse lower_snake_case operational kind for the peer when directly clear and useful. + """ + peerKindContains: String - """Conversation this context belongs to""" - conversationID: ID! + """ + Sparse lower_snake_case operational kind for the peer when directly clear and useful. + """ + peerKindHasPrefix: String """ - Type of the context entity. service: an application producing logs, log_event: a specific event pattern. + Sparse lower_snake_case operational kind for the peer when directly clear and useful. """ - entityType: ConversationContextEntityType! + peerKindHasSuffix: String - """ID of the context entity""" - entityID: UUID! + """ + Sparse lower_snake_case operational kind for the peer when directly clear and useful. + """ + peerKindEqualFold: String """ - Who added this entity to context. user: added via @-reference, assistant: added by AI during chat. + Sparse lower_snake_case operational kind for the peer when directly clear and useful. """ - addedBy: ConversationContextAddedBy! + peerKindContainsFold: String - """When the entity was added to context""" - createdAt: Time! + """ + Sparse lower_snake_case operational kind for the peer when directly clear and useful. + """ + hasPeerKind: Boolean + + """The coarse side of the peer relative to the current service.""" + boundarySide: BoundarySide + + """The coarse side of the peer relative to the current service.""" + boundarySideNEQ: BoundarySide + + """The coarse side of the peer relative to the current service.""" + boundarySideIn: [BoundarySide!] - """Conversation this context belongs to""" - conversation: Conversation! + """The coarse side of the peer relative to the current service.""" + boundarySideNotIn: [BoundarySide!] } -"""ConversationContextAddedBy is enum for the field added_by""" -enum ConversationContextAddedBy { - user - assistant +enum BoundarySide { + """The peer is another same-estate service or internal boundary.""" + internal + + """The peer initiates or sends work into the emitting service.""" + upstream + + """ + The emitting service calls, queries, publishes to, or depends on the peer. + """ + downstream } -"""A connection to a list of items.""" -type ConversationContextConnection { - """A list of edges.""" - edges: [ConversationContextEdge] +"""One code-defined standing product question and its account posture.""" +type Check { + id: ID! + domain: FindingCheckDomain! + type: FindingCheckType! + name: String! + description: String! + posture: CheckPosture! +} - """Information to aid in pagination.""" +type CheckConnection { + edges: [CheckEdge!]! pageInfo: PageInfo! - - """Identifies the total count of items in the connection.""" totalCount: Int! + facets: CheckFacets! +} + +type CheckDomainFacet { + buckets: [CheckDomainFacetBucket!]! } -"""An edge in a connection.""" -type ConversationContextEdge { - """The item at the end of the edge.""" - node: ConversationContext +type CheckDomainFacetBucket { + value: FindingCheckDomain! + count: Int! +} - """A cursor for use in pagination.""" +type CheckEdge { + node: Check! cursor: Cursor! } -"""ConversationContextEntityType is enum for the field entity_type""" -enum ConversationContextEntityType { - service - log_event +type CheckFacets { + domains(limit: Int): CheckDomainFacet! } -"""Ordering options for ConversationContext connections""" -input ConversationContextOrder { - """The ordering direction.""" +"""Ordering for the checks connection.""" +input CheckOrder { + field: CheckOrderField! = NAME direction: OrderDirection! = ASC - - """The field by which to order ConversationContexts.""" - field: ConversationContextOrderField! } -"""Properties by which ConversationContext connections can be ordered.""" -enum ConversationContextOrderField { - CREATED_AT +"""Fields supported when ordering checks.""" +enum CheckOrderField { + NAME + DOMAIN + OPEN_FINDING_COUNT + ACTIVE_ISSUE_COUNT + AFFECTED_SERVICE_COUNT + LATEST_OBSERVED_AT + CURRENT_COST } """ -ConversationContextWhereInput is used for filtering ConversationContext objects. -Input was generated by ent. +Account-scoped posture for one check, computed from findings and issues. """ -input ConversationContextWhereInput { - not: ConversationContextWhereInput - and: [ConversationContextWhereInput!] - or: [ConversationContextWhereInput!] - - """id field predicates""" +type CheckPosture { + openFindingCount: Int! + pendingFindingCount: Int! + escalatedFindingCount: Int! + activeIssueCount: Int! + affectedServiceCount: Int! + latestObservedAt: Time + current: StatusMeasurementTotals! + previewSavings: StatusMeasurementTotals! +} + +"""Filters applied to the code-defined check catalog.""" +input CheckWhereInput { + not: CheckWhereInput + and: [CheckWhereInput!] + or: [CheckWhereInput!] id: ID - idNEQ: ID idIn: [ID!] - idNotIn: [ID!] - idGT: ID - idGTE: ID - idLT: ID - idLTE: ID + domain: FindingCheckDomain + type: FindingCheckType + search: String +} - """account_id field predicates""" - accountID: UUID - accountIDNEQ: UUID - accountIDIn: [UUID!] - accountIDNotIn: [UUID!] - accountIDGT: UUID - accountIDGTE: UUID - accountIDLT: UUID - accountIDLTE: UUID +type ComplianceCountMeasurement { + value: Int! + asOf: Time! + delta: MeasurementDelta +} - """conversation_id field predicates""" - conversationID: ID - conversationIDNEQ: ID - conversationIDIn: [ID!] - conversationIDNotIn: [ID!] - - """entity_type field predicates""" - entityType: ConversationContextEntityType - entityTypeNEQ: ConversationContextEntityType - entityTypeIn: [ConversationContextEntityType!] - entityTypeNotIn: [ConversationContextEntityType!] - - """entity_id field predicates""" - entityID: UUID - entityIDNEQ: UUID - entityIDIn: [UUID!] - entityIDNotIn: [UUID!] - entityIDGT: UUID - entityIDGTE: UUID - entityIDLT: UUID - entityIDLTE: UUID - - """added_by field predicates""" - addedBy: ConversationContextAddedBy - addedByNEQ: ConversationContextAddedBy - addedByIn: [ConversationContextAddedBy!] - addedByNotIn: [ConversationContextAddedBy!] - - """created_at field predicates""" - createdAt: Time - createdAtNEQ: Time - createdAtIn: [Time!] - createdAtNotIn: [Time!] - createdAtGT: Time - createdAtGTE: Time - createdAtLT: Time - createdAtLTE: Time +type ComplianceLeakHighlightRange { + startOffset: Int! + endOffset: Int! +} - """conversation edge predicates""" - hasConversation: Boolean - hasConversationWith: [ConversationWhereInput!] +type ComplianceLeakPreview { + logEventID: ID! + logEventName: String! + exposureKind: FindingCheckType! + element: String! + path: [String!]! + pathFamily: [String!]! + emissionTemplate: String! + displayValue: String + highlightRanges: [ComplianceLeakHighlightRange!]! } -"""An edge in a connection.""" -type ConversationEdge { - """The item at the end of the edge.""" - node: Conversation +"""Finding detail payload for payment-data-exposure findings.""" +type CompliancePaymentDataExposure { + """Path family this finding is grouped around.""" + pathFamily: [String!]! - """A cursor for use in pagination.""" - cursor: Cursor! + """ + Stable emission template when this finding is grouped around the raw body payload. + """ + emissionTemplate: String + + """Exact exposed paths and their sensitive data elements.""" + exposures: [CompliancePaymentDataExposureExposures!]! } -"""Ordering options for Conversation connections""" -input ConversationOrder { - """The ordering direction.""" - direction: OrderDirection! = ASC +"""Exact exposed paths and their sensitive data elements.""" +type CompliancePaymentDataExposureExposures { + """Specific payment data element exposed at this path.""" + element: String! - """The field by which to order Conversations.""" - field: ConversationOrderField! + """The exact exposed event path.""" + path: CompliancePaymentDataExposureExposuresPath! } -"""Properties by which Conversation connections can be ordered.""" -enum ConversationOrderField { - CREATED_AT - UPDATED_AT +"""The exact exposed event path.""" +type CompliancePaymentDataExposureExposuresPath { + """The exact event field path.""" + path: [String!]! + + """The grouping path for sibling fields.""" + pathFamily: [String!]! } -""" -ConversationWhereInput is used for filtering Conversation objects. -Input was generated by ent. -""" -input ConversationWhereInput { - not: ConversationWhereInput - and: [ConversationWhereInput!] - or: [ConversationWhereInput!] +"""Finding detail payload for PHI-exposure findings.""" +type CompliancePHIExposure { + """Path family this finding is grouped around.""" + pathFamily: [String!]! - """id field predicates""" - id: ID - idNEQ: ID - idIn: [ID!] - idNotIn: [ID!] - idGT: ID - idGTE: ID - idLT: ID - idLTE: ID + """ + Stable emission template when this finding is grouped around the raw body payload. + """ + emissionTemplate: String - """account_id field predicates""" - accountID: UUID - accountIDNEQ: UUID - accountIDIn: [UUID!] - accountIDNotIn: [UUID!] - accountIDGT: UUID - accountIDGTE: UUID - accountIDLT: UUID - accountIDLTE: UUID + """Exact exposed paths and their sensitive data elements.""" + exposures: [CompliancePHIExposureExposures!]! +} - """workspace_id field predicates""" - workspaceID: ID - workspaceIDNEQ: ID - workspaceIDIn: [ID!] - workspaceIDNotIn: [ID!] - - """view_id field predicates""" - viewID: ID - viewIDNEQ: ID - viewIDIn: [ID!] - viewIDNotIn: [ID!] - viewIDIsNil: Boolean - viewIDNotNil: Boolean - - """user_id field predicates""" - userID: String - userIDNEQ: String - userIDIn: [String!] - userIDNotIn: [String!] - userIDGT: String - userIDGTE: String - userIDLT: String - userIDLTE: String - userIDContains: String - userIDHasPrefix: String - userIDHasSuffix: String - userIDEqualFold: String - userIDContainsFold: String - - """title field predicates""" - title: String - titleNEQ: String - titleIn: [String!] - titleNotIn: [String!] - titleGT: String - titleGTE: String - titleLT: String - titleLTE: String - titleContains: String - titleHasPrefix: String - titleHasSuffix: String - titleIsNil: Boolean - titleNotNil: Boolean - titleEqualFold: String - titleContainsFold: String - - """created_at field predicates""" - createdAt: Time - createdAtNEQ: Time - createdAtIn: [Time!] - createdAtNotIn: [Time!] - createdAtGT: Time - createdAtGTE: Time - createdAtLT: Time - createdAtLTE: Time +"""Exact exposed paths and their sensitive data elements.""" +type CompliancePHIExposureExposures { + """Specific PHI element exposed at this path.""" + element: String! - """updated_at field predicates""" - updatedAt: Time - updatedAtNEQ: Time - updatedAtIn: [Time!] - updatedAtNotIn: [Time!] - updatedAtGT: Time - updatedAtGTE: Time - updatedAtLT: Time - updatedAtLTE: Time + """The exact exposed event path.""" + path: CompliancePHIExposureExposuresPath! +} - """workspace edge predicates""" - hasWorkspace: Boolean - hasWorkspaceWith: [WorkspaceWhereInput!] +"""The exact exposed event path.""" +type CompliancePHIExposureExposuresPath { + """The exact event field path.""" + path: [String!]! - """messages edge predicates""" - hasMessages: Boolean - hasMessagesWith: [MessageWhereInput!] + """The grouping path for sibling fields.""" + pathFamily: [String!]! +} - """conversation_contexts edge predicates""" - hasConversationContexts: Boolean - hasConversationContextsWith: [ConversationContextWhereInput!] +"""Finding detail payload for PII-exposure findings.""" +type CompliancePIIExposure { + """Path family this finding is grouped around.""" + pathFamily: [String!]! - """views edge predicates""" - hasViews: Boolean - hasViewsWith: [ViewWhereInput!] + """ + Stable emission template when this finding is grouped around the raw body payload. + """ + emissionTemplate: String - """view edge predicates""" - hasView: Boolean - hasViewWith: [ViewWhereInput!] + """Exact exposed paths and their sensitive data elements.""" + exposures: [CompliancePIIExposureExposures!]! } -""" -CreateAccountInput is used for create Account object. -Input was generated by ent. -""" -input CreateAccountInput { - """Human-readable name within the organization""" - name: String! - organizationID: ID! - datadogAccountID: ID +"""Exact exposed paths and their sensitive data elements.""" +type CompliancePIIExposureExposures { + """Specific PII element exposed at this path.""" + element: String! - """ - Optional client-provided UUID for offline-first sync. - If provided and a record with this ID exists, returns the existing record. - """ - id: ID + """The exact exposed event path.""" + path: CompliancePIIExposureExposuresPath! } -""" -CreateConversationInput is used for create Conversation object. -Input was generated by ent. -""" -input CreateConversationInput { - """AI-generated title, set after first exchange""" - title: String - workspaceID: ID! - viewID: ID +"""The exact exposed event path.""" +type CompliancePIIExposureExposuresPath { + """The exact event field path.""" + path: [String!]! - """ - Optional client-provided UUID for offline-first sync. - If provided and a conversation with this ID exists, returns the existing record. - """ - id: ID + """The grouping path for sibling fields.""" + pathFamily: [String!]! } -""" -CreateDatadogAccountInput is used for create DatadogAccount object. -Input was generated by ent. -""" -input CreateDatadogAccountInput { - """Display name for this Datadog account""" - name: String! +type ComplianceReport { + summary: ComplianceReportSummary! +} +input ComplianceReportFilterInput { """ - Datadog regional site. US1: datadoghq.com, US3: us3.datadoghq.com, US5: - us5.datadoghq.com, EU1: datadoghq.eu, US1_FED: ddog-gov.com, AP1: - ap1.datadoghq.com, AP2: ap2.datadoghq.com. + Restrict the report to these services. Applies to every summary measurement. """ - site: DatadogAccountSite! + serviceIDs: [UUID!] """ - Cost per GB of log data ingested (USD). NULL = using Datadog's published rate - ($0.10/GB). Set to override with actual contract rate. + Restrict the report to services owned by these teams. Applies to every summary measurement. """ - costPerGBIngested: Float + teamIDs: [UUID!] + + """Restrict the report to these compliance finding check types.""" + issueCheckTypes: [FindingCheckType!] +} + +input ComplianceReportInput { + filter: ComplianceReportFilterInput +} + +type ComplianceReportSummary { + """Services with unresolved compliance issues.""" + affectedServices: ComplianceCountMeasurement! """ - Fraction of the API rate limit to consume (0.0-1.0). NULL = default (0.8 = - 80%). Leaves headroom for the customer's own API usage. + Exposed sensitive or regulated fields across unresolved compliance issues. """ - rateLimitUtilization: Float - accountID: ID! + fieldsExposed: ComplianceCountMeasurement! + + """Log events affected by unresolved compliance issues.""" + affectedEvents: ComplianceCountMeasurement! +} + +"""Finding detail payload for secret-exposure findings.""" +type ComplianceSecretExposure { + """Path family this finding is grouped around.""" + pathFamily: [String!]! """ - Optional client-provided UUID for offline-first sync. - If provided and a record with this ID exists, returns the existing record. + Stable emission template when this finding is grouped around the raw body payload. """ - id: ID + emissionTemplate: String + + """Exact exposed paths and their sensitive data elements.""" + exposures: [ComplianceSecretExposureExposures!]! } -input CreateDatadogAccountWithCredentialsInput { - attributes: CreateDatadogAccountInput! - credentials: CreateDatadogCredentialsInput! +"""Exact exposed paths and their sensitive data elements.""" +type ComplianceSecretExposureExposures { + """Specific secret element exposed at this path.""" + element: String! + + """The exact exposed event path.""" + path: ComplianceSecretExposureExposuresPath! } -input CreateDatadogCredentialsInput { - apiKey: String! - appKey: String! +"""The exact exposed event path.""" +type ComplianceSecretExposureExposuresPath { + """The exact event field path.""" + path: [String!]! + + """The grouping path for sibling fields.""" + pathFamily: [String!]! } -input CreateEdgeApiKeyInput { - """ - Optional client-provided ID for idempotent creates. - If provided and already exists, returns the existing key (without plain key). - """ - id: ID +type CostAnnualizedMoneyMeasurement { + amount: MoneyAmount! + asOf: Time! + delta: MeasurementDelta +} - """The account this API key authenticates to""" - accountID: ID! +type CostAnnualizedOpportunityMoneyMeasurement { + amount: MoneyAmount! + percentOfSpend: PercentAmount! + asOf: Time! + delta: MeasurementDelta +} - """User-provided name for this key (e.g., 'Production Collector')""" - name: String! +"""Annualized money amount for items observed during a concrete period.""" +type CostAnnualizedPeriodMoneyMeasurement { + amount: MoneyAmount! + period: TimeRange! + delta: MeasurementDelta } -type CreateEdgeApiKeyResult { - """The created API key record (without the secret)""" - edgeApiKey: EdgeApiKey! +"""Finding detail payload for background-noise findings.""" +type CostBackgroundNoise { + """Named peer label involved in the noisy interaction when one is known.""" + peerLabel: String + + """Coarse role of the peer involved in the noisy interaction.""" + peerRole: String! """ - The plain API key - only returned once at creation time. - Store this securely, it cannot be retrieved again. + Coarse kind of peer involved in the noisy interaction when one is known. """ - plainKey: String! + peerKind: String } -""" -CreateMessageInput is used for create Message object. -Input was generated by ent. -""" -input CreateMessageInput { - """ - Who sent this message. user: human-originated, assistant: AI-originated. - """ - role: MessageRole! +"""Finding detail payload for commodity-traffic findings.""" +type CostCommodityTraffic { + """Log event name identified as commodity traffic.""" + eventName: String! +} - """ - Why the assistant stopped generating. end_turn: completed response, tool_use: - paused to call a tool. Null for user messages. - """ - stopReason: MessageStopReason +"""Finding detail payload for configuration-noise findings.""" +type CostConfigurationNoise { + """Log event name identified as repeated configuration noise.""" + eventName: String! - """AI model that produced this message. Null for user messages.""" - model: String - conversationID: ID! + """Configuration-related failure kind associated with the event.""" + failureKind: String! +} - """ - Optional client-provided UUID for offline-first sync. - If provided and a message with this ID exists, returns the existing record. - """ - id: ID +"""Finding detail payload for dead-weight findings.""" +type CostDeadWeight { + """Log event name identified as dead weight.""" + eventName: String! +} - """Array of typed content blocks.""" - content: [ContentBlockInput!]! +"""Finding detail payload for debug-artifact findings.""" +type CostDebugArtifacts { + """Log event name identified as a debug artifact.""" + eventName: String! } -""" -CreateOrganizationInput is used for create Organization object. -Input was generated by ent. -""" -input CreateOrganizationInput { - """Human-readable name, unique across the system""" - name: String! +"""Finding detail payload for debug-marker findings.""" +type CostDebugMarker { + """Log event name identified as a debug marker.""" + eventName: String! +} - """ - Optional client-provided UUID for offline-first sync. - If provided and a record with this ID exists, returns the existing record. - """ - id: ID +"""Finding detail payload for debug-noise findings.""" +type CostDebugNoise { + """Service that owns the debug-noise log event.""" + serviceName: String! + + """Debug-noise log event name.""" + eventName: String! } -""" -CreateTeamInput is used for create Team object. -Input was generated by ent. -""" -input CreateTeamInput { - """Human-readable name within the workspace""" - name: String! - workspaceID: ID! +"""Finding detail payload for hot-path findings.""" +type CostHotPath { + """Stable emission template associated with the hot path event.""" + emissionTemplate: String! +} + +"""Finding detail payload for reactive-flood findings.""" +type CostReactiveFlood { + """Stable operation associated with the reactive flood behavior.""" + operation: String! + + """Durable outcome associated with the reactive flood behavior.""" + outcome: String! """ - Optional client-provided UUID for offline-first sync. - If provided and a record with this ID exists, returns the existing record. + Named peer label involved in the reactive interaction when one is known. """ - id: ID + peerLabel: String } -""" -CreateViewFavoriteInput is used for create ViewFavorite object. -Input was generated by ent. -""" -input CreateViewFavoriteInput { - viewID: ID! +type CostReport { + summary: CostReportSummary! +} +input CostReportFilterInput { """ - Optional client-provided UUID for offline-first sync. - If provided and a favorite with this ID exists, returns the existing record. + Restrict the report to these services. Applies to every summary measurement. """ - id: ID -} + serviceIDs: [UUID!] -""" -CreateViewInput is used for create View object. -Input was generated by ent. -""" -input CreateViewInput { """ - Which catalog entity this view queries. service: applications, log_event: event patterns, policy: quality recommendations. + Restrict the report to services owned by these teams. Applies to every summary measurement. """ - entityType: ViewEntityType! - - """Raw SQL query executed against the client's local SQLite database""" - query: String! - messageID: ID! - conversationID: ID! - forkedFromID: ID - forkIDs: [ID!] + teamIDs: [UUID!] """ - Optional client-provided UUID for offline-first sync. - If provided and a view with this ID exists, returns the existing record. + Restrict issue-derived measurements to these finding check types. + Does not restrict annualized service spend. """ - id: ID + issueCheckTypes: [FindingCheckType!] } -""" -CreateWorkspaceInput is used for create Workspace object. -Input was generated by ent. -""" -input CreateWorkspaceInput { - """Human-readable name within the account""" - name: String! +input CostReportInput { + filter: CostReportFilterInput +} + +type CostReportSummary { + """Current service spend projected over one year.""" + annualizedSpend: CostAnnualizedMoneyMeasurement! """ - Primary purpose determining evaluation strategy. observability: performance - and reliability, security: threat detection, compliance: regulatory requirements. + Annualized savings from cost issues resolved during the last 12 months. """ - purpose: WorkspacePurpose + savedLast12Months: CostAnnualizedPeriodMoneyMeasurement! + + """Annualized savings available from unresolved cost issues.""" + potentialSavings: CostAnnualizedOpportunityMoneyMeasurement! +} + +"""Finding detail payload for routine-system-chatter findings.""" +type CostRoutineSystemChatter { + """Service that owns the routine chatter log event.""" + serviceName: String! + + """Routine chatter log event name.""" + eventName: String! +} + +input CreateEdgeApiKeyInput { + """The account this API key authenticates to""" accountID: ID! + """User-provided name for this key (e.g., 'Production Collector')""" + name: String! +} + +type CreateEdgeApiKeyResult { + """The created API key record (without the secret)""" + edgeApiKey: EdgeApiKey! + """ - Optional client-provided UUID for offline-first sync. - If provided and a record with this ID exists, returns the existing record. + The plain API key - only returned once at creation time. + Store this securely, it cannot be retrieved again. """ - id: ID + plainKey: String! } -""" -Define a Relay Cursor type: -https://relay.dev/graphql/connections.htm#sec-Cursor -""" scalar Cursor -type DatadogAccount implements Node { +type DatadogAccount { """Unique identifier of the Datadog configuration""" id: ID! @@ -834,9 +957,9 @@ type DatadogAccount implements Node { name: String! """ - Datadog regional site. US1: datadoghq.com, US3: us3.datadoghq.com, US5: - us5.datadoghq.com, EU1: datadoghq.eu, US1_FED: ddog-gov.com, AP1: - ap1.datadoghq.com, AP2: ap2.datadoghq.com. + Datadog regional site. Values: US1 = datadoghq.com.; US3 = us3.datadoghq.com.; + US5 = us5.datadoghq.com.; EU1 = datadoghq.eu.; US1_FED = ddog-gov.com.; AP1 = + ap1.datadoghq.com.; AP2 = ap2.datadoghq.com. """ site: DatadogAccountSite! @@ -861,57 +984,143 @@ type DatadogAccount implements Node { """Account this Datadog configuration belongs to""" account: Account! - """Discovered log indexes in this Datadog account""" - logIndexes: [DatadogLogIndex!] - """ - Status of this Datadog account in the discovery pipeline. + Status of this Datadog account in the catalog pipeline. Derived from the status of all services discovered from this account. - Returns null if cache has not been populated yet. + Returns null if the status view has no row yet. + """ + status: DatadogAccountStatus + + """ + Bucketed Datadog account log usage split into indexed and non-indexed + volume, with estimated savings for blocking non-indexed logs at the edge. """ - status: DatadogAccountStatusCache + logUsage(input: DatadogAccountLogUsageInput!): DatadogAccountLogUsageSeries! } -"""A connection to a list of items.""" type DatadogAccountConnection { - """A list of edges.""" - edges: [DatadogAccountEdge] - - """Information to aid in pagination.""" + edges: [DatadogAccountEdge!]! pageInfo: PageInfo! - - """Identifies the total count of items in the connection.""" totalCount: Int! } -"""An edge in a connection.""" -type DatadogAccountEdge { - """The item at the end of the edge.""" - node: DatadogAccount +"""DatadogAccount creation input.""" +input DatadogAccountCreateInput { + """Parent account this configuration belongs to""" + accountID: ID! + + """Display name for this Datadog account""" + name: String! + + """ + Datadog regional site. Values: US1 = datadoghq.com.; US3 = us3.datadoghq.com.; + US5 = us5.datadoghq.com.; EU1 = datadoghq.eu.; US1_FED = ddog-gov.com.; AP1 = + ap1.datadoghq.com.; AP2 = ap2.datadoghq.com. + """ + site: DatadogAccountSite! + + """Datadog API credentials. Stored in the secret store, not Postgres.""" + credentials: DatadogAccountCredentialsInput! +} - """A cursor for use in pagination.""" +"""Datadog credentials for account creation.""" +input DatadogAccountCredentialsInput { + apiKey: String! + appKey: String! +} + +type DatadogAccountCurrentStatus { + services: StatusServiceTotals! + totals: StatusMeasurementTotals! +} + +type DatadogAccountEdge { + node: DatadogAccount! cursor: Cursor! } -"""Ordering options for DatadogAccount connections""" +"""Usage attributed to one Datadog index during the requested range.""" +type DatadogAccountLogUsageIndexTotal { + datadogIndex: String! + events: Float! + bytes: Float! + shareOfTotalEvents: Float! + shareOfTotalBytes: Float! +} + +input DatadogAccountLogUsageInput { + """Half-open range to bucket. Boundaries must align to granularity.""" + range: TimeRangeInput! + + """ + Granularity for returned points. Requests may include at most 1000 points. + """ + granularity: TimeGranularity! +} + +"""One Datadog account usage point.""" +type DatadogAccountLogUsagePoint { + start: Time! + end: Time! + totalEvents: Float! + totalBytes: Float! + indexedEvents: Float! + indexedBytes: Float! + nonIndexedEvents: Float! + nonIndexedBytes: Float! + excludedEvents: Float! + estimatedEdgeSavingsBytes: Float! + estimatedEdgeSavingsUsd: Float! + quality: MeasurementQuality! +} + +""" +Datadog account log usage summarized for cost and policy migration views. +""" +type DatadogAccountLogUsageSeries { + range: TimeRange! + granularity: TimeGranularity! + points: [DatadogAccountLogUsagePoint!]! + totals: DatadogAccountLogUsageTotals! + indexTotals: [DatadogAccountLogUsageIndexTotal!]! + costPerGBIngested: Float! +} + +""" +Datadog account log usage totals over a requested range. +Non-indexed bytes are inferred from excluded events and the hourly average bytes +per ingested event, because Datadog does not expose excluded bytes directly. +""" +type DatadogAccountLogUsageTotals { + totalEvents: Float! + totalBytes: Float! + indexedEvents: Float! + indexedBytes: Float! + nonIndexedEvents: Float! + nonIndexedBytes: Float! + excludedEvents: Float! + estimatedEdgeSavingsBytes: Float! + estimatedEdgeSavingsUsd: Float! + projectedMonthlyEdgeSavingsBytes: Float! + projectedMonthlyEdgeSavingsUsd: Float! +} + input DatadogAccountOrder { - """The ordering direction.""" direction: OrderDirection! = ASC - - """The field by which to order DatadogAccounts.""" field: DatadogAccountOrderField! } -"""Properties by which DatadogAccount connections can be ordered.""" enum DatadogAccountOrderField { + ID NAME SITE CREATED_AT UPDATED_AT COST_PER_GB_INGESTED + LOG_INDEX_INVENTORY_REFRESHED_AT + LOG_USAGE_REFRESHED_AT } -"""DatadogAccountSite is enum for the field site""" enum DatadogAccountSite { US1 US3 @@ -922,16 +1131,16 @@ enum DatadogAccountSite { AP2 } -type DatadogAccountStatusCache implements Node { - id: ID! - datadogAccountID: ID! - accountID: UUID! +type DatadogAccountStatus { + health: StatusHealth! + readiness: StatusReadiness! + coverage: DatadogAccountStatusCoverage! + current: DatadogAccountCurrentStatus! + preview: StatusScenario! + effective: StatusScenario! +} - """ - Overall health of the Datadog account. DISABLED (integration turned off), INACTIVE (no data received), OK (healthy). - """ - health: DatadogAccountStatusCacheHealth! - readyForUse: Boolean! +type DatadogAccountStatusCoverage { logEventAnalyzedCount: Int! logServiceCount: Int! logActiveServices: Int! @@ -939,1111 +1148,4336 @@ type DatadogAccountStatusCache implements Node { inactiveServices: Int! okServices: Int! logEventCount: Int! - policyPendingCount: Int! - policyApprovedCount: Int! - policyDismissedCount: Int! - policyPendingLowCount: Int! - policyPendingMediumCount: Int! - policyPendingHighCount: Int! - policyPendingCriticalCount: Int! - serviceVolumePerHour: Float - serviceCostPerHourVolumeUsd: Float - logEventVolumePerHour: Float - logEventBytesPerHour: Float - logEventCostPerHourBytesUsd: Float - logEventCostPerHourVolumeUsd: Float - logEventCostPerHourUsd: Float - estimatedVolumeReductionPerHour: Float - estimatedBytesReductionPerHour: Float - estimatedCostReductionPerHourBytesUsd: Float - estimatedCostReductionPerHourVolumeUsd: Float - estimatedCostReductionPerHourUsd: Float - observedVolumePerHourBefore: Float - observedVolumePerHourAfter: Float - observedBytesPerHourBefore: Float - observedBytesPerHourAfter: Float - observedCostPerHourBeforeBytesUsd: Float - observedCostPerHourBeforeVolumeUsd: Float - observedCostPerHourBeforeUsd: Float - observedCostPerHourAfterBytesUsd: Float - observedCostPerHourAfterVolumeUsd: Float - observedCostPerHourAfterUsd: Float - refreshedAt: Time! + previewLogEventCount: Int! + effectiveLogEventCount: Int! } -"""DatadogAccountStatusCacheHealth is enum for the field health""" -enum DatadogAccountStatusCacheHealth { - DISABLED - INACTIVE - ERROR - OK +"""DatadogAccount update input.""" +input DatadogAccountUpdateInput { + """Display name for this Datadog account""" + name: String + + """ + Datadog regional site. Values: US1 = datadoghq.com.; US3 = us3.datadoghq.com.; + US5 = us5.datadoghq.com.; EU1 = datadoghq.eu.; US1_FED = ddog-gov.com.; AP1 = + ap1.datadoghq.com.; AP2 = ap2.datadoghq.com. + """ + site: DatadogAccountSite + + """Clear costPerGBIngested.""" + clearCostPerGBIngested: Boolean + + """ + Cost per GB of log data ingested (USD). NULL = using Datadog's published rate + ($0.10/GB). Set to override with actual contract rate. + """ + costPerGBIngested: Float + + """Clear rateLimitUtilization.""" + clearRateLimitUtilization: Boolean + + """ + Fraction of the API rate limit to consume (0.0-1.0). NULL = default (0.8 = + 80%). Leaves headroom for the customer's own API usage. + """ + rateLimitUtilization: Float } -""" -DatadogAccountStatusCacheWhereInput is used for filtering DatadogAccountStatusCache objects. -Input was generated by ent. -""" -input DatadogAccountStatusCacheWhereInput { - not: DatadogAccountStatusCacheWhereInput - and: [DatadogAccountStatusCacheWhereInput!] - or: [DatadogAccountStatusCacheWhereInput!] +input DatadogAccountWhereInput { + not: DatadogAccountWhereInput + and: [DatadogAccountWhereInput!] + or: [DatadogAccountWhereInput!] + + """"account" edge predicates.""" + account: AccountWhereInput + + """Whether the "account" edge has at least one related row.""" + hasAccount: Boolean - """id field predicates""" + """"id" field predicates.""" id: ID + + """"id" field predicates.""" idNEQ: ID + + """"id" field predicates.""" idIn: [ID!] + + """"id" field predicates.""" idNotIn: [ID!] + + """"id" field predicates.""" idGT: ID + + """"id" field predicates.""" idGTE: ID + + """"id" field predicates.""" idLT: ID + + """"id" field predicates.""" idLTE: ID - """datadog_account_id field predicates""" - datadogAccountID: ID - datadogAccountIDNEQ: ID - datadogAccountIDIn: [ID!] - datadogAccountIDNotIn: [ID!] + """"account_id" field predicates.""" + accountID: ID - """account_id field predicates""" - accountID: UUID - accountIDNEQ: UUID - accountIDIn: [UUID!] - accountIDNotIn: [UUID!] - accountIDGT: UUID - accountIDGTE: UUID - accountIDLT: UUID - accountIDLTE: UUID - - """health field predicates""" - health: DatadogAccountStatusCacheHealth - healthNEQ: DatadogAccountStatusCacheHealth - healthIn: [DatadogAccountStatusCacheHealth!] - healthNotIn: [DatadogAccountStatusCacheHealth!] - - """ready_for_use field predicates""" - readyForUse: Boolean - readyForUseNEQ: Boolean - - """log_event_analyzed_count field predicates""" - logEventAnalyzedCount: Int - logEventAnalyzedCountNEQ: Int - logEventAnalyzedCountIn: [Int!] - logEventAnalyzedCountNotIn: [Int!] - logEventAnalyzedCountGT: Int - logEventAnalyzedCountGTE: Int - logEventAnalyzedCountLT: Int - logEventAnalyzedCountLTE: Int - - """log_service_count field predicates""" - logServiceCount: Int - logServiceCountNEQ: Int - logServiceCountIn: [Int!] - logServiceCountNotIn: [Int!] - logServiceCountGT: Int - logServiceCountGTE: Int - logServiceCountLT: Int - logServiceCountLTE: Int - - """log_active_services field predicates""" - logActiveServices: Int - logActiveServicesNEQ: Int - logActiveServicesIn: [Int!] - logActiveServicesNotIn: [Int!] - logActiveServicesGT: Int - logActiveServicesGTE: Int - logActiveServicesLT: Int - logActiveServicesLTE: Int - - """disabled_services field predicates""" - disabledServices: Int - disabledServicesNEQ: Int - disabledServicesIn: [Int!] - disabledServicesNotIn: [Int!] - disabledServicesGT: Int - disabledServicesGTE: Int - disabledServicesLT: Int - disabledServicesLTE: Int - - """inactive_services field predicates""" - inactiveServices: Int - inactiveServicesNEQ: Int - inactiveServicesIn: [Int!] - inactiveServicesNotIn: [Int!] - inactiveServicesGT: Int - inactiveServicesGTE: Int - inactiveServicesLT: Int - inactiveServicesLTE: Int - - """ok_services field predicates""" - okServices: Int - okServicesNEQ: Int - okServicesIn: [Int!] - okServicesNotIn: [Int!] - okServicesGT: Int - okServicesGTE: Int - okServicesLT: Int - okServicesLTE: Int - - """log_event_count field predicates""" - logEventCount: Int - logEventCountNEQ: Int - logEventCountIn: [Int!] - logEventCountNotIn: [Int!] - logEventCountGT: Int - logEventCountGTE: Int - logEventCountLT: Int - logEventCountLTE: Int - - """policy_pending_count field predicates""" - policyPendingCount: Int - policyPendingCountNEQ: Int - policyPendingCountIn: [Int!] - policyPendingCountNotIn: [Int!] - policyPendingCountGT: Int - policyPendingCountGTE: Int - policyPendingCountLT: Int - policyPendingCountLTE: Int - - """policy_approved_count field predicates""" - policyApprovedCount: Int - policyApprovedCountNEQ: Int - policyApprovedCountIn: [Int!] - policyApprovedCountNotIn: [Int!] - policyApprovedCountGT: Int - policyApprovedCountGTE: Int - policyApprovedCountLT: Int - policyApprovedCountLTE: Int - - """policy_dismissed_count field predicates""" - policyDismissedCount: Int - policyDismissedCountNEQ: Int - policyDismissedCountIn: [Int!] - policyDismissedCountNotIn: [Int!] - policyDismissedCountGT: Int - policyDismissedCountGTE: Int - policyDismissedCountLT: Int - policyDismissedCountLTE: Int - - """policy_pending_low_count field predicates""" - policyPendingLowCount: Int - policyPendingLowCountNEQ: Int - policyPendingLowCountIn: [Int!] - policyPendingLowCountNotIn: [Int!] - policyPendingLowCountGT: Int - policyPendingLowCountGTE: Int - policyPendingLowCountLT: Int - policyPendingLowCountLTE: Int - - """policy_pending_medium_count field predicates""" - policyPendingMediumCount: Int - policyPendingMediumCountNEQ: Int - policyPendingMediumCountIn: [Int!] - policyPendingMediumCountNotIn: [Int!] - policyPendingMediumCountGT: Int - policyPendingMediumCountGTE: Int - policyPendingMediumCountLT: Int - policyPendingMediumCountLTE: Int - - """policy_pending_high_count field predicates""" - policyPendingHighCount: Int - policyPendingHighCountNEQ: Int - policyPendingHighCountIn: [Int!] - policyPendingHighCountNotIn: [Int!] - policyPendingHighCountGT: Int - policyPendingHighCountGTE: Int - policyPendingHighCountLT: Int - policyPendingHighCountLTE: Int - - """policy_pending_critical_count field predicates""" - policyPendingCriticalCount: Int - policyPendingCriticalCountNEQ: Int - policyPendingCriticalCountIn: [Int!] - policyPendingCriticalCountNotIn: [Int!] - policyPendingCriticalCountGT: Int - policyPendingCriticalCountGTE: Int - policyPendingCriticalCountLT: Int - policyPendingCriticalCountLTE: Int - - """service_volume_per_hour field predicates""" - serviceVolumePerHour: Float - serviceVolumePerHourNEQ: Float - serviceVolumePerHourIn: [Float!] - serviceVolumePerHourNotIn: [Float!] - serviceVolumePerHourGT: Float - serviceVolumePerHourGTE: Float - serviceVolumePerHourLT: Float - serviceVolumePerHourLTE: Float - serviceVolumePerHourIsNil: Boolean - serviceVolumePerHourNotNil: Boolean - - """service_cost_per_hour_volume_usd field predicates""" - serviceCostPerHourVolumeUsd: Float - serviceCostPerHourVolumeUsdNEQ: Float - serviceCostPerHourVolumeUsdIn: [Float!] - serviceCostPerHourVolumeUsdNotIn: [Float!] - serviceCostPerHourVolumeUsdGT: Float - serviceCostPerHourVolumeUsdGTE: Float - serviceCostPerHourVolumeUsdLT: Float - serviceCostPerHourVolumeUsdLTE: Float - serviceCostPerHourVolumeUsdIsNil: Boolean - serviceCostPerHourVolumeUsdNotNil: Boolean - - """log_event_volume_per_hour field predicates""" - logEventVolumePerHour: Float - logEventVolumePerHourNEQ: Float - logEventVolumePerHourIn: [Float!] - logEventVolumePerHourNotIn: [Float!] - logEventVolumePerHourGT: Float - logEventVolumePerHourGTE: Float - logEventVolumePerHourLT: Float - logEventVolumePerHourLTE: Float - logEventVolumePerHourIsNil: Boolean - logEventVolumePerHourNotNil: Boolean - - """log_event_bytes_per_hour field predicates""" - logEventBytesPerHour: Float - logEventBytesPerHourNEQ: Float - logEventBytesPerHourIn: [Float!] - logEventBytesPerHourNotIn: [Float!] - logEventBytesPerHourGT: Float - logEventBytesPerHourGTE: Float - logEventBytesPerHourLT: Float - logEventBytesPerHourLTE: Float - logEventBytesPerHourIsNil: Boolean - logEventBytesPerHourNotNil: Boolean - - """log_event_cost_per_hour_bytes_usd field predicates""" - logEventCostPerHourBytesUsd: Float - logEventCostPerHourBytesUsdNEQ: Float - logEventCostPerHourBytesUsdIn: [Float!] - logEventCostPerHourBytesUsdNotIn: [Float!] - logEventCostPerHourBytesUsdGT: Float - logEventCostPerHourBytesUsdGTE: Float - logEventCostPerHourBytesUsdLT: Float - logEventCostPerHourBytesUsdLTE: Float - logEventCostPerHourBytesUsdIsNil: Boolean - logEventCostPerHourBytesUsdNotNil: Boolean - - """log_event_cost_per_hour_volume_usd field predicates""" - logEventCostPerHourVolumeUsd: Float - logEventCostPerHourVolumeUsdNEQ: Float - logEventCostPerHourVolumeUsdIn: [Float!] - logEventCostPerHourVolumeUsdNotIn: [Float!] - logEventCostPerHourVolumeUsdGT: Float - logEventCostPerHourVolumeUsdGTE: Float - logEventCostPerHourVolumeUsdLT: Float - logEventCostPerHourVolumeUsdLTE: Float - logEventCostPerHourVolumeUsdIsNil: Boolean - logEventCostPerHourVolumeUsdNotNil: Boolean - - """log_event_cost_per_hour_usd field predicates""" - logEventCostPerHourUsd: Float - logEventCostPerHourUsdNEQ: Float - logEventCostPerHourUsdIn: [Float!] - logEventCostPerHourUsdNotIn: [Float!] - logEventCostPerHourUsdGT: Float - logEventCostPerHourUsdGTE: Float - logEventCostPerHourUsdLT: Float - logEventCostPerHourUsdLTE: Float - logEventCostPerHourUsdIsNil: Boolean - logEventCostPerHourUsdNotNil: Boolean - - """estimated_volume_reduction_per_hour field predicates""" - estimatedVolumeReductionPerHour: Float - estimatedVolumeReductionPerHourNEQ: Float - estimatedVolumeReductionPerHourIn: [Float!] - estimatedVolumeReductionPerHourNotIn: [Float!] - estimatedVolumeReductionPerHourGT: Float - estimatedVolumeReductionPerHourGTE: Float - estimatedVolumeReductionPerHourLT: Float - estimatedVolumeReductionPerHourLTE: Float - estimatedVolumeReductionPerHourIsNil: Boolean - estimatedVolumeReductionPerHourNotNil: Boolean - - """estimated_bytes_reduction_per_hour field predicates""" - estimatedBytesReductionPerHour: Float - estimatedBytesReductionPerHourNEQ: Float - estimatedBytesReductionPerHourIn: [Float!] - estimatedBytesReductionPerHourNotIn: [Float!] - estimatedBytesReductionPerHourGT: Float - estimatedBytesReductionPerHourGTE: Float - estimatedBytesReductionPerHourLT: Float - estimatedBytesReductionPerHourLTE: Float - estimatedBytesReductionPerHourIsNil: Boolean - estimatedBytesReductionPerHourNotNil: Boolean - - """estimated_cost_reduction_per_hour_bytes_usd field predicates""" - estimatedCostReductionPerHourBytesUsd: Float - estimatedCostReductionPerHourBytesUsdNEQ: Float - estimatedCostReductionPerHourBytesUsdIn: [Float!] - estimatedCostReductionPerHourBytesUsdNotIn: [Float!] - estimatedCostReductionPerHourBytesUsdGT: Float - estimatedCostReductionPerHourBytesUsdGTE: Float - estimatedCostReductionPerHourBytesUsdLT: Float - estimatedCostReductionPerHourBytesUsdLTE: Float - estimatedCostReductionPerHourBytesUsdIsNil: Boolean - estimatedCostReductionPerHourBytesUsdNotNil: Boolean - - """estimated_cost_reduction_per_hour_volume_usd field predicates""" - estimatedCostReductionPerHourVolumeUsd: Float - estimatedCostReductionPerHourVolumeUsdNEQ: Float - estimatedCostReductionPerHourVolumeUsdIn: [Float!] - estimatedCostReductionPerHourVolumeUsdNotIn: [Float!] - estimatedCostReductionPerHourVolumeUsdGT: Float - estimatedCostReductionPerHourVolumeUsdGTE: Float - estimatedCostReductionPerHourVolumeUsdLT: Float - estimatedCostReductionPerHourVolumeUsdLTE: Float - estimatedCostReductionPerHourVolumeUsdIsNil: Boolean - estimatedCostReductionPerHourVolumeUsdNotNil: Boolean - - """estimated_cost_reduction_per_hour_usd field predicates""" - estimatedCostReductionPerHourUsd: Float - estimatedCostReductionPerHourUsdNEQ: Float - estimatedCostReductionPerHourUsdIn: [Float!] - estimatedCostReductionPerHourUsdNotIn: [Float!] - estimatedCostReductionPerHourUsdGT: Float - estimatedCostReductionPerHourUsdGTE: Float - estimatedCostReductionPerHourUsdLT: Float - estimatedCostReductionPerHourUsdLTE: Float - estimatedCostReductionPerHourUsdIsNil: Boolean - estimatedCostReductionPerHourUsdNotNil: Boolean - - """observed_volume_per_hour_before field predicates""" - observedVolumePerHourBefore: Float - observedVolumePerHourBeforeNEQ: Float - observedVolumePerHourBeforeIn: [Float!] - observedVolumePerHourBeforeNotIn: [Float!] - observedVolumePerHourBeforeGT: Float - observedVolumePerHourBeforeGTE: Float - observedVolumePerHourBeforeLT: Float - observedVolumePerHourBeforeLTE: Float - observedVolumePerHourBeforeIsNil: Boolean - observedVolumePerHourBeforeNotNil: Boolean - - """observed_volume_per_hour_after field predicates""" - observedVolumePerHourAfter: Float - observedVolumePerHourAfterNEQ: Float - observedVolumePerHourAfterIn: [Float!] - observedVolumePerHourAfterNotIn: [Float!] - observedVolumePerHourAfterGT: Float - observedVolumePerHourAfterGTE: Float - observedVolumePerHourAfterLT: Float - observedVolumePerHourAfterLTE: Float - observedVolumePerHourAfterIsNil: Boolean - observedVolumePerHourAfterNotNil: Boolean - - """observed_bytes_per_hour_before field predicates""" - observedBytesPerHourBefore: Float - observedBytesPerHourBeforeNEQ: Float - observedBytesPerHourBeforeIn: [Float!] - observedBytesPerHourBeforeNotIn: [Float!] - observedBytesPerHourBeforeGT: Float - observedBytesPerHourBeforeGTE: Float - observedBytesPerHourBeforeLT: Float - observedBytesPerHourBeforeLTE: Float - observedBytesPerHourBeforeIsNil: Boolean - observedBytesPerHourBeforeNotNil: Boolean - - """observed_bytes_per_hour_after field predicates""" - observedBytesPerHourAfter: Float - observedBytesPerHourAfterNEQ: Float - observedBytesPerHourAfterIn: [Float!] - observedBytesPerHourAfterNotIn: [Float!] - observedBytesPerHourAfterGT: Float - observedBytesPerHourAfterGTE: Float - observedBytesPerHourAfterLT: Float - observedBytesPerHourAfterLTE: Float - observedBytesPerHourAfterIsNil: Boolean - observedBytesPerHourAfterNotNil: Boolean - - """observed_cost_per_hour_before_bytes_usd field predicates""" - observedCostPerHourBeforeBytesUsd: Float - observedCostPerHourBeforeBytesUsdNEQ: Float - observedCostPerHourBeforeBytesUsdIn: [Float!] - observedCostPerHourBeforeBytesUsdNotIn: [Float!] - observedCostPerHourBeforeBytesUsdGT: Float - observedCostPerHourBeforeBytesUsdGTE: Float - observedCostPerHourBeforeBytesUsdLT: Float - observedCostPerHourBeforeBytesUsdLTE: Float - observedCostPerHourBeforeBytesUsdIsNil: Boolean - observedCostPerHourBeforeBytesUsdNotNil: Boolean - - """observed_cost_per_hour_before_volume_usd field predicates""" - observedCostPerHourBeforeVolumeUsd: Float - observedCostPerHourBeforeVolumeUsdNEQ: Float - observedCostPerHourBeforeVolumeUsdIn: [Float!] - observedCostPerHourBeforeVolumeUsdNotIn: [Float!] - observedCostPerHourBeforeVolumeUsdGT: Float - observedCostPerHourBeforeVolumeUsdGTE: Float - observedCostPerHourBeforeVolumeUsdLT: Float - observedCostPerHourBeforeVolumeUsdLTE: Float - observedCostPerHourBeforeVolumeUsdIsNil: Boolean - observedCostPerHourBeforeVolumeUsdNotNil: Boolean - - """observed_cost_per_hour_before_usd field predicates""" - observedCostPerHourBeforeUsd: Float - observedCostPerHourBeforeUsdNEQ: Float - observedCostPerHourBeforeUsdIn: [Float!] - observedCostPerHourBeforeUsdNotIn: [Float!] - observedCostPerHourBeforeUsdGT: Float - observedCostPerHourBeforeUsdGTE: Float - observedCostPerHourBeforeUsdLT: Float - observedCostPerHourBeforeUsdLTE: Float - observedCostPerHourBeforeUsdIsNil: Boolean - observedCostPerHourBeforeUsdNotNil: Boolean - - """observed_cost_per_hour_after_bytes_usd field predicates""" - observedCostPerHourAfterBytesUsd: Float - observedCostPerHourAfterBytesUsdNEQ: Float - observedCostPerHourAfterBytesUsdIn: [Float!] - observedCostPerHourAfterBytesUsdNotIn: [Float!] - observedCostPerHourAfterBytesUsdGT: Float - observedCostPerHourAfterBytesUsdGTE: Float - observedCostPerHourAfterBytesUsdLT: Float - observedCostPerHourAfterBytesUsdLTE: Float - observedCostPerHourAfterBytesUsdIsNil: Boolean - observedCostPerHourAfterBytesUsdNotNil: Boolean - - """observed_cost_per_hour_after_volume_usd field predicates""" - observedCostPerHourAfterVolumeUsd: Float - observedCostPerHourAfterVolumeUsdNEQ: Float - observedCostPerHourAfterVolumeUsdIn: [Float!] - observedCostPerHourAfterVolumeUsdNotIn: [Float!] - observedCostPerHourAfterVolumeUsdGT: Float - observedCostPerHourAfterVolumeUsdGTE: Float - observedCostPerHourAfterVolumeUsdLT: Float - observedCostPerHourAfterVolumeUsdLTE: Float - observedCostPerHourAfterVolumeUsdIsNil: Boolean - observedCostPerHourAfterVolumeUsdNotNil: Boolean - - """observed_cost_per_hour_after_usd field predicates""" - observedCostPerHourAfterUsd: Float - observedCostPerHourAfterUsdNEQ: Float - observedCostPerHourAfterUsdIn: [Float!] - observedCostPerHourAfterUsdNotIn: [Float!] - observedCostPerHourAfterUsdGT: Float - observedCostPerHourAfterUsdGTE: Float - observedCostPerHourAfterUsdLT: Float - observedCostPerHourAfterUsdLTE: Float - observedCostPerHourAfterUsdIsNil: Boolean - observedCostPerHourAfterUsdNotNil: Boolean - - """refreshed_at field predicates""" - refreshedAt: Time - refreshedAtNEQ: Time - refreshedAtIn: [Time!] - refreshedAtNotIn: [Time!] - refreshedAtGT: Time - refreshedAtGTE: Time - refreshedAtLT: Time - refreshedAtLTE: Time -} - -""" -DatadogAccountWhereInput is used for filtering DatadogAccount objects. -Input was generated by ent. -""" -input DatadogAccountWhereInput { - not: DatadogAccountWhereInput - and: [DatadogAccountWhereInput!] - or: [DatadogAccountWhereInput!] - - """id field predicates""" - id: ID - idNEQ: ID - idIn: [ID!] - idNotIn: [ID!] - idGT: ID - idGTE: ID - idLT: ID - idLTE: ID - - """account_id field predicates""" - accountID: ID + """"account_id" field predicates.""" accountIDNEQ: ID + + """"account_id" field predicates.""" accountIDIn: [ID!] + + """"account_id" field predicates.""" accountIDNotIn: [ID!] - """name field predicates""" + """"name" field predicates.""" name: String + + """"name" field predicates.""" nameNEQ: String + + """"name" field predicates.""" nameIn: [String!] + + """"name" field predicates.""" nameNotIn: [String!] + + """"name" field predicates.""" nameGT: String + + """"name" field predicates.""" nameGTE: String + + """"name" field predicates.""" nameLT: String + + """"name" field predicates.""" nameLTE: String + + """"name" field predicates.""" nameContains: String + + """"name" field predicates.""" nameHasPrefix: String + + """"name" field predicates.""" nameHasSuffix: String + + """"name" field predicates.""" nameEqualFold: String + + """"name" field predicates.""" nameContainsFold: String - """site field predicates""" + """"site" field predicates.""" site: DatadogAccountSite + + """"site" field predicates.""" siteNEQ: DatadogAccountSite + + """"site" field predicates.""" siteIn: [DatadogAccountSite!] + + """"site" field predicates.""" siteNotIn: [DatadogAccountSite!] - """created_at field predicates""" + """"created_at" field predicates.""" createdAt: Time + + """"created_at" field predicates.""" createdAtNEQ: Time + + """"created_at" field predicates.""" createdAtIn: [Time!] + + """"created_at" field predicates.""" createdAtNotIn: [Time!] + + """"created_at" field predicates.""" createdAtGT: Time + + """"created_at" field predicates.""" createdAtGTE: Time + + """"created_at" field predicates.""" createdAtLT: Time + + """"created_at" field predicates.""" createdAtLTE: Time - """updated_at field predicates""" + """"updated_at" field predicates.""" updatedAt: Time + + """"updated_at" field predicates.""" updatedAtNEQ: Time + + """"updated_at" field predicates.""" updatedAtIn: [Time!] + + """"updated_at" field predicates.""" updatedAtNotIn: [Time!] + + """"updated_at" field predicates.""" updatedAtGT: Time + + """"updated_at" field predicates.""" updatedAtGTE: Time + + """"updated_at" field predicates.""" updatedAtLT: Time + + """"updated_at" field predicates.""" updatedAtLTE: Time - """cost_per_gb_ingested field predicates""" - costPerGBIngested: Float - costPerGBIngestedNEQ: Float - costPerGBIngestedIn: [Float!] - costPerGBIngestedNotIn: [Float!] - costPerGBIngestedGT: Float - costPerGBIngestedGTE: Float - costPerGBIngestedLT: Float - costPerGBIngestedLTE: Float - costPerGBIngestedIsNil: Boolean - costPerGBIngestedNotNil: Boolean - - """rate_limit_utilization field predicates""" - rateLimitUtilization: Float - rateLimitUtilizationNEQ: Float - rateLimitUtilizationIn: [Float!] - rateLimitUtilizationNotIn: [Float!] - rateLimitUtilizationGT: Float - rateLimitUtilizationGTE: Float - rateLimitUtilizationLT: Float - rateLimitUtilizationLTE: Float - rateLimitUtilizationIsNil: Boolean - rateLimitUtilizationNotNil: Boolean - - """account edge predicates""" - hasAccount: Boolean - hasAccountWith: [AccountWhereInput!] + """"services_auto_enabled_at" field predicates.""" + servicesAutoEnabledAt: Time + + """"services_auto_enabled_at" field predicates.""" + servicesAutoEnabledAtNEQ: Time + + """"services_auto_enabled_at" field predicates.""" + servicesAutoEnabledAtIn: [Time!] - """log_indexes edge predicates""" - hasLogIndexes: Boolean - hasLogIndexesWith: [DatadogLogIndexWhereInput!] + """"services_auto_enabled_at" field predicates.""" + servicesAutoEnabledAtNotIn: [Time!] + + """"services_auto_enabled_at" field predicates.""" + servicesAutoEnabledAtGT: Time + + """"services_auto_enabled_at" field predicates.""" + servicesAutoEnabledAtGTE: Time + + """"services_auto_enabled_at" field predicates.""" + servicesAutoEnabledAtLT: Time + + """"services_auto_enabled_at" field predicates.""" + servicesAutoEnabledAtLTE: Time + + """"services_auto_enabled_at" field predicates.""" + servicesAutoEnabledAtIsNil: Boolean + + """"services_auto_enabled_at" field predicates.""" + servicesAutoEnabledAtNotNil: Boolean + + """"log_index_inventory_refreshed_at" field predicates.""" + logIndexInventoryRefreshedAt: Time + + """"log_index_inventory_refreshed_at" field predicates.""" + logIndexInventoryRefreshedAtNEQ: Time + + """"log_index_inventory_refreshed_at" field predicates.""" + logIndexInventoryRefreshedAtIn: [Time!] + + """"log_index_inventory_refreshed_at" field predicates.""" + logIndexInventoryRefreshedAtNotIn: [Time!] + + """"log_index_inventory_refreshed_at" field predicates.""" + logIndexInventoryRefreshedAtGT: Time + + """"log_index_inventory_refreshed_at" field predicates.""" + logIndexInventoryRefreshedAtGTE: Time + + """"log_index_inventory_refreshed_at" field predicates.""" + logIndexInventoryRefreshedAtLT: Time + + """"log_index_inventory_refreshed_at" field predicates.""" + logIndexInventoryRefreshedAtLTE: Time + + """"log_index_inventory_refreshed_at" field predicates.""" + logIndexInventoryRefreshedAtIsNil: Boolean + + """"log_index_inventory_refreshed_at" field predicates.""" + logIndexInventoryRefreshedAtNotNil: Boolean + + """"log_usage_refreshed_at" field predicates.""" + logUsageRefreshedAt: Time + + """"log_usage_refreshed_at" field predicates.""" + logUsageRefreshedAtNEQ: Time + + """"log_usage_refreshed_at" field predicates.""" + logUsageRefreshedAtIn: [Time!] + + """"log_usage_refreshed_at" field predicates.""" + logUsageRefreshedAtNotIn: [Time!] + + """"log_usage_refreshed_at" field predicates.""" + logUsageRefreshedAtGT: Time + + """"log_usage_refreshed_at" field predicates.""" + logUsageRefreshedAtGTE: Time + + """"log_usage_refreshed_at" field predicates.""" + logUsageRefreshedAtLT: Time + + """"log_usage_refreshed_at" field predicates.""" + logUsageRefreshedAtLTE: Time + + """"log_usage_refreshed_at" field predicates.""" + logUsageRefreshedAtIsNil: Boolean + + """"log_usage_refreshed_at" field predicates.""" + logUsageRefreshedAtNotNil: Boolean } -type DatadogLogIndex implements Node { - """Unique identifier for this index record""" +type DatadogLogExclusionFilter { + """Unique identifier""" id: ID! """ - Denormalized for tenant isolation. Auto-set via trigger from datadog_account.account_id. + Denormalized for tenant isolation. Auto-set via trigger from datadog_log_index.account_id. """ accountID: UUID! - """The Datadog account this index belongs to""" - datadogAccountID: ID! + """Issue this Datadog exclusion filter remediates.""" + issueID: ID + + """Log event this Datadog exclusion filter targets.""" + logEventID: ID + + """Datadog log index this exclusion filter applies to.""" + datadogLogIndexID: ID! """ - Index name from Datadog (e.g., 'main', 'security', 'compliance') - this is the stable identifier + Ownership source for this Datadog log exclusion filter. Values: tero = Filter + is managed by Tero in Datadog.; customer = Filter is customer-managed in + Datadog and mirrored by Tero. """ - name: String! + source: DatadogLogExclusionFilterSource! """ - Cost per million events indexed in this index (USD). NULL = using Datadog's - published rate ($1.70/M). SIEM indexes cost more — set accordingly. + Datadog exclusion filter name. Datadog allows duplicate names within one log index. """ - costPerMillionEventsIndexed: Float + filterName: String! - """When this index was first discovered""" - createdAt: Time! + """Approved Datadog log query for this exclusion filter.""" + query: String! - """Last time we saw logs flowing to this index""" - lastSeenAt: Time! + """ + Fraction of matching logs excluded by Datadog. A value of 1 excludes all matching logs. + """ + sampleRate: Float! - """The Datadog account this index belongs to""" - datadogAccount: DatadogAccount! -} + """Whether the exclusion filter should be active in Datadog.""" + isEnabled: Boolean! -"""Ordering options for DatadogLogIndex connections""" -input DatadogLogIndexOrder { - """The ordering direction.""" - direction: OrderDirection! = ASC + """Zero-based position of this exclusion filter inside its Datadog index.""" + position: Int - """The field by which to order DatadogLogIndexes.""" - field: DatadogLogIndexOrderField! -} + """ + Lifecycle state for this Datadog log exclusion filter. Values: pending = + Queued for Datadog application.; applying = Claimed by a worker and being + applied to Datadog.; applied = Applied to the Datadog log index.; failed = + Datadog application failed. + """ + status: DatadogLogExclusionFilterStatus! -"""Properties by which DatadogLogIndex connections can be ordered.""" -enum DatadogLogIndexOrderField { - NAME - CREATED_AT - LAST_SEEN_AT -} + """First inventory refresh where Tero observed this filter in Datadog.""" + detectedAt: Time -""" -DatadogLogIndexWhereInput is used for filtering DatadogLogIndex objects. -Input was generated by ent. -""" -input DatadogLogIndexWhereInput { - not: DatadogLogIndexWhereInput - and: [DatadogLogIndexWhereInput!] - or: [DatadogLogIndexWhereInput!] + """ + Most recent inventory refresh where Tero observed this filter in Datadog. + """ + lastSeenAt: Time - """id field predicates""" - id: ID - idNEQ: ID - idIn: [ID!] - idNotIn: [ID!] - idGT: ID - idGTE: ID - idLT: ID - idLTE: ID + """ + First inventory refresh where this previously observed filter was absent from Datadog. + """ + removedAt: Time - """account_id field predicates""" - accountID: UUID - accountIDNEQ: UUID - accountIDIn: [UUID!] - accountIDNotIn: [UUID!] - accountIDGT: UUID - accountIDGTE: UUID - accountIDLT: UUID - accountIDLTE: UUID + """Stable hash of the remote Datadog filter configuration.""" + remoteConfigHash: String - """datadog_account_id field predicates""" - datadogAccountID: ID - datadogAccountIDNEQ: ID - datadogAccountIDIn: [ID!] - datadogAccountIDNotIn: [ID!] + """ + Import workflow state for this Datadog log exclusion filter. Values: + not_evaluated = Filter has not been evaluated for Tero policy import.; + unsupported = Filter cannot currently be imported as Tero policies.; + proposal_ready = Filter has Tero policy proposals ready.; sample_key_required + = Filter needs a sample key before Tero policy import.; policy_active = + Imported Tero policies are active.; policy_verified = Imported Tero policies + have been verified.; datadog_filter_removal_pending = Original Datadog filter + removal or disablement is pending.; datadog_filter_removed = Original Datadog + filter has been disabled or removed.; rejected = Import was rejected by the + user.; failed = Import workflow failed. + """ + conversionStatus: DatadogLogExclusionFilterConversionStatus! - """name field predicates""" - name: String - nameNEQ: String - nameIn: [String!] - nameNotIn: [String!] - nameGT: String - nameGTE: String - nameLT: String - nameLTE: String - nameContains: String - nameHasPrefix: String - nameHasSuffix: String - nameEqualFold: String - nameContainsFold: String + """Last policy import conversion error, if conversion failed.""" + conversionError: String - """cost_per_million_events_indexed field predicates""" - costPerMillionEventsIndexed: Float - costPerMillionEventsIndexedNEQ: Float - costPerMillionEventsIndexedIn: [Float!] - costPerMillionEventsIndexedNotIn: [Float!] - costPerMillionEventsIndexedGT: Float - costPerMillionEventsIndexedGTE: Float - costPerMillionEventsIndexedLT: Float - costPerMillionEventsIndexedLTE: Float - costPerMillionEventsIndexedIsNil: Boolean - costPerMillionEventsIndexedNotNil: Boolean + """When imported Tero policies were created for this Datadog filter.""" + convertedAt: Time - """created_at field predicates""" - createdAt: Time - createdAtNEQ: Time - createdAtIn: [Time!] - createdAtNotIn: [Time!] - createdAtGT: Time - createdAtGTE: Time - createdAtLT: Time - createdAtLTE: Time + """When imported Tero policies were verified as operating.""" + verifiedAt: Time - """last_seen_at field predicates""" - lastSeenAt: Time - lastSeenAtNEQ: Time - lastSeenAtIn: [Time!] - lastSeenAtNotIn: [Time!] - lastSeenAtGT: Time - lastSeenAtGTE: Time - lastSeenAtLT: Time - lastSeenAtLTE: Time + """ + When the source Datadog filter was disabled or removed after policy verification. + """ + datadogFilterRemovedAt: Time - """datadog_account edge predicates""" - hasDatadogAccount: Boolean - hasDatadogAccountWith: [DatadogAccountWhereInput!] -} + """Last application error, if Datadog application failed.""" + error: String -type EdgeApiKey implements Node { - """Unique identifier of this API key""" - id: ID! + """When Tero last attempted to apply this filter to Datadog.""" + lastAttemptedAt: Time - """The account this API key authenticates to""" - accountID: ID! + """When Datadog last accepted this filter configuration.""" + appliedAt: Time - """User-provided name for this key (e.g., 'Production Collector')""" - name: String! + """When this filter row was created.""" + createdAt: Time! - """First characters of the key for identification (e.g., 'tero_sk_abc1')""" - keyPrefix: String! + """When this filter row was last updated.""" + updatedAt: Time! - """When this key was last used for authentication""" - lastUsedAt: Time + """Issue this Datadog exclusion filter remediates""" + issue: Issue - """When this key was revoked (null if active)""" - revokedAt: Time + """Log event this Datadog exclusion filter targets""" + logEvent: LogEvent - """When this key was created""" - createdAt: Time! + """Datadog log index this exclusion filter applies to""" + datadogLogIndex: DatadogLogIndex! - """The account this API key belongs to""" - account: Account! + """Tero policies imported from this Datadog exclusion filter""" + importedLogEventPolicies: [LogEventPolicy!]! + sampleKey: [String!]! + logEventPolicyProposalSet(input: DatadogLogExclusionFilterPolicyProposalInput): LogEventPolicyProposalSet! } -"""A connection to a list of items.""" -type EdgeApiKeyConnection { - """A list of edges.""" - edges: [EdgeApiKeyEdge] - - """Information to aid in pagination.""" +type DatadogLogExclusionFilterConnection { + edges: [DatadogLogExclusionFilterEdge!]! pageInfo: PageInfo! - - """Identifies the total count of items in the connection.""" totalCount: Int! } -"""An edge in a connection.""" -type EdgeApiKeyEdge { - """The item at the end of the edge.""" - node: EdgeApiKey +enum DatadogLogExclusionFilterConversionStatus { + not_evaluated + unsupported + proposal_ready + sample_key_required + policy_active + policy_verified + datadog_filter_removal_pending + datadog_filter_removed + rejected + failed +} - """A cursor for use in pagination.""" +"""DatadogLogExclusionFilter creation input.""" +input DatadogLogExclusionFilterCreateInput { + """Issue this Datadog exclusion filter remediates.""" + issueID: ID + + """Log event this Datadog exclusion filter targets.""" + logEventID: ID + + """Datadog log index this exclusion filter applies to.""" + datadogLogIndexID: ID! + + """Approved Datadog log query for this exclusion filter.""" + query: String! + + """ + Fraction of matching logs excluded by Datadog. A value of 1 excludes all matching logs. + """ + sampleRate: Float + + """Whether the exclusion filter should be active in Datadog.""" + isEnabled: Boolean +} + +type DatadogLogExclusionFilterDraft { + issueID: ID! + logEventID: ID! + datadogLogIndexID: ID! + query: String! + sampleRate: Float + isEnabled: Boolean +} + +type DatadogLogExclusionFilterEdge { + node: DatadogLogExclusionFilter! cursor: Cursor! } -"""Ordering options for EdgeApiKey connections""" -input EdgeApiKeyOrder { - """The ordering direction.""" +input DatadogLogExclusionFilterOrder { direction: OrderDirection! = ASC - - """The field by which to order EdgeApiKeys.""" - field: EdgeApiKeyOrderField! + field: DatadogLogExclusionFilterOrderField! } -"""Properties by which EdgeApiKey connections can be ordered.""" -enum EdgeApiKeyOrderField { - NAME - LAST_USED_AT - REVOKED_AT +enum DatadogLogExclusionFilterOrderField { + ID + SOURCE + POSITION + STATUS + DETECTED_AT + LAST_SEEN_AT + REMOVED_AT + CONVERSION_STATUS + CONVERTED_AT + VERIFIED_AT + DATADOG_FILTER_REMOVED_AT + LAST_ATTEMPTED_AT + APPLIED_AT CREATED_AT + UPDATED_AT } -""" -EdgeApiKeyWhereInput is used for filtering EdgeApiKey objects. -Input was generated by ent. -""" -input EdgeApiKeyWhereInput { - not: EdgeApiKeyWhereInput - and: [EdgeApiKeyWhereInput!] - or: [EdgeApiKeyWhereInput!] +input DatadogLogExclusionFilterPolicyImportInput { + sourceBranchIndexes: [Int!]! + sampleKey: [String!] +} - """id field predicates""" - id: ID +input DatadogLogExclusionFilterPolicyProposalInput { + sampleKey: [String!] +} + +enum DatadogLogExclusionFilterSource { + tero + customer +} + +enum DatadogLogExclusionFilterStatus { + pending + applying + applied + failed +} + +input DatadogLogExclusionFilterWhereInput { + not: DatadogLogExclusionFilterWhereInput + and: [DatadogLogExclusionFilterWhereInput!] + or: [DatadogLogExclusionFilterWhereInput!] + + """"issue" edge predicates.""" + issue: IssueWhereInput + + """Whether the "issue" edge has at least one related row.""" + hasIssue: Boolean + + """"log_event" edge predicates.""" + logEvent: LogEventWhereInput + + """Whether the "log_event" edge has at least one related row.""" + hasLogEvent: Boolean + + """"datadog_log_index" edge predicates.""" + datadogLogIndex: DatadogLogIndexWhereInput + + """Whether the "datadog_log_index" edge has at least one related row.""" + hasDatadogLogIndex: Boolean + + """"imported_log_event_policies" edge predicates.""" + importedLogEventPolicies: LogEventPolicyWhereInput + + """ + Whether the "imported_log_event_policies" edge has at least one related row. + """ + hasImportedLogEventPolicies: Boolean + + """"id" field predicates.""" + id: ID + + """"id" field predicates.""" idNEQ: ID + + """"id" field predicates.""" idIn: [ID!] + + """"id" field predicates.""" idNotIn: [ID!] + + """"id" field predicates.""" idGT: ID + + """"id" field predicates.""" idGTE: ID + + """"id" field predicates.""" idLT: ID + + """"id" field predicates.""" idLTE: ID - """account_id field predicates""" - accountID: ID - accountIDNEQ: ID - accountIDIn: [ID!] - accountIDNotIn: [ID!] + """"issue_id" field predicates.""" + issueID: ID - """name field predicates""" - name: String - nameNEQ: String - nameIn: [String!] - nameNotIn: [String!] - nameGT: String - nameGTE: String - nameLT: String - nameLTE: String - nameContains: String - nameHasPrefix: String - nameHasSuffix: String - nameEqualFold: String - nameContainsFold: String + """"issue_id" field predicates.""" + issueIDNEQ: ID - """key_prefix field predicates""" - keyPrefix: String - keyPrefixNEQ: String - keyPrefixIn: [String!] - keyPrefixNotIn: [String!] - keyPrefixGT: String - keyPrefixGTE: String - keyPrefixLT: String - keyPrefixLTE: String - keyPrefixContains: String - keyPrefixHasPrefix: String - keyPrefixHasSuffix: String - keyPrefixEqualFold: String - keyPrefixContainsFold: String + """"issue_id" field predicates.""" + issueIDIn: [ID!] - """last_used_at field predicates""" - lastUsedAt: Time - lastUsedAtNEQ: Time - lastUsedAtIn: [Time!] - lastUsedAtNotIn: [Time!] - lastUsedAtGT: Time - lastUsedAtGTE: Time - lastUsedAtLT: Time - lastUsedAtLTE: Time - lastUsedAtIsNil: Boolean - lastUsedAtNotNil: Boolean + """"issue_id" field predicates.""" + issueIDNotIn: [ID!] - """revoked_at field predicates""" - revokedAt: Time - revokedAtNEQ: Time - revokedAtIn: [Time!] - revokedAtNotIn: [Time!] - revokedAtGT: Time - revokedAtGTE: Time - revokedAtLT: Time - revokedAtLTE: Time - revokedAtIsNil: Boolean - revokedAtNotNil: Boolean + """"issue_id" field predicates.""" + issueIDIsNil: Boolean - """created_at field predicates""" - createdAt: Time - createdAtNEQ: Time - createdAtIn: [Time!] - createdAtNotIn: [Time!] - createdAtGT: Time - createdAtGTE: Time - createdAtLT: Time - createdAtLTE: Time + """"issue_id" field predicates.""" + issueIDNotNil: Boolean - """account edge predicates""" - hasAccount: Boolean - hasAccountWith: [AccountWhereInput!] -} + """"log_event_id" field predicates.""" + logEventID: ID -type EdgeInstance implements Node { - """Unique identifier of this edge instance""" - id: ID! + """"log_event_id" field predicates.""" + logEventIDNEQ: ID - """The account this edge instance belongs to""" - accountID: ID! + """"log_event_id" field predicates.""" + logEventIDIn: [ID!] - """The service.instance.id resource attribute identifying this instance""" - instanceID: String! + """"log_event_id" field predicates.""" + logEventIDNotIn: [ID!] - """The service.name resource attribute""" - serviceName: String! + """"log_event_id" field predicates.""" + logEventIDIsNil: Boolean - """The service.namespace resource attribute""" - serviceNamespace: String + """"log_event_id" field predicates.""" + logEventIDNotNil: Boolean - """The service.version resource attribute""" - serviceVersion: String + """"datadog_log_index_id" field predicates.""" + datadogLogIndexID: ID - """When this edge instance first synced""" - firstSeenAt: Time! + """"datadog_log_index_id" field predicates.""" + datadogLogIndexIDNEQ: ID - """When this edge instance last synced""" - lastSyncAt: Time! + """"datadog_log_index_id" field predicates.""" + datadogLogIndexIDIn: [ID!] - """When this record was created""" - createdAt: Time! + """"datadog_log_index_id" field predicates.""" + datadogLogIndexIDNotIn: [ID!] - """When this record was last updated""" - updatedAt: Time! + """"source" field predicates.""" + source: DatadogLogExclusionFilterSource - """The account this edge instance belongs to""" - account: Account! -} + """"source" field predicates.""" + sourceNEQ: DatadogLogExclusionFilterSource -"""A connection to a list of items.""" -type EdgeInstanceConnection { - """A list of edges.""" - edges: [EdgeInstanceEdge] + """"source" field predicates.""" + sourceIn: [DatadogLogExclusionFilterSource!] - """Information to aid in pagination.""" - pageInfo: PageInfo! + """"source" field predicates.""" + sourceNotIn: [DatadogLogExclusionFilterSource!] - """Identifies the total count of items in the connection.""" - totalCount: Int! -} + """"filter_name" field predicates.""" + filterName: String -"""An edge in a connection.""" -type EdgeInstanceEdge { - """The item at the end of the edge.""" - node: EdgeInstance + """"filter_name" field predicates.""" + filterNameNEQ: String - """A cursor for use in pagination.""" - cursor: Cursor! -} + """"filter_name" field predicates.""" + filterNameIn: [String!] -"""Ordering options for EdgeInstance connections""" -input EdgeInstanceOrder { - """The ordering direction.""" - direction: OrderDirection! = ASC + """"filter_name" field predicates.""" + filterNameNotIn: [String!] - """The field by which to order EdgeInstances.""" - field: EdgeInstanceOrderField! -} + """"filter_name" field predicates.""" + filterNameGT: String -"""Properties by which EdgeInstance connections can be ordered.""" -enum EdgeInstanceOrderField { - INSTANCE_ID - SERVICE_NAME - SERVICE_NAMESPACE - SERVICE_VERSION - FIRST_SEEN_AT - LAST_SYNC_AT - CREATED_AT - UPDATED_AT -} + """"filter_name" field predicates.""" + filterNameGTE: String -""" -EdgeInstanceWhereInput is used for filtering EdgeInstance objects. -Input was generated by ent. -""" -input EdgeInstanceWhereInput { - not: EdgeInstanceWhereInput - and: [EdgeInstanceWhereInput!] - or: [EdgeInstanceWhereInput!] + """"filter_name" field predicates.""" + filterNameLT: String - """id field predicates""" - id: ID - idNEQ: ID - idIn: [ID!] - idNotIn: [ID!] - idGT: ID - idGTE: ID - idLT: ID - idLTE: ID + """"filter_name" field predicates.""" + filterNameLTE: String - """account_id field predicates""" - accountID: ID - accountIDNEQ: ID - accountIDIn: [ID!] - accountIDNotIn: [ID!] + """"filter_name" field predicates.""" + filterNameContains: String - """instance_id field predicates""" - instanceID: String - instanceIDNEQ: String - instanceIDIn: [String!] - instanceIDNotIn: [String!] - instanceIDGT: String - instanceIDGTE: String - instanceIDLT: String - instanceIDLTE: String - instanceIDContains: String - instanceIDHasPrefix: String - instanceIDHasSuffix: String - instanceIDEqualFold: String - instanceIDContainsFold: String + """"filter_name" field predicates.""" + filterNameHasPrefix: String - """service_name field predicates""" - serviceName: String - serviceNameNEQ: String - serviceNameIn: [String!] - serviceNameNotIn: [String!] - serviceNameGT: String - serviceNameGTE: String - serviceNameLT: String - serviceNameLTE: String - serviceNameContains: String - serviceNameHasPrefix: String - serviceNameHasSuffix: String - serviceNameEqualFold: String - serviceNameContainsFold: String + """"filter_name" field predicates.""" + filterNameHasSuffix: String - """service_namespace field predicates""" - serviceNamespace: String - serviceNamespaceNEQ: String - serviceNamespaceIn: [String!] - serviceNamespaceNotIn: [String!] - serviceNamespaceGT: String - serviceNamespaceGTE: String - serviceNamespaceLT: String - serviceNamespaceLTE: String - serviceNamespaceContains: String - serviceNamespaceHasPrefix: String - serviceNamespaceHasSuffix: String - serviceNamespaceIsNil: Boolean - serviceNamespaceNotNil: Boolean - serviceNamespaceEqualFold: String - serviceNamespaceContainsFold: String + """"filter_name" field predicates.""" + filterNameEqualFold: String - """service_version field predicates""" - serviceVersion: String - serviceVersionNEQ: String - serviceVersionIn: [String!] - serviceVersionNotIn: [String!] - serviceVersionGT: String - serviceVersionGTE: String - serviceVersionLT: String - serviceVersionLTE: String - serviceVersionContains: String - serviceVersionHasPrefix: String - serviceVersionHasSuffix: String - serviceVersionIsNil: Boolean - serviceVersionNotNil: Boolean - serviceVersionEqualFold: String - serviceVersionContainsFold: String + """"filter_name" field predicates.""" + filterNameContainsFold: String - """first_seen_at field predicates""" - firstSeenAt: Time - firstSeenAtNEQ: Time - firstSeenAtIn: [Time!] - firstSeenAtNotIn: [Time!] - firstSeenAtGT: Time - firstSeenAtGTE: Time - firstSeenAtLT: Time - firstSeenAtLTE: Time + """"sample_rate" field predicates.""" + sampleRate: Float - """last_sync_at field predicates""" - lastSyncAt: Time - lastSyncAtNEQ: Time - lastSyncAtIn: [Time!] - lastSyncAtNotIn: [Time!] - lastSyncAtGT: Time - lastSyncAtGTE: Time - lastSyncAtLT: Time - lastSyncAtLTE: Time + """"sample_rate" field predicates.""" + sampleRateNEQ: Float - """created_at field predicates""" - createdAt: Time - createdAtNEQ: Time - createdAtIn: [Time!] - createdAtNotIn: [Time!] - createdAtGT: Time - createdAtGTE: Time - createdAtLT: Time - createdAtLTE: Time + """"sample_rate" field predicates.""" + sampleRateIn: [Float!] - """updated_at field predicates""" - updatedAt: Time - updatedAtNEQ: Time - updatedAtIn: [Time!] - updatedAtNotIn: [Time!] - updatedAtGT: Time - updatedAtGTE: Time - updatedAtLT: Time - updatedAtLTE: Time + """"sample_rate" field predicates.""" + sampleRateNotIn: [Float!] + + """"sample_rate" field predicates.""" + sampleRateGT: Float + + """"sample_rate" field predicates.""" + sampleRateGTE: Float + + """"sample_rate" field predicates.""" + sampleRateLT: Float + + """"sample_rate" field predicates.""" + sampleRateLTE: Float + + """"is_enabled" field predicates.""" + isEnabled: Boolean + + """"is_enabled" field predicates.""" + isEnabledNEQ: Boolean + + """"position" field predicates.""" + position: Int + + """"position" field predicates.""" + positionNEQ: Int + + """"position" field predicates.""" + positionIn: [Int!] + + """"position" field predicates.""" + positionNotIn: [Int!] + + """"position" field predicates.""" + positionGT: Int + + """"position" field predicates.""" + positionGTE: Int + + """"position" field predicates.""" + positionLT: Int + + """"position" field predicates.""" + positionLTE: Int + + """"position" field predicates.""" + positionIsNil: Boolean + + """"position" field predicates.""" + positionNotNil: Boolean + + """"status" field predicates.""" + status: DatadogLogExclusionFilterStatus + + """"status" field predicates.""" + statusNEQ: DatadogLogExclusionFilterStatus + + """"status" field predicates.""" + statusIn: [DatadogLogExclusionFilterStatus!] + + """"status" field predicates.""" + statusNotIn: [DatadogLogExclusionFilterStatus!] + + """"detected_at" field predicates.""" + detectedAt: Time + + """"detected_at" field predicates.""" + detectedAtNEQ: Time + + """"detected_at" field predicates.""" + detectedAtIn: [Time!] + + """"detected_at" field predicates.""" + detectedAtNotIn: [Time!] + + """"detected_at" field predicates.""" + detectedAtGT: Time + + """"detected_at" field predicates.""" + detectedAtGTE: Time + + """"detected_at" field predicates.""" + detectedAtLT: Time + + """"detected_at" field predicates.""" + detectedAtLTE: Time + + """"detected_at" field predicates.""" + detectedAtIsNil: Boolean + + """"detected_at" field predicates.""" + detectedAtNotNil: Boolean + + """"last_seen_at" field predicates.""" + lastSeenAt: Time + + """"last_seen_at" field predicates.""" + lastSeenAtNEQ: Time + + """"last_seen_at" field predicates.""" + lastSeenAtIn: [Time!] + + """"last_seen_at" field predicates.""" + lastSeenAtNotIn: [Time!] + + """"last_seen_at" field predicates.""" + lastSeenAtGT: Time + + """"last_seen_at" field predicates.""" + lastSeenAtGTE: Time + + """"last_seen_at" field predicates.""" + lastSeenAtLT: Time + + """"last_seen_at" field predicates.""" + lastSeenAtLTE: Time + + """"last_seen_at" field predicates.""" + lastSeenAtIsNil: Boolean + + """"last_seen_at" field predicates.""" + lastSeenAtNotNil: Boolean + + """"removed_at" field predicates.""" + removedAt: Time + + """"removed_at" field predicates.""" + removedAtNEQ: Time + + """"removed_at" field predicates.""" + removedAtIn: [Time!] + + """"removed_at" field predicates.""" + removedAtNotIn: [Time!] + + """"removed_at" field predicates.""" + removedAtGT: Time + + """"removed_at" field predicates.""" + removedAtGTE: Time + + """"removed_at" field predicates.""" + removedAtLT: Time + + """"removed_at" field predicates.""" + removedAtLTE: Time + + """"removed_at" field predicates.""" + removedAtIsNil: Boolean + + """"removed_at" field predicates.""" + removedAtNotNil: Boolean + + """"conversion_status" field predicates.""" + conversionStatus: DatadogLogExclusionFilterConversionStatus + + """"conversion_status" field predicates.""" + conversionStatusNEQ: DatadogLogExclusionFilterConversionStatus + + """"conversion_status" field predicates.""" + conversionStatusIn: [DatadogLogExclusionFilterConversionStatus!] + + """"conversion_status" field predicates.""" + conversionStatusNotIn: [DatadogLogExclusionFilterConversionStatus!] + + """"converted_at" field predicates.""" + convertedAt: Time + + """"converted_at" field predicates.""" + convertedAtNEQ: Time + + """"converted_at" field predicates.""" + convertedAtIn: [Time!] + + """"converted_at" field predicates.""" + convertedAtNotIn: [Time!] + + """"converted_at" field predicates.""" + convertedAtGT: Time + + """"converted_at" field predicates.""" + convertedAtGTE: Time + + """"converted_at" field predicates.""" + convertedAtLT: Time + + """"converted_at" field predicates.""" + convertedAtLTE: Time + + """"converted_at" field predicates.""" + convertedAtIsNil: Boolean + + """"converted_at" field predicates.""" + convertedAtNotNil: Boolean + + """"verified_at" field predicates.""" + verifiedAt: Time + + """"verified_at" field predicates.""" + verifiedAtNEQ: Time + + """"verified_at" field predicates.""" + verifiedAtIn: [Time!] + + """"verified_at" field predicates.""" + verifiedAtNotIn: [Time!] + + """"verified_at" field predicates.""" + verifiedAtGT: Time + + """"verified_at" field predicates.""" + verifiedAtGTE: Time + + """"verified_at" field predicates.""" + verifiedAtLT: Time + + """"verified_at" field predicates.""" + verifiedAtLTE: Time + + """"verified_at" field predicates.""" + verifiedAtIsNil: Boolean + + """"verified_at" field predicates.""" + verifiedAtNotNil: Boolean + + """"datadog_filter_removed_at" field predicates.""" + datadogFilterRemovedAt: Time + + """"datadog_filter_removed_at" field predicates.""" + datadogFilterRemovedAtNEQ: Time + + """"datadog_filter_removed_at" field predicates.""" + datadogFilterRemovedAtIn: [Time!] + + """"datadog_filter_removed_at" field predicates.""" + datadogFilterRemovedAtNotIn: [Time!] + + """"datadog_filter_removed_at" field predicates.""" + datadogFilterRemovedAtGT: Time + + """"datadog_filter_removed_at" field predicates.""" + datadogFilterRemovedAtGTE: Time + + """"datadog_filter_removed_at" field predicates.""" + datadogFilterRemovedAtLT: Time + + """"datadog_filter_removed_at" field predicates.""" + datadogFilterRemovedAtLTE: Time + + """"datadog_filter_removed_at" field predicates.""" + datadogFilterRemovedAtIsNil: Boolean + + """"datadog_filter_removed_at" field predicates.""" + datadogFilterRemovedAtNotNil: Boolean + + """"last_attempted_at" field predicates.""" + lastAttemptedAt: Time + + """"last_attempted_at" field predicates.""" + lastAttemptedAtNEQ: Time + + """"last_attempted_at" field predicates.""" + lastAttemptedAtIn: [Time!] + + """"last_attempted_at" field predicates.""" + lastAttemptedAtNotIn: [Time!] + + """"last_attempted_at" field predicates.""" + lastAttemptedAtGT: Time + + """"last_attempted_at" field predicates.""" + lastAttemptedAtGTE: Time + + """"last_attempted_at" field predicates.""" + lastAttemptedAtLT: Time + + """"last_attempted_at" field predicates.""" + lastAttemptedAtLTE: Time + + """"last_attempted_at" field predicates.""" + lastAttemptedAtIsNil: Boolean + + """"last_attempted_at" field predicates.""" + lastAttemptedAtNotNil: Boolean + + """"applied_at" field predicates.""" + appliedAt: Time + + """"applied_at" field predicates.""" + appliedAtNEQ: Time + + """"applied_at" field predicates.""" + appliedAtIn: [Time!] + + """"applied_at" field predicates.""" + appliedAtNotIn: [Time!] + + """"applied_at" field predicates.""" + appliedAtGT: Time + + """"applied_at" field predicates.""" + appliedAtGTE: Time + + """"applied_at" field predicates.""" + appliedAtLT: Time + + """"applied_at" field predicates.""" + appliedAtLTE: Time + + """"applied_at" field predicates.""" + appliedAtIsNil: Boolean + + """"applied_at" field predicates.""" + appliedAtNotNil: Boolean + + """"created_at" field predicates.""" + createdAt: Time + + """"created_at" field predicates.""" + createdAtNEQ: Time + + """"created_at" field predicates.""" + createdAtIn: [Time!] + + """"created_at" field predicates.""" + createdAtNotIn: [Time!] + + """"created_at" field predicates.""" + createdAtGT: Time + + """"created_at" field predicates.""" + createdAtGTE: Time + + """"created_at" field predicates.""" + createdAtLT: Time + + """"created_at" field predicates.""" + createdAtLTE: Time + + """"updated_at" field predicates.""" + updatedAt: Time + + """"updated_at" field predicates.""" + updatedAtNEQ: Time + + """"updated_at" field predicates.""" + updatedAtIn: [Time!] + + """"updated_at" field predicates.""" + updatedAtNotIn: [Time!] + + """"updated_at" field predicates.""" + updatedAtGT: Time + + """"updated_at" field predicates.""" + updatedAtGTE: Time + + """"updated_at" field predicates.""" + updatedAtLT: Time + + """"updated_at" field predicates.""" + updatedAtLTE: Time +} + +type DatadogLogIndex { + """Unique identifier for this index record""" + id: ID! + + """ + Denormalized for tenant isolation. Auto-set via trigger from datadog_account.account_id. + """ + accountID: UUID! + + """The Datadog account this index belongs to""" + datadogAccountID: ID! + + """ + Index name from Datadog (e.g., 'main', 'security', 'compliance') - this is the stable identifier + """ + name: String! + + """ + Cost per million events indexed in this index (USD). NULL = using Datadog's + published rate ($1.70/M). SIEM indexes cost more — set accordingly. + """ + costPerMillionEventsIndexed: Float + + """When this index was first discovered""" + createdAt: Time! + + """Last time we saw logs flowing to this index""" + lastSeenAt: Time! + + """The Datadog account this index belongs to""" + datadogAccount: DatadogAccount! + + """Datadog exclusion filters applied to this index""" + datadogLogExclusionFilters: [DatadogLogExclusionFilter!]! + + """Current Datadog-owned settings for this log index""" + datadogSettings: DatadogLogIndexSettings +} + +type DatadogLogIndexConnection { + edges: [DatadogLogIndexEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type DatadogLogIndexEdge { + node: DatadogLogIndex! + cursor: Cursor! +} + +input DatadogLogIndexOrder { + direction: OrderDirection! = ASC + field: DatadogLogIndexOrderField! +} + +enum DatadogLogIndexOrderField { + ID +} + +type DatadogLogIndexSettings { + """Unique identifier""" + id: ID! + + """ + Denormalized for tenant isolation. Auto-set via trigger from datadog_log_index.account_id. + """ + accountID: UUID! + + """Datadog log index these settings belong to.""" + datadogLogIndexID: ID! + + """Datadog routing query for this index.""" + routingQuery: String + + """ + Zero-based routing order returned by Datadog. Lower order wins when routing filters overlap. + """ + routingOrder: Int + + """Datadog daily indexed-log limit for this index.""" + dailyLimit: Int + + """Datadog daily-limit reset time for this index.""" + dailyLimitResetTime: String + + """UTC offset used for Datadog daily-limit reset time.""" + dailyLimitResetUtcOffset: String + + """Datadog daily-limit warning threshold percentage.""" + dailyLimitWarningThresholdPercentage: Int + + """Whether Datadog reports this index as rate-limited.""" + isRateLimited: Boolean + + """Datadog retention days for this index.""" + numRetentionDays: Int + + """Datadog Flex Logs retention days for this index.""" + numFlexLogsRetentionDays: Int + + """ + Canonical raw Datadog index configuration captured during inventory refresh. + """ + rawConfig: Map! + + """Stable hash of the raw Datadog index configuration.""" + configHash: String! + + """When these settings were last refreshed from Datadog.""" + refreshedAt: Time! + + """When this settings row was last updated.""" + updatedAt: Time! + + """Datadog log index these settings belong to""" + datadogLogIndex: DatadogLogIndex! +} + +input DatadogLogIndexSettingsWhereInput { + not: DatadogLogIndexSettingsWhereInput + and: [DatadogLogIndexSettingsWhereInput!] + or: [DatadogLogIndexSettingsWhereInput!] + + """"datadog_log_index" edge predicates.""" + datadogLogIndex: DatadogLogIndexWhereInput + + """Whether the "datadog_log_index" edge has at least one related row.""" + hasDatadogLogIndex: Boolean + + """"id" field predicates.""" + id: ID + + """"id" field predicates.""" + idNEQ: ID + + """"id" field predicates.""" + idIn: [ID!] + + """"id" field predicates.""" + idNotIn: [ID!] + + """"id" field predicates.""" + idGT: ID + + """"id" field predicates.""" + idGTE: ID + + """"id" field predicates.""" + idLT: ID + + """"id" field predicates.""" + idLTE: ID + + """"datadog_log_index_id" field predicates.""" + datadogLogIndexID: ID + + """"datadog_log_index_id" field predicates.""" + datadogLogIndexIDNEQ: ID + + """"datadog_log_index_id" field predicates.""" + datadogLogIndexIDIn: [ID!] + + """"datadog_log_index_id" field predicates.""" + datadogLogIndexIDNotIn: [ID!] + + """"routing_order" field predicates.""" + routingOrder: Int + + """"routing_order" field predicates.""" + routingOrderNEQ: Int + + """"routing_order" field predicates.""" + routingOrderIn: [Int!] + + """"routing_order" field predicates.""" + routingOrderNotIn: [Int!] + + """"routing_order" field predicates.""" + routingOrderGT: Int + + """"routing_order" field predicates.""" + routingOrderGTE: Int + + """"routing_order" field predicates.""" + routingOrderLT: Int + + """"routing_order" field predicates.""" + routingOrderLTE: Int + + """"routing_order" field predicates.""" + routingOrderIsNil: Boolean + + """"routing_order" field predicates.""" + routingOrderNotNil: Boolean + + """"refreshed_at" field predicates.""" + refreshedAt: Time + + """"refreshed_at" field predicates.""" + refreshedAtNEQ: Time + + """"refreshed_at" field predicates.""" + refreshedAtIn: [Time!] + + """"refreshed_at" field predicates.""" + refreshedAtNotIn: [Time!] + + """"refreshed_at" field predicates.""" + refreshedAtGT: Time + + """"refreshed_at" field predicates.""" + refreshedAtGTE: Time + + """"refreshed_at" field predicates.""" + refreshedAtLT: Time + + """"refreshed_at" field predicates.""" + refreshedAtLTE: Time + + """"updated_at" field predicates.""" + updatedAt: Time + + """"updated_at" field predicates.""" + updatedAtNEQ: Time + + """"updated_at" field predicates.""" + updatedAtIn: [Time!] + + """"updated_at" field predicates.""" + updatedAtNotIn: [Time!] + + """"updated_at" field predicates.""" + updatedAtGT: Time + + """"updated_at" field predicates.""" + updatedAtGTE: Time + + """"updated_at" field predicates.""" + updatedAtLT: Time + + """"updated_at" field predicates.""" + updatedAtLTE: Time +} + +input DatadogLogIndexWhereInput { + not: DatadogLogIndexWhereInput + and: [DatadogLogIndexWhereInput!] + or: [DatadogLogIndexWhereInput!] + + """"datadog_account" edge predicates.""" + datadogAccount: DatadogAccountWhereInput + + """Whether the "datadog_account" edge has at least one related row.""" + hasDatadogAccount: Boolean + + """"datadog_log_exclusion_filters" edge predicates.""" + datadogLogExclusionFilters: DatadogLogExclusionFilterWhereInput + + """ + Whether the "datadog_log_exclusion_filters" edge has at least one related row. + """ + hasDatadogLogExclusionFilters: Boolean + + """"datadog_settings" edge predicates.""" + datadogSettings: DatadogLogIndexSettingsWhereInput + + """Whether the "datadog_settings" edge has at least one related row.""" + hasDatadogSettings: Boolean + + """"id" field predicates.""" + id: ID + + """"id" field predicates.""" + idNEQ: ID + + """"id" field predicates.""" + idIn: [ID!] + + """"id" field predicates.""" + idNotIn: [ID!] + + """"id" field predicates.""" + idGT: ID + + """"id" field predicates.""" + idGTE: ID + + """"id" field predicates.""" + idLT: ID + + """"id" field predicates.""" + idLTE: ID + + """"account_id" field predicates.""" + accountID: UUID + + """"account_id" field predicates.""" + accountIDNEQ: UUID + + """"account_id" field predicates.""" + accountIDIn: [UUID!] + + """"account_id" field predicates.""" + accountIDNotIn: [UUID!] + + """"account_id" field predicates.""" + accountIDGT: UUID + + """"account_id" field predicates.""" + accountIDGTE: UUID + + """"account_id" field predicates.""" + accountIDLT: UUID + + """"account_id" field predicates.""" + accountIDLTE: UUID + + """"datadog_account_id" field predicates.""" + datadogAccountID: ID + + """"datadog_account_id" field predicates.""" + datadogAccountIDNEQ: ID + + """"datadog_account_id" field predicates.""" + datadogAccountIDIn: [ID!] + + """"datadog_account_id" field predicates.""" + datadogAccountIDNotIn: [ID!] + + """"name" field predicates.""" + name: String + + """"name" field predicates.""" + nameNEQ: String + + """"name" field predicates.""" + nameIn: [String!] + + """"name" field predicates.""" + nameNotIn: [String!] + + """"name" field predicates.""" + nameGT: String + + """"name" field predicates.""" + nameGTE: String + + """"name" field predicates.""" + nameLT: String + + """"name" field predicates.""" + nameLTE: String + + """"name" field predicates.""" + nameContains: String + + """"name" field predicates.""" + nameHasPrefix: String + + """"name" field predicates.""" + nameHasSuffix: String + + """"name" field predicates.""" + nameEqualFold: String + + """"name" field predicates.""" + nameContainsFold: String + + """"cost_per_million_events_indexed" field predicates.""" + costPerMillionEventsIndexed: Float + + """"cost_per_million_events_indexed" field predicates.""" + costPerMillionEventsIndexedNEQ: Float + + """"cost_per_million_events_indexed" field predicates.""" + costPerMillionEventsIndexedIn: [Float!] + + """"cost_per_million_events_indexed" field predicates.""" + costPerMillionEventsIndexedNotIn: [Float!] + + """"cost_per_million_events_indexed" field predicates.""" + costPerMillionEventsIndexedGT: Float + + """"cost_per_million_events_indexed" field predicates.""" + costPerMillionEventsIndexedGTE: Float + + """"cost_per_million_events_indexed" field predicates.""" + costPerMillionEventsIndexedLT: Float + + """"cost_per_million_events_indexed" field predicates.""" + costPerMillionEventsIndexedLTE: Float + + """"cost_per_million_events_indexed" field predicates.""" + costPerMillionEventsIndexedIsNil: Boolean + + """"cost_per_million_events_indexed" field predicates.""" + costPerMillionEventsIndexedNotNil: Boolean + + """"created_at" field predicates.""" + createdAt: Time + + """"created_at" field predicates.""" + createdAtNEQ: Time + + """"created_at" field predicates.""" + createdAtIn: [Time!] + + """"created_at" field predicates.""" + createdAtNotIn: [Time!] + + """"created_at" field predicates.""" + createdAtGT: Time + + """"created_at" field predicates.""" + createdAtGTE: Time + + """"created_at" field predicates.""" + createdAtLT: Time + + """"created_at" field predicates.""" + createdAtLTE: Time + + """"last_seen_at" field predicates.""" + lastSeenAt: Time + + """"last_seen_at" field predicates.""" + lastSeenAtNEQ: Time + + """"last_seen_at" field predicates.""" + lastSeenAtIn: [Time!] + + """"last_seen_at" field predicates.""" + lastSeenAtNotIn: [Time!] + + """"last_seen_at" field predicates.""" + lastSeenAtGT: Time + + """"last_seen_at" field predicates.""" + lastSeenAtGTE: Time + + """"last_seen_at" field predicates.""" + lastSeenAtLT: Time + + """"last_seen_at" field predicates.""" + lastSeenAtLTE: Time +} + +""" +Describes what this service does for the business, product, or platform. +""" +type DomainFunction { + """Short grounded summary of what this service does.""" + summary: String! + + """Short normalized function labels.""" + functions: [String!]! +} + +""" +Describes what this service does for the business, product, or platform. +""" +input DomainFunctionWhereInput { + """Negated predicates.""" + not: DomainFunctionWhereInput + + """Predicates that must all match.""" + and: [DomainFunctionWhereInput!] + + """Predicates where at least one must match.""" + or: [DomainFunctionWhereInput!] + + """Whether the domain_function JSONB value is present.""" + present: Boolean + + """Short grounded summary of what this service does.""" + summary: String + + """Short grounded summary of what this service does.""" + summaryNEQ: String + + """Short grounded summary of what this service does.""" + summaryIn: [String!] + + """Short grounded summary of what this service does.""" + summaryNotIn: [String!] + + """Short grounded summary of what this service does.""" + summaryContains: String + + """Short grounded summary of what this service does.""" + summaryHasPrefix: String + + """Short grounded summary of what this service does.""" + summaryHasSuffix: String + + """Short grounded summary of what this service does.""" + summaryEqualFold: String + + """Short grounded summary of what this service does.""" + summaryContainsFold: String + + """Short grounded summary of what this service does.""" + hasSummary: Boolean + + """Short normalized function labels.""" + hasFunctions: Boolean + + """Short normalized function labels.""" + functionsContainsAny: [String!] + + """Short normalized function labels.""" + functionsContainsAll: [String!] +} + +scalar Duration + +type EdgeApiKey { + """Unique identifier of this API key""" + id: ID! + + """The account this API key authenticates to""" + accountID: ID! + + """User-provided name for this key (e.g., 'Production Collector')""" + name: String! + + """First characters of the key for identification (e.g., 'tero_sk_abc1')""" + keyPrefix: String! + + """When this key was last used for authentication""" + lastUsedAt: Time + + """When this key was revoked (null if active)""" + revokedAt: Time + + """When this key was created""" + createdAt: Time! + + """The account this API key belongs to""" + account: Account! +} + +type EdgeApiKeyConnection { + edges: [EdgeApiKeyEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type EdgeApiKeyEdge { + node: EdgeApiKey! + cursor: Cursor! +} + +input EdgeApiKeyOrder { + direction: OrderDirection! = ASC + field: EdgeApiKeyOrderField! +} + +enum EdgeApiKeyOrderField { + ID + NAME + LAST_USED_AT + REVOKED_AT + CREATED_AT +} + +input EdgeApiKeyWhereInput { + not: EdgeApiKeyWhereInput + and: [EdgeApiKeyWhereInput!] + or: [EdgeApiKeyWhereInput!] + + """"account" edge predicates.""" + account: AccountWhereInput + + """Whether the "account" edge has at least one related row.""" + hasAccount: Boolean + + """"id" field predicates.""" + id: ID + + """"id" field predicates.""" + idNEQ: ID + + """"id" field predicates.""" + idIn: [ID!] + + """"id" field predicates.""" + idNotIn: [ID!] + + """"id" field predicates.""" + idGT: ID + + """"id" field predicates.""" + idGTE: ID + + """"id" field predicates.""" + idLT: ID + + """"id" field predicates.""" + idLTE: ID + + """"account_id" field predicates.""" + accountID: ID + + """"account_id" field predicates.""" + accountIDNEQ: ID + + """"account_id" field predicates.""" + accountIDIn: [ID!] + + """"account_id" field predicates.""" + accountIDNotIn: [ID!] + + """"name" field predicates.""" + name: String + + """"name" field predicates.""" + nameNEQ: String + + """"name" field predicates.""" + nameIn: [String!] + + """"name" field predicates.""" + nameNotIn: [String!] + + """"name" field predicates.""" + nameGT: String + + """"name" field predicates.""" + nameGTE: String + + """"name" field predicates.""" + nameLT: String + + """"name" field predicates.""" + nameLTE: String + + """"name" field predicates.""" + nameContains: String + + """"name" field predicates.""" + nameHasPrefix: String + + """"name" field predicates.""" + nameHasSuffix: String + + """"name" field predicates.""" + nameEqualFold: String + + """"name" field predicates.""" + nameContainsFold: String + + """"key_prefix" field predicates.""" + keyPrefix: String + + """"key_prefix" field predicates.""" + keyPrefixNEQ: String + + """"key_prefix" field predicates.""" + keyPrefixIn: [String!] + + """"key_prefix" field predicates.""" + keyPrefixNotIn: [String!] + + """"key_prefix" field predicates.""" + keyPrefixGT: String + + """"key_prefix" field predicates.""" + keyPrefixGTE: String + + """"key_prefix" field predicates.""" + keyPrefixLT: String + + """"key_prefix" field predicates.""" + keyPrefixLTE: String + + """"key_prefix" field predicates.""" + keyPrefixContains: String + + """"key_prefix" field predicates.""" + keyPrefixHasPrefix: String + + """"key_prefix" field predicates.""" + keyPrefixHasSuffix: String + + """"key_prefix" field predicates.""" + keyPrefixEqualFold: String + + """"key_prefix" field predicates.""" + keyPrefixContainsFold: String + + """"key_hash" field predicates.""" + keyHash: String + + """"key_hash" field predicates.""" + keyHashNEQ: String + + """"key_hash" field predicates.""" + keyHashIn: [String!] + + """"key_hash" field predicates.""" + keyHashNotIn: [String!] + + """"key_hash" field predicates.""" + keyHashGT: String + + """"key_hash" field predicates.""" + keyHashGTE: String + + """"key_hash" field predicates.""" + keyHashLT: String + + """"key_hash" field predicates.""" + keyHashLTE: String + + """"key_hash" field predicates.""" + keyHashContains: String + + """"key_hash" field predicates.""" + keyHashHasPrefix: String + + """"key_hash" field predicates.""" + keyHashHasSuffix: String + + """"key_hash" field predicates.""" + keyHashEqualFold: String + + """"key_hash" field predicates.""" + keyHashContainsFold: String + + """"last_used_at" field predicates.""" + lastUsedAt: Time + + """"last_used_at" field predicates.""" + lastUsedAtNEQ: Time + + """"last_used_at" field predicates.""" + lastUsedAtIn: [Time!] + + """"last_used_at" field predicates.""" + lastUsedAtNotIn: [Time!] + + """"last_used_at" field predicates.""" + lastUsedAtGT: Time + + """"last_used_at" field predicates.""" + lastUsedAtGTE: Time + + """"last_used_at" field predicates.""" + lastUsedAtLT: Time + + """"last_used_at" field predicates.""" + lastUsedAtLTE: Time + + """"last_used_at" field predicates.""" + lastUsedAtIsNil: Boolean + + """"last_used_at" field predicates.""" + lastUsedAtNotNil: Boolean + + """"revoked_at" field predicates.""" + revokedAt: Time + + """"revoked_at" field predicates.""" + revokedAtNEQ: Time + + """"revoked_at" field predicates.""" + revokedAtIn: [Time!] + + """"revoked_at" field predicates.""" + revokedAtNotIn: [Time!] + + """"revoked_at" field predicates.""" + revokedAtGT: Time + + """"revoked_at" field predicates.""" + revokedAtGTE: Time + + """"revoked_at" field predicates.""" + revokedAtLT: Time + + """"revoked_at" field predicates.""" + revokedAtLTE: Time + + """"revoked_at" field predicates.""" + revokedAtIsNil: Boolean + + """"revoked_at" field predicates.""" + revokedAtNotNil: Boolean + + """"created_at" field predicates.""" + createdAt: Time + + """"created_at" field predicates.""" + createdAtNEQ: Time + + """"created_at" field predicates.""" + createdAtIn: [Time!] + + """"created_at" field predicates.""" + createdAtNotIn: [Time!] + + """"created_at" field predicates.""" + createdAtGT: Time + + """"created_at" field predicates.""" + createdAtGTE: Time + + """"created_at" field predicates.""" + createdAtLT: Time + + """"created_at" field predicates.""" + createdAtLTE: Time +} + +"""Dimension to group fleet rows by.""" +enum EdgeFleetGroupBy { + SERVICE + NAMESPACE + VERSION +} + +""" +One row per distinct value of the chosen groupBy dimension. Null key means the +instance had no value for that field. +""" +type EdgeFleetGroupRow { + key: String + instanceCount: Int! + connectedInstanceCount: Int! + totalHits: Int! + totalMisses: Int! + totalErrors: Int! + latestSyncAt: Time +} + +"""One point of aggregated counters in a policy time series.""" +type EdgeFleetPolicyPoint { + start: Time! + end: Time! + hits: Int! + misses: Int! + errors: Int! +} + +""" +A policy time series for the filtered fleet, or a synthetic "Other" rollup of +non-top-K policies when policyId is null. +""" +type EdgeFleetPolicySeries { + """Null for the synthetic "Other" rollup row.""" + policyId: ID + logEventName: String! + serviceName: String + totalHits: Int! + totalMisses: Int! + totalErrors: Int! + series: [EdgeFleetPolicyPoint!]! +} + +"""Fleet-wide aggregated telemetry across edge instances.""" +type EdgeFleetTelemetry { + range: TimeRange! + granularity: TimeGranularity! + instanceCount: Int! + connectedInstanceCount: Int! + totalHits: Int! + totalMisses: Int! + totalErrors: Int! + + """ + Top-K policies ordered by totalHits desc. When fewer than topK policies exist, + contains them all and otherPolicies is null. + """ + topPolicies: [EdgeFleetPolicySeries!]! + + """ + Synthetic rollup of non-top policies when topK truncated the set; null otherwise. + """ + otherPolicies: EdgeFleetPolicySeries + + """ + One row per distinct value of the groupBy dimension, sorted by instanceCount + desc then key asc. + """ + groups: [EdgeFleetGroupRow!]! +} + +"""Input for aggregated edge fleet telemetry.""" +input EdgeFleetTelemetryInput { + where: EdgeFleetTelemetryWhere + range: TimeRangeInput! + granularity: TimeGranularity! + groupBy: EdgeFleetGroupBy! + + """ + Maximum number of policy series to return. Defaults to 10 and server caps + at 100. + """ + topK: Int = 10 + + """ + Threshold for connectedInstanceCount: instances with lastSyncAt >= now() - connectedWithin. + """ + connectedWithin: Duration! +} + +""" +Filters applied to the set of edge instances summarized by edgeFleetTelemetry. +""" +input EdgeFleetTelemetryWhere { + serviceName: String + serviceNamespace: String + serviceVersion: String + + """ + Restrict to instances with lastSyncAt at or after this time. Independent of the + telemetry time range. + """ + lastSyncAtGTE: Time +} + +type EdgeInstance { + """Unique identifier of this edge instance""" + id: ID! + + """The account this edge instance belongs to""" + accountID: ID! + + """The service.instance.id resource attribute identifying this instance""" + instanceID: String! + + """The service.name resource attribute""" + serviceName: String! + + """The service.namespace resource attribute""" + serviceNamespace: String + + """The service.version resource attribute""" + serviceVersion: String + + """Additional client metadata labels from sync request metadata.""" + labels: Map! + + """Raw client resource attributes from sync request metadata.""" + resourceAttributes: Map! + + """Last policy hash this instance reported as successfully applied.""" + lastSuccessfulHash: String + + """Last policy hash returned to this instance by sync.""" + lastResponseHash: String + + """When this edge instance first synced""" + firstSeenAt: Time! + + """When this edge instance last synced""" + lastSyncAt: Time! + + """When this record was created""" + createdAt: Time! + + """When this record was last updated""" + updatedAt: Time! + + """The account this edge instance belongs to""" + account: Account! + + """Log volume totals observed by this edge instance over a time range.""" + logVolumeSummary(input: LogVolumeSummaryInput!): LogVolumeSummary! + + """Bucketed log volume observed by this edge instance.""" + logVolume(input: LogVolumeInput!): LogVolumeSeries! + + """Bucketed policy execution telemetry reported by this edge instance.""" + policyTelemetry(input: EdgeInstancePolicyTelemetryInput!): PolicyTelemetrySeries! +} + +type EdgeInstanceConnection { + edges: [EdgeInstanceEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type EdgeInstanceEdge { + node: EdgeInstance! + cursor: Cursor! +} + +input EdgeInstanceOrder { + direction: OrderDirection! = ASC + field: EdgeInstanceOrderField! +} + +enum EdgeInstanceOrderField { + ID + INSTANCE_ID + SERVICE_NAME + SERVICE_NAMESPACE + SERVICE_VERSION + FIRST_SEEN_AT + LAST_SYNC_AT + CREATED_AT + UPDATED_AT +} + +input EdgeInstancePolicyTelemetryFilterInput { + """ + Restrict edge-instance telemetry to these log event policies. At most 100 + values. + """ + logEventPolicyIDs: [UUID!] +} + +input EdgeInstancePolicyTelemetryInput { + """Half-open range to bucket. Boundaries must align to granularity.""" + range: TimeRangeInput! + + """ + Granularity for returned points. Requests may include at most 1000 points. + """ + granularity: TimeGranularity! + filter: EdgeInstancePolicyTelemetryFilterInput +} + +input EdgeInstanceWhereInput { + not: EdgeInstanceWhereInput + and: [EdgeInstanceWhereInput!] + or: [EdgeInstanceWhereInput!] + + """"account" edge predicates.""" + account: AccountWhereInput + + """Whether the "account" edge has at least one related row.""" + hasAccount: Boolean + + """"id" field predicates.""" + id: ID + + """"id" field predicates.""" + idNEQ: ID + + """"id" field predicates.""" + idIn: [ID!] + + """"id" field predicates.""" + idNotIn: [ID!] + + """"id" field predicates.""" + idGT: ID + + """"id" field predicates.""" + idGTE: ID + + """"id" field predicates.""" + idLT: ID + + """"id" field predicates.""" + idLTE: ID + + """"account_id" field predicates.""" + accountID: ID + + """"account_id" field predicates.""" + accountIDNEQ: ID + + """"account_id" field predicates.""" + accountIDIn: [ID!] + + """"account_id" field predicates.""" + accountIDNotIn: [ID!] + + """"instance_id" field predicates.""" + instanceID: String + + """"instance_id" field predicates.""" + instanceIDNEQ: String + + """"instance_id" field predicates.""" + instanceIDIn: [String!] + + """"instance_id" field predicates.""" + instanceIDNotIn: [String!] + + """"instance_id" field predicates.""" + instanceIDGT: String + + """"instance_id" field predicates.""" + instanceIDGTE: String + + """"instance_id" field predicates.""" + instanceIDLT: String + + """"instance_id" field predicates.""" + instanceIDLTE: String + + """"instance_id" field predicates.""" + instanceIDContains: String + + """"instance_id" field predicates.""" + instanceIDHasPrefix: String + + """"instance_id" field predicates.""" + instanceIDHasSuffix: String + + """"instance_id" field predicates.""" + instanceIDEqualFold: String + + """"instance_id" field predicates.""" + instanceIDContainsFold: String + + """"service_name" field predicates.""" + serviceName: String + + """"service_name" field predicates.""" + serviceNameNEQ: String + + """"service_name" field predicates.""" + serviceNameIn: [String!] + + """"service_name" field predicates.""" + serviceNameNotIn: [String!] + + """"service_name" field predicates.""" + serviceNameGT: String + + """"service_name" field predicates.""" + serviceNameGTE: String + + """"service_name" field predicates.""" + serviceNameLT: String + + """"service_name" field predicates.""" + serviceNameLTE: String + + """"service_name" field predicates.""" + serviceNameContains: String + + """"service_name" field predicates.""" + serviceNameHasPrefix: String + + """"service_name" field predicates.""" + serviceNameHasSuffix: String + + """"service_name" field predicates.""" + serviceNameEqualFold: String + + """"service_name" field predicates.""" + serviceNameContainsFold: String + + """"service_namespace" field predicates.""" + serviceNamespace: String + + """"service_namespace" field predicates.""" + serviceNamespaceNEQ: String + + """"service_namespace" field predicates.""" + serviceNamespaceIn: [String!] + + """"service_namespace" field predicates.""" + serviceNamespaceNotIn: [String!] + + """"service_namespace" field predicates.""" + serviceNamespaceGT: String + + """"service_namespace" field predicates.""" + serviceNamespaceGTE: String + + """"service_namespace" field predicates.""" + serviceNamespaceLT: String + + """"service_namespace" field predicates.""" + serviceNamespaceLTE: String + + """"service_namespace" field predicates.""" + serviceNamespaceContains: String + + """"service_namespace" field predicates.""" + serviceNamespaceHasPrefix: String + + """"service_namespace" field predicates.""" + serviceNamespaceHasSuffix: String + + """"service_namespace" field predicates.""" + serviceNamespaceIsNil: Boolean + + """"service_namespace" field predicates.""" + serviceNamespaceNotNil: Boolean + + """"service_namespace" field predicates.""" + serviceNamespaceEqualFold: String + + """"service_namespace" field predicates.""" + serviceNamespaceContainsFold: String + + """"service_version" field predicates.""" + serviceVersion: String + + """"service_version" field predicates.""" + serviceVersionNEQ: String + + """"service_version" field predicates.""" + serviceVersionIn: [String!] + + """"service_version" field predicates.""" + serviceVersionNotIn: [String!] + + """"service_version" field predicates.""" + serviceVersionGT: String + + """"service_version" field predicates.""" + serviceVersionGTE: String + + """"service_version" field predicates.""" + serviceVersionLT: String + + """"service_version" field predicates.""" + serviceVersionLTE: String + + """"service_version" field predicates.""" + serviceVersionContains: String + + """"service_version" field predicates.""" + serviceVersionHasPrefix: String + + """"service_version" field predicates.""" + serviceVersionHasSuffix: String + + """"service_version" field predicates.""" + serviceVersionIsNil: Boolean + + """"service_version" field predicates.""" + serviceVersionNotNil: Boolean + + """"service_version" field predicates.""" + serviceVersionEqualFold: String + + """"service_version" field predicates.""" + serviceVersionContainsFold: String + + """"first_seen_at" field predicates.""" + firstSeenAt: Time + + """"first_seen_at" field predicates.""" + firstSeenAtNEQ: Time + + """"first_seen_at" field predicates.""" + firstSeenAtIn: [Time!] + + """"first_seen_at" field predicates.""" + firstSeenAtNotIn: [Time!] + + """"first_seen_at" field predicates.""" + firstSeenAtGT: Time + + """"first_seen_at" field predicates.""" + firstSeenAtGTE: Time + + """"first_seen_at" field predicates.""" + firstSeenAtLT: Time + + """"first_seen_at" field predicates.""" + firstSeenAtLTE: Time + + """"last_sync_at" field predicates.""" + lastSyncAt: Time + + """"last_sync_at" field predicates.""" + lastSyncAtNEQ: Time + + """"last_sync_at" field predicates.""" + lastSyncAtIn: [Time!] + + """"last_sync_at" field predicates.""" + lastSyncAtNotIn: [Time!] + + """"last_sync_at" field predicates.""" + lastSyncAtGT: Time + + """"last_sync_at" field predicates.""" + lastSyncAtGTE: Time + + """"last_sync_at" field predicates.""" + lastSyncAtLT: Time + + """"last_sync_at" field predicates.""" + lastSyncAtLTE: Time + + """"created_at" field predicates.""" + createdAt: Time + + """"created_at" field predicates.""" + createdAtNEQ: Time + + """"created_at" field predicates.""" + createdAtIn: [Time!] + + """"created_at" field predicates.""" + createdAtNotIn: [Time!] + + """"created_at" field predicates.""" + createdAtGT: Time + + """"created_at" field predicates.""" + createdAtGTE: Time + + """"created_at" field predicates.""" + createdAtLT: Time + + """"created_at" field predicates.""" + createdAtLTE: Time + + """"updated_at" field predicates.""" + updatedAt: Time + + """"updated_at" field predicates.""" + updatedAtNEQ: Time + + """"updated_at" field predicates.""" + updatedAtIn: [Time!] + + """"updated_at" field predicates.""" + updatedAtNotIn: [Time!] + + """"updated_at" field predicates.""" + updatedAtGT: Time + + """"updated_at" field predicates.""" + updatedAtGTE: Time + + """"updated_at" field predicates.""" + updatedAtLT: Time + + """"updated_at" field predicates.""" + updatedAtLTE: Time +} + +enum EventRole { + """ + A readable substantive production event, state change, operation, outcome, alert, or failure artifact. + """ + signal + + """ + A breadcrumb, checkpoint, receipt, wrapper, or progress artifact that mainly proves control flow. + """ + marker + + """ + An inline dump, payload, snapshot, serialized object, request body, response + body, query body, or similar debugging artifact. + """ + debug_artifact + + """ + An effectively unintelligible emitted artifact whose role cannot be recovered. + """ + garbled +} + +""" +Classifies the coarse observability artifact role of a recurring log event. +""" +type EventRoleProfile { + """The coarse observability artifact role of the recurring log event.""" + role: EventRole! +} + +""" +Classifies the coarse observability artifact role of a recurring log event. +""" +input EventRoleProfileWhereInput { + """Negated predicates.""" + not: EventRoleProfileWhereInput + + """Predicates that must all match.""" + and: [EventRoleProfileWhereInput!] + + """Predicates where at least one must match.""" + or: [EventRoleProfileWhereInput!] + + """Whether the event_role_profile JSONB value is present.""" + present: Boolean + + """The coarse observability artifact role of the recurring log event.""" + role: EventRole + + """The coarse observability artifact role of the recurring log event.""" + roleNEQ: EventRole + + """The coarse observability artifact role of the recurring log event.""" + roleIn: [EventRole!] + + """The coarse observability artifact role of the recurring log event.""" + roleNotIn: [EventRole!] +} + +enum ExpectedLevel { + """ + Implementation-facing inspection, planning, decision-path detail, step + tracing, threshold evaluation, or internal execution detail. + """ + debug + + """ + Normal work, lifecycle, housekeeping, coordination, startup, readiness, state change, or successful completion. + """ + info + + """ + Abnormal or degraded operation where the system is still operating or still trying to complete work. + """ + warn + + """ + Intended work that did not complete for this attempt, including rejection, + denial, abort, timeout, unrecovered exception, failed request, or hard broken condition. + """ + error +} + +type Finding { + """Unique identifier""" + id: ID! + + """ + Service this finding belongs to. Finding memberships must not span services. + """ + serviceID: ID! + + """ + Product domain that owns this finding. Values: cost = Cost optimization + findings that identify telemetry spend with weak value or misplaced volume.; + compliance = Compliance findings that identify sensitive or regulated data exposure. + """ + checkDomain: FindingCheckDomain! + + """ + Specific finding category raised within its owning domain. Values: + routine_system_chatter = Routine system chatter with low operator prominence + and weak per-event value.; debug_noise = Production log events whose expected + severity is debug.; dead_weight = Log events with no meaningful observability + value.; background_noise = Infrastructure, bot, probe, or other background + traffic that should not consume production log budget.; commodity_traffic = + Aggregate operational series where repeated volume adds little per-event + value.; hot_path = High-volume application-path logging that appears misplaced + for the execution path.; reactive_flood = Failure or dependency-driven floods + that materially inflate log volume.; configuration_noise = Repeated + configuration-related failures that consume production log budget.; + debug_marker = Developer breadcrumb markers left in production logging.; + debug_artifacts = Inline debug payloads or object dumps that should live + outside normal production logs.; secret_exposure = Log events that expose + secrets, credentials, or authentication material.; payment_data_exposure = Log + events that expose payment instrument or payment-processing data.; + pii_exposure = Log events that expose meaningful customer or end-user identity + data.; phi_exposure = Log events that expose health or treatment-related personal data. + """ + checkType: FindingCheckType! + + """Version of the check logic that most recently reconciled this finding.""" + checkVersion: Int! + + """ + Current workflow state for this finding. Values: pending = Finding is open and + waiting for investigation.; suppressed = Finding was investigated and + intentionally suppressed.; escalated = Finding was investigated and escalated + into an issue.; closed = Finding no longer matches the current world. + """ + status: FindingStatus! + + """ + When this finding stopped matching the current world. Null while active. + """ + closedAt: Time + + """When this finding row was created.""" + createdAt: Time! + + """When this finding row was last updated.""" + updatedAt: Time! + + """Typed finding details for the finding's check type.""" + details: FindingDetails! +} + +enum FindingCheckDomain { + cost + compliance +} + +enum FindingCheckType { + routine_system_chatter + debug_noise + dead_weight + background_noise + commodity_traffic + hot_path + reactive_flood + configuration_noise + debug_marker + debug_artifacts + secret_exposure + payment_data_exposure + pii_exposure + phi_exposure +} + +"""Typed finding details for the finding's check type.""" +type FindingDetails { + """Finding detail payload for background-noise findings.""" + costBackgroundNoise: CostBackgroundNoise + + """Finding detail payload for commodity-traffic findings.""" + costCommodityTraffic: CostCommodityTraffic + + """Finding detail payload for dead-weight findings.""" + costDeadWeight: CostDeadWeight + + """Finding detail payload for debug-artifact findings.""" + costDebugArtifacts: CostDebugArtifacts + + """Finding detail payload for debug-marker findings.""" + costDebugMarker: CostDebugMarker + + """Finding detail payload for debug-noise findings.""" + costDebugNoise: CostDebugNoise + + """Finding detail payload for configuration-noise findings.""" + costConfigurationNoise: CostConfigurationNoise + + """Finding detail payload for hot-path findings.""" + costHotPath: CostHotPath + + """Finding detail payload for reactive-flood findings.""" + costReactiveFlood: CostReactiveFlood + + """Finding detail payload for routine-system-chatter findings.""" + costRoutineSystemChatter: CostRoutineSystemChatter + + """Finding detail payload for secret-exposure findings.""" + complianceSecretExposure: ComplianceSecretExposure + + """Finding detail payload for payment-data-exposure findings.""" + compliancePaymentDataExposure: CompliancePaymentDataExposure + + """Finding detail payload for PII-exposure findings.""" + compliancePIIExposure: CompliancePIIExposure + + """Finding detail payload for PHI-exposure findings.""" + compliancePHIExposure: CompliancePHIExposure +} + +enum FindingStatus { + pending + suppressed + escalated + closed +} + +input FindingWhereInput { + not: FindingWhereInput + and: [FindingWhereInput!] + or: [FindingWhereInput!] + + """"id" field predicates.""" + id: ID + + """"id" field predicates.""" + idNEQ: ID + + """"id" field predicates.""" + idIn: [ID!] + + """"id" field predicates.""" + idNotIn: [ID!] + + """"id" field predicates.""" + idGT: ID + + """"id" field predicates.""" + idGTE: ID + + """"id" field predicates.""" + idLT: ID + + """"id" field predicates.""" + idLTE: ID + + """"service_id" field predicates.""" + serviceID: ID + + """"service_id" field predicates.""" + serviceIDNEQ: ID + + """"service_id" field predicates.""" + serviceIDIn: [ID!] + + """"service_id" field predicates.""" + serviceIDNotIn: [ID!] + + """"check_domain" field predicates.""" + checkDomain: FindingCheckDomain + + """"check_domain" field predicates.""" + checkDomainNEQ: FindingCheckDomain + + """"check_domain" field predicates.""" + checkDomainIn: [FindingCheckDomain!] + + """"check_domain" field predicates.""" + checkDomainNotIn: [FindingCheckDomain!] + + """"check_type" field predicates.""" + checkType: FindingCheckType + + """"check_type" field predicates.""" + checkTypeNEQ: FindingCheckType + + """"check_type" field predicates.""" + checkTypeIn: [FindingCheckType!] + + """"check_type" field predicates.""" + checkTypeNotIn: [FindingCheckType!] + + """"check_version" field predicates.""" + checkVersion: Int + + """"check_version" field predicates.""" + checkVersionNEQ: Int + + """"check_version" field predicates.""" + checkVersionIn: [Int!] + + """"check_version" field predicates.""" + checkVersionNotIn: [Int!] + + """"check_version" field predicates.""" + checkVersionGT: Int + + """"check_version" field predicates.""" + checkVersionGTE: Int + + """"check_version" field predicates.""" + checkVersionLT: Int + + """"check_version" field predicates.""" + checkVersionLTE: Int + + """"fingerprint" field predicates.""" + fingerprint: String + + """"fingerprint" field predicates.""" + fingerprintNEQ: String + + """"fingerprint" field predicates.""" + fingerprintIn: [String!] + + """"fingerprint" field predicates.""" + fingerprintNotIn: [String!] + + """"fingerprint" field predicates.""" + fingerprintGT: String + + """"fingerprint" field predicates.""" + fingerprintGTE: String + + """"fingerprint" field predicates.""" + fingerprintLT: String + + """"fingerprint" field predicates.""" + fingerprintLTE: String + + """"fingerprint" field predicates.""" + fingerprintContains: String + + """"fingerprint" field predicates.""" + fingerprintHasPrefix: String + + """"fingerprint" field predicates.""" + fingerprintHasSuffix: String + + """"fingerprint" field predicates.""" + fingerprintEqualFold: String + + """"fingerprint" field predicates.""" + fingerprintContainsFold: String + + """"status" field predicates.""" + status: FindingStatus + + """"status" field predicates.""" + statusNEQ: FindingStatus + + """"status" field predicates.""" + statusIn: [FindingStatus!] + + """"status" field predicates.""" + statusNotIn: [FindingStatus!] + + """"current_basis" field predicates.""" + currentBasis: String + + """"current_basis" field predicates.""" + currentBasisNEQ: String + + """"current_basis" field predicates.""" + currentBasisIn: [String!] + + """"current_basis" field predicates.""" + currentBasisNotIn: [String!] + + """"current_basis" field predicates.""" + currentBasisGT: String + + """"current_basis" field predicates.""" + currentBasisGTE: String + + """"current_basis" field predicates.""" + currentBasisLT: String + + """"current_basis" field predicates.""" + currentBasisLTE: String + + """"current_basis" field predicates.""" + currentBasisContains: String + + """"current_basis" field predicates.""" + currentBasisHasPrefix: String + + """"current_basis" field predicates.""" + currentBasisHasSuffix: String + + """"current_basis" field predicates.""" + currentBasisEqualFold: String + + """"current_basis" field predicates.""" + currentBasisContainsFold: String + + """"reviewed_basis" field predicates.""" + reviewedBasis: String + + """"reviewed_basis" field predicates.""" + reviewedBasisNEQ: String + + """"reviewed_basis" field predicates.""" + reviewedBasisIn: [String!] + + """"reviewed_basis" field predicates.""" + reviewedBasisNotIn: [String!] + + """"reviewed_basis" field predicates.""" + reviewedBasisGT: String + + """"reviewed_basis" field predicates.""" + reviewedBasisGTE: String + + """"reviewed_basis" field predicates.""" + reviewedBasisLT: String + + """"reviewed_basis" field predicates.""" + reviewedBasisLTE: String + + """"reviewed_basis" field predicates.""" + reviewedBasisContains: String + + """"reviewed_basis" field predicates.""" + reviewedBasisHasPrefix: String + + """"reviewed_basis" field predicates.""" + reviewedBasisHasSuffix: String + + """"reviewed_basis" field predicates.""" + reviewedBasisIsNil: Boolean + + """"reviewed_basis" field predicates.""" + reviewedBasisNotNil: Boolean + + """"reviewed_basis" field predicates.""" + reviewedBasisEqualFold: String + + """"reviewed_basis" field predicates.""" + reviewedBasisContainsFold: String + + """"reviewed_investigation_version" field predicates.""" + reviewedInvestigationVersion: Int + + """"reviewed_investigation_version" field predicates.""" + reviewedInvestigationVersionNEQ: Int + + """"reviewed_investigation_version" field predicates.""" + reviewedInvestigationVersionIn: [Int!] + + """"reviewed_investigation_version" field predicates.""" + reviewedInvestigationVersionNotIn: [Int!] + + """"reviewed_investigation_version" field predicates.""" + reviewedInvestigationVersionGT: Int + + """"reviewed_investigation_version" field predicates.""" + reviewedInvestigationVersionGTE: Int + + """"reviewed_investigation_version" field predicates.""" + reviewedInvestigationVersionLT: Int + + """"reviewed_investigation_version" field predicates.""" + reviewedInvestigationVersionLTE: Int + + """"reviewed_investigation_version" field predicates.""" + reviewedInvestigationVersionIsNil: Boolean + + """"reviewed_investigation_version" field predicates.""" + reviewedInvestigationVersionNotNil: Boolean + + """"closed_at" field predicates.""" + closedAt: Time + + """"closed_at" field predicates.""" + closedAtNEQ: Time + + """"closed_at" field predicates.""" + closedAtIn: [Time!] + + """"closed_at" field predicates.""" + closedAtNotIn: [Time!] + + """"closed_at" field predicates.""" + closedAtGT: Time + + """"closed_at" field predicates.""" + closedAtGTE: Time + + """"closed_at" field predicates.""" + closedAtLT: Time + + """"closed_at" field predicates.""" + closedAtLTE: Time + + """"closed_at" field predicates.""" + closedAtIsNil: Boolean + + """"closed_at" field predicates.""" + closedAtNotNil: Boolean + + """"created_at" field predicates.""" + createdAt: Time + + """"created_at" field predicates.""" + createdAtNEQ: Time + + """"created_at" field predicates.""" + createdAtIn: [Time!] + + """"created_at" field predicates.""" + createdAtNotIn: [Time!] + + """"created_at" field predicates.""" + createdAtGT: Time + + """"created_at" field predicates.""" + createdAtGTE: Time + + """"created_at" field predicates.""" + createdAtLT: Time + + """"created_at" field predicates.""" + createdAtLTE: Time + + """"updated_at" field predicates.""" + updatedAt: Time + + """"updated_at" field predicates.""" + updatedAtNEQ: Time + + """"updated_at" field predicates.""" + updatedAtIn: [Time!] + + """"updated_at" field predicates.""" + updatedAtNotIn: [Time!] + + """"updated_at" field predicates.""" + updatedAtGT: Time + + """"updated_at" field predicates.""" + updatedAtGTE: Time + + """"updated_at" field predicates.""" + updatedAtLT: Time + + """"updated_at" field predicates.""" + updatedAtLTE: Time +} + +"""Captures the stable local semantic identity of a recurring log event.""" +type IdentityProfile { + """ + The coarse local kind of thing that owns the recurring event, emitted as a canonical snake_case label. + """ + subjectClass: String! + + """ + The durable local object, subsystem, resource family, component, job, handler, + middleware step, lifecycle area, or same-service thing the event is + fundamentally about, emitted as a canonical snake_case label. + """ + subject: String! + + """ + The stable reusable work family this event represents, when one is materially + present, emitted as a canonical snake_case label. + """ + operation: String + + """ + The terminal polarity of attempted work when the record carries a completed success or failure outcome. + """ + terminalStatus: String + + """ + The coarse failure subtype when terminal_status is failure, emitted as a canonical snake_case label. + """ + failureKind: String +} + +"""Captures the stable local semantic identity of a recurring log event.""" +input IdentityProfileWhereInput { + """Negated predicates.""" + not: IdentityProfileWhereInput + + """Predicates that must all match.""" + and: [IdentityProfileWhereInput!] + + """Predicates where at least one must match.""" + or: [IdentityProfileWhereInput!] + + """Whether the identity_profile JSONB value is present.""" + present: Boolean + + """ + The coarse local kind of thing that owns the recurring event, emitted as a canonical snake_case label. + """ + subjectClass: String + + """ + The coarse local kind of thing that owns the recurring event, emitted as a canonical snake_case label. + """ + subjectClassNEQ: String + + """ + The coarse local kind of thing that owns the recurring event, emitted as a canonical snake_case label. + """ + subjectClassIn: [String!] + + """ + The coarse local kind of thing that owns the recurring event, emitted as a canonical snake_case label. + """ + subjectClassNotIn: [String!] + + """ + The coarse local kind of thing that owns the recurring event, emitted as a canonical snake_case label. + """ + subjectClassContains: String + + """ + The coarse local kind of thing that owns the recurring event, emitted as a canonical snake_case label. + """ + subjectClassHasPrefix: String + + """ + The coarse local kind of thing that owns the recurring event, emitted as a canonical snake_case label. + """ + subjectClassHasSuffix: String + + """ + The coarse local kind of thing that owns the recurring event, emitted as a canonical snake_case label. + """ + subjectClassEqualFold: String + + """ + The coarse local kind of thing that owns the recurring event, emitted as a canonical snake_case label. + """ + subjectClassContainsFold: String + + """ + The coarse local kind of thing that owns the recurring event, emitted as a canonical snake_case label. + """ + hasSubjectClass: Boolean + + """ + The durable local object, subsystem, resource family, component, job, handler, + middleware step, lifecycle area, or same-service thing the event is + fundamentally about, emitted as a canonical snake_case label. + """ + subject: String + + """ + The durable local object, subsystem, resource family, component, job, handler, + middleware step, lifecycle area, or same-service thing the event is + fundamentally about, emitted as a canonical snake_case label. + """ + subjectNEQ: String + + """ + The durable local object, subsystem, resource family, component, job, handler, + middleware step, lifecycle area, or same-service thing the event is + fundamentally about, emitted as a canonical snake_case label. + """ + subjectIn: [String!] + + """ + The durable local object, subsystem, resource family, component, job, handler, + middleware step, lifecycle area, or same-service thing the event is + fundamentally about, emitted as a canonical snake_case label. + """ + subjectNotIn: [String!] + + """ + The durable local object, subsystem, resource family, component, job, handler, + middleware step, lifecycle area, or same-service thing the event is + fundamentally about, emitted as a canonical snake_case label. + """ + subjectContains: String + + """ + The durable local object, subsystem, resource family, component, job, handler, + middleware step, lifecycle area, or same-service thing the event is + fundamentally about, emitted as a canonical snake_case label. + """ + subjectHasPrefix: String + + """ + The durable local object, subsystem, resource family, component, job, handler, + middleware step, lifecycle area, or same-service thing the event is + fundamentally about, emitted as a canonical snake_case label. + """ + subjectHasSuffix: String + + """ + The durable local object, subsystem, resource family, component, job, handler, + middleware step, lifecycle area, or same-service thing the event is + fundamentally about, emitted as a canonical snake_case label. + """ + subjectEqualFold: String + + """ + The durable local object, subsystem, resource family, component, job, handler, + middleware step, lifecycle area, or same-service thing the event is + fundamentally about, emitted as a canonical snake_case label. + """ + subjectContainsFold: String + + """ + The durable local object, subsystem, resource family, component, job, handler, + middleware step, lifecycle area, or same-service thing the event is + fundamentally about, emitted as a canonical snake_case label. + """ + hasSubject: Boolean + + """ + The stable reusable work family this event represents, when one is materially + present, emitted as a canonical snake_case label. + """ + operation: String + + """ + The stable reusable work family this event represents, when one is materially + present, emitted as a canonical snake_case label. + """ + operationNEQ: String + + """ + The stable reusable work family this event represents, when one is materially + present, emitted as a canonical snake_case label. + """ + operationIn: [String!] + + """ + The stable reusable work family this event represents, when one is materially + present, emitted as a canonical snake_case label. + """ + operationNotIn: [String!] + + """ + The stable reusable work family this event represents, when one is materially + present, emitted as a canonical snake_case label. + """ + operationContains: String + + """ + The stable reusable work family this event represents, when one is materially + present, emitted as a canonical snake_case label. + """ + operationHasPrefix: String + + """ + The stable reusable work family this event represents, when one is materially + present, emitted as a canonical snake_case label. + """ + operationHasSuffix: String + + """ + The stable reusable work family this event represents, when one is materially + present, emitted as a canonical snake_case label. + """ + operationEqualFold: String + + """ + The stable reusable work family this event represents, when one is materially + present, emitted as a canonical snake_case label. + """ + operationContainsFold: String + + """ + The stable reusable work family this event represents, when one is materially + present, emitted as a canonical snake_case label. + """ + hasOperation: Boolean + + """ + The terminal polarity of attempted work when the record carries a completed success or failure outcome. + """ + terminalStatus: String + + """ + The terminal polarity of attempted work when the record carries a completed success or failure outcome. + """ + terminalStatusNEQ: String + + """ + The terminal polarity of attempted work when the record carries a completed success or failure outcome. + """ + terminalStatusIn: [String!] + + """ + The terminal polarity of attempted work when the record carries a completed success or failure outcome. + """ + terminalStatusNotIn: [String!] + + """ + The coarse failure subtype when terminal_status is failure, emitted as a canonical snake_case label. + """ + failureKind: String + + """ + The coarse failure subtype when terminal_status is failure, emitted as a canonical snake_case label. + """ + failureKindNEQ: String + + """ + The coarse failure subtype when terminal_status is failure, emitted as a canonical snake_case label. + """ + failureKindIn: [String!] + + """ + The coarse failure subtype when terminal_status is failure, emitted as a canonical snake_case label. + """ + failureKindNotIn: [String!] + + """ + The coarse failure subtype when terminal_status is failure, emitted as a canonical snake_case label. + """ + failureKindContains: String + + """ + The coarse failure subtype when terminal_status is failure, emitted as a canonical snake_case label. + """ + failureKindHasPrefix: String + + """ + The coarse failure subtype when terminal_status is failure, emitted as a canonical snake_case label. + """ + failureKindHasSuffix: String + + """ + The coarse failure subtype when terminal_status is failure, emitted as a canonical snake_case label. + """ + failureKindEqualFold: String + + """ + The coarse failure subtype when terminal_status is failure, emitted as a canonical snake_case label. + """ + failureKindContainsFold: String + + """ + The coarse failure subtype when terminal_status is failure, emitted as a canonical snake_case label. + """ + hasFailureKind: Boolean +} + +""" +Describes visible implementation language, framework, runtime, and stack details. +""" +type ImplementationProfile { + """Short grounded summary of the implementation profile.""" + summary: String! + + """Likely implementation language when obvious, or `unknown`.""" + language: String! + + """Visible frameworks, runtimes, libraries, or deployment stack labels.""" + stack: [String!]! +} + +""" +Describes visible implementation language, framework, runtime, and stack details. +""" +input ImplementationProfileWhereInput { + """Negated predicates.""" + not: ImplementationProfileWhereInput + + """Predicates that must all match.""" + and: [ImplementationProfileWhereInput!] + + """Predicates where at least one must match.""" + or: [ImplementationProfileWhereInput!] + + """Whether the implementation_profile JSONB value is present.""" + present: Boolean + + """Short grounded summary of the implementation profile.""" + summary: String + + """Short grounded summary of the implementation profile.""" + summaryNEQ: String + + """Short grounded summary of the implementation profile.""" + summaryIn: [String!] + + """Short grounded summary of the implementation profile.""" + summaryNotIn: [String!] + + """Short grounded summary of the implementation profile.""" + summaryContains: String + + """Short grounded summary of the implementation profile.""" + summaryHasPrefix: String + + """Short grounded summary of the implementation profile.""" + summaryHasSuffix: String + + """Short grounded summary of the implementation profile.""" + summaryEqualFold: String + + """Short grounded summary of the implementation profile.""" + summaryContainsFold: String + + """Short grounded summary of the implementation profile.""" + hasSummary: Boolean + + """Likely implementation language when obvious, or `unknown`.""" + language: String + + """Likely implementation language when obvious, or `unknown`.""" + languageNEQ: String + + """Likely implementation language when obvious, or `unknown`.""" + languageIn: [String!] + + """Likely implementation language when obvious, or `unknown`.""" + languageNotIn: [String!] + + """Likely implementation language when obvious, or `unknown`.""" + languageContains: String + + """Likely implementation language when obvious, or `unknown`.""" + languageHasPrefix: String + + """Likely implementation language when obvious, or `unknown`.""" + languageHasSuffix: String + + """Likely implementation language when obvious, or `unknown`.""" + languageEqualFold: String + + """Likely implementation language when obvious, or `unknown`.""" + languageContainsFold: String + + """Likely implementation language when obvious, or `unknown`.""" + hasLanguage: Boolean + + """Visible frameworks, runtimes, libraries, or deployment stack labels.""" + hasStack: Boolean + + """Visible frameworks, runtimes, libraries, or deployment stack labels.""" + stackContainsAny: [String!] + + """Visible frameworks, runtimes, libraries, or deployment stack labels.""" + stackContainsAll: [String!] +} + +enum InvitationState { + PENDING + ACCEPTED + REVOKED + EXPIRED +} + +type Issue { + """Unique identifier""" + id: ID! + + """ + Denormalized for tenant isolation. Auto-set via trigger from finding.account_id. + """ + accountID: UUID! + + """The finding this issue belongs to""" + findingID: ID! + + """ + Denormalized service subject. Auto-set via trigger from finding.service_id. + """ + serviceID: UUID! + + """ + How much attention a kept finding deserves. Values: low = Legitimate finding, + but low urgency or prominence.; medium = Legitimate finding with clear but not + top-tier urgency.; high = Legitimate finding that deserves strong user attention. + """ + priority: IssuePriority! + + """Short user-facing title for this issue.""" + title: String! + + """User-facing explanation of this issue.""" + summary: String! + + """ + Optional grounded hypothesis about the likely underlying cause of this issue. + """ + causeHypothesis: String + + """When this issue episode ended. Null while this issue is still active.""" + closedAt: Time + + """ + When this issue was intentionally ignored by a user. Null while still active. + """ + ignoredAt: Time + + """When this issue row was created.""" + createdAt: Time! + + """When this issue row was last updated.""" + updatedAt: Time! + + """The finding this issue episode belongs to""" + finding: Finding! + + """Chosen actions owned by this issue.""" + issueActions(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: IssueActionOrder, where: IssueActionWhereInput): IssueActionConnection! + + """Log event policies created for this issue.""" + logEventPolicies(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: LogEventPolicyOrder, where: LogEventPolicyWhereInput): LogEventPolicyConnection! + + """Datadog log exclusion filters created for this issue.""" + datadogLogExclusionFilters(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: DatadogLogExclusionFilterOrder, where: DatadogLogExclusionFilterWhereInput): DatadogLogExclusionFilterConnection! + + """ + Stable account-local issue identifier for product links and support workflows. + """ + displayID: String! + + """Account-qualified issue identifier for cross-account presentation.""" + qualifiedDisplayID: String! + + """Product check that produced this issue's finding.""" + check: Check! + state: IssueState! + logEvents: [LogEvent!]! + service: Service + actionable: Boolean! + cost: StatusMeasurementTotals! + + """Frozen issue cost remaining after applying preview remediation.""" + projectedCost: StatusMeasurementTotals! + + """Bucketed issue cost over a half-open range.""" + costTrend(input: IssueCostTrendInput!): IssueCostTrendSeries! + savings: StatusMeasurementTotals! + logEventPolicyProposals: [LogEventPolicyProposal!]! + datadogLogExclusionFilterDrafts: [DatadogLogExclusionFilterDraft!]! + complianceLeakPreviews: [ComplianceLeakPreview!]! +} + +type IssueAction { + """Unique identifier""" + id: ID! + + """ + Denormalized for tenant isolation. Auto-set via trigger from issues.account_id. + """ + accountID: UUID! + + """The issue this action belongs to.""" + issueID: ID! + + """ + Issue action kind. Values: apply_log_event_policy = Apply a log-event policy + for this issue.; notify_team = Notify the responsible team about this issue.; + open_external_issue = Open an external work item for this issue.; + create_pull_request = Create a pull request to address this issue.; + run_infra_bot = Run an infrastructure bot against this issue.; mark_waiting = + Record that this issue is waiting on something external.; dismiss_issue = + Dismiss this issue intentionally. + """ + kind: IssueActionKind! + + """ + Current lifecycle state for this issue action. Values: pending = Chosen and + waiting to execute.; running = Execution is in progress.; succeeded = + Execution completed successfully.; failed = Execution failed.; cancelled = + Canceled before completion. + """ + status: IssueActionStatus! + + """Action-specific structured payload.""" + payload: Map! + + """Most recent execution failure for this action, when status is failed.""" + error: String + + """When this issue action row was created.""" + createdAt: Time! + + """When this issue action row was last updated.""" + updatedAt: Time! + + """The issue this action belongs to.""" + issue: Issue! +} + +type IssueActionConnection { + edges: [IssueActionEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type IssueActionEdge { + node: IssueAction! + cursor: Cursor! +} + +enum IssueActionKind { + apply_log_event_policy + notify_team + open_external_issue + create_pull_request + run_infra_bot + mark_waiting + dismiss_issue +} + +input IssueActionOrder { + direction: OrderDirection! = ASC + field: IssueActionOrderField! +} + +enum IssueActionOrderField { + ID +} + +"""Issue-action payload that applies per-log-event policy specifications.""" +input IssueActionPayloadWhereInput { + not: IssueActionPayloadWhereInput + and: [IssueActionPayloadWhereInput!] + or: [IssueActionPayloadWhereInput!] + + """Per-log-event policy specs to apply for this issue action.""" + hasSpecs: Boolean +} + +enum IssueActionStatus { + pending + running + succeeded + failed + cancelled +} + +input IssueActionWhereInput { + not: IssueActionWhereInput + and: [IssueActionWhereInput!] + or: [IssueActionWhereInput!] + + """"issue" edge predicates.""" + issue: IssueWhereInput + + """Whether the "issue" edge has at least one related row.""" + hasIssue: Boolean + + """"id" field predicates.""" + id: ID + + """"id" field predicates.""" + idNEQ: ID + + """"id" field predicates.""" + idIn: [ID!] + + """"id" field predicates.""" + idNotIn: [ID!] + + """"id" field predicates.""" + idGT: ID + + """"id" field predicates.""" + idGTE: ID + + """"id" field predicates.""" + idLT: ID + + """"id" field predicates.""" + idLTE: ID + + """"account_id" field predicates.""" + accountID: UUID + + """"account_id" field predicates.""" + accountIDNEQ: UUID + + """"account_id" field predicates.""" + accountIDIn: [UUID!] + + """"account_id" field predicates.""" + accountIDNotIn: [UUID!] + + """"account_id" field predicates.""" + accountIDGT: UUID + + """"account_id" field predicates.""" + accountIDGTE: UUID + + """"account_id" field predicates.""" + accountIDLT: UUID + + """"account_id" field predicates.""" + accountIDLTE: UUID + + """"issue_id" field predicates.""" + issueID: ID + + """"issue_id" field predicates.""" + issueIDNEQ: ID + + """"issue_id" field predicates.""" + issueIDIn: [ID!] + + """"issue_id" field predicates.""" + issueIDNotIn: [ID!] + + """"kind" field predicates.""" + kind: IssueActionKind + + """"kind" field predicates.""" + kindNEQ: IssueActionKind + + """"kind" field predicates.""" + kindIn: [IssueActionKind!] + + """"kind" field predicates.""" + kindNotIn: [IssueActionKind!] + + """"status" field predicates.""" + status: IssueActionStatus + + """"status" field predicates.""" + statusNEQ: IssueActionStatus + + """"status" field predicates.""" + statusIn: [IssueActionStatus!] + + """"status" field predicates.""" + statusNotIn: [IssueActionStatus!] + + """"error" field predicates.""" + error: String + + """"error" field predicates.""" + errorNEQ: String + + """"error" field predicates.""" + errorIn: [String!] + + """"error" field predicates.""" + errorNotIn: [String!] + + """"error" field predicates.""" + errorGT: String + + """"error" field predicates.""" + errorGTE: String + + """"error" field predicates.""" + errorLT: String + + """"error" field predicates.""" + errorLTE: String + + """"error" field predicates.""" + errorContains: String + + """"error" field predicates.""" + errorHasPrefix: String + + """"error" field predicates.""" + errorHasSuffix: String + + """"error" field predicates.""" + errorIsNil: Boolean + + """"error" field predicates.""" + errorNotNil: Boolean + + """"error" field predicates.""" + errorEqualFold: String + + """"error" field predicates.""" + errorContainsFold: String + + """"created_at" field predicates.""" + createdAt: Time + + """"created_at" field predicates.""" + createdAtNEQ: Time + + """"created_at" field predicates.""" + createdAtIn: [Time!] + + """"created_at" field predicates.""" + createdAtNotIn: [Time!] + + """"created_at" field predicates.""" + createdAtGT: Time + + """"created_at" field predicates.""" + createdAtGTE: Time + + """"created_at" field predicates.""" + createdAtLT: Time + + """"created_at" field predicates.""" + createdAtLTE: Time + + """"updated_at" field predicates.""" + updatedAt: Time + + """"updated_at" field predicates.""" + updatedAtNEQ: Time + + """"updated_at" field predicates.""" + updatedAtIn: [Time!] + + """"updated_at" field predicates.""" + updatedAtNotIn: [Time!] + + """"updated_at" field predicates.""" + updatedAtGT: Time + + """"updated_at" field predicates.""" + updatedAtGTE: Time + + """"updated_at" field predicates.""" + updatedAtLT: Time + + """"updated_at" field predicates.""" + updatedAtLTE: Time +} + +type IssueConnection { + edges: [IssueEdge!]! + pageInfo: PageInfo! + totalCount: Int! + summary: IssueSummary! + facets: IssueFacets! +} + +input IssueCostTrendInput { + """Half-open range to bucket. Boundaries must align to granularity.""" + range: TimeRangeInput! + + """ + Granularity for returned points. Requests may include at most 1000 points. + """ + granularity: TimeGranularity! +} + +type IssueCostTrendPoint { + start: Time! + end: Time! + cost: StatusMeasurementTotals! +} + +type IssueCostTrendSeries { + range: TimeRange! + granularity: TimeGranularity! + points: [IssueCostTrendPoint!]! +} + +input IssueCostWhereInput { + minUsdPerHour: Float + maxUsdPerHour: Float +} + +type IssueEdge { + node: Issue! + cursor: Cursor! +} + +type IssueFacets { + priorities(limit: Int): IssuePriorityFacet! + services(limit: Int): IssueServiceFacet! +} + +input IssueOrder { + direction: OrderDirection! = ASC + field: IssueOrderField! +} + +enum IssueOrderField { + ID + PRIORITY + TITLE + CLOSED_AT + IGNORED_AT + CREATED_AT + UPDATED_AT + SERVICE_NAME + TEAM_NAME + EVENTS_PER_HOUR +} + +enum IssuePriority { + low + medium + high +} + +type IssuePriorityFacet { + buckets: [IssuePriorityFacetBucket!]! +} + +type IssuePriorityFacetBucket { + value: IssuePriority! + count: Int! +} + +input IssueSavingsWhereInput { + minUsdPerHour: Float + maxUsdPerHour: Float +} + +type IssueServiceFacet { + buckets: [IssueServiceFacetBucket!]! +} + +type IssueServiceFacetBucket { + service: Service! + count: Int! +} + +enum IssueStage { + open + in_progress + waiting + failed + ignored + resolved +} + +type IssueState { + stage: IssueStage! +} + +type IssueSummary { + count: Int! +} + +input IssueWhereInput { + not: IssueWhereInput + and: [IssueWhereInput!] + or: [IssueWhereInput!] + + """"finding" edge predicates.""" + finding: FindingWhereInput + + """Whether the "finding" edge has at least one related row.""" + hasFinding: Boolean + + """"issue_actions" edge predicates.""" + issueActions: IssueActionWhereInput + + """Whether the "issue_actions" edge has at least one related row.""" + hasIssueActions: Boolean + + """"log_event_policies" edge predicates.""" + logEventPolicies: LogEventPolicyWhereInput + + """Whether the "log_event_policies" edge has at least one related row.""" + hasLogEventPolicies: Boolean + + """"datadog_log_exclusion_filters" edge predicates.""" + datadogLogExclusionFilters: DatadogLogExclusionFilterWhereInput + + """ + Whether the "datadog_log_exclusion_filters" edge has at least one related row. + """ + hasDatadogLogExclusionFilters: Boolean + + """"id" field predicates.""" + id: ID + + """"id" field predicates.""" + idNEQ: ID + + """"id" field predicates.""" + idIn: [ID!] + + """"id" field predicates.""" + idNotIn: [ID!] + + """"id" field predicates.""" + idGT: ID + + """"id" field predicates.""" + idGTE: ID + + """"id" field predicates.""" + idLT: ID + + """"id" field predicates.""" + idLTE: ID + + """"finding_id" field predicates.""" + findingID: ID + + """"finding_id" field predicates.""" + findingIDNEQ: ID + + """"finding_id" field predicates.""" + findingIDIn: [ID!] + + """"finding_id" field predicates.""" + findingIDNotIn: [ID!] + + """"priority" field predicates.""" + priority: IssuePriority + + """"priority" field predicates.""" + priorityNEQ: IssuePriority + + """"priority" field predicates.""" + priorityIn: [IssuePriority!] + + """"priority" field predicates.""" + priorityNotIn: [IssuePriority!] + + """"title" field predicates.""" + title: String + + """"title" field predicates.""" + titleNEQ: String + + """"title" field predicates.""" + titleIn: [String!] + + """"title" field predicates.""" + titleNotIn: [String!] + + """"title" field predicates.""" + titleGT: String + + """"title" field predicates.""" + titleGTE: String + + """"title" field predicates.""" + titleLT: String + + """"title" field predicates.""" + titleLTE: String + + """"title" field predicates.""" + titleContains: String + + """"title" field predicates.""" + titleHasPrefix: String + + """"title" field predicates.""" + titleHasSuffix: String + + """"title" field predicates.""" + titleEqualFold: String + + """"title" field predicates.""" + titleContainsFold: String + + """"closed_at" field predicates.""" + closedAt: Time + + """"closed_at" field predicates.""" + closedAtNEQ: Time + + """"closed_at" field predicates.""" + closedAtIn: [Time!] + + """"closed_at" field predicates.""" + closedAtNotIn: [Time!] + + """"closed_at" field predicates.""" + closedAtGT: Time + + """"closed_at" field predicates.""" + closedAtGTE: Time + + """"closed_at" field predicates.""" + closedAtLT: Time + + """"closed_at" field predicates.""" + closedAtLTE: Time + + """"closed_at" field predicates.""" + closedAtIsNil: Boolean + + """"closed_at" field predicates.""" + closedAtNotNil: Boolean + + """"ignored_at" field predicates.""" + ignoredAt: Time + + """"ignored_at" field predicates.""" + ignoredAtNEQ: Time + + """"ignored_at" field predicates.""" + ignoredAtIn: [Time!] + + """"ignored_at" field predicates.""" + ignoredAtNotIn: [Time!] + + """"ignored_at" field predicates.""" + ignoredAtGT: Time + + """"ignored_at" field predicates.""" + ignoredAtGTE: Time + + """"ignored_at" field predicates.""" + ignoredAtLT: Time + + """"ignored_at" field predicates.""" + ignoredAtLTE: Time + + """"ignored_at" field predicates.""" + ignoredAtIsNil: Boolean + + """"ignored_at" field predicates.""" + ignoredAtNotNil: Boolean + + """"created_at" field predicates.""" + createdAt: Time + + """"created_at" field predicates.""" + createdAtNEQ: Time + + """"created_at" field predicates.""" + createdAtIn: [Time!] + + """"created_at" field predicates.""" + createdAtNotIn: [Time!] + + """"created_at" field predicates.""" + createdAtGT: Time + + """"created_at" field predicates.""" + createdAtGTE: Time + + """"created_at" field predicates.""" + createdAtLT: Time + + """"created_at" field predicates.""" + createdAtLTE: Time + + """"updated_at" field predicates.""" + updatedAt: Time + + """"updated_at" field predicates.""" + updatedAtNEQ: Time + + """"updated_at" field predicates.""" + updatedAtIn: [Time!] + + """"updated_at" field predicates.""" + updatedAtNotIn: [Time!] + + """"updated_at" field predicates.""" + updatedAtGT: Time + + """"updated_at" field predicates.""" + updatedAtGTE: Time + + """"updated_at" field predicates.""" + updatedAtLT: Time + + """"updated_at" field predicates.""" + updatedAtLTE: Time + + """Severity alias for priority triage.""" + severity: IssuePriority + + """Severity alias for priority triage.""" + severityIn: [IssuePriority!] + + """Derived lifecycle stage triage status.""" + stage: IssueStage + + """Derived lifecycle stage triage status.""" + stageIn: [IssueStage!] + + """Service this issue belongs to.""" + serviceID: ID + + """Services these issues belong to.""" + serviceIDIn: [ID!] + + """Team that owns the issue's service.""" + teamID: ID + + """Teams that own the issues' services.""" + teamIDIn: [ID!] + + """Log event referenced by the issue's finding.""" + logEventID: ID + + """Log events referenced by the issue's finding.""" + logEventIDIn: [ID!] + + """Edge instance that observed one of the issue's finding log events.""" + edgeInstanceID: ID + + """Edge instances that observed one of the issue's finding log events.""" + edgeInstanceIDIn: [ID!] + + """ + Whether this issue currently has preview remediation actions available. + """ + actionable: Boolean + + """Current issue cost envelope in USD per hour.""" + cost: IssueCostWhereInput + + """Preview savings envelope in USD per hour.""" + savings: IssueSavingsWhereInput + + """Product domain that owns the issue's finding.""" + domain: FindingCheckDomain + + """Product domains that own the issue's finding.""" + domainIn: [FindingCheckDomain!] + + """Specific finding category raised within its owning domain.""" + checkType: FindingCheckType + + """Specific finding categories raised within their owning domains.""" + checkTypeIn: [FindingCheckType!] + + """Public check identity attached through the issue's finding.""" + checkID: ID + + """Public check identities attached through issues' findings.""" + checkIDIn: [ID!] +} + +scalar JSON + +""" +Captures the normalized severity level a recurring log event deserves, independent of the level currently emitted. +""" +type LevelExpectationProfile { + """ + The severity level this recurring event family deserves when instrumented cleanly. + """ + expectedLevel: ExpectedLevel! +} + +""" +Captures the normalized severity level a recurring log event deserves, independent of the level currently emitted. +""" +input LevelExpectationProfileWhereInput { + """Negated predicates.""" + not: LevelExpectationProfileWhereInput + + """Predicates that must all match.""" + and: [LevelExpectationProfileWhereInput!] + + """Predicates where at least one must match.""" + or: [LevelExpectationProfileWhereInput!] + + """Whether the level_expectation_profile JSONB value is present.""" + present: Boolean + + """ + The severity level this recurring event family deserves when instrumented cleanly. + """ + expectedLevel: ExpectedLevel + + """ + The severity level this recurring event family deserves when instrumented cleanly. + """ + expectedLevelNEQ: ExpectedLevel + + """ + The severity level this recurring event family deserves when instrumented cleanly. + """ + expectedLevelIn: [ExpectedLevel!] + + """ + The severity level this recurring event family deserves when instrumented cleanly. + """ + expectedLevelNotIn: [ExpectedLevel!] +} + +""" +Describes how one service's own logs should compress into recurring emitted lines and occurrence details. +""" +type LogEmissionModel { + """ + Compressed service-specific summary of how this service's logs tend to form recurring emitted-line families. + """ + summary: String! + + """ + Service-specific rule for what should bind records into one recurring emitted-line family. + """ + emissionBoundary: String! + + """ + Service-specific rule for what should stay below the event boundary as per-occurrence detail. + """ + occurrenceDetailBoundary: String! +} + +""" +Describes how one service's own logs should compress into recurring emitted lines and occurrence details. +""" +input LogEmissionModelWhereInput { + """Negated predicates.""" + not: LogEmissionModelWhereInput + + """Predicates that must all match.""" + and: [LogEmissionModelWhereInput!] + + """Predicates where at least one must match.""" + or: [LogEmissionModelWhereInput!] + + """Whether the log_emission_model JSONB value is present.""" + present: Boolean + + """ + Compressed service-specific summary of how this service's logs tend to form recurring emitted-line families. + """ + summary: String + + """ + Compressed service-specific summary of how this service's logs tend to form recurring emitted-line families. + """ + summaryNEQ: String + + """ + Compressed service-specific summary of how this service's logs tend to form recurring emitted-line families. + """ + summaryIn: [String!] + + """ + Compressed service-specific summary of how this service's logs tend to form recurring emitted-line families. + """ + summaryNotIn: [String!] + + """ + Compressed service-specific summary of how this service's logs tend to form recurring emitted-line families. + """ + summaryContains: String + + """ + Compressed service-specific summary of how this service's logs tend to form recurring emitted-line families. + """ + summaryHasPrefix: String + + """ + Compressed service-specific summary of how this service's logs tend to form recurring emitted-line families. + """ + summaryHasSuffix: String + + """ + Compressed service-specific summary of how this service's logs tend to form recurring emitted-line families. + """ + summaryEqualFold: String + + """ + Compressed service-specific summary of how this service's logs tend to form recurring emitted-line families. + """ + summaryContainsFold: String + + """ + Compressed service-specific summary of how this service's logs tend to form recurring emitted-line families. + """ + hasSummary: Boolean + + """ + Service-specific rule for what should bind records into one recurring emitted-line family. + """ + emissionBoundary: String + + """ + Service-specific rule for what should bind records into one recurring emitted-line family. + """ + emissionBoundaryNEQ: String + + """ + Service-specific rule for what should bind records into one recurring emitted-line family. + """ + emissionBoundaryIn: [String!] + + """ + Service-specific rule for what should bind records into one recurring emitted-line family. + """ + emissionBoundaryNotIn: [String!] + + """ + Service-specific rule for what should bind records into one recurring emitted-line family. + """ + emissionBoundaryContains: String + + """ + Service-specific rule for what should bind records into one recurring emitted-line family. + """ + emissionBoundaryHasPrefix: String + + """ + Service-specific rule for what should bind records into one recurring emitted-line family. + """ + emissionBoundaryHasSuffix: String + + """ + Service-specific rule for what should bind records into one recurring emitted-line family. + """ + emissionBoundaryEqualFold: String + + """ + Service-specific rule for what should bind records into one recurring emitted-line family. + """ + emissionBoundaryContainsFold: String + + """ + Service-specific rule for what should bind records into one recurring emitted-line family. + """ + hasEmissionBoundary: Boolean + + """ + Service-specific rule for what should stay below the event boundary as per-occurrence detail. + """ + occurrenceDetailBoundary: String + + """ + Service-specific rule for what should stay below the event boundary as per-occurrence detail. + """ + occurrenceDetailBoundaryNEQ: String + + """ + Service-specific rule for what should stay below the event boundary as per-occurrence detail. + """ + occurrenceDetailBoundaryIn: [String!] + + """ + Service-specific rule for what should stay below the event boundary as per-occurrence detail. + """ + occurrenceDetailBoundaryNotIn: [String!] + + """ + Service-specific rule for what should stay below the event boundary as per-occurrence detail. + """ + occurrenceDetailBoundaryContains: String + + """ + Service-specific rule for what should stay below the event boundary as per-occurrence detail. + """ + occurrenceDetailBoundaryHasPrefix: String - """account edge predicates""" - hasAccount: Boolean - hasAccountWith: [AccountWhereInput!] + """ + Service-specific rule for what should stay below the event boundary as per-occurrence detail. + """ + occurrenceDetailBoundaryHasSuffix: String + + """ + Service-specific rule for what should stay below the event boundary as per-occurrence detail. + """ + occurrenceDetailBoundaryEqualFold: String + + """ + Service-specific rule for what should stay below the event boundary as per-occurrence detail. + """ + occurrenceDetailBoundaryContainsFold: String + + """ + Service-specific rule for what should stay below the event boundary as per-occurrence detail. + """ + hasOccurrenceDetailBoundary: Boolean } -type LogEvent implements Node { +type LogEvent { """Unique identifier of the log event""" id: ID! @@ -2058,38 +5492,33 @@ type LogEvent implements Node { """Snake_case identifier unique per service, e.g. nginx_access_log""" name: String! - """ - What the event is and what data instances carry. Helps engineers decide whether to look here. - """ - description: String! - - """ - Predominant log severity level, derived from example records. Nullable when - examples have no severity info. Values: debug, info, warn, error, other. - """ - severity: LogEventSeverity + """Human-readable title for the log event, suitable for product surfaces.""" + displayName: String """ - What role this event serves: diagnostic (investigate incidents), operational - (system behavior), lifecycle (state transitions), ephemeral (transient state). + Whether this event is a recurring fragment of a larger multi-line event rather than a complete semantic event. """ - signalPurpose: LogEventSignalPurpose! + isFragment: Boolean! """ - What this event records: system (internal mechanics), traffic (request flow), - activity (actor+action+resource), control (access/permission decisions). + Stable emitted or structural template for this log-event family, when one is visible. """ - eventNature: LogEventEventNature! + template: String! """ - True when this event represents a multi-line fragment rather than a complete log entry. Set by the classifier. + Predominant log severity level, derived from example records. Nullable when + examples have no severity info. Values: debug = The event usually appears at + debug severity.; info = The event usually appears at info severity.; warn = + The event usually appears at warn severity.; error = The event usually appears + at error severity.; other = The event usually appears at a non-standard or + other severity. """ - isFragment: Boolean! + severity: LogEventSeverity """ - Sample log records captured during discovery, used for AI analysis and pattern validation + Canonical sampled record this event was created from. Matchers must continue to match this record. """ - examples: [LogRecord!]! + targetLog: LogRecord """ Current trailing 7-day average events/hour. Refreshed on volume ingestion. @@ -2101,6 +5530,11 @@ type LogEvent implements Node { """ baselineAvgBytes: Float + """ + Current share of this service's trailing 7-day baseline event volume. Refreshed alongside per-event baselines. + """ + baselineVolumeShareOfService: Float + """When the log event was created""" createdAt: Time! @@ -2110,1338 +5544,839 @@ type LogEvent implements Node { """Service that produces this event""" service: Service! - """Log sample that produced this event during classification""" - logSample: LogSample! + """Contribution, preview, and effective per-log-event policy rows.""" + logEventPolicies(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: LogEventPolicyOrder, where: LogEventPolicyWhereInput): LogEventPolicyConnection! - """Category-specific policies across workspaces""" - policies: [LogEventPolicy!] - - """Fields discovered in this event's example records""" - logEventFields: [LogEventField!] + """Datadog log exclusion filters targeting this log event""" + datadogLogExclusionFilters(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: DatadogLogExclusionFilterOrder, where: DatadogLogExclusionFilterWhereInput): DatadogLogExclusionFilterConnection! """ Status of this log event. Shows where the log event is in the preparation pipeline. - Returns null if cache has not been populated yet. + Returns null if the status view has no row yet. """ - status: LogEventStatusCache + status: LogEventStatus + + """Raw matcher rules that identify incoming logs for this event.""" + matchers: JSON! + + """Bucketed log volume for this log event.""" + logVolume(input: LogVolumeInput!): LogVolumeSeries! + + """Typed log-event JSONB facts.""" + logEventFacts: LogEventFacts! } -"""A connection to a list of items.""" type LogEventConnection { - """A list of edges.""" - edges: [LogEventEdge] - - """Information to aid in pagination.""" + edges: [LogEventEdge!]! pageInfo: PageInfo! - - """Identifies the total count of items in the connection.""" totalCount: Int! + summary: LogEventSummary! + facets: LogEventFacets! } -"""An edge in a connection.""" type LogEventEdge { - """The item at the end of the edge.""" - node: LogEvent - - """A cursor for use in pagination.""" + node: LogEvent! cursor: Cursor! } -"""LogEventEventNature is enum for the field event_nature""" -enum LogEventEventNature { - system - traffic - activity - control +type LogEventFacets { + isFragment(limit: Int): LogEventIsFragmentFacet! + severities(limit: Int): LogEventSeverityFacet! + services(limit: Int): LogEventServiceFacet! } -type LogEventField implements Node { - """Unique identifier""" - id: ID! +"""Typed log-event JSONB facts.""" +type LogEventFacts { + """Captures the stable local semantic identity of a recurring log event.""" + identityProfile: IdentityProfile """ - Denormalized for tenant isolation. Auto-set via trigger from log_event.account_id. + Captures a clearly exposed separate peer materially involved in a recurring log event. """ - accountID: UUID! - - """The log event this field belongs to""" - logEventID: ID! + attributionProfile: AttributionProfile - """Unambiguous path segments, e.g. {attributes, http, status}""" - fieldPath: [String!]! + """ + Classifies the coarse observability artifact role of a recurring log event. + """ + eventRoleProfile: EventRoleProfile """ - Current trailing 7-day volume-weighted average bytes for this attribute. Refreshed on volume ingestion. + Captures one-copy observability value and additional repeated-stream value for a recurring log event. """ - baselineAvgBytes: Float + observabilityValueProfile: ObservabilityValueProfile - """When this field was last seen in production log samples.""" - lastSeenAt: Time! + """ + Captures the normalized severity level a recurring log event deserves, independent of the level currently emitted. + """ + levelExpectationProfile: LevelExpectationProfile - """When this field was first discovered""" - createdAt: Time! + """Captures normal operator-facing visibility for a recurring log event.""" + operatorProminenceProfile: OperatorProminenceProfile - """The log event this field belongs to""" - logEvent: LogEvent! + """ + Lists exact observed paths that expose material sensitive data in a recurring log event. + """ + sensitiveDataProfile: SensitiveDataProfile } -""" -LogEventFieldWhereInput is used for filtering LogEventField objects. -Input was generated by ent. -""" -input LogEventFieldWhereInput { - not: LogEventFieldWhereInput - and: [LogEventFieldWhereInput!] - or: [LogEventFieldWhereInput!] +"""Filters for log-event JSONB facts.""" +input LogEventFactsWhereInput { + """Negated predicates.""" + not: LogEventFactsWhereInput - """id field predicates""" - id: ID - idNEQ: ID - idIn: [ID!] - idNotIn: [ID!] - idGT: ID - idGTE: ID - idLT: ID - idLTE: ID + """Predicates that must all match.""" + and: [LogEventFactsWhereInput!] - """account_id field predicates""" - accountID: UUID - accountIDNEQ: UUID - accountIDIn: [UUID!] - accountIDNotIn: [UUID!] - accountIDGT: UUID - accountIDGTE: UUID - accountIDLT: UUID - accountIDLTE: UUID + """Predicates where at least one must match.""" + or: [LogEventFactsWhereInput!] - """log_event_id field predicates""" - logEventID: ID - logEventIDNEQ: ID - logEventIDIn: [ID!] - logEventIDNotIn: [ID!] + """Captures the stable local semantic identity of a recurring log event.""" + identityProfile: IdentityProfileWhereInput - """field_path field predicates""" - fieldPath: [String!] - fieldPathNEQ: [String!] - fieldPathIn: [[String!]!] - fieldPathNotIn: [[String!]!] - fieldPathGT: [String!] - fieldPathGTE: [String!] - fieldPathLT: [String!] - fieldPathLTE: [String!] - - """baseline_avg_bytes field predicates""" - baselineAvgBytes: Float - baselineAvgBytesNEQ: Float - baselineAvgBytesIn: [Float!] - baselineAvgBytesNotIn: [Float!] - baselineAvgBytesGT: Float - baselineAvgBytesGTE: Float - baselineAvgBytesLT: Float - baselineAvgBytesLTE: Float - baselineAvgBytesIsNil: Boolean - baselineAvgBytesNotNil: Boolean + """ + Captures a clearly exposed separate peer materially involved in a recurring log event. + """ + attributionProfile: AttributionProfileWhereInput - """last_seen_at field predicates""" - lastSeenAt: Time - lastSeenAtNEQ: Time - lastSeenAtIn: [Time!] - lastSeenAtNotIn: [Time!] - lastSeenAtGT: Time - lastSeenAtGTE: Time - lastSeenAtLT: Time - lastSeenAtLTE: Time + """ + Classifies the coarse observability artifact role of a recurring log event. + """ + eventRoleProfile: EventRoleProfileWhereInput - """created_at field predicates""" - createdAt: Time - createdAtNEQ: Time - createdAtIn: [Time!] - createdAtNotIn: [Time!] - createdAtGT: Time - createdAtGTE: Time - createdAtLT: Time - createdAtLTE: Time + """ + Captures one-copy observability value and additional repeated-stream value for a recurring log event. + """ + observabilityValueProfile: ObservabilityValueProfileWhereInput - """log_event edge predicates""" - hasLogEvent: Boolean - hasLogEventWith: [LogEventWhereInput!] + """ + Captures the normalized severity level a recurring log event deserves, independent of the level currently emitted. + """ + levelExpectationProfile: LevelExpectationProfileWhereInput + + """Captures normal operator-facing visibility for a recurring log event.""" + operatorProminenceProfile: OperatorProminenceProfileWhereInput + + """ + Lists exact observed paths that expose material sensitive data in a recurring log event. + """ + sensitiveDataProfile: SensitiveDataProfileWhereInput +} + +type LogEventIsFragmentFacet { + buckets: [LogEventIsFragmentFacetBucket!]! +} + +type LogEventIsFragmentFacetBucket { + value: Boolean! + count: Int! } -"""Ordering options for LogEvent connections""" input LogEventOrder { - """The ordering direction.""" direction: OrderDirection! = ASC - - """The field by which to order LogEvents.""" field: LogEventOrderField! } -"""Properties by which LogEvent connections can be ordered.""" enum LogEventOrderField { + ID NAME + DISPLAY_NAME SEVERITY - SIGNAL_PURPOSE - EVENT_NATURE CREATED_AT UPDATED_AT + CURRENT_EVENTS_PER_HOUR + CURRENT_TOTAL_USD_PER_HOUR } -type LogEventPolicy implements Node { +type LogEventPolicy { """Unique identifier""" id: ID! """ - Denormalized for tenant isolation. Auto-set via trigger from workspace.account_id. + Denormalized for tenant isolation. Auto-set via trigger from the policy source. """ accountID: UUID! - """The log event this policy applies to""" - logEventID: ID! + """Issue this policy remediates.""" + issueID: ID - """The workspace that owns this policy""" - workspaceID: ID! + """The log event this policy applies to""" + logEventID: ID """ - Quality issue category this policy addresses. Compliance: pii_leakage, - secrets_leakage, phi_leakage, payment_data_leakage. Waste: health_checks, - bot_traffic, debug_artifacts, malformed, broken_records, commodity_traffic, - redundant_events, dead_weight. Quality: duplicate_fields, - instrumentation_bloat, oversized_fields, wrong_level. + Source that created or owns this log event policy. Values: log_event = Policy + sourced from a catalog log event.; datadog_exclusion_filter = Policy imported + from a Datadog log exclusion filter.; manual = Policy created manually without + a catalog log event source. """ - category: LogEventPolicyCategory! + sourceKind: LogEventPolicySourceKind! - """ - Whether this category requires AI judgment (true) vs mechanically verifiable - (false). Auto-set via trigger from CategoryMeta. - """ - subjective: Boolean! + """Datadog exclusion filter this policy was imported from.""" + sourceDatadogLogExclusionFilterID: ID """ - Type of problem: compliance (legal/security risk), waste (event-level cuts), - or quality (field-level improvements). Auto-set via trigger from CategoryMeta. + Zero-based matcher branch index when one Datadog filter imports into multiple policies. """ - categoryType: LogEventPolicyCategoryType! + sourceBranchIndex: Int """ - Max compliance severity across sensitivity types. NULL for non-compliance categories. Auto-set via trigger. + Policy-owned log matchers used when this policy is not sourced from a catalog log event. """ - severity: LogEventPolicySeverity + matchers: Map """ - What this policy does when enforced: 'drop' (remove all events), 'sample' - (keep at reduced rate), 'filter' (drop subset by field value), 'trim' - (remove/truncate fields), 'none' (informational only). Auto-set via trigger. + Lifecycle state for this log event policy. Values: active = Policy is active + and eligible for publication.; disabled = Policy is retained but not published. """ - action: LogEventPolicyAction! + status: LogEventPolicyStatus! - """When this policy was approved by a user""" - approvedAt: Time + """When this policy row was last compiled from its current inputs.""" + compiledAt: Time! - """User ID who approved this policy""" - approvedBy: String + """When this policy row was created.""" + createdAt: Time! - """ - Baseline volume/hour frozen at approval time. Snapshot of log_event.baseline_volume_per_hour. - """ - approvedBaselineVolumePerHour: Float + """When this policy row was last updated.""" + updatedAt: Time! + + """Issue this policy remediates""" + issue: Issue + + """The log event this policy applies to""" + logEvent: LogEvent + + """Datadog exclusion filter this policy was imported from""" + sourceDatadogLogExclusionFilter: DatadogLogExclusionFilter """ - Baseline avg bytes frozen at approval time. Snapshot of log_event.baseline_avg_bytes. + Stable account-local policy identifier for product links and support workflows. """ - approvedBaselineAvgBytes: Float - - """When this policy was dismissed by a user""" - dismissedAt: Time + displayID: String! - """User ID who dismissed this policy""" - dismissedBy: String + """Account-qualified policy identifier for cross-account presentation.""" + qualifiedDisplayID: String! - """When this policy was created""" - createdAt: Time! + """Execution counter totals for this policy over a time range.""" + executionSummary(input: PolicyExecutionSummaryInput!): PolicyExecutionSummary! - """When this policy was last updated""" - updatedAt: Time! + """Bucketed execution telemetry for this policy.""" + executionTelemetry(input: PolicyTelemetryInput!): PolicyTelemetrySeries! - """The log event this policy applies to""" - logEvent: LogEvent! + """Edge deployments that reported execution telemetry for this policy.""" + deployments(input: LogEventPolicyDeploymentsInput!): [LogEventPolicyDeployment!]! - """The workspace that owns this policy""" - workspace: Workspace! + """Log-event policy spec applied to a log event.""" + spec: LogEventPolicySpec! } -"""LogEventPolicyAction is enum for the field action""" -enum LogEventPolicyAction { - drop - sample - filter - trim - none +type LogEventPolicyConnection { + edges: [LogEventPolicyEdge!]! + pageInfo: PageInfo! + totalCount: Int! } -"""LogEventPolicyCategory is enum for the field category""" -enum LogEventPolicyCategory { - pii_leakage - secrets_leakage - phi_leakage - payment_data_leakage - health_checks - bot_traffic - debug_artifacts - malformed - broken_records - commodity_traffic - redundant_events - dead_weight - duplicate_fields - instrumentation_bloat - oversized_fields - wrong_level -} +"""LogEventPolicy creation input.""" +input LogEventPolicyCreateInput { + """Issue this policy remediates.""" + issueID: ID -type LogEventPolicyCategoryStatusCache implements Node { - id: ID! + """The log event this policy applies to""" + logEventID: ID - """Account ID for tenant isolation""" - accountID: UUID! + """Typed compiled log stream change approved for this policy.""" + spec: LogEventPolicySpecInput! +} + +type LogEventPolicyDeployment { + edgeInstance: EdgeInstance! + executionSummary: PolicyExecutionSummary! + lastHitAt: Time +} - """Quality issue category (e.g., pii_leakage, noise, health_checks)""" - category: String! +input LogEventPolicyDeploymentsInput { + """Half-open range used to decide which deployments reported this policy.""" + range: TimeRangeInput! + where: LogEventPolicyDeploymentWhereInput +} +input LogEventPolicyDeploymentWhereInput { """ - Type of problem: compliance (legal/security risk), waste (event-level cuts), quality (field-level improvements). + Restrict deployments to edge instances that synced at or after this time. """ - categoryType: LogEventPolicyCategoryStatusCacheCategoryType! + lastSyncAtGTE: Time +} - """Human-readable category name (e.g., 'PII Leakage')""" - displayName: String! +"""Drop policy settings.""" +type LogEventPolicyDrop { + """Whether matching log events should be dropped.""" + enabled: Boolean! +} - """ - Whether this category requires AI judgment (true) vs mechanically verifiable (false) - """ - subjective: Boolean! +input LogEventPolicyDropInput { + enabled: Boolean! +} - """What this category detects — the fundamental test for membership""" - principle: String! +type LogEventPolicyEdge { + node: LogEventPolicy! + cursor: Cursor! +} - """Where this category stops applying — what NOT to flag""" - boundary: String! +"""Configured field rewrite rules.""" +type LogEventPolicyFieldRewrite { + """Source field path in the event payload.""" + from: [String!]! - """ - What the policy does: drop (remove events), sample (reduce rate), filter (drop - subset), trim (modify fields), none (informational) - """ - action: String + """Destination field path in the event payload.""" + to: [String!]! +} - """Policies awaiting user review in this category""" - pendingCount: Int! +input LogEventPolicyFieldRewriteInput { + from: [String!]! + to: [String!]! +} - """Policies approved by user in this category""" - approvedCount: Int! +input LogEventPolicyOrder { + direction: OrderDirection! = ASC + field: LogEventPolicyOrderField! +} - """Policies dismissed by user in this category""" - dismissedCount: Int! +enum LogEventPolicyOrderField { + ID + SOURCE_KIND + SOURCE_BRANCH_INDEX + STATUS + COMPILED_AT + CREATED_AT + UPDATED_AT +} - """Pending policies with low compliance severity""" - policyPendingLowCount: Int! +type LogEventPolicyProposal { + key: String! + sourceKind: LogEventPolicyProposalSourceKind! + issueID: ID + logEventID: ID + datadogLogExclusionFilterID: ID + sourceBranchIndex: Int + matchers: JSON + spec: LogEventPolicySpec! + compiledPolicies: JSON +} - """Pending policies with medium compliance severity""" - policyPendingMediumCount: Int! +type LogEventPolicyProposalSet { + proposals: [LogEventPolicyProposal!]! + blockers: [String!]! + warnings: [String!]! + unsupportedSemantics: [String!]! + sampleKeyRequired: Boolean! +} - """Pending policies with high compliance severity""" - policyPendingHighCount: Int! +enum LogEventPolicyProposalSourceKind { + issue + datadog_log_exclusion_filter +} - """Pending policies with critical compliance severity""" - policyPendingCriticalCount: Int! +"""Redaction policy settings.""" +type LogEventPolicyRedact { + """Configured redaction entries.""" + entries: [LogEventPolicyRedactEntry!]! +} - """Total log events that have a policy in this category""" - totalEventCount: Int! +"""Configured redaction entries.""" +type LogEventPolicyRedactEntry { + """Event field paths that should be redacted.""" + targetPaths: [[String!]!]! """ - Log events in this category that have volume data (subset of total_event_count) + Optional RE2 pattern that narrows redaction to a substring of the value. When empty, the entire field value is redacted. """ - eventsWithVolumes: Int! - - """Events/hour saved by all pending policies in this category combined""" - estimatedVolumeReductionPerHour: Float - - """Bytes/hour saved by all pending policies in this category combined""" - estimatedBytesReductionPerHour: Float + regex: String """ - Estimated ingestion savings in USD/hour from pending policies in this category + Optional RE2 replacement template applied to the regex match. May reference + capture groups with $1, ${name}, etc. When empty, the matched substring (or + whole value) becomes [REDACTED]. """ - estimatedCostReductionPerHourBytesUsd: Float + replacement: String +} + +input LogEventPolicyRedactEntryInput { + targetPaths: [[String!]!] """ - Estimated indexing savings in USD/hour from pending policies in this category + Optional RE2 pattern that narrows redaction to a substring of the value. + Empty or null means the entire field value is redacted. """ - estimatedCostReductionPerHourVolumeUsd: Float + regex: String """ - Estimated total savings in USD/hour from pending policies in this category + Optional RE2 replacement template applied to regex matches. May reference + capture groups with $1, ${name}, etc. Empty or null means the match + becomes [REDACTED]. Requires regex to be set. """ - estimatedCostReductionPerHourUsd: Float - refreshedAt: Time! + replacement: String } -""" -LogEventPolicyCategoryStatusCacheCategoryType is enum for the field category_type -""" -enum LogEventPolicyCategoryStatusCacheCategoryType { - compliance - waste - quality +input LogEventPolicyRedactInput { + entries: [LogEventPolicyRedactEntryInput!] } -""" -LogEventPolicyCategoryStatusCacheWhereInput is used for filtering LogEventPolicyCategoryStatusCache objects. -Input was generated by ent. -""" -input LogEventPolicyCategoryStatusCacheWhereInput { - not: LogEventPolicyCategoryStatusCacheWhereInput - and: [LogEventPolicyCategoryStatusCacheWhereInput!] - or: [LogEventPolicyCategoryStatusCacheWhereInput!] +"""Rewrite policy settings.""" +type LogEventPolicyRewrite { + """Severity rewrite applied to the event.""" + severity: LogEventPolicySeverityRewrite - """id field predicates""" - id: ID - idNEQ: ID - idIn: [ID!] - idNotIn: [ID!] - idGT: ID - idGTE: ID - idLT: ID - idLTE: ID + """Configured field rewrite rules.""" + fields: [LogEventPolicyFieldRewrite!]! +} - """account_id field predicates""" - accountID: UUID - accountIDNEQ: UUID - accountIDIn: [UUID!] - accountIDNotIn: [UUID!] - accountIDGT: UUID - accountIDGTE: UUID - accountIDLT: UUID - accountIDLTE: UUID +input LogEventPolicyRewriteInput { + severity: LogEventPolicySeverityRewriteInput + fields: [LogEventPolicyFieldRewriteInput!] +} - """category field predicates""" - category: String - categoryNEQ: String - categoryIn: [String!] - categoryNotIn: [String!] - categoryGT: String - categoryGTE: String - categoryLT: String - categoryLTE: String - categoryContains: String - categoryHasPrefix: String - categoryHasSuffix: String - categoryEqualFold: String - categoryContainsFold: String - - """category_type field predicates""" - categoryType: LogEventPolicyCategoryStatusCacheCategoryType - categoryTypeNEQ: LogEventPolicyCategoryStatusCacheCategoryType - categoryTypeIn: [LogEventPolicyCategoryStatusCacheCategoryType!] - categoryTypeNotIn: [LogEventPolicyCategoryStatusCacheCategoryType!] - - """display_name field predicates""" - displayName: String - displayNameNEQ: String - displayNameIn: [String!] - displayNameNotIn: [String!] - displayNameGT: String - displayNameGTE: String - displayNameLT: String - displayNameLTE: String - displayNameContains: String - displayNameHasPrefix: String - displayNameHasSuffix: String - displayNameEqualFold: String - displayNameContainsFold: String +"""Sampling policy settings.""" +type LogEventPolicySample { + """Whether sampling is enabled.""" + enabled: Boolean! - """subjective field predicates""" - subjective: Boolean - subjectiveNEQ: Boolean - - """principle field predicates""" - principle: String - principleNEQ: String - principleIn: [String!] - principleNotIn: [String!] - principleGT: String - principleGTE: String - principleLT: String - principleLTE: String - principleContains: String - principleHasPrefix: String - principleHasSuffix: String - principleEqualFold: String - principleContainsFold: String - - """boundary field predicates""" - boundary: String - boundaryNEQ: String - boundaryIn: [String!] - boundaryNotIn: [String!] - boundaryGT: String - boundaryGTE: String - boundaryLT: String - boundaryLTE: String - boundaryContains: String - boundaryHasPrefix: String - boundaryHasSuffix: String - boundaryEqualFold: String - boundaryContainsFold: String - - """action field predicates""" - action: String - actionNEQ: String - actionIn: [String!] - actionNotIn: [String!] - actionGT: String - actionGTE: String - actionLT: String - actionLTE: String - actionContains: String - actionHasPrefix: String - actionHasSuffix: String - actionIsNil: Boolean - actionNotNil: Boolean - actionEqualFold: String - actionContainsFold: String - - """pending_count field predicates""" - pendingCount: Int - pendingCountNEQ: Int - pendingCountIn: [Int!] - pendingCountNotIn: [Int!] - pendingCountGT: Int - pendingCountGTE: Int - pendingCountLT: Int - pendingCountLTE: Int - - """approved_count field predicates""" - approvedCount: Int - approvedCountNEQ: Int - approvedCountIn: [Int!] - approvedCountNotIn: [Int!] - approvedCountGT: Int - approvedCountGTE: Int - approvedCountLT: Int - approvedCountLTE: Int - - """dismissed_count field predicates""" - dismissedCount: Int - dismissedCountNEQ: Int - dismissedCountIn: [Int!] - dismissedCountNotIn: [Int!] - dismissedCountGT: Int - dismissedCountGTE: Int - dismissedCountLT: Int - dismissedCountLTE: Int - - """policy_pending_low_count field predicates""" - policyPendingLowCount: Int - policyPendingLowCountNEQ: Int - policyPendingLowCountIn: [Int!] - policyPendingLowCountNotIn: [Int!] - policyPendingLowCountGT: Int - policyPendingLowCountGTE: Int - policyPendingLowCountLT: Int - policyPendingLowCountLTE: Int - - """policy_pending_medium_count field predicates""" - policyPendingMediumCount: Int - policyPendingMediumCountNEQ: Int - policyPendingMediumCountIn: [Int!] - policyPendingMediumCountNotIn: [Int!] - policyPendingMediumCountGT: Int - policyPendingMediumCountGTE: Int - policyPendingMediumCountLT: Int - policyPendingMediumCountLTE: Int - - """policy_pending_high_count field predicates""" - policyPendingHighCount: Int - policyPendingHighCountNEQ: Int - policyPendingHighCountIn: [Int!] - policyPendingHighCountNotIn: [Int!] - policyPendingHighCountGT: Int - policyPendingHighCountGTE: Int - policyPendingHighCountLT: Int - policyPendingHighCountLTE: Int - - """policy_pending_critical_count field predicates""" - policyPendingCriticalCount: Int - policyPendingCriticalCountNEQ: Int - policyPendingCriticalCountIn: [Int!] - policyPendingCriticalCountNotIn: [Int!] - policyPendingCriticalCountGT: Int - policyPendingCriticalCountGTE: Int - policyPendingCriticalCountLT: Int - policyPendingCriticalCountLTE: Int - - """total_event_count field predicates""" - totalEventCount: Int - totalEventCountNEQ: Int - totalEventCountIn: [Int!] - totalEventCountNotIn: [Int!] - totalEventCountGT: Int - totalEventCountGTE: Int - totalEventCountLT: Int - totalEventCountLTE: Int - - """events_with_volumes field predicates""" - eventsWithVolumes: Int - eventsWithVolumesNEQ: Int - eventsWithVolumesIn: [Int!] - eventsWithVolumesNotIn: [Int!] - eventsWithVolumesGT: Int - eventsWithVolumesGTE: Int - eventsWithVolumesLT: Int - eventsWithVolumesLTE: Int - - """estimated_volume_reduction_per_hour field predicates""" - estimatedVolumeReductionPerHour: Float - estimatedVolumeReductionPerHourNEQ: Float - estimatedVolumeReductionPerHourIn: [Float!] - estimatedVolumeReductionPerHourNotIn: [Float!] - estimatedVolumeReductionPerHourGT: Float - estimatedVolumeReductionPerHourGTE: Float - estimatedVolumeReductionPerHourLT: Float - estimatedVolumeReductionPerHourLTE: Float - estimatedVolumeReductionPerHourIsNil: Boolean - estimatedVolumeReductionPerHourNotNil: Boolean - - """estimated_bytes_reduction_per_hour field predicates""" - estimatedBytesReductionPerHour: Float - estimatedBytesReductionPerHourNEQ: Float - estimatedBytesReductionPerHourIn: [Float!] - estimatedBytesReductionPerHourNotIn: [Float!] - estimatedBytesReductionPerHourGT: Float - estimatedBytesReductionPerHourGTE: Float - estimatedBytesReductionPerHourLT: Float - estimatedBytesReductionPerHourLTE: Float - estimatedBytesReductionPerHourIsNil: Boolean - estimatedBytesReductionPerHourNotNil: Boolean - - """estimated_cost_reduction_per_hour_bytes_usd field predicates""" - estimatedCostReductionPerHourBytesUsd: Float - estimatedCostReductionPerHourBytesUsdNEQ: Float - estimatedCostReductionPerHourBytesUsdIn: [Float!] - estimatedCostReductionPerHourBytesUsdNotIn: [Float!] - estimatedCostReductionPerHourBytesUsdGT: Float - estimatedCostReductionPerHourBytesUsdGTE: Float - estimatedCostReductionPerHourBytesUsdLT: Float - estimatedCostReductionPerHourBytesUsdLTE: Float - estimatedCostReductionPerHourBytesUsdIsNil: Boolean - estimatedCostReductionPerHourBytesUsdNotNil: Boolean - - """estimated_cost_reduction_per_hour_volume_usd field predicates""" - estimatedCostReductionPerHourVolumeUsd: Float - estimatedCostReductionPerHourVolumeUsdNEQ: Float - estimatedCostReductionPerHourVolumeUsdIn: [Float!] - estimatedCostReductionPerHourVolumeUsdNotIn: [Float!] - estimatedCostReductionPerHourVolumeUsdGT: Float - estimatedCostReductionPerHourVolumeUsdGTE: Float - estimatedCostReductionPerHourVolumeUsdLT: Float - estimatedCostReductionPerHourVolumeUsdLTE: Float - estimatedCostReductionPerHourVolumeUsdIsNil: Boolean - estimatedCostReductionPerHourVolumeUsdNotNil: Boolean - - """estimated_cost_reduction_per_hour_usd field predicates""" - estimatedCostReductionPerHourUsd: Float - estimatedCostReductionPerHourUsdNEQ: Float - estimatedCostReductionPerHourUsdIn: [Float!] - estimatedCostReductionPerHourUsdNotIn: [Float!] - estimatedCostReductionPerHourUsdGT: Float - estimatedCostReductionPerHourUsdGTE: Float - estimatedCostReductionPerHourUsdLT: Float - estimatedCostReductionPerHourUsdLTE: Float - estimatedCostReductionPerHourUsdIsNil: Boolean - estimatedCostReductionPerHourUsdNotNil: Boolean - - """refreshed_at field predicates""" - refreshedAt: Time - refreshedAtNEQ: Time - refreshedAtIn: [Time!] - refreshedAtNotIn: [Time!] - refreshedAtGT: Time - refreshedAtGTE: Time - refreshedAtLT: Time - refreshedAtLTE: Time + """Sampling interval in seconds when sampling is enabled.""" + intervalSeconds: Int + + """ + Percentage of matching events to keep when percentage sampling is enabled. + """ + keepPercentage: Float + + """Log-record path used as the deterministic sampling key.""" + sampleKey: [String!] } -"""LogEventPolicyCategoryType is enum for the field category_type""" -enum LogEventPolicyCategoryType { - compliance - waste - quality +input LogEventPolicySampleInput { + enabled: Boolean! + intervalSeconds: Int! } -"""A connection to a list of items.""" -type LogEventPolicyConnection { - """A list of edges.""" - edges: [LogEventPolicyEdge] +"""Severity rewrite applied to the event.""" +type LogEventPolicySeverityRewrite { + """New severity value assigned to the event.""" + value: String! +} - """Information to aid in pagination.""" - pageInfo: PageInfo! +input LogEventPolicySeverityRewriteInput { + value: String! +} - """Identifies the total count of items in the connection.""" - totalCount: Int! +enum LogEventPolicySourceKind { + log_event + datadog_exclusion_filter + manual } -"""An edge in a connection.""" -type LogEventPolicyEdge { - """The item at the end of the edge.""" - node: LogEventPolicy +"""Log-event policy spec applied to a log event.""" +type LogEventPolicySpec { + """Drop policy settings.""" + drop: LogEventPolicyDrop! - """A cursor for use in pagination.""" - cursor: Cursor! -} + """Sampling policy settings.""" + sample: LogEventPolicySample! -"""Ordering options for LogEventPolicy connections""" -input LogEventPolicyOrder { - """The ordering direction.""" - direction: OrderDirection! = ASC + """Trim policy settings.""" + trim: LogEventPolicyTrim! - """The field by which to order LogEventPolicies.""" - field: LogEventPolicyOrderField! -} + """Redaction policy settings.""" + redact: LogEventPolicyRedact! -"""Properties by which LogEventPolicy connections can be ordered.""" -enum LogEventPolicyOrderField { - CATEGORY - SUBJECTIVE - CATEGORY_TYPE - SEVERITY - ACTION - APPROVED_AT - DISMISSED_AT - CREATED_AT - UPDATED_AT + """Rewrite policy settings.""" + rewrite: LogEventPolicyRewrite! } -"""LogEventPolicySeverity is enum for the field severity""" -enum LogEventPolicySeverity { - low - medium - high - critical +"""Log-event policy spec applied to a log event.""" +input LogEventPolicySpecDropWhereInput { + not: LogEventPolicySpecDropWhereInput + and: [LogEventPolicySpecDropWhereInput!] + or: [LogEventPolicySpecDropWhereInput!] + + """Whether matching log events should be dropped.""" + enabledEQ: Boolean + + """Whether matching log events should be dropped.""" + enabledNEQ: Boolean } -type LogEventPolicyStatusCache implements Node { - id: ID! - - """Account ID for tenant isolation""" - accountID: UUID! +input LogEventPolicySpecInput { + drop: LogEventPolicyDropInput + sample: LogEventPolicySampleInput + trim: LogEventPolicyTrimInput + redact: LogEventPolicyRedactInput + rewrite: LogEventPolicyRewriteInput +} - """The policy this status row represents""" - policyID: ID! +"""Log-event policy spec applied to a log event.""" +input LogEventPolicySpecRedactWhereInput { + not: LogEventPolicySpecRedactWhereInput + and: [LogEventPolicySpecRedactWhereInput!] + or: [LogEventPolicySpecRedactWhereInput!] - """The log event this policy targets""" - logEventID: UUID! + """Configured redaction entries.""" + hasEntries: Boolean +} - """The workspace that owns this policy""" - workspaceID: UUID! +"""Log-event policy spec applied to a log event.""" +input LogEventPolicySpecRewriteSeverityWhereInput { + not: LogEventPolicySpecRewriteSeverityWhereInput + and: [LogEventPolicySpecRewriteSeverityWhereInput!] + or: [LogEventPolicySpecRewriteSeverityWhereInput!] - """ - Quality issue category this policy addresses (e.g., pii_leakage, noise, health_checks) - """ - category: String! + """New severity value assigned to the event.""" + valueEQ: String - """ - User decision on this policy. PENDING (awaiting review), APPROVED (accepted - for enforcement), DISMISSED (rejected by user). - """ - status: LogEventPolicyStatusCacheStatus! + """New severity value assigned to the event.""" + valueNEQ: String - """ - Whether this category requires AI judgment (true) vs mechanically verifiable (false) - """ - subjective: Boolean! + """New severity value assigned to the event.""" + valueIn: [String!] - """ - Type of problem: compliance (legal/security risk), waste (event-level cuts), quality (field-level improvements). - """ - categoryType: LogEventPolicyStatusCacheCategoryType! + """New severity value assigned to the event.""" + valueNotIn: [String!] - """ - Max compliance severity across sensitivity types. NULL for non-compliance categories. - """ - severity: LogEventPolicyStatusCacheSeverity + """New severity value assigned to the event.""" + valueContains: String - """When this policy was approved by a user""" - approvedAt: Time + """New severity value assigned to the event.""" + valueHasPrefix: String - """When this policy was dismissed by a user""" - dismissedAt: Time + """New severity value assigned to the event.""" + valueHasSuffix: String - """When this policy was created""" - createdAt: Time! + """New severity value assigned to the event.""" + valueEqualFold: String - """ - What the policy does: drop (remove events), sample (reduce rate), filter (drop - subset), trim (modify fields), none (informational) - """ - action: String + """New severity value assigned to the event.""" + valueContainsFold: String - """ - Fraction of events that survive this policy (0.0 = all dropped, 1.0 = all kept). NULL if not estimable. - """ - survivalRate: Float + """New severity value assigned to the event.""" + valuePresent: Boolean +} - """Events/hour saved if this policy applied alone. NULL if not estimable.""" - estimatedVolumeReductionPerHour: Float +"""Log-event policy spec applied to a log event.""" +input LogEventPolicySpecRewriteWhereInput { + not: LogEventPolicySpecRewriteWhereInput + and: [LogEventPolicySpecRewriteWhereInput!] + or: [LogEventPolicySpecRewriteWhereInput!] - """Bytes/hour saved if this policy applied alone. NULL if not estimable.""" - estimatedBytesReductionPerHour: Float + """Severity rewrite applied to the event.""" + severity: LogEventPolicySpecRewriteSeverityWhereInput - """Estimated ingestion savings in USD/hour from bytes reduction""" - estimatedCostReductionPerHourBytesUsd: Float + """Configured field rewrite rules.""" + hasFields: Boolean +} - """Estimated indexing savings in USD/hour from volume reduction""" - estimatedCostReductionPerHourVolumeUsd: Float +"""Log-event policy spec applied to a log event.""" +input LogEventPolicySpecSampleWhereInput { + not: LogEventPolicySpecSampleWhereInput + and: [LogEventPolicySpecSampleWhereInput!] + or: [LogEventPolicySpecSampleWhereInput!] - """Estimated total savings in USD/hour (bytes + volume)""" - estimatedCostReductionPerHourUsd: Float + """Whether sampling is enabled.""" + enabledEQ: Boolean - """Service that produces the targeted log event (denormalized)""" - serviceID: UUID + """Whether sampling is enabled.""" + enabledNEQ: Boolean +} - """Name of the service (denormalized for display)""" - serviceName: String +"""Log-event policy spec applied to a log event.""" +input LogEventPolicySpecTrimWhereInput { + not: LogEventPolicySpecTrimWhereInput + and: [LogEventPolicySpecTrimWhereInput!] + or: [LogEventPolicySpecTrimWhereInput!] - """Name of the targeted log event (denormalized for display)""" - logEventName: String + """Whether trimming is enabled.""" + enabledEQ: Boolean - """Current throughput of the targeted log event in events/hour""" - volumePerHour: Float + """Whether trimming is enabled.""" + enabledNEQ: Boolean - """Current throughput of the targeted log event in bytes/hour""" - bytesPerHour: Float - refreshedAt: Time! + """Field paths that should be trimmed from the event payload.""" + hasTargetPaths: Boolean } -""" -LogEventPolicyStatusCacheCategoryType is enum for the field category_type -""" -enum LogEventPolicyStatusCacheCategoryType { - compliance - waste - quality -} +"""Log-event policy spec applied to a log event.""" +input LogEventPolicySpecWhereInput { + not: LogEventPolicySpecWhereInput + and: [LogEventPolicySpecWhereInput!] + or: [LogEventPolicySpecWhereInput!] -"""LogEventPolicyStatusCacheSeverity is enum for the field severity""" -enum LogEventPolicyStatusCacheSeverity { - low - medium - high - critical -} + """Drop policy settings.""" + drop: LogEventPolicySpecDropWhereInput -"""LogEventPolicyStatusCacheStatus is enum for the field status""" -enum LogEventPolicyStatusCacheStatus { - PENDING - APPROVED - DISMISSED -} + """Sampling policy settings.""" + sample: LogEventPolicySpecSampleWhereInput -""" -LogEventPolicyStatusCacheWhereInput is used for filtering LogEventPolicyStatusCache objects. -Input was generated by ent. -""" -input LogEventPolicyStatusCacheWhereInput { - not: LogEventPolicyStatusCacheWhereInput - and: [LogEventPolicyStatusCacheWhereInput!] - or: [LogEventPolicyStatusCacheWhereInput!] + """Trim policy settings.""" + trim: LogEventPolicySpecTrimWhereInput - """id field predicates""" - id: ID - idNEQ: ID - idIn: [ID!] - idNotIn: [ID!] - idGT: ID - idGTE: ID - idLT: ID - idLTE: ID + """Redaction policy settings.""" + redact: LogEventPolicySpecRedactWhereInput - """account_id field predicates""" - accountID: UUID - accountIDNEQ: UUID - accountIDIn: [UUID!] - accountIDNotIn: [UUID!] - accountIDGT: UUID - accountIDGTE: UUID - accountIDLT: UUID - accountIDLTE: UUID + """Rewrite policy settings.""" + rewrite: LogEventPolicySpecRewriteWhereInput - """policy_id field predicates""" - policyID: ID - policyIDNEQ: ID - policyIDIn: [ID!] - policyIDNotIn: [ID!] - - """log_event_id field predicates""" - logEventID: UUID - logEventIDNEQ: UUID - logEventIDIn: [UUID!] - logEventIDNotIn: [UUID!] - logEventIDGT: UUID - logEventIDGTE: UUID - logEventIDLT: UUID - logEventIDLTE: UUID - - """workspace_id field predicates""" - workspaceID: UUID - workspaceIDNEQ: UUID - workspaceIDIn: [UUID!] - workspaceIDNotIn: [UUID!] - workspaceIDGT: UUID - workspaceIDGTE: UUID - workspaceIDLT: UUID - workspaceIDLTE: UUID - - """category field predicates""" - category: String - categoryNEQ: String - categoryIn: [String!] - categoryNotIn: [String!] - categoryGT: String - categoryGTE: String - categoryLT: String - categoryLTE: String - categoryContains: String - categoryHasPrefix: String - categoryHasSuffix: String - categoryEqualFold: String - categoryContainsFold: String - - """status field predicates""" - status: LogEventPolicyStatusCacheStatus - statusNEQ: LogEventPolicyStatusCacheStatus - statusIn: [LogEventPolicyStatusCacheStatus!] - statusNotIn: [LogEventPolicyStatusCacheStatus!] - - """subjective field predicates""" - subjective: Boolean - subjectiveNEQ: Boolean - - """category_type field predicates""" - categoryType: LogEventPolicyStatusCacheCategoryType - categoryTypeNEQ: LogEventPolicyStatusCacheCategoryType - categoryTypeIn: [LogEventPolicyStatusCacheCategoryType!] - categoryTypeNotIn: [LogEventPolicyStatusCacheCategoryType!] - - """severity field predicates""" - severity: LogEventPolicyStatusCacheSeverity - severityNEQ: LogEventPolicyStatusCacheSeverity - severityIn: [LogEventPolicyStatusCacheSeverity!] - severityNotIn: [LogEventPolicyStatusCacheSeverity!] - severityIsNil: Boolean - severityNotNil: Boolean + """"HasSelectionOperations" method predicates.""" + hasSelectionOperations: Boolean - """approved_at field predicates""" - approvedAt: Time - approvedAtNEQ: Time - approvedAtIn: [Time!] - approvedAtNotIn: [Time!] - approvedAtGT: Time - approvedAtGTE: Time - approvedAtLT: Time - approvedAtLTE: Time - approvedAtIsNil: Boolean - approvedAtNotNil: Boolean - - """dismissed_at field predicates""" - dismissedAt: Time - dismissedAtNEQ: Time - dismissedAtIn: [Time!] - dismissedAtNotIn: [Time!] - dismissedAtGT: Time - dismissedAtGTE: Time - dismissedAtLT: Time - dismissedAtLTE: Time - dismissedAtIsNil: Boolean - dismissedAtNotNil: Boolean - - """created_at field predicates""" - createdAt: Time - createdAtNEQ: Time - createdAtIn: [Time!] - createdAtNotIn: [Time!] - createdAtGT: Time - createdAtGTE: Time - createdAtLT: Time - createdAtLTE: Time + """"HasTransformOperations" method predicates.""" + hasTransformOperations: Boolean +} - """action field predicates""" - action: String - actionNEQ: String - actionIn: [String!] - actionNotIn: [String!] - actionGT: String - actionGTE: String - actionLT: String - actionLTE: String - actionContains: String - actionHasPrefix: String - actionHasSuffix: String - actionIsNil: Boolean - actionNotNil: Boolean - actionEqualFold: String - actionContainsFold: String - - """survival_rate field predicates""" - survivalRate: Float - survivalRateNEQ: Float - survivalRateIn: [Float!] - survivalRateNotIn: [Float!] - survivalRateGT: Float - survivalRateGTE: Float - survivalRateLT: Float - survivalRateLTE: Float - survivalRateIsNil: Boolean - survivalRateNotNil: Boolean - - """estimated_volume_reduction_per_hour field predicates""" - estimatedVolumeReductionPerHour: Float - estimatedVolumeReductionPerHourNEQ: Float - estimatedVolumeReductionPerHourIn: [Float!] - estimatedVolumeReductionPerHourNotIn: [Float!] - estimatedVolumeReductionPerHourGT: Float - estimatedVolumeReductionPerHourGTE: Float - estimatedVolumeReductionPerHourLT: Float - estimatedVolumeReductionPerHourLTE: Float - estimatedVolumeReductionPerHourIsNil: Boolean - estimatedVolumeReductionPerHourNotNil: Boolean - - """estimated_bytes_reduction_per_hour field predicates""" - estimatedBytesReductionPerHour: Float - estimatedBytesReductionPerHourNEQ: Float - estimatedBytesReductionPerHourIn: [Float!] - estimatedBytesReductionPerHourNotIn: [Float!] - estimatedBytesReductionPerHourGT: Float - estimatedBytesReductionPerHourGTE: Float - estimatedBytesReductionPerHourLT: Float - estimatedBytesReductionPerHourLTE: Float - estimatedBytesReductionPerHourIsNil: Boolean - estimatedBytesReductionPerHourNotNil: Boolean - - """estimated_cost_reduction_per_hour_bytes_usd field predicates""" - estimatedCostReductionPerHourBytesUsd: Float - estimatedCostReductionPerHourBytesUsdNEQ: Float - estimatedCostReductionPerHourBytesUsdIn: [Float!] - estimatedCostReductionPerHourBytesUsdNotIn: [Float!] - estimatedCostReductionPerHourBytesUsdGT: Float - estimatedCostReductionPerHourBytesUsdGTE: Float - estimatedCostReductionPerHourBytesUsdLT: Float - estimatedCostReductionPerHourBytesUsdLTE: Float - estimatedCostReductionPerHourBytesUsdIsNil: Boolean - estimatedCostReductionPerHourBytesUsdNotNil: Boolean - - """estimated_cost_reduction_per_hour_volume_usd field predicates""" - estimatedCostReductionPerHourVolumeUsd: Float - estimatedCostReductionPerHourVolumeUsdNEQ: Float - estimatedCostReductionPerHourVolumeUsdIn: [Float!] - estimatedCostReductionPerHourVolumeUsdNotIn: [Float!] - estimatedCostReductionPerHourVolumeUsdGT: Float - estimatedCostReductionPerHourVolumeUsdGTE: Float - estimatedCostReductionPerHourVolumeUsdLT: Float - estimatedCostReductionPerHourVolumeUsdLTE: Float - estimatedCostReductionPerHourVolumeUsdIsNil: Boolean - estimatedCostReductionPerHourVolumeUsdNotNil: Boolean - - """estimated_cost_reduction_per_hour_usd field predicates""" - estimatedCostReductionPerHourUsd: Float - estimatedCostReductionPerHourUsdNEQ: Float - estimatedCostReductionPerHourUsdIn: [Float!] - estimatedCostReductionPerHourUsdNotIn: [Float!] - estimatedCostReductionPerHourUsdGT: Float - estimatedCostReductionPerHourUsdGTE: Float - estimatedCostReductionPerHourUsdLT: Float - estimatedCostReductionPerHourUsdLTE: Float - estimatedCostReductionPerHourUsdIsNil: Boolean - estimatedCostReductionPerHourUsdNotNil: Boolean - - """service_id field predicates""" - serviceID: UUID - serviceIDNEQ: UUID - serviceIDIn: [UUID!] - serviceIDNotIn: [UUID!] - serviceIDGT: UUID - serviceIDGTE: UUID - serviceIDLT: UUID - serviceIDLTE: UUID - serviceIDIsNil: Boolean - serviceIDNotNil: Boolean - - """service_name field predicates""" - serviceName: String - serviceNameNEQ: String - serviceNameIn: [String!] - serviceNameNotIn: [String!] - serviceNameGT: String - serviceNameGTE: String - serviceNameLT: String - serviceNameLTE: String - serviceNameContains: String - serviceNameHasPrefix: String - serviceNameHasSuffix: String - serviceNameIsNil: Boolean - serviceNameNotNil: Boolean - serviceNameEqualFold: String - serviceNameContainsFold: String +enum LogEventPolicyStatus { + active + disabled +} - """log_event_name field predicates""" - logEventName: String - logEventNameNEQ: String - logEventNameIn: [String!] - logEventNameNotIn: [String!] - logEventNameGT: String - logEventNameGTE: String - logEventNameLT: String - logEventNameLTE: String - logEventNameContains: String - logEventNameHasPrefix: String - logEventNameHasSuffix: String - logEventNameIsNil: Boolean - logEventNameNotNil: Boolean - logEventNameEqualFold: String - logEventNameContainsFold: String - - """volume_per_hour field predicates""" - volumePerHour: Float - volumePerHourNEQ: Float - volumePerHourIn: [Float!] - volumePerHourNotIn: [Float!] - volumePerHourGT: Float - volumePerHourGTE: Float - volumePerHourLT: Float - volumePerHourLTE: Float - volumePerHourIsNil: Boolean - volumePerHourNotNil: Boolean - - """bytes_per_hour field predicates""" - bytesPerHour: Float - bytesPerHourNEQ: Float - bytesPerHourIn: [Float!] - bytesPerHourNotIn: [Float!] - bytesPerHourGT: Float - bytesPerHourGTE: Float - bytesPerHourLT: Float - bytesPerHourLTE: Float - bytesPerHourIsNil: Boolean - bytesPerHourNotNil: Boolean - - """refreshed_at field predicates""" - refreshedAt: Time - refreshedAtNEQ: Time - refreshedAtIn: [Time!] - refreshedAtNotIn: [Time!] - refreshedAtGT: Time - refreshedAtGTE: Time - refreshedAtLT: Time - refreshedAtLTE: Time +"""Trim policy settings.""" +type LogEventPolicyTrim { + """Whether trimming is enabled.""" + enabled: Boolean! + + """Field paths that should be trimmed from the event payload.""" + targetPaths: [[String!]!]! +} + +input LogEventPolicyTrimInput { + enabled: Boolean! + targetPaths: [[String!]!] } -""" -LogEventPolicyWhereInput is used for filtering LogEventPolicy objects. -Input was generated by ent. -""" input LogEventPolicyWhereInput { not: LogEventPolicyWhereInput and: [LogEventPolicyWhereInput!] or: [LogEventPolicyWhereInput!] - """id field predicates""" + """"issue" edge predicates.""" + issue: IssueWhereInput + + """Whether the "issue" edge has at least one related row.""" + hasIssue: Boolean + + """"log_event" edge predicates.""" + logEvent: LogEventWhereInput + + """Whether the "log_event" edge has at least one related row.""" + hasLogEvent: Boolean + + """"source_datadog_log_exclusion_filter" edge predicates.""" + sourceDatadogLogExclusionFilter: DatadogLogExclusionFilterWhereInput + + """ + Whether the "source_datadog_log_exclusion_filter" edge has at least one related row. + """ + hasSourceDatadogLogExclusionFilter: Boolean + + """"spec" JSONB field predicates.""" + spec: LogEventPolicySpecWhereInput + + """"id" field predicates.""" id: ID + + """"id" field predicates.""" idNEQ: ID + + """"id" field predicates.""" idIn: [ID!] + + """"id" field predicates.""" idNotIn: [ID!] + + """"id" field predicates.""" idGT: ID + + """"id" field predicates.""" idGTE: ID + + """"id" field predicates.""" idLT: ID + + """"id" field predicates.""" idLTE: ID - """account_id field predicates""" - accountID: UUID - accountIDNEQ: UUID - accountIDIn: [UUID!] - accountIDNotIn: [UUID!] - accountIDGT: UUID - accountIDGTE: UUID - accountIDLT: UUID - accountIDLTE: UUID + """"issue_id" field predicates.""" + issueID: ID + + """"issue_id" field predicates.""" + issueIDNEQ: ID + + """"issue_id" field predicates.""" + issueIDIn: [ID!] - """log_event_id field predicates""" + """"issue_id" field predicates.""" + issueIDNotIn: [ID!] + + """"issue_id" field predicates.""" + issueIDIsNil: Boolean + + """"issue_id" field predicates.""" + issueIDNotNil: Boolean + + """"log_event_id" field predicates.""" logEventID: ID + + """"log_event_id" field predicates.""" logEventIDNEQ: ID + + """"log_event_id" field predicates.""" logEventIDIn: [ID!] + + """"log_event_id" field predicates.""" logEventIDNotIn: [ID!] - """workspace_id field predicates""" - workspaceID: ID - workspaceIDNEQ: ID - workspaceIDIn: [ID!] - workspaceIDNotIn: [ID!] - - """category field predicates""" - category: LogEventPolicyCategory - categoryNEQ: LogEventPolicyCategory - categoryIn: [LogEventPolicyCategory!] - categoryNotIn: [LogEventPolicyCategory!] - - """subjective field predicates""" - subjective: Boolean - subjectiveNEQ: Boolean - - """category_type field predicates""" - categoryType: LogEventPolicyCategoryType - categoryTypeNEQ: LogEventPolicyCategoryType - categoryTypeIn: [LogEventPolicyCategoryType!] - categoryTypeNotIn: [LogEventPolicyCategoryType!] - - """severity field predicates""" - severity: LogEventPolicySeverity - severityNEQ: LogEventPolicySeverity - severityIn: [LogEventPolicySeverity!] - severityNotIn: [LogEventPolicySeverity!] - severityIsNil: Boolean - severityNotNil: Boolean + """"log_event_id" field predicates.""" + logEventIDIsNil: Boolean + + """"log_event_id" field predicates.""" + logEventIDNotNil: Boolean + + """"source_kind" field predicates.""" + sourceKind: LogEventPolicySourceKind + + """"source_kind" field predicates.""" + sourceKindNEQ: LogEventPolicySourceKind + + """"source_kind" field predicates.""" + sourceKindIn: [LogEventPolicySourceKind!] + + """"source_kind" field predicates.""" + sourceKindNotIn: [LogEventPolicySourceKind!] + + """"source_branch_index" field predicates.""" + sourceBranchIndex: Int + + """"source_branch_index" field predicates.""" + sourceBranchIndexNEQ: Int + + """"source_branch_index" field predicates.""" + sourceBranchIndexIn: [Int!] + + """"source_branch_index" field predicates.""" + sourceBranchIndexNotIn: [Int!] + + """"source_branch_index" field predicates.""" + sourceBranchIndexGT: Int + + """"source_branch_index" field predicates.""" + sourceBranchIndexGTE: Int + + """"source_branch_index" field predicates.""" + sourceBranchIndexLT: Int - """action field predicates""" - action: LogEventPolicyAction - actionNEQ: LogEventPolicyAction - actionIn: [LogEventPolicyAction!] - actionNotIn: [LogEventPolicyAction!] - - """approved_at field predicates""" - approvedAt: Time - approvedAtNEQ: Time - approvedAtIn: [Time!] - approvedAtNotIn: [Time!] - approvedAtGT: Time - approvedAtGTE: Time - approvedAtLT: Time - approvedAtLTE: Time - approvedAtIsNil: Boolean - approvedAtNotNil: Boolean - - """approved_by field predicates""" - approvedBy: String - approvedByNEQ: String - approvedByIn: [String!] - approvedByNotIn: [String!] - approvedByGT: String - approvedByGTE: String - approvedByLT: String - approvedByLTE: String - approvedByContains: String - approvedByHasPrefix: String - approvedByHasSuffix: String - approvedByIsNil: Boolean - approvedByNotNil: Boolean - approvedByEqualFold: String - approvedByContainsFold: String - - """approved_baseline_volume_per_hour field predicates""" - approvedBaselineVolumePerHour: Float - approvedBaselineVolumePerHourNEQ: Float - approvedBaselineVolumePerHourIn: [Float!] - approvedBaselineVolumePerHourNotIn: [Float!] - approvedBaselineVolumePerHourGT: Float - approvedBaselineVolumePerHourGTE: Float - approvedBaselineVolumePerHourLT: Float - approvedBaselineVolumePerHourLTE: Float - approvedBaselineVolumePerHourIsNil: Boolean - approvedBaselineVolumePerHourNotNil: Boolean - - """approved_baseline_avg_bytes field predicates""" - approvedBaselineAvgBytes: Float - approvedBaselineAvgBytesNEQ: Float - approvedBaselineAvgBytesIn: [Float!] - approvedBaselineAvgBytesNotIn: [Float!] - approvedBaselineAvgBytesGT: Float - approvedBaselineAvgBytesGTE: Float - approvedBaselineAvgBytesLT: Float - approvedBaselineAvgBytesLTE: Float - approvedBaselineAvgBytesIsNil: Boolean - approvedBaselineAvgBytesNotNil: Boolean - - """dismissed_at field predicates""" - dismissedAt: Time - dismissedAtNEQ: Time - dismissedAtIn: [Time!] - dismissedAtNotIn: [Time!] - dismissedAtGT: Time - dismissedAtGTE: Time - dismissedAtLT: Time - dismissedAtLTE: Time - dismissedAtIsNil: Boolean - dismissedAtNotNil: Boolean - - """dismissed_by field predicates""" - dismissedBy: String - dismissedByNEQ: String - dismissedByIn: [String!] - dismissedByNotIn: [String!] - dismissedByGT: String - dismissedByGTE: String - dismissedByLT: String - dismissedByLTE: String - dismissedByContains: String - dismissedByHasPrefix: String - dismissedByHasSuffix: String - dismissedByIsNil: Boolean - dismissedByNotNil: Boolean - dismissedByEqualFold: String - dismissedByContainsFold: String - - """model field predicates""" - model: String - modelNEQ: String - modelIn: [String!] - modelNotIn: [String!] - modelGT: String - modelGTE: String - modelLT: String - modelLTE: String - modelContains: String - modelHasPrefix: String - modelHasSuffix: String - modelEqualFold: String - modelContainsFold: String - - """created_at field predicates""" + """"source_branch_index" field predicates.""" + sourceBranchIndexLTE: Int + + """"source_branch_index" field predicates.""" + sourceBranchIndexIsNil: Boolean + + """"source_branch_index" field predicates.""" + sourceBranchIndexNotNil: Boolean + + """"status" field predicates.""" + status: LogEventPolicyStatus + + """"status" field predicates.""" + statusNEQ: LogEventPolicyStatus + + """"status" field predicates.""" + statusIn: [LogEventPolicyStatus!] + + """"status" field predicates.""" + statusNotIn: [LogEventPolicyStatus!] + + """"compiled_at" field predicates.""" + compiledAt: Time + + """"compiled_at" field predicates.""" + compiledAtNEQ: Time + + """"compiled_at" field predicates.""" + compiledAtIn: [Time!] + + """"compiled_at" field predicates.""" + compiledAtNotIn: [Time!] + + """"compiled_at" field predicates.""" + compiledAtGT: Time + + """"compiled_at" field predicates.""" + compiledAtGTE: Time + + """"compiled_at" field predicates.""" + compiledAtLT: Time + + """"compiled_at" field predicates.""" + compiledAtLTE: Time + + """"created_at" field predicates.""" createdAt: Time + + """"created_at" field predicates.""" createdAtNEQ: Time + + """"created_at" field predicates.""" createdAtIn: [Time!] + + """"created_at" field predicates.""" createdAtNotIn: [Time!] + + """"created_at" field predicates.""" createdAtGT: Time + + """"created_at" field predicates.""" createdAtGTE: Time + + """"created_at" field predicates.""" createdAtLT: Time + + """"created_at" field predicates.""" createdAtLTE: Time - """updated_at field predicates""" + """"updated_at" field predicates.""" updatedAt: Time + + """"updated_at" field predicates.""" updatedAtNEQ: Time + + """"updated_at" field predicates.""" updatedAtIn: [Time!] + + """"updated_at" field predicates.""" updatedAtNotIn: [Time!] + + """"updated_at" field predicates.""" updatedAtGT: Time + + """"updated_at" field predicates.""" updatedAtGTE: Time + + """"updated_at" field predicates.""" updatedAtLT: Time + + """"updated_at" field predicates.""" updatedAtLTE: Time - """log_event edge predicates""" - hasLogEvent: Boolean - hasLogEventWith: [LogEventWhereInput!] + """Edge instance that reported execution telemetry for this policy.""" + edgeInstanceID: ID + + """Edge instances that reported execution telemetry for this policy.""" + edgeInstanceIDIn: [ID!] +} - """workspace edge predicates""" - hasWorkspace: Boolean - hasWorkspaceWith: [WorkspaceWhereInput!] +type LogEventServiceFacet { + buckets: [LogEventServiceFacetBucket!]! +} + +type LogEventServiceFacetBucket { + service: Service! + count: Int! } -"""LogEventSeverity is enum for the field severity""" enum LogEventSeverity { debug info @@ -3450,584 +6385,411 @@ enum LogEventSeverity { other } -"""LogEventSignalPurpose is enum for the field signal_purpose""" -enum LogEventSignalPurpose { - diagnostic - operational - lifecycle - ephemeral +type LogEventSeverityFacet { + buckets: [LogEventSeverityFacetBucket!]! } -type LogEventStatusCache implements Node { - id: ID! - accountID: UUID! - logEventID: ID! - serviceID: UUID! - hasVolumes: Boolean! - hasBeenAnalyzed: Boolean! - policyCount: Int! - pendingPolicyCount: Int! - approvedPolicyCount: Int! - dismissedPolicyCount: Int! - policyPendingLowCount: Int! - policyPendingMediumCount: Int! - policyPendingHighCount: Int! - policyPendingCriticalCount: Int! - estimatedVolumeReductionPerHour: Float - estimatedBytesReductionPerHour: Float - volumePerHour: Float - bytesPerHour: Float - costPerHourBytesUsd: Float - costPerHourVolumeUsd: Float - costPerHourUsd: Float - estimatedCostReductionPerHourBytesUsd: Float - estimatedCostReductionPerHourVolumeUsd: Float - estimatedCostReductionPerHourUsd: Float - observedVolumePerHourBefore: Float - observedVolumePerHourAfter: Float - observedBytesPerHourBefore: Float - observedBytesPerHourAfter: Float - observedCostPerHourBeforeBytesUsd: Float - observedCostPerHourBeforeVolumeUsd: Float - observedCostPerHourBeforeUsd: Float - observedCostPerHourAfterBytesUsd: Float - observedCostPerHourAfterVolumeUsd: Float - observedCostPerHourAfterUsd: Float - refreshedAt: Time! +type LogEventSeverityFacetBucket { + value: LogEventSeverity! + count: Int! } -""" -LogEventStatusCacheWhereInput is used for filtering LogEventStatusCache objects. -Input was generated by ent. -""" -input LogEventStatusCacheWhereInput { - not: LogEventStatusCacheWhereInput - and: [LogEventStatusCacheWhereInput!] - or: [LogEventStatusCacheWhereInput!] - - """id field predicates""" - id: ID - idNEQ: ID - idIn: [ID!] - idNotIn: [ID!] - idGT: ID - idGTE: ID - idLT: ID - idLTE: ID - - """account_id field predicates""" - accountID: UUID - accountIDNEQ: UUID - accountIDIn: [UUID!] - accountIDNotIn: [UUID!] - accountIDGT: UUID - accountIDGTE: UUID - accountIDLT: UUID - accountIDLTE: UUID +type LogEventStatus { + signals: LogEventStatusSignals! + current: StatusMeasurementTotals! + preview: StatusScenario! + effective: StatusScenario! +} - """log_event_id field predicates""" - logEventID: ID - logEventIDNEQ: ID - logEventIDIn: [ID!] - logEventIDNotIn: [ID!] +type LogEventStatusSignals { + hasVolumes: Boolean! + hasBeenAnalyzed: Boolean! + hasPreviewPolicy: Boolean! + hasEffectivePolicy: Boolean! +} - """service_id field predicates""" - serviceID: UUID - serviceIDNEQ: UUID - serviceIDIn: [UUID!] - serviceIDNotIn: [UUID!] - serviceIDGT: UUID - serviceIDGTE: UUID - serviceIDLT: UUID - serviceIDLTE: UUID - - """has_volumes field predicates""" - hasVolumes: Boolean - hasVolumesNEQ: Boolean - - """has_been_analyzed field predicates""" - hasBeenAnalyzed: Boolean - hasBeenAnalyzedNEQ: Boolean - - """policy_count field predicates""" - policyCount: Int - policyCountNEQ: Int - policyCountIn: [Int!] - policyCountNotIn: [Int!] - policyCountGT: Int - policyCountGTE: Int - policyCountLT: Int - policyCountLTE: Int - - """pending_policy_count field predicates""" - pendingPolicyCount: Int - pendingPolicyCountNEQ: Int - pendingPolicyCountIn: [Int!] - pendingPolicyCountNotIn: [Int!] - pendingPolicyCountGT: Int - pendingPolicyCountGTE: Int - pendingPolicyCountLT: Int - pendingPolicyCountLTE: Int - - """approved_policy_count field predicates""" - approvedPolicyCount: Int - approvedPolicyCountNEQ: Int - approvedPolicyCountIn: [Int!] - approvedPolicyCountNotIn: [Int!] - approvedPolicyCountGT: Int - approvedPolicyCountGTE: Int - approvedPolicyCountLT: Int - approvedPolicyCountLTE: Int - - """dismissed_policy_count field predicates""" - dismissedPolicyCount: Int - dismissedPolicyCountNEQ: Int - dismissedPolicyCountIn: [Int!] - dismissedPolicyCountNotIn: [Int!] - dismissedPolicyCountGT: Int - dismissedPolicyCountGTE: Int - dismissedPolicyCountLT: Int - dismissedPolicyCountLTE: Int - - """policy_pending_low_count field predicates""" - policyPendingLowCount: Int - policyPendingLowCountNEQ: Int - policyPendingLowCountIn: [Int!] - policyPendingLowCountNotIn: [Int!] - policyPendingLowCountGT: Int - policyPendingLowCountGTE: Int - policyPendingLowCountLT: Int - policyPendingLowCountLTE: Int - - """policy_pending_medium_count field predicates""" - policyPendingMediumCount: Int - policyPendingMediumCountNEQ: Int - policyPendingMediumCountIn: [Int!] - policyPendingMediumCountNotIn: [Int!] - policyPendingMediumCountGT: Int - policyPendingMediumCountGTE: Int - policyPendingMediumCountLT: Int - policyPendingMediumCountLTE: Int - - """policy_pending_high_count field predicates""" - policyPendingHighCount: Int - policyPendingHighCountNEQ: Int - policyPendingHighCountIn: [Int!] - policyPendingHighCountNotIn: [Int!] - policyPendingHighCountGT: Int - policyPendingHighCountGTE: Int - policyPendingHighCountLT: Int - policyPendingHighCountLTE: Int - - """policy_pending_critical_count field predicates""" - policyPendingCriticalCount: Int - policyPendingCriticalCountNEQ: Int - policyPendingCriticalCountIn: [Int!] - policyPendingCriticalCountNotIn: [Int!] - policyPendingCriticalCountGT: Int - policyPendingCriticalCountGTE: Int - policyPendingCriticalCountLT: Int - policyPendingCriticalCountLTE: Int - - """estimated_volume_reduction_per_hour field predicates""" - estimatedVolumeReductionPerHour: Float - estimatedVolumeReductionPerHourNEQ: Float - estimatedVolumeReductionPerHourIn: [Float!] - estimatedVolumeReductionPerHourNotIn: [Float!] - estimatedVolumeReductionPerHourGT: Float - estimatedVolumeReductionPerHourGTE: Float - estimatedVolumeReductionPerHourLT: Float - estimatedVolumeReductionPerHourLTE: Float - estimatedVolumeReductionPerHourIsNil: Boolean - estimatedVolumeReductionPerHourNotNil: Boolean - - """estimated_bytes_reduction_per_hour field predicates""" - estimatedBytesReductionPerHour: Float - estimatedBytesReductionPerHourNEQ: Float - estimatedBytesReductionPerHourIn: [Float!] - estimatedBytesReductionPerHourNotIn: [Float!] - estimatedBytesReductionPerHourGT: Float - estimatedBytesReductionPerHourGTE: Float - estimatedBytesReductionPerHourLT: Float - estimatedBytesReductionPerHourLTE: Float - estimatedBytesReductionPerHourIsNil: Boolean - estimatedBytesReductionPerHourNotNil: Boolean - - """volume_per_hour field predicates""" - volumePerHour: Float - volumePerHourNEQ: Float - volumePerHourIn: [Float!] - volumePerHourNotIn: [Float!] - volumePerHourGT: Float - volumePerHourGTE: Float - volumePerHourLT: Float - volumePerHourLTE: Float - volumePerHourIsNil: Boolean - volumePerHourNotNil: Boolean - - """bytes_per_hour field predicates""" - bytesPerHour: Float - bytesPerHourNEQ: Float - bytesPerHourIn: [Float!] - bytesPerHourNotIn: [Float!] - bytesPerHourGT: Float - bytesPerHourGTE: Float - bytesPerHourLT: Float - bytesPerHourLTE: Float - bytesPerHourIsNil: Boolean - bytesPerHourNotNil: Boolean - - """cost_per_hour_bytes_usd field predicates""" - costPerHourBytesUsd: Float - costPerHourBytesUsdNEQ: Float - costPerHourBytesUsdIn: [Float!] - costPerHourBytesUsdNotIn: [Float!] - costPerHourBytesUsdGT: Float - costPerHourBytesUsdGTE: Float - costPerHourBytesUsdLT: Float - costPerHourBytesUsdLTE: Float - costPerHourBytesUsdIsNil: Boolean - costPerHourBytesUsdNotNil: Boolean - - """cost_per_hour_volume_usd field predicates""" - costPerHourVolumeUsd: Float - costPerHourVolumeUsdNEQ: Float - costPerHourVolumeUsdIn: [Float!] - costPerHourVolumeUsdNotIn: [Float!] - costPerHourVolumeUsdGT: Float - costPerHourVolumeUsdGTE: Float - costPerHourVolumeUsdLT: Float - costPerHourVolumeUsdLTE: Float - costPerHourVolumeUsdIsNil: Boolean - costPerHourVolumeUsdNotNil: Boolean - - """cost_per_hour_usd field predicates""" - costPerHourUsd: Float - costPerHourUsdNEQ: Float - costPerHourUsdIn: [Float!] - costPerHourUsdNotIn: [Float!] - costPerHourUsdGT: Float - costPerHourUsdGTE: Float - costPerHourUsdLT: Float - costPerHourUsdLTE: Float - costPerHourUsdIsNil: Boolean - costPerHourUsdNotNil: Boolean - - """estimated_cost_reduction_per_hour_bytes_usd field predicates""" - estimatedCostReductionPerHourBytesUsd: Float - estimatedCostReductionPerHourBytesUsdNEQ: Float - estimatedCostReductionPerHourBytesUsdIn: [Float!] - estimatedCostReductionPerHourBytesUsdNotIn: [Float!] - estimatedCostReductionPerHourBytesUsdGT: Float - estimatedCostReductionPerHourBytesUsdGTE: Float - estimatedCostReductionPerHourBytesUsdLT: Float - estimatedCostReductionPerHourBytesUsdLTE: Float - estimatedCostReductionPerHourBytesUsdIsNil: Boolean - estimatedCostReductionPerHourBytesUsdNotNil: Boolean - - """estimated_cost_reduction_per_hour_volume_usd field predicates""" - estimatedCostReductionPerHourVolumeUsd: Float - estimatedCostReductionPerHourVolumeUsdNEQ: Float - estimatedCostReductionPerHourVolumeUsdIn: [Float!] - estimatedCostReductionPerHourVolumeUsdNotIn: [Float!] - estimatedCostReductionPerHourVolumeUsdGT: Float - estimatedCostReductionPerHourVolumeUsdGTE: Float - estimatedCostReductionPerHourVolumeUsdLT: Float - estimatedCostReductionPerHourVolumeUsdLTE: Float - estimatedCostReductionPerHourVolumeUsdIsNil: Boolean - estimatedCostReductionPerHourVolumeUsdNotNil: Boolean - - """estimated_cost_reduction_per_hour_usd field predicates""" - estimatedCostReductionPerHourUsd: Float - estimatedCostReductionPerHourUsdNEQ: Float - estimatedCostReductionPerHourUsdIn: [Float!] - estimatedCostReductionPerHourUsdNotIn: [Float!] - estimatedCostReductionPerHourUsdGT: Float - estimatedCostReductionPerHourUsdGTE: Float - estimatedCostReductionPerHourUsdLT: Float - estimatedCostReductionPerHourUsdLTE: Float - estimatedCostReductionPerHourUsdIsNil: Boolean - estimatedCostReductionPerHourUsdNotNil: Boolean - - """observed_volume_per_hour_before field predicates""" - observedVolumePerHourBefore: Float - observedVolumePerHourBeforeNEQ: Float - observedVolumePerHourBeforeIn: [Float!] - observedVolumePerHourBeforeNotIn: [Float!] - observedVolumePerHourBeforeGT: Float - observedVolumePerHourBeforeGTE: Float - observedVolumePerHourBeforeLT: Float - observedVolumePerHourBeforeLTE: Float - observedVolumePerHourBeforeIsNil: Boolean - observedVolumePerHourBeforeNotNil: Boolean - - """observed_volume_per_hour_after field predicates""" - observedVolumePerHourAfter: Float - observedVolumePerHourAfterNEQ: Float - observedVolumePerHourAfterIn: [Float!] - observedVolumePerHourAfterNotIn: [Float!] - observedVolumePerHourAfterGT: Float - observedVolumePerHourAfterGTE: Float - observedVolumePerHourAfterLT: Float - observedVolumePerHourAfterLTE: Float - observedVolumePerHourAfterIsNil: Boolean - observedVolumePerHourAfterNotNil: Boolean - - """observed_bytes_per_hour_before field predicates""" - observedBytesPerHourBefore: Float - observedBytesPerHourBeforeNEQ: Float - observedBytesPerHourBeforeIn: [Float!] - observedBytesPerHourBeforeNotIn: [Float!] - observedBytesPerHourBeforeGT: Float - observedBytesPerHourBeforeGTE: Float - observedBytesPerHourBeforeLT: Float - observedBytesPerHourBeforeLTE: Float - observedBytesPerHourBeforeIsNil: Boolean - observedBytesPerHourBeforeNotNil: Boolean - - """observed_bytes_per_hour_after field predicates""" - observedBytesPerHourAfter: Float - observedBytesPerHourAfterNEQ: Float - observedBytesPerHourAfterIn: [Float!] - observedBytesPerHourAfterNotIn: [Float!] - observedBytesPerHourAfterGT: Float - observedBytesPerHourAfterGTE: Float - observedBytesPerHourAfterLT: Float - observedBytesPerHourAfterLTE: Float - observedBytesPerHourAfterIsNil: Boolean - observedBytesPerHourAfterNotNil: Boolean - - """observed_cost_per_hour_before_bytes_usd field predicates""" - observedCostPerHourBeforeBytesUsd: Float - observedCostPerHourBeforeBytesUsdNEQ: Float - observedCostPerHourBeforeBytesUsdIn: [Float!] - observedCostPerHourBeforeBytesUsdNotIn: [Float!] - observedCostPerHourBeforeBytesUsdGT: Float - observedCostPerHourBeforeBytesUsdGTE: Float - observedCostPerHourBeforeBytesUsdLT: Float - observedCostPerHourBeforeBytesUsdLTE: Float - observedCostPerHourBeforeBytesUsdIsNil: Boolean - observedCostPerHourBeforeBytesUsdNotNil: Boolean - - """observed_cost_per_hour_before_volume_usd field predicates""" - observedCostPerHourBeforeVolumeUsd: Float - observedCostPerHourBeforeVolumeUsdNEQ: Float - observedCostPerHourBeforeVolumeUsdIn: [Float!] - observedCostPerHourBeforeVolumeUsdNotIn: [Float!] - observedCostPerHourBeforeVolumeUsdGT: Float - observedCostPerHourBeforeVolumeUsdGTE: Float - observedCostPerHourBeforeVolumeUsdLT: Float - observedCostPerHourBeforeVolumeUsdLTE: Float - observedCostPerHourBeforeVolumeUsdIsNil: Boolean - observedCostPerHourBeforeVolumeUsdNotNil: Boolean - - """observed_cost_per_hour_before_usd field predicates""" - observedCostPerHourBeforeUsd: Float - observedCostPerHourBeforeUsdNEQ: Float - observedCostPerHourBeforeUsdIn: [Float!] - observedCostPerHourBeforeUsdNotIn: [Float!] - observedCostPerHourBeforeUsdGT: Float - observedCostPerHourBeforeUsdGTE: Float - observedCostPerHourBeforeUsdLT: Float - observedCostPerHourBeforeUsdLTE: Float - observedCostPerHourBeforeUsdIsNil: Boolean - observedCostPerHourBeforeUsdNotNil: Boolean - - """observed_cost_per_hour_after_bytes_usd field predicates""" - observedCostPerHourAfterBytesUsd: Float - observedCostPerHourAfterBytesUsdNEQ: Float - observedCostPerHourAfterBytesUsdIn: [Float!] - observedCostPerHourAfterBytesUsdNotIn: [Float!] - observedCostPerHourAfterBytesUsdGT: Float - observedCostPerHourAfterBytesUsdGTE: Float - observedCostPerHourAfterBytesUsdLT: Float - observedCostPerHourAfterBytesUsdLTE: Float - observedCostPerHourAfterBytesUsdIsNil: Boolean - observedCostPerHourAfterBytesUsdNotNil: Boolean - - """observed_cost_per_hour_after_volume_usd field predicates""" - observedCostPerHourAfterVolumeUsd: Float - observedCostPerHourAfterVolumeUsdNEQ: Float - observedCostPerHourAfterVolumeUsdIn: [Float!] - observedCostPerHourAfterVolumeUsdNotIn: [Float!] - observedCostPerHourAfterVolumeUsdGT: Float - observedCostPerHourAfterVolumeUsdGTE: Float - observedCostPerHourAfterVolumeUsdLT: Float - observedCostPerHourAfterVolumeUsdLTE: Float - observedCostPerHourAfterVolumeUsdIsNil: Boolean - observedCostPerHourAfterVolumeUsdNotNil: Boolean - - """observed_cost_per_hour_after_usd field predicates""" - observedCostPerHourAfterUsd: Float - observedCostPerHourAfterUsdNEQ: Float - observedCostPerHourAfterUsdIn: [Float!] - observedCostPerHourAfterUsdNotIn: [Float!] - observedCostPerHourAfterUsdGT: Float - observedCostPerHourAfterUsdGTE: Float - observedCostPerHourAfterUsdLT: Float - observedCostPerHourAfterUsdLTE: Float - observedCostPerHourAfterUsdIsNil: Boolean - observedCostPerHourAfterUsdNotNil: Boolean - - """refreshed_at field predicates""" - refreshedAt: Time - refreshedAtNEQ: Time - refreshedAtIn: [Time!] - refreshedAtNotIn: [Time!] - refreshedAtGT: Time - refreshedAtGTE: Time - refreshedAtLT: Time - refreshedAtLTE: Time +type LogEventSummary { + count: Int! } -""" -LogEventWhereInput is used for filtering LogEvent objects. -Input was generated by ent. -""" input LogEventWhereInput { not: LogEventWhereInput and: [LogEventWhereInput!] or: [LogEventWhereInput!] - """id field predicates""" + """"service" edge predicates.""" + service: ServiceWhereInput + + """Whether the "service" edge has at least one related row.""" + hasService: Boolean + + """"log_event_policies" edge predicates.""" + logEventPolicies: LogEventPolicyWhereInput + + """Whether the "log_event_policies" edge has at least one related row.""" + hasLogEventPolicies: Boolean + + """"datadog_log_exclusion_filters" edge predicates.""" + datadogLogExclusionFilters: DatadogLogExclusionFilterWhereInput + + """ + Whether the "datadog_log_exclusion_filters" edge has at least one related row. + """ + hasDatadogLogExclusionFilters: Boolean + + """"id" field predicates.""" id: ID + + """"id" field predicates.""" idNEQ: ID + + """"id" field predicates.""" idIn: [ID!] + + """"id" field predicates.""" idNotIn: [ID!] + + """"id" field predicates.""" idGT: ID + + """"id" field predicates.""" idGTE: ID + + """"id" field predicates.""" idLT: ID - idLTE: ID - """account_id field predicates""" - accountID: UUID - accountIDNEQ: UUID - accountIDIn: [UUID!] - accountIDNotIn: [UUID!] - accountIDGT: UUID - accountIDGTE: UUID - accountIDLT: UUID - accountIDLTE: UUID + """"id" field predicates.""" + idLTE: ID - """service_id field predicates""" + """"service_id" field predicates.""" serviceID: ID + + """"service_id" field predicates.""" serviceIDNEQ: ID + + """"service_id" field predicates.""" serviceIDIn: [ID!] + + """"service_id" field predicates.""" serviceIDNotIn: [ID!] - """name field predicates""" + """"log_sample_id" field predicates.""" + logSampleID: ID + + """"log_sample_id" field predicates.""" + logSampleIDNEQ: ID + + """"log_sample_id" field predicates.""" + logSampleIDIn: [ID!] + + """"log_sample_id" field predicates.""" + logSampleIDNotIn: [ID!] + + """"name" field predicates.""" name: String + + """"name" field predicates.""" nameNEQ: String + + """"name" field predicates.""" nameIn: [String!] + + """"name" field predicates.""" nameNotIn: [String!] + + """"name" field predicates.""" nameGT: String + + """"name" field predicates.""" nameGTE: String + + """"name" field predicates.""" nameLT: String + + """"name" field predicates.""" nameLTE: String + + """"name" field predicates.""" nameContains: String + + """"name" field predicates.""" nameHasPrefix: String + + """"name" field predicates.""" nameHasSuffix: String + + """"name" field predicates.""" nameEqualFold: String + + """"name" field predicates.""" nameContainsFold: String - """description field predicates""" - description: String - descriptionNEQ: String - descriptionIn: [String!] - descriptionNotIn: [String!] - descriptionGT: String - descriptionGTE: String - descriptionLT: String - descriptionLTE: String - descriptionContains: String - descriptionHasPrefix: String - descriptionHasSuffix: String - descriptionEqualFold: String - descriptionContainsFold: String + """"display_name" field predicates.""" + displayName: String + + """"display_name" field predicates.""" + displayNameNEQ: String + + """"display_name" field predicates.""" + displayNameIn: [String!] + + """"display_name" field predicates.""" + displayNameNotIn: [String!] + + """"display_name" field predicates.""" + displayNameGT: String + + """"display_name" field predicates.""" + displayNameGTE: String + + """"display_name" field predicates.""" + displayNameLT: String + + """"display_name" field predicates.""" + displayNameLTE: String + + """"display_name" field predicates.""" + displayNameContains: String + + """"display_name" field predicates.""" + displayNameHasPrefix: String + + """"display_name" field predicates.""" + displayNameHasSuffix: String + + """"display_name" field predicates.""" + displayNameIsNil: Boolean + + """"display_name" field predicates.""" + displayNameNotNil: Boolean + + """"display_name" field predicates.""" + displayNameEqualFold: String + + """"display_name" field predicates.""" + displayNameContainsFold: String + + """"is_fragment" field predicates.""" + isFragment: Boolean + + """"is_fragment" field predicates.""" + isFragmentNEQ: Boolean + + """"template" field predicates.""" + template: String + + """"template" field predicates.""" + templateNEQ: String + + """"template" field predicates.""" + templateIn: [String!] + + """"template" field predicates.""" + templateNotIn: [String!] + + """"template" field predicates.""" + templateGT: String - """severity field predicates""" + """"template" field predicates.""" + templateGTE: String + + """"template" field predicates.""" + templateLT: String + + """"template" field predicates.""" + templateLTE: String + + """"template" field predicates.""" + templateContains: String + + """"template" field predicates.""" + templateHasPrefix: String + + """"template" field predicates.""" + templateHasSuffix: String + + """"template" field predicates.""" + templateEqualFold: String + + """"template" field predicates.""" + templateContainsFold: String + + """"severity" field predicates.""" severity: LogEventSeverity + + """"severity" field predicates.""" severityNEQ: LogEventSeverity + + """"severity" field predicates.""" severityIn: [LogEventSeverity!] + + """"severity" field predicates.""" severityNotIn: [LogEventSeverity!] + + """"severity" field predicates.""" severityIsNil: Boolean + + """"severity" field predicates.""" severityNotNil: Boolean - """signal_purpose field predicates""" - signalPurpose: LogEventSignalPurpose - signalPurposeNEQ: LogEventSignalPurpose - signalPurposeIn: [LogEventSignalPurpose!] - signalPurposeNotIn: [LogEventSignalPurpose!] + """"matchers" field predicates.""" + matchersIsNil: Boolean - """event_nature field predicates""" - eventNature: LogEventEventNature - eventNatureNEQ: LogEventEventNature - eventNatureIn: [LogEventEventNature!] - eventNatureNotIn: [LogEventEventNature!] + """"matchers" field predicates.""" + matchersNotNil: Boolean - """is_fragment field predicates""" - isFragment: Boolean - isFragmentNEQ: Boolean + """"target_log" field predicates.""" + targetLogIsNil: Boolean + + """"target_log" field predicates.""" + targetLogNotNil: Boolean - """baseline_volume_per_hour field predicates""" + """"examples" field predicates.""" + examplesIsNil: Boolean + + """"examples" field predicates.""" + examplesNotNil: Boolean + + """"baseline_volume_per_hour" field predicates.""" baselineVolumePerHour: Float + + """"baseline_volume_per_hour" field predicates.""" baselineVolumePerHourNEQ: Float + + """"baseline_volume_per_hour" field predicates.""" baselineVolumePerHourIn: [Float!] + + """"baseline_volume_per_hour" field predicates.""" baselineVolumePerHourNotIn: [Float!] + + """"baseline_volume_per_hour" field predicates.""" baselineVolumePerHourGT: Float + + """"baseline_volume_per_hour" field predicates.""" baselineVolumePerHourGTE: Float + + """"baseline_volume_per_hour" field predicates.""" baselineVolumePerHourLT: Float + + """"baseline_volume_per_hour" field predicates.""" baselineVolumePerHourLTE: Float + + """"baseline_volume_per_hour" field predicates.""" baselineVolumePerHourIsNil: Boolean + + """"baseline_volume_per_hour" field predicates.""" baselineVolumePerHourNotNil: Boolean - """baseline_avg_bytes field predicates""" + """"baseline_avg_bytes" field predicates.""" baselineAvgBytes: Float + + """"baseline_avg_bytes" field predicates.""" baselineAvgBytesNEQ: Float + + """"baseline_avg_bytes" field predicates.""" baselineAvgBytesIn: [Float!] + + """"baseline_avg_bytes" field predicates.""" baselineAvgBytesNotIn: [Float!] + + """"baseline_avg_bytes" field predicates.""" baselineAvgBytesGT: Float + + """"baseline_avg_bytes" field predicates.""" baselineAvgBytesGTE: Float + + """"baseline_avg_bytes" field predicates.""" baselineAvgBytesLT: Float + + """"baseline_avg_bytes" field predicates.""" baselineAvgBytesLTE: Float + + """"baseline_avg_bytes" field predicates.""" baselineAvgBytesIsNil: Boolean + + """"baseline_avg_bytes" field predicates.""" baselineAvgBytesNotNil: Boolean - """created_at field predicates""" + """"baseline_volume_share_of_service" field predicates.""" + baselineVolumeShareOfService: Float + + """"baseline_volume_share_of_service" field predicates.""" + baselineVolumeShareOfServiceNEQ: Float + + """"baseline_volume_share_of_service" field predicates.""" + baselineVolumeShareOfServiceIn: [Float!] + + """"baseline_volume_share_of_service" field predicates.""" + baselineVolumeShareOfServiceNotIn: [Float!] + + """"baseline_volume_share_of_service" field predicates.""" + baselineVolumeShareOfServiceGT: Float + + """"baseline_volume_share_of_service" field predicates.""" + baselineVolumeShareOfServiceGTE: Float + + """"baseline_volume_share_of_service" field predicates.""" + baselineVolumeShareOfServiceLT: Float + + """"baseline_volume_share_of_service" field predicates.""" + baselineVolumeShareOfServiceLTE: Float + + """"baseline_volume_share_of_service" field predicates.""" + baselineVolumeShareOfServiceIsNil: Boolean + + """"baseline_volume_share_of_service" field predicates.""" + baselineVolumeShareOfServiceNotNil: Boolean + + """"created_at" field predicates.""" createdAt: Time + + """"created_at" field predicates.""" createdAtNEQ: Time + + """"created_at" field predicates.""" createdAtIn: [Time!] + + """"created_at" field predicates.""" createdAtNotIn: [Time!] + + """"created_at" field predicates.""" createdAtGT: Time + + """"created_at" field predicates.""" createdAtGTE: Time + + """"created_at" field predicates.""" createdAtLT: Time + + """"created_at" field predicates.""" createdAtLTE: Time - """updated_at field predicates""" + """"updated_at" field predicates.""" updatedAt: Time + + """"updated_at" field predicates.""" updatedAtNEQ: Time + + """"updated_at" field predicates.""" updatedAtIn: [Time!] + + """"updated_at" field predicates.""" updatedAtNotIn: [Time!] + + """"updated_at" field predicates.""" updatedAtGT: Time - updatedAtGTE: Time - updatedAtLT: Time - updatedAtLTE: Time - """service edge predicates""" - hasService: Boolean - hasServiceWith: [ServiceWhereInput!] + """"updated_at" field predicates.""" + updatedAtGTE: Time - """log_sample edge predicates""" - hasLogSample: Boolean - hasLogSampleWith: [LogSampleWhereInput!] + """"updated_at" field predicates.""" + updatedAtLT: Time - """policies edge predicates""" - hasPolicies: Boolean - hasPoliciesWith: [LogEventPolicyWhereInput!] + """"updated_at" field predicates.""" + updatedAtLTE: Time - """log_event_fields edge predicates""" - hasLogEventFields: Boolean - hasLogEventFieldsWith: [LogEventFieldWhereInput!] + """Filters for log-event JSONB facts.""" + logEventFacts: LogEventFactsWhereInput } """A normalized log record following OTEL conventions""" @@ -4051,455 +6813,375 @@ type LogRecord { scopeAttributes: Map } -type LogSample implements Node { - """Unique identifier of the log sample""" - id: ID! +input LogVolumeFilterInput { + """ + Sources to include. Omitted sources are inferred from source-specific filters, + or all supported sources when no source-specific filter is present. At most + 100 values. + """ + sources: [TelemetrySource!] - """Parent account for tenant isolation""" - accountID: ID! + """ + Restrict Datadog-backed observations to these log indexes. At most 100 + values. + """ + datadogLogIndexIDs: [UUID!] - """Service these logs belong to""" - serviceID: ID! + """ + Restrict edge-backed observations to these edge instances. At most 100 values. + """ + edgeInstanceIDs: [UUID!] +} - """Datadog account that produced this sample""" - datadogAccountID: ID! +input LogVolumeInput { + """Half-open range to bucket. Boundaries must align to granularity.""" + range: TimeRangeInput! - """Number of OTEL log records in this page""" - recordCount: Int! + """ + Granularity for returned points. Requests may include at most 1000 points. + """ + granularity: TimeGranularity! + filter: LogVolumeFilterInput +} - """Size of the GCS object in bytes""" - byteSize: Int! +type LogVolumePoint { + start: Time! + end: Time! + logCount: Float! + estimatedBytes: Float + avgBytes: Float + quality: MeasurementQuality! +} - """GCS object path for the log payload""" - storagePath: String! +type LogVolumeSeries { + range: TimeRange! + granularity: TimeGranularity! + points: [LogVolumePoint!]! + totals: LogVolumeTotals! +} - """Earliest log timestamp in this page""" - timeFrom: Time! +type LogVolumeSummary { + logCount: Float! + estimatedBytes: Float + avgBytes: Float +} - """Latest log timestamp in this page""" - timeTo: Time! +input LogVolumeSummaryFilterInput { + """ + Sources to include. Omitted sources are inferred from source-specific filters, + or all supported sources when no source-specific filter is present. At most + 100 values. + """ + sources: [TelemetrySource!] - """When this sample was ingested""" - createdAt: Time! + """ + Restrict Datadog-backed observations to these log indexes. At most 100 + values. + """ + datadogLogIndexIDs: [UUID!] +} - """When this record was last updated""" - updatedAt: Time! +input LogVolumeSummaryInput { + """Half-open range to summarize.""" + range: TimeRangeInput! + filter: LogVolumeSummaryFilterInput +} - """Account this sample belongs to""" - account: Account! +type LogVolumeTotals { + logCount: Float! + estimatedBytes: Float + avgBytes: Float +} - """Service these logs belong to""" - service: Service! +scalar Map - """Datadog account that produced this sample""" - datadogAccount: DatadogAccount! +"""Baseline used for a measurement delta.""" +type MeasurementComparison { + window: MeasurementComparisonWindow! +} - """Log events discovered from this sample""" - logEvents: [LogEvent!] +enum MeasurementComparisonWindow { + PRIOR_30_DAYS + PRIOR_90_DAYS + PRIOR_YEAR + PREVIOUS_PERIOD } -""" -LogSampleWhereInput is used for filtering LogSample objects. -Input was generated by ent. -""" -input LogSampleWhereInput { - not: LogSampleWhereInput - and: [LogSampleWhereInput!] - or: [LogSampleWhereInput!] +"""Movement from a named measurement comparison baseline.""" +type MeasurementDelta { + value: Float! + unit: MeasurementDeltaUnit! + direction: MeasurementDeltaDirection! + comparison: MeasurementComparison! +} - """id field predicates""" - id: ID - idNEQ: ID - idIn: [ID!] - idNotIn: [ID!] - idGT: ID - idGTE: ID - idLT: ID - idLTE: ID +enum MeasurementDeltaDirection { + UP + DOWN + FLAT +} - """account_id field predicates""" - accountID: ID - accountIDNEQ: ID - accountIDIn: [ID!] - accountIDNotIn: [ID!] +enum MeasurementDeltaUnit { + PERCENT + POINTS + MONEY_USD + COUNT +} - """service_id field predicates""" - serviceID: ID - serviceIDNEQ: ID - serviceIDIn: [ID!] - serviceIDNotIn: [ID!] +"""How the server interpreted a measurement.""" +enum MeasurementQuality { + OBSERVED + ZERO + PARTIAL + MISSING +} - """datadog_account_id field predicates""" - datadogAccountID: ID - datadogAccountIDNEQ: ID - datadogAccountIDIn: [ID!] - datadogAccountIDNotIn: [ID!] +"""Money amount in USD.""" +type MoneyAmount { + usd: Float! +} - """record_count field predicates""" - recordCount: Int - recordCountNEQ: Int - recordCountIn: [Int!] - recordCountNotIn: [Int!] - recordCountGT: Int - recordCountGTE: Int - recordCountLT: Int - recordCountLTE: Int - - """byte_size field predicates""" - byteSize: Int - byteSizeNEQ: Int - byteSizeIn: [Int!] - byteSizeNotIn: [Int!] - byteSizeGT: Int - byteSizeGTE: Int - byteSizeLT: Int - byteSizeLTE: Int - - """storage_path field predicates""" - storagePath: String - storagePathNEQ: String - storagePathIn: [String!] - storagePathNotIn: [String!] - storagePathGT: String - storagePathGTE: String - storagePathLT: String - storagePathLTE: String - storagePathContains: String - storagePathHasPrefix: String - storagePathHasSuffix: String - storagePathEqualFold: String - storagePathContainsFold: String - - """time_from field predicates""" - timeFrom: Time - timeFromNEQ: Time - timeFromIn: [Time!] - timeFromNotIn: [Time!] - timeFromGT: Time - timeFromGTE: Time - timeFromLT: Time - timeFromLTE: Time - - """time_to field predicates""" - timeTo: Time - timeToNEQ: Time - timeToIn: [Time!] - timeToNotIn: [Time!] - timeToGT: Time - timeToGTE: Time - timeToLT: Time - timeToLTE: Time - - """created_at field predicates""" - createdAt: Time - createdAtNEQ: Time - createdAtIn: [Time!] - createdAtNotIn: [Time!] - createdAtGT: Time - createdAtGTE: Time - createdAtLT: Time - createdAtLTE: Time +type Mutation { + """Create account.""" + createAccount(input: AccountCreateInput!): Account! - """updated_at field predicates""" - updatedAt: Time - updatedAtNEQ: Time - updatedAtIn: [Time!] - updatedAtNotIn: [Time!] - updatedAtGT: Time - updatedAtGTE: Time - updatedAtLT: Time - updatedAtLTE: Time + """Delete account.""" + deleteAccount(id: ID!, confirmation: String!): Boolean! - """account edge predicates""" - hasAccount: Boolean - hasAccountWith: [AccountWhereInput!] + """Update account.""" + updateAccount(id: ID!, input: AccountUpdateInput!): Account! - """service edge predicates""" - hasService: Boolean - hasServiceWith: [ServiceWhereInput!] + """Create datadogaccount.""" + createDatadogAccount(input: DatadogAccountCreateInput!): DatadogAccount! - """datadog_account edge predicates""" - hasDatadogAccount: Boolean - hasDatadogAccountWith: [DatadogAccountWhereInput!] + """Delete datadogaccount.""" + deleteDatadogAccount(id: ID!): Boolean! - """log_events edge predicates""" - hasLogEvents: Boolean - hasLogEventsWith: [LogEventWhereInput!] -} + """Update datadogaccount.""" + updateDatadogAccount(id: ID!, input: DatadogAccountUpdateInput!): DatadogAccount! -scalar Map + """Validate a Datadog API key against a Datadog site.""" + validateDatadogApiKey(input: ValidateDatadogApiKeyInput!): ValidateDatadogApiKeyResult! -type Message implements Node { - """Unique identifier""" - id: ID! + """Create datadoglogexclusionfilter.""" + createDatadogLogExclusionFilter(input: DatadogLogExclusionFilterCreateInput!): DatadogLogExclusionFilter! + createLogEventPoliciesFromDatadogExclusionFilter(id: ID!, input: DatadogLogExclusionFilterPolicyImportInput!): [LogEventPolicy!]! """ - Denormalized for tenant isolation. Auto-set via trigger from conversation.account_id. + Revoke an edge API key. The key can no longer be used for authentication. """ - accountID: UUID! - - """Conversation this message belongs to""" - conversationID: ID! + revokeEdgeApiKey(id: ID!): EdgeApiKey! """ - Who sent this message. user: human-originated, assistant: AI-originated. + Close an issue with a user-supplied note. The note is persisted on the + issue's resolution_reason alongside the actor; it is required and shown + beside the issue's resolution in the product. No-op when the issue is + already closed (returns the existing issue unchanged). """ - role: MessageRole! + closeIssue(id: ID!, note: String!): Issue! + + """Ignore an issue.""" + ignoreIssue(id: ID!): Issue! + + """Create logeventpolicy.""" + createLogEventPolicy(input: LogEventPolicyCreateInput!): LogEventPolicy! """ - Why the assistant stopped generating. end_turn: completed response, tool_use: - paused to call a tool. Null for user messages. + Create a new organization and its default account using the additive runtime. """ - stopReason: MessageStopReason + createOrganizationAndBootstrap(input: OrganizationCreateInput!): OrganizationBootstrapResult! - """AI model that produced this message. Null for user messages.""" - model: String + """Create organization.""" + createOrganization(input: OrganizationCreateInput!): Organization! - """When the message was created""" - createdAt: Time! + """Delete organization.""" + deleteOrganization(id: ID!, confirmation: String!): Boolean! - """Conversation this message belongs to""" - conversation: Conversation! + """Revoke an organization invitation.""" + revokeOrganizationInvitation(id: String!): OrganizationInvitation! - """Views created by this message via show_view tool calls""" - views: [View!] + """Send an organization invitation.""" + sendOrganizationInvitation(input: SendOrganizationInvitationInput!): OrganizationInvitation! - """Array of typed content blocks: text, thinking, tool_use, tool_result.""" - content: [ContentBlock!]! -} + """Update organization.""" + updateOrganization(id: ID!, input: OrganizationUpdateInput!): Organization! -"""A connection to a list of items.""" -type MessageConnection { - """A list of edges.""" - edges: [MessageEdge] + """Set service enabled state.""" + setServiceEnabled(id: ID!, enabled: Boolean!): Service! - """Information to aid in pagination.""" - pageInfo: PageInfo! + """Assign a service to a team.""" + assignServiceToTeam(serviceID: ID!, teamID: ID!): Service! - """Identifies the total count of items in the connection.""" - totalCount: Int! -} + """Remove the team mapping from a service.""" + removeServiceTeamMapping(serviceID: ID!): Service! -"""An edge in a connection.""" -type MessageEdge { - """The item at the end of the edge.""" - node: Message + """Create team.""" + createTeam(input: TeamCreateInput!): Team! - """A cursor for use in pagination.""" - cursor: Cursor! -} + """Delete team.""" + deleteTeam(id: ID!): Boolean! -"""Ordering options for Message connections""" -input MessageOrder { - """The ordering direction.""" - direction: OrderDirection! = ASC + """Add a user to a team.""" + addTeamMember(teamID: ID!, userID: String!): Team! - """The field by which to order Messages.""" - field: MessageOrderField! -} + """Remove a user from a team.""" + removeTeamMember(teamID: ID!, userID: String!): Team! -"""Properties by which Message connections can be ordered.""" -enum MessageOrderField { - CREATED_AT -} + """Update team.""" + updateTeam(id: ID!, input: TeamUpdateInput!): Team! -"""MessageRole is enum for the field role""" -enum MessageRole { - user - assistant + """ + Create a new API key for edge instance authentication. + Returns the plain key only once - it cannot be retrieved afterward. + """ + createEdgeApiKey(input: CreateEdgeApiKeyInput!): CreateEdgeApiKeyResult! } -"""MessageStopReason is enum for the field stop_reason""" -enum MessageStopReason { - end_turn - tool_use +enum ObservabilityValueLevel { + """Adds essentially no meaningful observability value.""" + none + + """Adds weak but real observability value.""" + low + + """Adds materially important observability value.""" + high } """ -MessageWhereInput is used for filtering Message objects. -Input was generated by ent. +Captures one-copy observability value and additional repeated-stream value for a recurring log event. """ -input MessageWhereInput { - not: MessageWhereInput - and: [MessageWhereInput!] - or: [MessageWhereInput!] +type ObservabilityValueProfile { + """ + How much observability value one representative copy of the recurring event provides on its own. + """ + instanceValue: ObservabilityValueLevel! - """id field predicates""" - id: ID - idNEQ: ID - idIn: [ID!] - idNotIn: [ID!] - idGT: ID - idGTE: ID - idLT: ID - idLTE: ID + """ + How much additional observability value the repeated stream provides after one representative copy is already understood. + """ + collectionGain: ObservabilityValueLevel! +} - """account_id field predicates""" - accountID: UUID - accountIDNEQ: UUID - accountIDIn: [UUID!] - accountIDNotIn: [UUID!] - accountIDGT: UUID - accountIDGTE: UUID - accountIDLT: UUID - accountIDLTE: UUID +""" +Captures one-copy observability value and additional repeated-stream value for a recurring log event. +""" +input ObservabilityValueProfileWhereInput { + """Negated predicates.""" + not: ObservabilityValueProfileWhereInput - """conversation_id field predicates""" - conversationID: ID - conversationIDNEQ: ID - conversationIDIn: [ID!] - conversationIDNotIn: [ID!] - - """role field predicates""" - role: MessageRole - roleNEQ: MessageRole - roleIn: [MessageRole!] - roleNotIn: [MessageRole!] - - """stop_reason field predicates""" - stopReason: MessageStopReason - stopReasonNEQ: MessageStopReason - stopReasonIn: [MessageStopReason!] - stopReasonNotIn: [MessageStopReason!] - stopReasonIsNil: Boolean - stopReasonNotNil: Boolean - - """model field predicates""" - model: String - modelNEQ: String - modelIn: [String!] - modelNotIn: [String!] - modelGT: String - modelGTE: String - modelLT: String - modelLTE: String - modelContains: String - modelHasPrefix: String - modelHasSuffix: String - modelIsNil: Boolean - modelNotNil: Boolean - modelEqualFold: String - modelContainsFold: String - - """created_at field predicates""" - createdAt: Time - createdAtNEQ: Time - createdAtIn: [Time!] - createdAtNotIn: [Time!] - createdAtGT: Time - createdAtGTE: Time - createdAtLT: Time - createdAtLTE: Time + """Predicates that must all match.""" + and: [ObservabilityValueProfileWhereInput!] - """conversation edge predicates""" - hasConversation: Boolean - hasConversationWith: [ConversationWhereInput!] + """Predicates where at least one must match.""" + or: [ObservabilityValueProfileWhereInput!] - """views edge predicates""" - hasViews: Boolean - hasViewsWith: [ViewWhereInput!] -} + """Whether the observability_value_profile JSONB value is present.""" + present: Boolean -type Mutation { - createAccount(input: CreateAccountInput!): Account! - updateAccount(id: ID!, input: UpdateAccountInput!): Account! - deleteAccount(id: ID!): Boolean! - createOrganization(input: CreateOrganizationInput!): Organization! - createOrganizationAndBootstrap(input: CreateOrganizationInput!): OrganizationBootstrapResult! - updateOrganization(id: ID!, input: UpdateOrganizationInput!): Organization! - deleteOrganization(id: ID!, confirmed: Boolean!): Boolean! - createDatadogAccount(input: CreateDatadogAccountWithCredentialsInput!): DatadogAccount! - updateDatadogAccount(id: ID!, input: UpdateDatadogAccountInput!): DatadogAccount! - deleteDatadogAccount(id: ID!): Boolean! - validateDatadogApiKey(input: ValidateDatadogApiKeyInput!): ValidateDatadogApiKeyResult! - createWorkspace(input: CreateWorkspaceInput!): Workspace! - updateWorkspace(id: ID!, input: UpdateWorkspaceInput!): Workspace! - deleteWorkspace(id: ID!): Boolean! - createTeam(input: CreateTeamInput!): Team! - updateTeam(id: ID!, input: UpdateTeamInput!): Team! - deleteTeam(id: ID!): Boolean! - updateService(id: ID!, input: UpdateServiceInput!): Service! + """ + How much observability value one representative copy of the recurring event provides on its own. + """ + instanceValue: ObservabilityValueLevel """ - Create a new API key for edge instance authentication. - Returns the plain key only once - it cannot be retrieved afterward. + How much observability value one representative copy of the recurring event provides on its own. """ - createEdgeApiKey(input: CreateEdgeApiKeyInput!): CreateEdgeApiKeyResult! + instanceValueNEQ: ObservabilityValueLevel """ - Revoke an edge API key. The key can no longer be used for authentication. + How much observability value one representative copy of the recurring event provides on its own. """ - revokeEdgeApiKey(id: ID!): EdgeApiKey! + instanceValueIn: [ObservabilityValueLevel!] + + """ + How much observability value one representative copy of the recurring event provides on its own. + """ + instanceValueNotIn: [ObservabilityValueLevel!] + + """ + How much additional observability value the repeated stream provides after one representative copy is already understood. + """ + collectionGain: ObservabilityValueLevel """ - Create a new conversation in a workspace. - The conversation is owned by the authenticated user. + How much additional observability value the repeated stream provides after one representative copy is already understood. """ - createConversation(input: CreateConversationInput!): Conversation! + collectionGainNEQ: ObservabilityValueLevel - """Update a conversation (e.g., set title).""" - updateConversation(id: ID!, input: UpdateConversationInput!): Conversation! + """ + How much additional observability value the repeated stream provides after one representative copy is already understood. + """ + collectionGainIn: [ObservabilityValueLevel!] + + """ + How much additional observability value the repeated stream provides after one representative copy is already understood. + """ + collectionGainNotIn: [ObservabilityValueLevel!] +} - """Delete a conversation and all its messages.""" - deleteConversation(id: ID!): Boolean! +enum OperatorProminenceLevel { + """Should fade out of the normal operator-facing log surface.""" + none - """Create a new message in a conversation.""" - createMessage(input: CreateMessageInput!): Message! + """Useful routine context that deserves visible but secondary presence.""" + low """ - Create a new view. Called by clients when executing show_view tool results. - If the conversation has view_id set (iteration mode), forked_from_id is auto-set. + Important operator-facing signal that should be noticed in normal workflows. """ - createView(input: CreateViewInput!): View! + high +} +"""Captures normal operator-facing visibility for a recurring log event.""" +type OperatorProminenceProfile { """ - Add a view to the user's favorites. - Idempotent: if already favorited, returns the existing favorite. + How prominently this recurring event family should remain visible in normal operator workflows. """ - createViewFavorite(input: CreateViewFavoriteInput!): ViewFavorite! + operatorProminence: OperatorProminenceLevel! +} + +"""Captures normal operator-facing visibility for a recurring log event.""" +input OperatorProminenceProfileWhereInput { + """Negated predicates.""" + not: OperatorProminenceProfileWhereInput - """Remove a view from the user's favorites.""" - deleteViewFavorite(viewId: ID!): Boolean! + """Predicates that must all match.""" + and: [OperatorProminenceProfileWhereInput!] + + """Predicates where at least one must match.""" + or: [OperatorProminenceProfileWhereInput!] + + """Whether the operator_prominence_profile JSONB value is present.""" + present: Boolean """ - Approve a log event policy, enabling it for enforcement. - Clears any previous dismissal. + How prominently this recurring event family should remain visible in normal operator workflows. """ - approveLogEventPolicy(id: ID!): LogEventPolicy! + operatorProminence: OperatorProminenceLevel """ - Dismiss a log event policy, hiding it from pending review. - Clears any previous approval. + How prominently this recurring event family should remain visible in normal operator workflows. """ - dismissLogEventPolicy(id: ID!): LogEventPolicy! + operatorProminenceNEQ: OperatorProminenceLevel """ - Reset a log event policy to pending, clearing any approval or dismissal. + How prominently this recurring event family should remain visible in normal operator workflows. """ - resetLogEventPolicy(id: ID!): LogEventPolicy! -} + operatorProminenceIn: [OperatorProminenceLevel!] -""" -An object with an ID. -Follows the [Relay Global Object Identification Specification](https://relay.dev/graphql/objectidentification.htm) -""" -interface Node { - """The id of the object.""" - id: ID! + """ + How prominently this recurring event family should remain visible in normal operator workflows. + """ + operatorProminenceNotIn: [OperatorProminenceLevel!] } -""" -Possible directions in which to order a list of items when provided an `orderBy` argument. -""" enum OrderDirection { - """Specifies an ascending order for a given `orderBy` argument.""" ASC - - """Specifies a descending order for a given `orderBy` argument.""" DESC } -type Organization implements Node { +type Organization { """Unique identifier of the organization""" id: ID! @@ -4516,2075 +7198,1800 @@ type Organization implements Node { updatedAt: Time! """Accounts belonging to this organization""" - accounts: [Account!] + accounts(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: AccountOrder, where: AccountWhereInput): AccountConnection! + + """Teams belonging to this organization""" + teams(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: TeamOrder, where: TeamWhereInput): TeamConnection! } type OrganizationBootstrapResult { organization: Organization! account: Account! - workspace: Workspace! } -"""A connection to a list of items.""" type OrganizationConnection { - """A list of edges.""" - edges: [OrganizationEdge] - - """Information to aid in pagination.""" + edges: [OrganizationEdge!]! pageInfo: PageInfo! - - """Identifies the total count of items in the connection.""" totalCount: Int! } -"""An edge in a connection.""" -type OrganizationEdge { - """The item at the end of the edge.""" - node: Organization +"""Organization creation input.""" +input OrganizationCreateInput { + """Human-readable name, unique across the system""" + name: String! +} - """A cursor for use in pagination.""" +type OrganizationEdge { + node: Organization! cursor: Cursor! } -"""Ordering options for Organization connections""" +""" +OrganizationInvitation is an invitation to join the viewer's current +organization. Returns all states; the webapp filters to PENDING client-side. +""" +type OrganizationInvitation { + """ + WorkOS invitation id (e.g. `invitation_01H...`). Typed as `String!` for the + same reason as `OrganizationMember.id`. + """ + id: String! + email: String! + role: OrganizationMemberRole! + state: InvitationState! + expiresAt: Time! + createdAt: Time! +} + +""" +OrganizationMember is a user belonging to the viewer's current WorkOS +organization. There is no internal Tero record for members yet — this is a +pass-through projection of the WorkOS User object scoped to the viewer's org. +""" +type OrganizationMember { + """ + WorkOS user id (e.g. `user_01H...`). Typed as `String!` rather than `ID!` + because the repo's `ID` scalar is mapped to `uuid.UUID` and WorkOS ids are + not UUIDs. Matches the convention used by `Viewer.id` and + `TeamMember.userID`. + """ + id: String! + email: String! + firstName: String + lastName: String + role: OrganizationMemberRole! + createdAt: Time! +} + +enum OrganizationMemberRole { + ADMIN + MEMBER +} + input OrganizationOrder { - """The ordering direction.""" direction: OrderDirection! = ASC - - """The field by which to order Organizations.""" field: OrganizationOrderField! } -"""Properties by which Organization connections can be ordered.""" enum OrganizationOrderField { + ID NAME CREATED_AT UPDATED_AT } -""" -OrganizationWhereInput is used for filtering Organization objects. -Input was generated by ent. -""" +"""Organization update input.""" +input OrganizationUpdateInput { + """Human-readable name, unique across the system""" + name: String +} + input OrganizationWhereInput { not: OrganizationWhereInput and: [OrganizationWhereInput!] or: [OrganizationWhereInput!] - """id field predicates""" + """"accounts" edge predicates.""" + accounts: AccountWhereInput + + """Whether the "accounts" edge has at least one related row.""" + hasAccounts: Boolean + + """"teams" edge predicates.""" + teams: TeamWhereInput + + """Whether the "teams" edge has at least one related row.""" + hasTeams: Boolean + + """"id" field predicates.""" id: ID + + """"id" field predicates.""" idNEQ: ID + + """"id" field predicates.""" idIn: [ID!] + + """"id" field predicates.""" idNotIn: [ID!] + + """"id" field predicates.""" idGT: ID + + """"id" field predicates.""" idGTE: ID + + """"id" field predicates.""" idLT: ID + + """"id" field predicates.""" idLTE: ID - """name field predicates""" + """"name" field predicates.""" name: String + + """"name" field predicates.""" nameNEQ: String + + """"name" field predicates.""" nameIn: [String!] + + """"name" field predicates.""" nameNotIn: [String!] + + """"name" field predicates.""" nameGT: String + + """"name" field predicates.""" nameGTE: String + + """"name" field predicates.""" nameLT: String + + """"name" field predicates.""" nameLTE: String + + """"name" field predicates.""" nameContains: String + + """"name" field predicates.""" nameHasPrefix: String + + """"name" field predicates.""" nameHasSuffix: String + + """"name" field predicates.""" nameEqualFold: String + + """"name" field predicates.""" nameContainsFold: String - """created_at field predicates""" + """"created_at" field predicates.""" createdAt: Time + + """"created_at" field predicates.""" createdAtNEQ: Time + + """"created_at" field predicates.""" createdAtIn: [Time!] + + """"created_at" field predicates.""" createdAtNotIn: [Time!] + + """"created_at" field predicates.""" createdAtGT: Time + + """"created_at" field predicates.""" createdAtGTE: Time + + """"created_at" field predicates.""" createdAtLT: Time + + """"created_at" field predicates.""" createdAtLTE: Time - """updated_at field predicates""" + """"updated_at" field predicates.""" updatedAt: Time + + """"updated_at" field predicates.""" updatedAtNEQ: Time + + """"updated_at" field predicates.""" updatedAtIn: [Time!] + + """"updated_at" field predicates.""" updatedAtNotIn: [Time!] + + """"updated_at" field predicates.""" updatedAtGT: Time + + """"updated_at" field predicates.""" updatedAtGTE: Time + + """"updated_at" field predicates.""" updatedAtLT: Time - updatedAtLTE: Time - """accounts edge predicates""" - hasAccounts: Boolean - hasAccountsWith: [AccountWhereInput!] + """"updated_at" field predicates.""" + updatedAtLTE: Time } """ -Information about pagination in a connection. -https://relay.dev/graphql/connections.htm#sec-undefined.PageInfo +Describes who owns the service code and who operates the running service. """ -type PageInfo { - """When paginating forwards, are there more items?""" - hasNextPage: Boolean! - - """When paginating backwards, are there more items?""" - hasPreviousPage: Boolean! - - """When paginating backwards, the cursor to continue.""" - startCursor: Cursor - - """When paginating forwards, the cursor to continue.""" - endCursor: Cursor -} - -type Query { - """Fetches an object given its ID.""" - node( - """ID of the object.""" - id: ID! - ): Node - - """Lookup nodes by a list of IDs.""" - nodes( - """The list of node IDs.""" - ids: [ID!]! - ): [Node]! +type OwnershipModel { + """Whether the service code is first-party, third-party, or unknown.""" + code: ServiceOwnershipCode! """ - Query accounts. Accounts belong to an organization and contain services and workspaces. + Whether the running service is self-operated, vendor-operated, or unknown. """ - accounts( - """Returns the elements in the list that come after the specified cursor.""" - after: Cursor + operation: ServiceOwnershipOperation! - """Returns the first _n_ elements from the list.""" - first: Int + """Short grounded summary of the ownership model.""" + summary: String! +} - """ - Returns the elements in the list that come before the specified cursor. - """ - before: Cursor +""" +Describes who owns the service code and who operates the running service. +""" +input OwnershipModelWhereInput { + """Negated predicates.""" + not: OwnershipModelWhereInput - """Returns the last _n_ elements from the list.""" - last: Int + """Predicates that must all match.""" + and: [OwnershipModelWhereInput!] - """Ordering options for Accounts returned from the connection.""" - orderBy: AccountOrder + """Predicates where at least one must match.""" + or: [OwnershipModelWhereInput!] - """Filtering options for Accounts returned from the connection.""" - where: AccountWhereInput - ): AccountConnection! + """Whether the ownership_model JSONB value is present.""" + present: Boolean - """Query conversations.""" - conversations( - """Returns the elements in the list that come after the specified cursor.""" - after: Cursor + """Whether the service code is first-party, third-party, or unknown.""" + code: ServiceOwnershipCode - """Returns the first _n_ elements from the list.""" - first: Int + """Whether the service code is first-party, third-party, or unknown.""" + codeNEQ: ServiceOwnershipCode - """ - Returns the elements in the list that come before the specified cursor. - """ - before: Cursor + """Whether the service code is first-party, third-party, or unknown.""" + codeIn: [ServiceOwnershipCode!] - """Returns the last _n_ elements from the list.""" - last: Int + """Whether the service code is first-party, third-party, or unknown.""" + codeNotIn: [ServiceOwnershipCode!] - """Ordering options for Conversations returned from the connection.""" - orderBy: ConversationOrder + """ + Whether the running service is self-operated, vendor-operated, or unknown. + """ + operation: ServiceOwnershipOperation - """Filtering options for Conversations returned from the connection.""" - where: ConversationWhereInput - ): ConversationConnection! + """ + Whether the running service is self-operated, vendor-operated, or unknown. + """ + operationNEQ: ServiceOwnershipOperation - """Query active context entities in conversations.""" - conversationContexts( - """Returns the elements in the list that come after the specified cursor.""" - after: Cursor + """ + Whether the running service is self-operated, vendor-operated, or unknown. + """ + operationIn: [ServiceOwnershipOperation!] - """Returns the first _n_ elements from the list.""" - first: Int + """ + Whether the running service is self-operated, vendor-operated, or unknown. + """ + operationNotIn: [ServiceOwnershipOperation!] - """ - Returns the elements in the list that come before the specified cursor. - """ - before: Cursor + """Short grounded summary of the ownership model.""" + summary: String - """Returns the last _n_ elements from the list.""" - last: Int + """Short grounded summary of the ownership model.""" + summaryNEQ: String - """ - Ordering options for ConversationContexts returned from the connection. - """ - orderBy: ConversationContextOrder + """Short grounded summary of the ownership model.""" + summaryIn: [String!] - """ - Filtering options for ConversationContexts returned from the connection. - """ - where: ConversationContextWhereInput - ): ConversationContextConnection! + """Short grounded summary of the ownership model.""" + summaryNotIn: [String!] - """Query connected Datadog accounts.""" - datadogAccounts( - """Returns the elements in the list that come after the specified cursor.""" - after: Cursor + """Short grounded summary of the ownership model.""" + summaryContains: String - """Returns the first _n_ elements from the list.""" - first: Int + """Short grounded summary of the ownership model.""" + summaryHasPrefix: String - """ - Returns the elements in the list that come before the specified cursor. - """ - before: Cursor + """Short grounded summary of the ownership model.""" + summaryHasSuffix: String - """Returns the last _n_ elements from the list.""" - last: Int + """Short grounded summary of the ownership model.""" + summaryEqualFold: String - """Ordering options for DatadogAccounts returned from the connection.""" - orderBy: DatadogAccountOrder + """Short grounded summary of the ownership model.""" + summaryContainsFold: String - """Filtering options for DatadogAccounts returned from the connection.""" - where: DatadogAccountWhereInput - ): DatadogAccountConnection! + """Short grounded summary of the ownership model.""" + hasSummary: Boolean +} - """Query API keys for edge instance authentication.""" - edgeAPIKeys( - """Returns the elements in the list that come after the specified cursor.""" - after: Cursor +type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: Cursor + endCursor: Cursor +} - """Returns the first _n_ elements from the list.""" - first: Int +enum PeerRole { + """A human or end-user actor.""" + user - """ - Returns the elements in the list that come before the specified cursor. - """ - before: Cursor + """ + An upstream webhook source, upstream business system, same-estate application, + partner platform, customer system, business destination, or other application boundary. + """ + service - """Returns the last _n_ elements from the list.""" - last: Int + """ + A technical API, provider, database, cache, queue, storage system, or + infrastructure component the emitting service calls or depends on to perform its own work. + """ + dependency - """Ordering options for EdgeApiKeys returned from the connection.""" - orderBy: EdgeApiKeyOrder + """A crawler or automated internet agent.""" + bot - """Filtering options for EdgeApiKeys returned from the connection.""" - where: EdgeApiKeyWhereInput - ): EdgeApiKeyConnection! + """A health, readiness, uptime, synthetic, or monitoring probe.""" + probe - """Query edge instances that sync policies from the control plane.""" - edgeInstances( - """Returns the elements in the list that come after the specified cursor.""" - after: Cursor + """A real peer is present, but its broad role is unclear.""" + unknown +} - """Returns the first _n_ elements from the list.""" - first: Int +"""Percentage value, expressed as percentage points rather than a ratio.""" +type PercentAmount { + value: Float! +} - """ - Returns the elements in the list that come before the specified cursor. - """ - before: Cursor +type PolicyExecutionSummary { + matchHits: Int! + errors: Int! +} - """Returns the last _n_ elements from the list.""" - last: Int +input PolicyExecutionSummaryInput { + """Half-open range to summarize.""" + range: TimeRangeInput! + filter: PolicyTelemetryFilterInput +} - """Ordering options for EdgeInstances returned from the connection.""" - orderBy: EdgeInstanceOrder +input PolicyTelemetryFilterInput { + """Restrict policy telemetry to these edge instances. At most 100 values.""" + edgeInstanceIDs: [UUID!] +} - """Filtering options for EdgeInstances returned from the connection.""" - where: EdgeInstanceWhereInput - ): EdgeInstanceConnection! +input PolicyTelemetryInput { + """Half-open range to bucket. Boundaries must align to granularity.""" + range: TimeRangeInput! """ - Query log events discovered in your services. Each log event is a distinct message pattern. + Granularity for returned points. Requests may include at most 1000 points. """ - logEvents( - """Returns the elements in the list that come after the specified cursor.""" - after: Cursor - - """Returns the first _n_ elements from the list.""" - first: Int - - """ - Returns the elements in the list that come before the specified cursor. - """ - before: Cursor + granularity: TimeGranularity! + filter: PolicyTelemetryFilterInput +} - """Returns the last _n_ elements from the list.""" - last: Int +type PolicyTelemetryPoint { + start: Time! + end: Time! + matchHits: Int! + matchMisses: Int! + errors: Int! + removeHits: Int! + redactHits: Int! + renameHits: Int! + addHits: Int! + quality: MeasurementQuality! +} - """Ordering options for LogEvents returned from the connection.""" - orderBy: LogEventOrder +type PolicyTelemetrySeries { + range: TimeRange! + granularity: TimeGranularity! + points: [PolicyTelemetryPoint!]! + totals: PolicyTelemetryTotals! +} - """Filtering options for LogEvents returned from the connection.""" - where: LogEventWhereInput - ): LogEventConnection! +type PolicyTelemetryTotals { + matchHits: Int! + matchMisses: Int! + errors: Int! + removeHits: Int! + redactHits: Int! + renameHits: Int! + addHits: Int! +} +type Query { """ - Query log event policies. Each policy is a category-specific recommendation. + Returns the currently authenticated user. + Use this to check authentication status and organization assignment. """ - logEventPolicies( - """Returns the elements in the list that come after the specified cursor.""" - after: Cursor - - """Returns the first _n_ elements from the list.""" - first: Int - - """ - Returns the elements in the list that come before the specified cursor. - """ - before: Cursor - - """Returns the last _n_ elements from the list.""" - last: Int - - """Ordering options for LogEventPolicies returned from the connection.""" - orderBy: LogEventPolicyOrder - - """Filtering options for LogEventPolicies returned from the connection.""" - where: LogEventPolicyWhereInput - ): LogEventPolicyConnection! - - """Query messages in chat conversations.""" - messages( - """Returns the elements in the list that come after the specified cursor.""" - after: Cursor - - """Returns the first _n_ elements from the list.""" - first: Int + viewer: Viewer! - """ - Returns the elements in the list that come before the specified cursor. - """ - before: Cursor + """Query one Account by ID.""" + account(id: ID!): Account - """Returns the last _n_ elements from the list.""" - last: Int + """Query Accounts records in your account.""" + accounts(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: AccountOrder, where: AccountWhereInput): AccountConnection! - """Ordering options for Messages returned from the connection.""" - orderBy: MessageOrder + """Query one code-defined product check by ID.""" + check(id: ID!): Check - """Filtering options for Messages returned from the connection.""" - where: MessageWhereInput - ): MessageConnection! + """Code-defined product checks with account-scoped posture measurements.""" + checks(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: CheckOrder, where: CheckWhereInput): CheckConnection! """ - Query organizations. An organization is the top-level container that holds accounts. + Compliance lane reporting summary for the current account and optional product filters. """ - organizations( - """Returns the elements in the list that come after the specified cursor.""" - after: Cursor - - """Returns the first _n_ elements from the list.""" - first: Int - - """ - Returns the elements in the list that come before the specified cursor. - """ - before: Cursor - - """Returns the last _n_ elements from the list.""" - last: Int - - """Ordering options for Organizations returned from the connection.""" - orderBy: OrganizationOrder - - """Filtering options for Organizations returned from the connection.""" - where: OrganizationWhereInput - ): OrganizationConnection! - - """Query services in your system.""" - services( - """Returns the elements in the list that come after the specified cursor.""" - after: Cursor - - """Returns the first _n_ elements from the list.""" - first: Int + complianceReport(input: ComplianceReportInput): ComplianceReport! - """ - Returns the elements in the list that come before the specified cursor. - """ - before: Cursor - - """Returns the last _n_ elements from the list.""" - last: Int + """ + Cost lane reporting summary for the current account and optional product filters. + """ + costReport(input: CostReportInput): CostReport! - """Ordering options for Services returned from the connection.""" - orderBy: ServiceOrder + """Query one DatadogAccount by ID.""" + datadogAccount(id: ID!): DatadogAccount - """Filtering options for Services returned from the connection.""" - where: ServiceWhereInput - ): ServiceConnection! + """Query DatadogAccounts records in your account.""" + datadogAccounts(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: DatadogAccountOrder, where: DatadogAccountWhereInput): DatadogAccountConnection! - """Query teams in your organization.""" - teams( - """Returns the elements in the list that come after the specified cursor.""" - after: Cursor + """Query one DatadogLogExclusionFilter by ID.""" + datadogLogExclusionFilter(id: ID!): DatadogLogExclusionFilter - """Returns the first _n_ elements from the list.""" - first: Int + """Query DatadogLogExclusionFilters records in your account.""" + datadogLogExclusionFilters(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: DatadogLogExclusionFilterOrder, where: DatadogLogExclusionFilterWhereInput): DatadogLogExclusionFilterConnection! - """ - Returns the elements in the list that come before the specified cursor. - """ - before: Cursor + """Query one DatadogLogIndex by ID.""" + datadogLogIndex(id: ID!): DatadogLogIndex - """Returns the last _n_ elements from the list.""" - last: Int + """Query DatadogLogIndices records in your account.""" + datadogLogIndices(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: DatadogLogIndexOrder, where: DatadogLogIndexWhereInput): DatadogLogIndexConnection! - """Ordering options for Teams returned from the connection.""" - orderBy: TeamOrder + """Query one EdgeApiKey by ID.""" + edgeAPIKey(id: ID!): EdgeApiKey - """Filtering options for Teams returned from the connection.""" - where: TeamWhereInput - ): TeamConnection! + """Query EdgeApiKeys records in your account.""" + edgeAPIKeys(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: EdgeApiKeyOrder, where: EdgeApiKeyWhereInput): EdgeApiKeyConnection! """ - Query saved views. Views are immutable queries against catalog entities. + Aggregated fleet-wide edge telemetry: per-policy time series, group rows, and + fleet totals. Time range, granularity, group dimension, and connectivity + threshold are caller-provided. Range boundaries must align to granularity. + Requests may include at most 1000 points. """ - views( - """Returns the elements in the list that come after the specified cursor.""" - after: Cursor - - """Returns the first _n_ elements from the list.""" - first: Int + edgeFleetTelemetry(input: EdgeFleetTelemetryInput!): EdgeFleetTelemetry! - """ - Returns the elements in the list that come before the specified cursor. - """ - before: Cursor + """Query one EdgeInstance by ID.""" + edgeInstance(id: ID!): EdgeInstance - """Returns the last _n_ elements from the list.""" - last: Int + """Query EdgeInstances records in your account.""" + edgeInstances(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: EdgeInstanceOrder, where: EdgeInstanceWhereInput): EdgeInstanceConnection! - """Ordering options for Views returned from the connection.""" - orderBy: ViewOrder + """Query one Issue by ID.""" + issue(id: ID!): Issue - """Filtering options for Views returned from the connection.""" - where: ViewWhereInput - ): ViewConnection! + """Query Issues records in your account.""" + issues(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: IssueOrder, where: IssueWhereInput): IssueConnection! - """Query view favorites. Each favorite links a user to a saved view.""" - viewFavorites( - """Returns the elements in the list that come after the specified cursor.""" - after: Cursor + """Query one LogEventPolicy by ID.""" + logEventPolicy(id: ID!): LogEventPolicy - """Returns the first _n_ elements from the list.""" - first: Int + """Query LogEventPolicies records in your account.""" + logEventPolicies(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: LogEventPolicyOrder, where: LogEventPolicyWhereInput): LogEventPolicyConnection! - """ - Returns the elements in the list that come before the specified cursor. - """ - before: Cursor + """Query one LogEvent by ID.""" + logEvent(id: ID!): LogEvent - """Returns the last _n_ elements from the list.""" - last: Int + """Query LogEvents records in your account.""" + logEvents(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: LogEventOrder, where: LogEventWhereInput): LogEventConnection! - """Ordering options for ViewFavorites returned from the connection.""" - orderBy: ViewFavoriteOrder + """Query one Organization by ID.""" + organization(id: ID!): Organization - """Filtering options for ViewFavorites returned from the connection.""" - where: ViewFavoriteWhereInput - ): ViewFavoriteConnection! + """Query Organizations records in your account.""" + organizations(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: OrganizationOrder, where: OrganizationWhereInput): OrganizationConnection! - """ - Query workspaces. Workspaces are used to analyze and classify telemetry. - """ - workspaces( - """Returns the elements in the list that come after the specified cursor.""" - after: Cursor + """Query one Service by ID.""" + service(id: ID!): Service - """Returns the first _n_ elements from the list.""" - first: Int + """Query Services records in your account.""" + services(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: ServiceOrder, where: ServiceWhereInput): ServiceConnection! - """ - Returns the elements in the list that come before the specified cursor. - """ - before: Cursor + """Query one Team by ID.""" + team(id: ID!): Team - """Returns the last _n_ elements from the list.""" - last: Int + """Query Teams records in your account.""" + teams(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: TeamOrder, where: TeamWhereInput): TeamConnection! - """Ordering options for Workspaces returned from the connection.""" - orderBy: WorkspaceOrder - - """Filtering options for Workspaces returned from the connection.""" - where: WorkspaceWhereInput - ): WorkspaceConnection! + """ + Members of the viewer's current WorkOS organization. + Wraps workos.userManagement.listUsers({ organizationId: viewer.OrganizationID }). + Any authenticated user with an OrganizationID may call this. + """ + organizationMembers: [OrganizationMember!]! """ - Returns the currently authenticated user. - Use this to check authentication status and organization assignment. + Invitations for the viewer's current organization. Returns all states; + the webapp filters to PENDING client-side. + Wraps workos.userManagement.listInvitations({ organizationId: viewer.OrganizationID }). + Any authenticated user with an OrganizationID may call this. """ - viewer: Viewer! + organizationInvitations: [OrganizationInvitation!]! } -type Service implements Node { - """Unique identifier of the service""" - id: ID! - - """Parent account this service belongs to""" - accountID: ID! - - """Service identifier in telemetry (e.g., 'checkout-service')""" - name: String! +"""Describes the technical runtime roles a service plays in the system.""" +type RuntimeRole { + """Short grounded summary of the service's runtime roles.""" + summary: String! - """ - AI-generated description of what this service does and its telemetry characteristics - """ - description: String! + """Short normalized runtime role labels.""" + roles: [String!]! +} - """Whether log analysis and policy generation is active for this service""" - enabled: Boolean! +"""Describes the technical runtime roles a service plays in the system.""" +input RuntimeRoleWhereInput { + """Negated predicates.""" + not: RuntimeRoleWhereInput - """ - Approximate weekly log count from initial discovery (7-day period from Datadog) - """ - initialWeeklyLogCount: Int + """Predicates that must all match.""" + and: [RuntimeRoleWhereInput!] - """When the service was created""" - createdAt: Time! + """Predicates where at least one must match.""" + or: [RuntimeRoleWhereInput!] - """When the service was last updated""" - updatedAt: Time! + """Whether the runtime_role JSONB value is present.""" + present: Boolean - """Account this service belongs to""" - account: Account! + """Short grounded summary of the service's runtime roles.""" + summary: String - """Log event types produced by this service""" - logEvents: [LogEvent!] + """Short grounded summary of the service's runtime roles.""" + summaryNEQ: String - """ - Status of this service from a specific Datadog account. - Shows where the service is in the discovery pipeline. - Returns null if cache has not been populated yet. - """ - status(datadogAccountID: ID!): ServiceStatusCache -} + """Short grounded summary of the service's runtime roles.""" + summaryIn: [String!] -"""A connection to a list of items.""" -type ServiceConnection { - """A list of edges.""" - edges: [ServiceEdge] + """Short grounded summary of the service's runtime roles.""" + summaryNotIn: [String!] - """Information to aid in pagination.""" - pageInfo: PageInfo! + """Short grounded summary of the service's runtime roles.""" + summaryContains: String - """Identifies the total count of items in the connection.""" - totalCount: Int! -} + """Short grounded summary of the service's runtime roles.""" + summaryHasPrefix: String -"""An edge in a connection.""" -type ServiceEdge { - """The item at the end of the edge.""" - node: Service + """Short grounded summary of the service's runtime roles.""" + summaryHasSuffix: String - """A cursor for use in pagination.""" - cursor: Cursor! -} + """Short grounded summary of the service's runtime roles.""" + summaryEqualFold: String -"""Ordering options for Service connections""" -input ServiceOrder { - """The ordering direction.""" - direction: OrderDirection! = ASC + """Short grounded summary of the service's runtime roles.""" + summaryContainsFold: String - """The field by which to order Services.""" - field: ServiceOrderField! -} + """Short grounded summary of the service's runtime roles.""" + hasSummary: Boolean -"""Properties by which Service connections can be ordered.""" -enum ServiceOrderField { - NAME - ENABLED - INITIAL_WEEKLY_LOG_COUNT - CREATED_AT - UPDATED_AT -} + """Short normalized runtime role labels.""" + hasRoles: Boolean -type ServiceStatusCache implements Node { - id: ID! - serviceID: ID! - accountID: UUID! - datadogAccountID: ID! + """Short normalized runtime role labels.""" + rolesContainsAny: [String!] - """ - Overall health of the service. DISABLED (integration turned off), INACTIVE (no data received), OK (healthy). - """ - health: ServiceStatusCacheHealth! - logEventCount: Int! - logEventAnalyzedCount: Int! - policyPendingCount: Int! - policyApprovedCount: Int! - policyDismissedCount: Int! - policyPendingLowCount: Int! - policyPendingMediumCount: Int! - policyPendingHighCount: Int! - policyPendingCriticalCount: Int! - serviceVolumePerHour: Float - serviceDebugVolumePerHour: Float - serviceInfoVolumePerHour: Float - serviceWarnVolumePerHour: Float - serviceErrorVolumePerHour: Float - serviceOtherVolumePerHour: Float - serviceCostPerHourVolumeUsd: Float - logEventVolumePerHour: Float - logEventBytesPerHour: Float - logEventCostPerHourBytesUsd: Float - logEventCostPerHourVolumeUsd: Float - logEventCostPerHourUsd: Float - estimatedVolumeReductionPerHour: Float - estimatedBytesReductionPerHour: Float - estimatedCostReductionPerHourBytesUsd: Float - estimatedCostReductionPerHourVolumeUsd: Float - estimatedCostReductionPerHourUsd: Float - observedVolumePerHourBefore: Float - observedVolumePerHourAfter: Float - observedBytesPerHourBefore: Float - observedBytesPerHourAfter: Float - observedCostPerHourBeforeBytesUsd: Float - observedCostPerHourBeforeVolumeUsd: Float - observedCostPerHourBeforeUsd: Float - observedCostPerHourAfterBytesUsd: Float - observedCostPerHourAfterVolumeUsd: Float - observedCostPerHourAfterUsd: Float - refreshedAt: Time! + """Short normalized runtime role labels.""" + rolesContainsAll: [String!] } -"""ServiceStatusCacheHealth is enum for the field health""" -enum ServiceStatusCacheHealth { - DISABLED - INACTIVE - ERROR - OK +input SendOrganizationInvitationInput { + email: String! + role: OrganizationMemberRole = MEMBER } -""" -ServiceStatusCacheWhereInput is used for filtering ServiceStatusCache objects. -Input was generated by ent. -""" -input ServiceStatusCacheWhereInput { - not: ServiceStatusCacheWhereInput - and: [ServiceStatusCacheWhereInput!] - or: [ServiceStatusCacheWhereInput!] +enum SensitiveDataPaymentElement { + """Primary account number or full card number.""" + pan - """id field predicates""" - id: ID - idNEQ: ID - idIn: [ID!] - idNotIn: [ID!] - idGT: ID - idGTE: ID - idLT: ID - idLTE: ID + """ + Card expiration month/year or expiration date exposed with full PAN or other + unmasked card data. Do not use for expiry alone with masked card metadata. + """ + card_expiration_date - """service_id field predicates""" - serviceID: ID - serviceIDNEQ: ID - serviceIDIn: [ID!] - serviceIDNotIn: [ID!] + """Card service code.""" + service_code - """account_id field predicates""" - accountID: UUID - accountIDNEQ: UUID - accountIDIn: [UUID!] - accountIDNotIn: [UUID!] - accountIDGT: UUID - accountIDGTE: UUID - accountIDLT: UUID - accountIDLTE: UUID + """Full magnetic stripe or chip track data.""" + full_track_data - """datadog_account_id field predicates""" - datadogAccountID: ID - datadogAccountIDNEQ: ID - datadogAccountIDIn: [ID!] - datadogAccountIDNotIn: [ID!] + """CVV, CVC, CID, CAV2, or similar card verification code.""" + card_verification_code - """health field predicates""" - health: ServiceStatusCacheHealth - healthNEQ: ServiceStatusCacheHealth - healthIn: [ServiceStatusCacheHealth!] - healthNotIn: [ServiceStatusCacheHealth!] - - """log_event_count field predicates""" - logEventCount: Int - logEventCountNEQ: Int - logEventCountIn: [Int!] - logEventCountNotIn: [Int!] - logEventCountGT: Int - logEventCountGTE: Int - logEventCountLT: Int - logEventCountLTE: Int - - """log_event_analyzed_count field predicates""" - logEventAnalyzedCount: Int - logEventAnalyzedCountNEQ: Int - logEventAnalyzedCountIn: [Int!] - logEventAnalyzedCountNotIn: [Int!] - logEventAnalyzedCountGT: Int - logEventAnalyzedCountGTE: Int - logEventAnalyzedCountLT: Int - logEventAnalyzedCountLTE: Int - - """policy_pending_count field predicates""" - policyPendingCount: Int - policyPendingCountNEQ: Int - policyPendingCountIn: [Int!] - policyPendingCountNotIn: [Int!] - policyPendingCountGT: Int - policyPendingCountGTE: Int - policyPendingCountLT: Int - policyPendingCountLTE: Int - - """policy_approved_count field predicates""" - policyApprovedCount: Int - policyApprovedCountNEQ: Int - policyApprovedCountIn: [Int!] - policyApprovedCountNotIn: [Int!] - policyApprovedCountGT: Int - policyApprovedCountGTE: Int - policyApprovedCountLT: Int - policyApprovedCountLTE: Int - - """policy_dismissed_count field predicates""" - policyDismissedCount: Int - policyDismissedCountNEQ: Int - policyDismissedCountIn: [Int!] - policyDismissedCountNotIn: [Int!] - policyDismissedCountGT: Int - policyDismissedCountGTE: Int - policyDismissedCountLT: Int - policyDismissedCountLTE: Int - - """policy_pending_low_count field predicates""" - policyPendingLowCount: Int - policyPendingLowCountNEQ: Int - policyPendingLowCountIn: [Int!] - policyPendingLowCountNotIn: [Int!] - policyPendingLowCountGT: Int - policyPendingLowCountGTE: Int - policyPendingLowCountLT: Int - policyPendingLowCountLTE: Int - - """policy_pending_medium_count field predicates""" - policyPendingMediumCount: Int - policyPendingMediumCountNEQ: Int - policyPendingMediumCountIn: [Int!] - policyPendingMediumCountNotIn: [Int!] - policyPendingMediumCountGT: Int - policyPendingMediumCountGTE: Int - policyPendingMediumCountLT: Int - policyPendingMediumCountLTE: Int - - """policy_pending_high_count field predicates""" - policyPendingHighCount: Int - policyPendingHighCountNEQ: Int - policyPendingHighCountIn: [Int!] - policyPendingHighCountNotIn: [Int!] - policyPendingHighCountGT: Int - policyPendingHighCountGTE: Int - policyPendingHighCountLT: Int - policyPendingHighCountLTE: Int - - """policy_pending_critical_count field predicates""" - policyPendingCriticalCount: Int - policyPendingCriticalCountNEQ: Int - policyPendingCriticalCountIn: [Int!] - policyPendingCriticalCountNotIn: [Int!] - policyPendingCriticalCountGT: Int - policyPendingCriticalCountGTE: Int - policyPendingCriticalCountLT: Int - policyPendingCriticalCountLTE: Int - - """service_volume_per_hour field predicates""" - serviceVolumePerHour: Float - serviceVolumePerHourNEQ: Float - serviceVolumePerHourIn: [Float!] - serviceVolumePerHourNotIn: [Float!] - serviceVolumePerHourGT: Float - serviceVolumePerHourGTE: Float - serviceVolumePerHourLT: Float - serviceVolumePerHourLTE: Float - serviceVolumePerHourIsNil: Boolean - serviceVolumePerHourNotNil: Boolean - - """service_debug_volume_per_hour field predicates""" - serviceDebugVolumePerHour: Float - serviceDebugVolumePerHourNEQ: Float - serviceDebugVolumePerHourIn: [Float!] - serviceDebugVolumePerHourNotIn: [Float!] - serviceDebugVolumePerHourGT: Float - serviceDebugVolumePerHourGTE: Float - serviceDebugVolumePerHourLT: Float - serviceDebugVolumePerHourLTE: Float - serviceDebugVolumePerHourIsNil: Boolean - serviceDebugVolumePerHourNotNil: Boolean - - """service_info_volume_per_hour field predicates""" - serviceInfoVolumePerHour: Float - serviceInfoVolumePerHourNEQ: Float - serviceInfoVolumePerHourIn: [Float!] - serviceInfoVolumePerHourNotIn: [Float!] - serviceInfoVolumePerHourGT: Float - serviceInfoVolumePerHourGTE: Float - serviceInfoVolumePerHourLT: Float - serviceInfoVolumePerHourLTE: Float - serviceInfoVolumePerHourIsNil: Boolean - serviceInfoVolumePerHourNotNil: Boolean - - """service_warn_volume_per_hour field predicates""" - serviceWarnVolumePerHour: Float - serviceWarnVolumePerHourNEQ: Float - serviceWarnVolumePerHourIn: [Float!] - serviceWarnVolumePerHourNotIn: [Float!] - serviceWarnVolumePerHourGT: Float - serviceWarnVolumePerHourGTE: Float - serviceWarnVolumePerHourLT: Float - serviceWarnVolumePerHourLTE: Float - serviceWarnVolumePerHourIsNil: Boolean - serviceWarnVolumePerHourNotNil: Boolean - - """service_error_volume_per_hour field predicates""" - serviceErrorVolumePerHour: Float - serviceErrorVolumePerHourNEQ: Float - serviceErrorVolumePerHourIn: [Float!] - serviceErrorVolumePerHourNotIn: [Float!] - serviceErrorVolumePerHourGT: Float - serviceErrorVolumePerHourGTE: Float - serviceErrorVolumePerHourLT: Float - serviceErrorVolumePerHourLTE: Float - serviceErrorVolumePerHourIsNil: Boolean - serviceErrorVolumePerHourNotNil: Boolean - - """service_other_volume_per_hour field predicates""" - serviceOtherVolumePerHour: Float - serviceOtherVolumePerHourNEQ: Float - serviceOtherVolumePerHourIn: [Float!] - serviceOtherVolumePerHourNotIn: [Float!] - serviceOtherVolumePerHourGT: Float - serviceOtherVolumePerHourGTE: Float - serviceOtherVolumePerHourLT: Float - serviceOtherVolumePerHourLTE: Float - serviceOtherVolumePerHourIsNil: Boolean - serviceOtherVolumePerHourNotNil: Boolean - - """service_cost_per_hour_volume_usd field predicates""" - serviceCostPerHourVolumeUsd: Float - serviceCostPerHourVolumeUsdNEQ: Float - serviceCostPerHourVolumeUsdIn: [Float!] - serviceCostPerHourVolumeUsdNotIn: [Float!] - serviceCostPerHourVolumeUsdGT: Float - serviceCostPerHourVolumeUsdGTE: Float - serviceCostPerHourVolumeUsdLT: Float - serviceCostPerHourVolumeUsdLTE: Float - serviceCostPerHourVolumeUsdIsNil: Boolean - serviceCostPerHourVolumeUsdNotNil: Boolean - - """log_event_volume_per_hour field predicates""" - logEventVolumePerHour: Float - logEventVolumePerHourNEQ: Float - logEventVolumePerHourIn: [Float!] - logEventVolumePerHourNotIn: [Float!] - logEventVolumePerHourGT: Float - logEventVolumePerHourGTE: Float - logEventVolumePerHourLT: Float - logEventVolumePerHourLTE: Float - logEventVolumePerHourIsNil: Boolean - logEventVolumePerHourNotNil: Boolean - - """log_event_bytes_per_hour field predicates""" - logEventBytesPerHour: Float - logEventBytesPerHourNEQ: Float - logEventBytesPerHourIn: [Float!] - logEventBytesPerHourNotIn: [Float!] - logEventBytesPerHourGT: Float - logEventBytesPerHourGTE: Float - logEventBytesPerHourLT: Float - logEventBytesPerHourLTE: Float - logEventBytesPerHourIsNil: Boolean - logEventBytesPerHourNotNil: Boolean - - """log_event_cost_per_hour_bytes_usd field predicates""" - logEventCostPerHourBytesUsd: Float - logEventCostPerHourBytesUsdNEQ: Float - logEventCostPerHourBytesUsdIn: [Float!] - logEventCostPerHourBytesUsdNotIn: [Float!] - logEventCostPerHourBytesUsdGT: Float - logEventCostPerHourBytesUsdGTE: Float - logEventCostPerHourBytesUsdLT: Float - logEventCostPerHourBytesUsdLTE: Float - logEventCostPerHourBytesUsdIsNil: Boolean - logEventCostPerHourBytesUsdNotNil: Boolean - - """log_event_cost_per_hour_volume_usd field predicates""" - logEventCostPerHourVolumeUsd: Float - logEventCostPerHourVolumeUsdNEQ: Float - logEventCostPerHourVolumeUsdIn: [Float!] - logEventCostPerHourVolumeUsdNotIn: [Float!] - logEventCostPerHourVolumeUsdGT: Float - logEventCostPerHourVolumeUsdGTE: Float - logEventCostPerHourVolumeUsdLT: Float - logEventCostPerHourVolumeUsdLTE: Float - logEventCostPerHourVolumeUsdIsNil: Boolean - logEventCostPerHourVolumeUsdNotNil: Boolean - - """log_event_cost_per_hour_usd field predicates""" - logEventCostPerHourUsd: Float - logEventCostPerHourUsdNEQ: Float - logEventCostPerHourUsdIn: [Float!] - logEventCostPerHourUsdNotIn: [Float!] - logEventCostPerHourUsdGT: Float - logEventCostPerHourUsdGTE: Float - logEventCostPerHourUsdLT: Float - logEventCostPerHourUsdLTE: Float - logEventCostPerHourUsdIsNil: Boolean - logEventCostPerHourUsdNotNil: Boolean - - """estimated_volume_reduction_per_hour field predicates""" - estimatedVolumeReductionPerHour: Float - estimatedVolumeReductionPerHourNEQ: Float - estimatedVolumeReductionPerHourIn: [Float!] - estimatedVolumeReductionPerHourNotIn: [Float!] - estimatedVolumeReductionPerHourGT: Float - estimatedVolumeReductionPerHourGTE: Float - estimatedVolumeReductionPerHourLT: Float - estimatedVolumeReductionPerHourLTE: Float - estimatedVolumeReductionPerHourIsNil: Boolean - estimatedVolumeReductionPerHourNotNil: Boolean - - """estimated_bytes_reduction_per_hour field predicates""" - estimatedBytesReductionPerHour: Float - estimatedBytesReductionPerHourNEQ: Float - estimatedBytesReductionPerHourIn: [Float!] - estimatedBytesReductionPerHourNotIn: [Float!] - estimatedBytesReductionPerHourGT: Float - estimatedBytesReductionPerHourGTE: Float - estimatedBytesReductionPerHourLT: Float - estimatedBytesReductionPerHourLTE: Float - estimatedBytesReductionPerHourIsNil: Boolean - estimatedBytesReductionPerHourNotNil: Boolean - - """estimated_cost_reduction_per_hour_bytes_usd field predicates""" - estimatedCostReductionPerHourBytesUsd: Float - estimatedCostReductionPerHourBytesUsdNEQ: Float - estimatedCostReductionPerHourBytesUsdIn: [Float!] - estimatedCostReductionPerHourBytesUsdNotIn: [Float!] - estimatedCostReductionPerHourBytesUsdGT: Float - estimatedCostReductionPerHourBytesUsdGTE: Float - estimatedCostReductionPerHourBytesUsdLT: Float - estimatedCostReductionPerHourBytesUsdLTE: Float - estimatedCostReductionPerHourBytesUsdIsNil: Boolean - estimatedCostReductionPerHourBytesUsdNotNil: Boolean - - """estimated_cost_reduction_per_hour_volume_usd field predicates""" - estimatedCostReductionPerHourVolumeUsd: Float - estimatedCostReductionPerHourVolumeUsdNEQ: Float - estimatedCostReductionPerHourVolumeUsdIn: [Float!] - estimatedCostReductionPerHourVolumeUsdNotIn: [Float!] - estimatedCostReductionPerHourVolumeUsdGT: Float - estimatedCostReductionPerHourVolumeUsdGTE: Float - estimatedCostReductionPerHourVolumeUsdLT: Float - estimatedCostReductionPerHourVolumeUsdLTE: Float - estimatedCostReductionPerHourVolumeUsdIsNil: Boolean - estimatedCostReductionPerHourVolumeUsdNotNil: Boolean - - """estimated_cost_reduction_per_hour_usd field predicates""" - estimatedCostReductionPerHourUsd: Float - estimatedCostReductionPerHourUsdNEQ: Float - estimatedCostReductionPerHourUsdIn: [Float!] - estimatedCostReductionPerHourUsdNotIn: [Float!] - estimatedCostReductionPerHourUsdGT: Float - estimatedCostReductionPerHourUsdGTE: Float - estimatedCostReductionPerHourUsdLT: Float - estimatedCostReductionPerHourUsdLTE: Float - estimatedCostReductionPerHourUsdIsNil: Boolean - estimatedCostReductionPerHourUsdNotNil: Boolean - - """observed_volume_per_hour_before field predicates""" - observedVolumePerHourBefore: Float - observedVolumePerHourBeforeNEQ: Float - observedVolumePerHourBeforeIn: [Float!] - observedVolumePerHourBeforeNotIn: [Float!] - observedVolumePerHourBeforeGT: Float - observedVolumePerHourBeforeGTE: Float - observedVolumePerHourBeforeLT: Float - observedVolumePerHourBeforeLTE: Float - observedVolumePerHourBeforeIsNil: Boolean - observedVolumePerHourBeforeNotNil: Boolean - - """observed_volume_per_hour_after field predicates""" - observedVolumePerHourAfter: Float - observedVolumePerHourAfterNEQ: Float - observedVolumePerHourAfterIn: [Float!] - observedVolumePerHourAfterNotIn: [Float!] - observedVolumePerHourAfterGT: Float - observedVolumePerHourAfterGTE: Float - observedVolumePerHourAfterLT: Float - observedVolumePerHourAfterLTE: Float - observedVolumePerHourAfterIsNil: Boolean - observedVolumePerHourAfterNotNil: Boolean - - """observed_bytes_per_hour_before field predicates""" - observedBytesPerHourBefore: Float - observedBytesPerHourBeforeNEQ: Float - observedBytesPerHourBeforeIn: [Float!] - observedBytesPerHourBeforeNotIn: [Float!] - observedBytesPerHourBeforeGT: Float - observedBytesPerHourBeforeGTE: Float - observedBytesPerHourBeforeLT: Float - observedBytesPerHourBeforeLTE: Float - observedBytesPerHourBeforeIsNil: Boolean - observedBytesPerHourBeforeNotNil: Boolean - - """observed_bytes_per_hour_after field predicates""" - observedBytesPerHourAfter: Float - observedBytesPerHourAfterNEQ: Float - observedBytesPerHourAfterIn: [Float!] - observedBytesPerHourAfterNotIn: [Float!] - observedBytesPerHourAfterGT: Float - observedBytesPerHourAfterGTE: Float - observedBytesPerHourAfterLT: Float - observedBytesPerHourAfterLTE: Float - observedBytesPerHourAfterIsNil: Boolean - observedBytesPerHourAfterNotNil: Boolean - - """observed_cost_per_hour_before_bytes_usd field predicates""" - observedCostPerHourBeforeBytesUsd: Float - observedCostPerHourBeforeBytesUsdNEQ: Float - observedCostPerHourBeforeBytesUsdIn: [Float!] - observedCostPerHourBeforeBytesUsdNotIn: [Float!] - observedCostPerHourBeforeBytesUsdGT: Float - observedCostPerHourBeforeBytesUsdGTE: Float - observedCostPerHourBeforeBytesUsdLT: Float - observedCostPerHourBeforeBytesUsdLTE: Float - observedCostPerHourBeforeBytesUsdIsNil: Boolean - observedCostPerHourBeforeBytesUsdNotNil: Boolean - - """observed_cost_per_hour_before_volume_usd field predicates""" - observedCostPerHourBeforeVolumeUsd: Float - observedCostPerHourBeforeVolumeUsdNEQ: Float - observedCostPerHourBeforeVolumeUsdIn: [Float!] - observedCostPerHourBeforeVolumeUsdNotIn: [Float!] - observedCostPerHourBeforeVolumeUsdGT: Float - observedCostPerHourBeforeVolumeUsdGTE: Float - observedCostPerHourBeforeVolumeUsdLT: Float - observedCostPerHourBeforeVolumeUsdLTE: Float - observedCostPerHourBeforeVolumeUsdIsNil: Boolean - observedCostPerHourBeforeVolumeUsdNotNil: Boolean - - """observed_cost_per_hour_before_usd field predicates""" - observedCostPerHourBeforeUsd: Float - observedCostPerHourBeforeUsdNEQ: Float - observedCostPerHourBeforeUsdIn: [Float!] - observedCostPerHourBeforeUsdNotIn: [Float!] - observedCostPerHourBeforeUsdGT: Float - observedCostPerHourBeforeUsdGTE: Float - observedCostPerHourBeforeUsdLT: Float - observedCostPerHourBeforeUsdLTE: Float - observedCostPerHourBeforeUsdIsNil: Boolean - observedCostPerHourBeforeUsdNotNil: Boolean - - """observed_cost_per_hour_after_bytes_usd field predicates""" - observedCostPerHourAfterBytesUsd: Float - observedCostPerHourAfterBytesUsdNEQ: Float - observedCostPerHourAfterBytesUsdIn: [Float!] - observedCostPerHourAfterBytesUsdNotIn: [Float!] - observedCostPerHourAfterBytesUsdGT: Float - observedCostPerHourAfterBytesUsdGTE: Float - observedCostPerHourAfterBytesUsdLT: Float - observedCostPerHourAfterBytesUsdLTE: Float - observedCostPerHourAfterBytesUsdIsNil: Boolean - observedCostPerHourAfterBytesUsdNotNil: Boolean - - """observed_cost_per_hour_after_volume_usd field predicates""" - observedCostPerHourAfterVolumeUsd: Float - observedCostPerHourAfterVolumeUsdNEQ: Float - observedCostPerHourAfterVolumeUsdIn: [Float!] - observedCostPerHourAfterVolumeUsdNotIn: [Float!] - observedCostPerHourAfterVolumeUsdGT: Float - observedCostPerHourAfterVolumeUsdGTE: Float - observedCostPerHourAfterVolumeUsdLT: Float - observedCostPerHourAfterVolumeUsdLTE: Float - observedCostPerHourAfterVolumeUsdIsNil: Boolean - observedCostPerHourAfterVolumeUsdNotNil: Boolean - - """observed_cost_per_hour_after_usd field predicates""" - observedCostPerHourAfterUsd: Float - observedCostPerHourAfterUsdNEQ: Float - observedCostPerHourAfterUsdIn: [Float!] - observedCostPerHourAfterUsdNotIn: [Float!] - observedCostPerHourAfterUsdGT: Float - observedCostPerHourAfterUsdGTE: Float - observedCostPerHourAfterUsdLT: Float - observedCostPerHourAfterUsdLTE: Float - observedCostPerHourAfterUsdIsNil: Boolean - observedCostPerHourAfterUsdNotNil: Boolean - - """refreshed_at field predicates""" - refreshedAt: Time - refreshedAtNEQ: Time - refreshedAtIn: [Time!] - refreshedAtNotIn: [Time!] - refreshedAtGT: Time - refreshedAtGTE: Time - refreshedAtLT: Time - refreshedAtLTE: Time -} + """PIN or PIN block.""" + pin -""" -ServiceWhereInput is used for filtering Service objects. -Input was generated by ent. -""" -input ServiceWhereInput { - not: ServiceWhereInput - and: [ServiceWhereInput!] - or: [ServiceWhereInput!] + """Bank account, routing, IBAN, or similar bank payment detail.""" + bank_account - """id field predicates""" - id: ID - idNEQ: ID - idIn: [ID!] - idNotIn: [ID!] - idGT: ID - idGTE: ID - idLT: ID - idLTE: ID + """ + Reusable tokenized payment credential, vault token, provider payment method + token, payment account token, or stored payment method token. Do not use for + transaction IDs, charge IDs, dispute IDs, payment method IDs, payment + instrument IDs, PSP references, or one-off workflow references. + """ + payment_token - """account_id field predicates""" - accountID: ID - accountIDNEQ: ID - accountIDIn: [ID!] - accountIDNotIn: [ID!] + """ + Billing address or billing-address input exposed in a payment or billing + context, including natural-person name fields when they are part of the + billing address object. Do not use for country, region, state, locale, + currency, status, or address-like operational metadata by itself, even inside + a billing-address object. + """ + billing_address - """name field predicates""" - name: String - nameNEQ: String - nameIn: [String!] - nameNotIn: [String!] - nameGT: String - nameGTE: String - nameLT: String - nameLTE: String - nameContains: String - nameHasPrefix: String - nameHasSuffix: String - nameEqualFold: String - nameContainsFold: String + """ + Phone number exposed in a payment or billing context, including telephone + fields inside a billing address/contact input. Prefer this over generic PII + phone when the phone is materially part of billing or payment handling. + """ + billing_phone - """description field predicates""" - description: String - descriptionNEQ: String - descriptionIn: [String!] - descriptionNotIn: [String!] - descriptionGT: String - descriptionGTE: String - descriptionLT: String - descriptionLTE: String - descriptionContains: String - descriptionHasPrefix: String - descriptionHasSuffix: String - descriptionEqualFold: String - descriptionContainsFold: String + """ + Government, tax, or identity document number exposed in a payment or billing + context. Prefer this over generic PII government ID when the identifier is + materially part of billing or payment handling. + """ + billing_government_id +} - """enabled field predicates""" - enabled: Boolean - enabledNEQ: Boolean +enum SensitiveDataPHIElement { + """ + Patient, medical record, or healthcare-system patient identifier. Do not use + for lab order IDs, provider IDs, event IDs, encounter workflow IDs, request + IDs, or other operational workflow references. + """ + patient_identifier - """initial_weekly_log_count field predicates""" - initialWeeklyLogCount: Int - initialWeeklyLogCountNEQ: Int - initialWeeklyLogCountIn: [Int!] - initialWeeklyLogCountNotIn: [Int!] - initialWeeklyLogCountGT: Int - initialWeeklyLogCountGTE: Int - initialWeeklyLogCountLT: Int - initialWeeklyLogCountLTE: Int - initialWeeklyLogCountIsNil: Boolean - initialWeeklyLogCountNotNil: Boolean + """Diagnosed condition.""" + diagnosis - """created_at field predicates""" - createdAt: Time - createdAtNEQ: Time - createdAtIn: [Time!] - createdAtNotIn: [Time!] - createdAtGT: Time - createdAtGTE: Time - createdAtLT: Time - createdAtLTE: Time + """ + Health condition, symptom, pregnancy status, mental health detail, substance-use detail, or similar health status. + """ + health_condition - """updated_at field predicates""" - updatedAt: Time - updatedAtNEQ: Time - updatedAtIn: [Time!] - updatedAtNotIn: [Time!] - updatedAtGT: Time - updatedAtGTE: Time - updatedAtLT: Time - updatedAtLTE: Time + """ + Treatment, procedure, surgery, therapy, care plan, or clinical activity. + """ + treatment_or_procedure - """account edge predicates""" - hasAccount: Boolean - hasAccountWith: [AccountWhereInput!] + """Medication or prescription detail.""" + medication - """log_events edge predicates""" - hasLogEvents: Boolean - hasLogEventsWith: [LogEventWhereInput!] -} + """ + Lab result, vital sign, test result, or clinical measurement tied to care. Use + this for measured clinical values rather than health_condition. + """ + lab_result -type Team implements Node { - """Unique identifier of the team""" - id: ID! + """ + Clinical note or health narrative. A single free-text clinical narrative is + one clinical_note exposure unless separate structured fields expose distinct PHI elements. + """ + clinical_note + + """Health plan member, policy, beneficiary, or coverage identifier.""" + health_plan_identifier """ - Denormalized for tenant isolation. Auto-set via trigger from workspace.account_id. + Healthcare claim, explanation-of-benefits, or payment-for-care identifier or + detail. Do not use for generic claim status values by themselves. """ - accountID: UUID! + healthcare_claim + + """ + Healthcare appointment, encounter, admission, discharge, or visit detail that + reveals care context. Do not use for generic appointment or visit status/date + values, even when adjacent fields reveal care context. + """ + appointment_or_visit +} - """Parent workspace this team belongs to""" - workspaceID: ID! +enum SensitiveDataPIIElement { + """Email address.""" + email - """Human-readable name within the workspace""" - name: String! + """Phone number.""" + phone - """When the team was created""" - createdAt: Time! + """Person name.""" + name - """When the team was last updated""" - updatedAt: Time! + """Mailing, shipping, or residential address.""" + address - """Workspace this team belongs to""" - workspace: Workspace! + """Date of birth.""" + date_of_birth + + """ + Government-issued identifier such as SSN, tax ID, passport, or driver's license. + """ + government_id } -"""A connection to a list of items.""" -type TeamConnection { - """A list of edges.""" - edges: [TeamEdge] +""" +Lists exact observed paths that expose material sensitive data in a recurring log event. +""" +type SensitiveDataProfile { + """ + Exposed real secrets, credentials, authentication tokens, signing material, encryption keys, or connection secrets. + """ + secretExposures: [SensitiveDataProfileSecretExposures!]! - """Information to aid in pagination.""" - pageInfo: PageInfo! + """Exposed real payment-sensitive data.""" + paymentExposures: [SensitiveDataProfilePaymentExposures!]! - """Identifies the total count of items in the connection.""" - totalCount: Int! + """Exposed meaningful natural-person identity data.""" + piiExposures: [SensitiveDataProfilePIIExposures!]! + + """Exposed individually identifiable health, care, or coverage data.""" + phiExposures: [SensitiveDataProfilePHIExposures!]! } -"""An edge in a connection.""" -type TeamEdge { - """The item at the end of the edge.""" - node: Team +"""Exposed real payment-sensitive data.""" +type SensitiveDataProfilePaymentExposures { + """The specific payment data element exposed at this path.""" + element: SensitiveDataPaymentElement! - """A cursor for use in pagination.""" - cursor: Cursor! + """ + The minimal exact observed leaf path whose value exposes the sensitive element. + """ + path: SensitiveDataProfilePaymentExposuresPath! } -"""Ordering options for Team connections""" -input TeamOrder { - """The ordering direction.""" - direction: OrderDirection! = ASC +""" +The minimal exact observed leaf path whose value exposes the sensitive element. +""" +type SensitiveDataProfilePaymentExposuresPath { + """The exact observed field path.""" + path: [String!]! - """The field by which to order Teams.""" - field: TeamOrderField! + """ + The grouping path for sibling fields that should be reasoned about together. + """ + pathFamily: [String!]! } -"""Properties by which Team connections can be ordered.""" -enum TeamOrderField { - NAME - CREATED_AT - UPDATED_AT +"""Exposed individually identifiable health, care, or coverage data.""" +type SensitiveDataProfilePHIExposures { + """The specific PHI element exposed at this path.""" + element: SensitiveDataPHIElement! + + """ + The minimal exact observed leaf path whose value exposes the sensitive element. + """ + path: SensitiveDataProfilePHIExposuresPath! } """ -TeamWhereInput is used for filtering Team objects. -Input was generated by ent. +The minimal exact observed leaf path whose value exposes the sensitive element. """ -input TeamWhereInput { - not: TeamWhereInput - and: [TeamWhereInput!] - or: [TeamWhereInput!] +type SensitiveDataProfilePHIExposuresPath { + """The exact observed field path.""" + path: [String!]! - """id field predicates""" - id: ID - idNEQ: ID - idIn: [ID!] - idNotIn: [ID!] - idGT: ID - idGTE: ID - idLT: ID - idLTE: ID + """ + The grouping path for sibling fields that should be reasoned about together. + """ + pathFamily: [String!]! +} - """account_id field predicates""" - accountID: UUID - accountIDNEQ: UUID - accountIDIn: [UUID!] - accountIDNotIn: [UUID!] - accountIDGT: UUID - accountIDGTE: UUID - accountIDLT: UUID - accountIDLTE: UUID +"""Exposed meaningful natural-person identity data.""" +type SensitiveDataProfilePIIExposures { + """The specific PII element exposed at this path.""" + element: SensitiveDataPIIElement! - """workspace_id field predicates""" - workspaceID: ID - workspaceIDNEQ: ID - workspaceIDIn: [ID!] - workspaceIDNotIn: [ID!] + """ + The minimal exact observed leaf path whose value exposes the sensitive element. + """ + path: SensitiveDataProfilePIIExposuresPath! +} - """name field predicates""" - name: String - nameNEQ: String - nameIn: [String!] - nameNotIn: [String!] - nameGT: String - nameGTE: String - nameLT: String - nameLTE: String - nameContains: String - nameHasPrefix: String - nameHasSuffix: String - nameEqualFold: String - nameContainsFold: String +""" +The minimal exact observed leaf path whose value exposes the sensitive element. +""" +type SensitiveDataProfilePIIExposuresPath { + """The exact observed field path.""" + path: [String!]! - """created_at field predicates""" - createdAt: Time - createdAtNEQ: Time - createdAtIn: [Time!] - createdAtNotIn: [Time!] - createdAtGT: Time - createdAtGTE: Time - createdAtLT: Time - createdAtLTE: Time + """ + The grouping path for sibling fields that should be reasoned about together. + """ + pathFamily: [String!]! +} - """updated_at field predicates""" - updatedAt: Time - updatedAtNEQ: Time - updatedAtIn: [Time!] - updatedAtNotIn: [Time!] - updatedAtGT: Time - updatedAtGTE: Time - updatedAtLT: Time - updatedAtLTE: Time +""" +Exposed real secrets, credentials, authentication tokens, signing material, encryption keys, or connection secrets. +""" +type SensitiveDataProfileSecretExposures { + """The specific secret element exposed at this path.""" + element: SensitiveDataSecretElement! - """workspace edge predicates""" - hasWorkspace: Boolean - hasWorkspaceWith: [WorkspaceWhereInput!] + """ + The minimal exact observed leaf path whose value exposes the sensitive element. + """ + path: SensitiveDataProfileSecretExposuresPath! } -"""Text content from the user or assistant.""" -type TextBlock { - content: String! +""" +The minimal exact observed leaf path whose value exposes the sensitive element. +""" +type SensitiveDataProfileSecretExposuresPath { + """The exact observed field path.""" + path: [String!]! + + """ + The grouping path for sibling fields that should be reasoned about together. + """ + pathFamily: [String!]! } -"""Text content from the user or assistant.""" -input TextBlockInput { - content: String! -} +""" +Lists exact observed paths that expose material sensitive data in a recurring log event. +""" +input SensitiveDataProfileWhereInput { + """Negated predicates.""" + not: SensitiveDataProfileWhereInput -"""The AI's internal reasoning (extended thinking).""" -type ThinkingBlock { - content: String! -} + """Predicates that must all match.""" + and: [SensitiveDataProfileWhereInput!] -"""The AI's internal reasoning (extended thinking).""" -input ThinkingBlockInput { - content: String! -} + """Predicates where at least one must match.""" + or: [SensitiveDataProfileWhereInput!] -"""The builtin Time type""" -scalar Time + """Whether the sensitive_data_profile JSONB value is present.""" + present: Boolean -"""The result of a tool call.""" -type ToolResult { - """The ID of the tool call this result is for.""" - toolUseId: String! + """ + Exposed real secrets, credentials, authentication tokens, signing material, encryption keys, or connection secrets. + """ + hasSecretExposures: Boolean - """Whether the tool execution resulted in an error.""" - isError: Boolean + """Exposed real payment-sensitive data.""" + hasPaymentExposures: Boolean - """Human-readable error message when isError is true.""" - error: String + """Exposed meaningful natural-person identity data.""" + hasPIIExposures: Boolean - """Structured result data (JSON object).""" - content: Map + """Exposed individually identifiable health, care, or coverage data.""" + hasPHIExposures: Boolean } -"""The result of a tool call.""" -input ToolResultInput { - """The ID of the tool call this result is for.""" - toolUseId: String! +enum SensitiveDataSecretElement { + """API key or service key material.""" + api_key - """Whether the tool execution resulted in an error.""" - isError: Boolean! + """ + Bearer token, OAuth token, JWT, refresh token, or similar access credential. + """ + access_token - """Human-readable error message when isError is true.""" - error: String + """ + Session token, session cookie, or session secret usable for authentication. + """ + session_token - """Structured result data (JSON object).""" - content: Map -} + """Password, passphrase, or password-like credential.""" + password -"""A tool call from the assistant.""" -type ToolUse { - """Unique identifier for this tool call.""" - id: String! + """ + Private key, signing key, certificate private material, or similar cryptographic secret. + """ + private_key - """The name of the tool being called.""" - name: String! + """ + Database URL, service URL, connection string, DSN, or embedded connection credential. + """ + connection_secret + + """ + OAuth client secret, application client secret, or similar client credential. Do not use for client IDs. + """ + client_secret - """The input parameters for the tool (JSON object).""" - input: Map! + """ + HMAC secret, shared signing secret, JWT signing secret, webhook signing + secret, callback verification secret, or similar message-signing material. + """ + signing_secret + + """Symmetric encryption key or data-encryption key material.""" + encryption_key } -"""A tool call from the assistant.""" -input ToolUseInput { - """Unique identifier for this tool call.""" - id: String! +type Service { + """Unique identifier of the service""" + id: ID! + + """Parent account this service belongs to""" + accountID: ID! - """The name of the tool being called.""" + """Service identifier in telemetry (e.g., 'checkout-service')""" name: String! - """The input parameters for the tool (JSON object).""" - input: Map! -} + """Whether log analysis and policy generation is active for this service""" + enabled: Boolean! -""" -UpdateAccountInput is used for update Account object. -Input was generated by ent. -""" -input UpdateAccountInput { - """Human-readable name within the organization""" - name: String + """ + Whether pre-catalog audit workflows should collect logs, build service context, and run preclustering for this service + """ + auditEnabled: Boolean! """ - Multiplier applied to volume data via trigger. 1 = real data, >1 = scaled for demos. + Approximate weekly log count from initial catalog loop pass (7-day period from Datadog) """ - demoScaleFactor: Int - organizationID: ID - datadogAccountID: ID - clearDatadogAccount: Boolean -} + initialWeeklyLogCount: Int -""" -UpdateConversationInput is used for update Conversation object. -Input was generated by ent. -""" -input UpdateConversationInput { - """AI-generated title, set after first exchange""" - title: String - clearTitle: Boolean - viewID: ID - clearView: Boolean -} + """When the service was created""" + createdAt: Time! -""" -UpdateDatadogAccountInput is used for update DatadogAccount object. -Input was generated by ent. -""" -input UpdateDatadogAccountInput { - """Display name for this Datadog account""" - name: String + """When the service was last updated""" + updatedAt: Time! + + """Account this service belongs to""" + account: Account! + + """Log event types produced by this service""" + logEvents(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: LogEventOrder, where: LogEventWhereInput): LogEventConnection! """ - Datadog regional site. US1: datadoghq.com, US3: us3.datadoghq.com, US5: - us5.datadoghq.com, EU1: datadoghq.eu, US1_FED: ddog-gov.com, AP1: - ap1.datadoghq.com, AP2: ap2.datadoghq.com. + Status of this service in the catalog pipeline. + Shows where the service is in the catalog pipeline. + Returns null if the status view has no row yet. """ - site: DatadogAccountSite + status: ServiceStatus """ - Cost per GB of log data ingested (USD). NULL = using Datadog's published rate - ($0.10/GB). Set to override with actual contract rate. + Narrow status surface for the services list. + Drops issue-projection rollups (preview / effective / savings) and + per-service indexing cost from the full status to keep the list query cheap. + Use status for the service detail page. + Returns null if no baseline or volume data is available yet. """ - costPerGBIngested: Float - clearCostPerGBIngested: Boolean + statusSummary: ServiceStatusSummary """ - Fraction of the API rate limit to consume (0.0-1.0). NULL = default (0.8 = - 80%). Leaves headroom for the customer's own API usage. + Active issue counts for this service, grouped by product domain and priority. """ - rateLimitUtilization: Float - clearRateLimitUtilization: Boolean - accountID: ID -} + issueSummary: ServiceIssueSummary! -""" -UpdateEdgeApiKeyInput is used for update EdgeApiKey object. -Input was generated by ent. -""" -input UpdateEdgeApiKeyInput { - """User-provided name for this key (e.g., 'Production Collector')""" - name: String + """Owning team for this service, if one has been assigned.""" + team: Team - """When this key was revoked (null if active)""" - revokedAt: Time - clearRevokedAt: Boolean -} + """Bucketed log volume for this service.""" + logVolume(input: LogVolumeInput!): LogVolumeSeries! -""" -UpdateOrganizationInput is used for update Organization object. -Input was generated by ent. -""" -input UpdateOrganizationInput { - """Human-readable name, unique across the system""" - name: String + """Typed service JSONB facts.""" + serviceFacts: ServiceFacts! } -""" -UpdateServiceInput is used for update Service object. -Input was generated by ent. -""" -input UpdateServiceInput { - """Whether log analysis and policy generation is active for this service""" - enabled: Boolean +type ServiceAuditEnabledFacet { + buckets: [ServiceAuditEnabledFacetBucket!]! } -""" -UpdateTeamInput is used for update Team object. -Input was generated by ent. -""" -input UpdateTeamInput { - """Human-readable name within the workspace""" - name: String - workspaceID: ID +type ServiceAuditEnabledFacetBucket { + value: Boolean! + count: Int! } -""" -UpdateWorkspaceInput is used for update Workspace object. -Input was generated by ent. -""" -input UpdateWorkspaceInput { - """Human-readable name within the account""" - name: String +type ServiceConnection { + edges: [ServiceEdge!]! + pageInfo: PageInfo! + totalCount: Int! + summary: ServiceSummary! + facets: ServiceFacets! +} - """ - Primary purpose determining evaluation strategy. observability: performance - and reliability, security: threat detection, compliance: regulatory requirements. - """ - purpose: WorkspacePurpose - accountID: ID +type ServiceCurrentStatus { + serviceTotals: StatusServiceTotals! + severity: StatusSeverityTotals! + totals: StatusMeasurementTotals! } -scalar UUID +type ServiceEdge { + node: Service! + cursor: Cursor! +} -input ValidateDatadogApiKeyInput { - apiKey: String! - site: DatadogAccountSite! +type ServiceEnabledFacet { + buckets: [ServiceEnabledFacetBucket!]! } -type ValidateDatadogApiKeyResult { - valid: Boolean! - error: String +type ServiceEnabledFacetBucket { + value: Boolean! + count: Int! } -type View implements Node { - """Unique identifier""" - id: ID! +type ServiceFacets { + enabled(limit: Int): ServiceEnabledFacet! + auditEnabled(limit: Int): ServiceAuditEnabledFacet! +} +"""Typed service JSONB facts.""" +type ServiceFacts { """ - Denormalized for tenant isolation. Auto-set via trigger from message.account_id. + Names the concrete telemetry-emitting technology when it is recognizable. """ - accountID: UUID! - - """Assistant message that created this view via show_view tool call""" - messageID: ID! + technologyIdentity: TechnologyIdentity - """Denormalized from message for easier queries""" - conversationID: ID! + """ + Describes who owns the service code and who operates the running service. + """ + ownershipModel: OwnershipModel - """Parent view if this is a refinement/iteration""" - forkedFromID: ID + """Describes the technical runtime roles a service plays in the system.""" + runtimeRole: RuntimeRole """ - Which catalog entity this view queries. service: applications, log_event: event patterns, policy: quality recommendations. + Describes visible implementation language, framework, runtime, and stack details. """ - entityType: ViewEntityType! + implementationProfile: ImplementationProfile - """Raw SQL query executed against the client's local SQLite database""" - query: String! + """ + Describes what this service does for the business, product, or platform. + """ + domainFunction: DomainFunction - """WorkOS user ID who triggered this view creation""" - createdBy: String! + """ + Describes how one service's own logs should compress into recurring emitted lines and occurrence details. + """ + logEmissionModel: LogEmissionModel +} - """When the view was created""" - createdAt: Time! +"""Filters for service JSONB facts.""" +input ServiceFactsWhereInput { + """Negated predicates.""" + not: ServiceFactsWhereInput - """Assistant message that created this view""" - message: Message! + """Predicates that must all match.""" + and: [ServiceFactsWhereInput!] - """Conversation this view was created in""" - conversation: Conversation! + """Predicates where at least one must match.""" + or: [ServiceFactsWhereInput!] - """Parent view if this is a fork""" - forkedFrom: View - forks: [View!] + """ + Names the concrete telemetry-emitting technology when it is recognizable. + """ + technologyIdentity: TechnologyIdentityWhereInput - """Users who favorited this view""" - favorites: [ViewFavorite!] + """ + Describes who owns the service code and who operates the running service. + """ + ownershipModel: OwnershipModelWhereInput - """Conversations created to iterate on this view""" - iterationConversations: [Conversation!] -} + """Describes the technical runtime roles a service plays in the system.""" + runtimeRole: RuntimeRoleWhereInput -"""A connection to a list of items.""" -type ViewConnection { - """A list of edges.""" - edges: [ViewEdge] + """ + Describes visible implementation language, framework, runtime, and stack details. + """ + implementationProfile: ImplementationProfileWhereInput - """Information to aid in pagination.""" - pageInfo: PageInfo! + """ + Describes what this service does for the business, product, or platform. + """ + domainFunction: DomainFunctionWhereInput - """Identifies the total count of items in the connection.""" - totalCount: Int! + """ + Describes how one service's own logs should compress into recurring emitted lines and occurrence details. + """ + logEmissionModel: LogEmissionModelWhereInput } -"""An edge in a connection.""" -type ViewEdge { - """The item at the end of the edge.""" - node: View +type ServiceIssueSummary { + openCount: Int! + highPriorityOpenCount: Int! + costOpenCount: Int! + costHighPriorityOpenCount: Int! + complianceOpenCount: Int! + complianceHighPriorityOpenCount: Int! +} - """A cursor for use in pagination.""" - cursor: Cursor! +input ServiceOrder { + direction: OrderDirection! = ASC + field: ServiceOrderField! } -"""ViewEntityType is enum for the field entity_type""" -enum ViewEntityType { - service - log_event - policy +enum ServiceOrderField { + ID + NAME + ENABLED + AUDIT_ENABLED + CREATED_AT + UPDATED_AT + TEAM_NAME + CURRENT_BYTES_PER_HOUR } -""" -The authenticated user making the request. -Returns null for organizationId if the user is not assigned to an organization. -""" -type Viewer { - """The user's unique identifier (WorkOS user ID).""" - id: String! +enum ServiceOwnershipCode { + """First-party software the team appears to own or write.""" + first_party - """The user's email address.""" - email: String! + """Third-party software, product, library, or vendor code.""" + third_party - """ - The organization ID the user is authenticated for, or null if not assigned to any organization. - """ - organizationId: UUID + """Insufficient evidence for code ownership.""" + unknown } -type ViewFavorite implements Node { - """Unique identifier""" - id: ID! - +enum ServiceOwnershipOperation { """ - Denormalized for tenant isolation. Auto-set via trigger from view.account_id. + The team appears to operate, deploy, configure, or administer the running software. """ - accountID: UUID! - - """The view being favorited""" - viewID: ID! - - """WorkOS user ID who favorited this view""" - userID: String! + self_operated - """When the view was favorited""" - createdAt: Time! + """A vendor or external provider appears to operate the running software.""" + vendor_operated - """The view being favorited""" - view: View! + """Insufficient evidence for operational ownership.""" + unknown } -"""A connection to a list of items.""" -type ViewFavoriteConnection { - """A list of edges.""" - edges: [ViewFavoriteEdge] +type ServiceStatus { + health: StatusHealth! + coverage: ServiceStatusCoverage! + current: ServiceCurrentStatus! + preview: StatusScenario! + effective: StatusScenario! +} - """Information to aid in pagination.""" - pageInfo: PageInfo! +type ServiceStatusCoverage { + logEventCount: Int! + logEventAnalyzedCount: Int! + previewLogEventCount: Int! + effectiveLogEventCount: Int! +} - """Identifies the total count of items in the connection.""" - totalCount: Int! +type ServiceStatusSummary { + health: StatusHealth! + logEventCount: Int! + logEventAnalyzedCount: Int! + current: ServiceStatusSummaryCurrent! } -"""An edge in a connection.""" -type ViewFavoriteEdge { - """The item at the end of the edge.""" - node: ViewFavorite +type ServiceStatusSummaryCurrent { + eventsPerHour: Float + bytesPerHour: Float + totalUsdPerHour: Float + severity: StatusSeverityTotals! +} - """A cursor for use in pagination.""" - cursor: Cursor! +type ServiceSummary { + count: Int! } -"""Ordering options for ViewFavorite connections""" -input ViewFavoriteOrder { - """The ordering direction.""" - direction: OrderDirection! = ASC +input ServiceWhereInput { + not: ServiceWhereInput + and: [ServiceWhereInput!] + or: [ServiceWhereInput!] - """The field by which to order ViewFavorites.""" - field: ViewFavoriteOrderField! -} + """"account" edge predicates.""" + account: AccountWhereInput -"""Properties by which ViewFavorite connections can be ordered.""" -enum ViewFavoriteOrderField { - CREATED_AT -} + """Whether the "account" edge has at least one related row.""" + hasAccount: Boolean -""" -ViewFavoriteWhereInput is used for filtering ViewFavorite objects. -Input was generated by ent. -""" -input ViewFavoriteWhereInput { - not: ViewFavoriteWhereInput - and: [ViewFavoriteWhereInput!] - or: [ViewFavoriteWhereInput!] + """"log_events" edge predicates.""" + logEvents: LogEventWhereInput + + """Whether the "log_events" edge has at least one related row.""" + hasLogEvents: Boolean - """id field predicates""" + """"id" field predicates.""" id: ID + + """"id" field predicates.""" idNEQ: ID + + """"id" field predicates.""" idIn: [ID!] + + """"id" field predicates.""" idNotIn: [ID!] + + """"id" field predicates.""" idGT: ID + + """"id" field predicates.""" idGTE: ID + + """"id" field predicates.""" idLT: ID + + """"id" field predicates.""" idLTE: ID - """account_id field predicates""" - accountID: UUID - accountIDNEQ: UUID - accountIDIn: [UUID!] - accountIDNotIn: [UUID!] - accountIDGT: UUID - accountIDGTE: UUID - accountIDLT: UUID - accountIDLTE: UUID + """"account_id" field predicates.""" + accountID: ID + + """"account_id" field predicates.""" + accountIDNEQ: ID + + """"account_id" field predicates.""" + accountIDIn: [ID!] + + """"account_id" field predicates.""" + accountIDNotIn: [ID!] + + """"name" field predicates.""" + name: String + + """"name" field predicates.""" + nameNEQ: String + + """"name" field predicates.""" + nameIn: [String!] + + """"name" field predicates.""" + nameNotIn: [String!] + + """"name" field predicates.""" + nameGT: String + + """"name" field predicates.""" + nameGTE: String + + """"name" field predicates.""" + nameLT: String + + """"name" field predicates.""" + nameLTE: String + + """"name" field predicates.""" + nameContains: String + + """"name" field predicates.""" + nameHasPrefix: String + + """"name" field predicates.""" + nameHasSuffix: String + + """"name" field predicates.""" + nameEqualFold: String + + """"name" field predicates.""" + nameContainsFold: String + + """"enabled" field predicates.""" + enabled: Boolean + + """"enabled" field predicates.""" + enabledNEQ: Boolean + + """"audit_enabled" field predicates.""" + auditEnabled: Boolean + + """"audit_enabled" field predicates.""" + auditEnabledNEQ: Boolean + + """"initial_weekly_log_count" field predicates.""" + initialWeeklyLogCount: Int + + """"initial_weekly_log_count" field predicates.""" + initialWeeklyLogCountNEQ: Int + + """"initial_weekly_log_count" field predicates.""" + initialWeeklyLogCountIn: [Int!] + + """"initial_weekly_log_count" field predicates.""" + initialWeeklyLogCountNotIn: [Int!] + + """"initial_weekly_log_count" field predicates.""" + initialWeeklyLogCountGT: Int + + """"initial_weekly_log_count" field predicates.""" + initialWeeklyLogCountGTE: Int + + """"initial_weekly_log_count" field predicates.""" + initialWeeklyLogCountLT: Int + + """"initial_weekly_log_count" field predicates.""" + initialWeeklyLogCountLTE: Int + + """"initial_weekly_log_count" field predicates.""" + initialWeeklyLogCountIsNil: Boolean + + """"initial_weekly_log_count" field predicates.""" + initialWeeklyLogCountNotNil: Boolean - """view_id field predicates""" - viewID: ID - viewIDNEQ: ID - viewIDIn: [ID!] - viewIDNotIn: [ID!] - - """user_id field predicates""" - userID: String - userIDNEQ: String - userIDIn: [String!] - userIDNotIn: [String!] - userIDGT: String - userIDGTE: String - userIDLT: String - userIDLTE: String - userIDContains: String - userIDHasPrefix: String - userIDHasSuffix: String - userIDEqualFold: String - userIDContainsFold: String - - """created_at field predicates""" + """"created_at" field predicates.""" createdAt: Time + + """"created_at" field predicates.""" createdAtNEQ: Time + + """"created_at" field predicates.""" createdAtIn: [Time!] + + """"created_at" field predicates.""" createdAtNotIn: [Time!] + + """"created_at" field predicates.""" createdAtGT: Time + + """"created_at" field predicates.""" createdAtGTE: Time + + """"created_at" field predicates.""" createdAtLT: Time + + """"created_at" field predicates.""" createdAtLTE: Time - """view edge predicates""" - hasView: Boolean - hasViewWith: [ViewWhereInput!] -} + """"updated_at" field predicates.""" + updatedAt: Time -"""Ordering options for View connections""" -input ViewOrder { - """The ordering direction.""" - direction: OrderDirection! = ASC + """"updated_at" field predicates.""" + updatedAtNEQ: Time - """The field by which to order Views.""" - field: ViewOrderField! -} + """"updated_at" field predicates.""" + updatedAtIn: [Time!] -"""Properties by which View connections can be ordered.""" -enum ViewOrderField { - ENTITY_TYPE - CREATED_AT -} + """"updated_at" field predicates.""" + updatedAtNotIn: [Time!] -""" -ViewWhereInput is used for filtering View objects. -Input was generated by ent. -""" -input ViewWhereInput { - not: ViewWhereInput - and: [ViewWhereInput!] - or: [ViewWhereInput!] + """"updated_at" field predicates.""" + updatedAtGT: Time - """id field predicates""" - id: ID - idNEQ: ID - idIn: [ID!] - idNotIn: [ID!] - idGT: ID - idGTE: ID - idLT: ID - idLTE: ID + """"updated_at" field predicates.""" + updatedAtGTE: Time - """account_id field predicates""" - accountID: UUID - accountIDNEQ: UUID - accountIDIn: [UUID!] - accountIDNotIn: [UUID!] - accountIDGT: UUID - accountIDGTE: UUID - accountIDLT: UUID - accountIDLTE: UUID + """"updated_at" field predicates.""" + updatedAtLT: Time - """message_id field predicates""" - messageID: ID - messageIDNEQ: ID - messageIDIn: [ID!] - messageIDNotIn: [ID!] - - """conversation_id field predicates""" - conversationID: ID - conversationIDNEQ: ID - conversationIDIn: [ID!] - conversationIDNotIn: [ID!] - - """forked_from_id field predicates""" - forkedFromID: ID - forkedFromIDNEQ: ID - forkedFromIDIn: [ID!] - forkedFromIDNotIn: [ID!] - forkedFromIDIsNil: Boolean - forkedFromIDNotNil: Boolean - - """entity_type field predicates""" - entityType: ViewEntityType - entityTypeNEQ: ViewEntityType - entityTypeIn: [ViewEntityType!] - entityTypeNotIn: [ViewEntityType!] - - """query field predicates""" - query: String - queryNEQ: String - queryIn: [String!] - queryNotIn: [String!] - queryGT: String - queryGTE: String - queryLT: String - queryLTE: String - queryContains: String - queryHasPrefix: String - queryHasSuffix: String - queryEqualFold: String - queryContainsFold: String - - """created_by field predicates""" - createdBy: String - createdByNEQ: String - createdByIn: [String!] - createdByNotIn: [String!] - createdByGT: String - createdByGTE: String - createdByLT: String - createdByLTE: String - createdByContains: String - createdByHasPrefix: String - createdByHasSuffix: String - createdByEqualFold: String - createdByContainsFold: String - - """created_at field predicates""" - createdAt: Time - createdAtNEQ: Time - createdAtIn: [Time!] - createdAtNotIn: [Time!] - createdAtGT: Time - createdAtGTE: Time - createdAtLT: Time - createdAtLTE: Time + """"updated_at" field predicates.""" + updatedAtLTE: Time + + """Filters for service JSONB facts.""" + serviceFacts: ServiceFactsWhereInput +} - """message edge predicates""" - hasMessage: Boolean - hasMessageWith: [MessageWhereInput!] +enum StatusHealth { + DISABLED + INACTIVE + ERROR + OK +} - """conversation edge predicates""" - hasConversation: Boolean - hasConversationWith: [ConversationWhereInput!] +type StatusMeasurementTotals { + eventsPerHour: Float + bytesPerHour: Float + bytesUsdPerHour: Float + volumeUsdPerHour: Float + totalUsdPerHour: Float +} - """forked_from edge predicates""" - hasForkedFrom: Boolean - hasForkedFromWith: [ViewWhereInput!] +type StatusReadiness { + readyForUse: Boolean! +} - """forks edge predicates""" - hasForks: Boolean - hasForksWith: [ViewWhereInput!] +type StatusScenario { + totals: StatusMeasurementTotals! + savings: StatusMeasurementTotals! +} - """favorites edge predicates""" - hasFavorites: Boolean - hasFavoritesWith: [ViewFavoriteWhereInput!] +type StatusServiceTotals { + eventsPerHour: Float + volumeUsdPerHour: Float +} - """iteration_conversations edge predicates""" - hasIterationConversations: Boolean - hasIterationConversationsWith: [ConversationWhereInput!] +type StatusSeverityTotals { + debugEventsPerHour: Float + infoEventsPerHour: Float + warnEventsPerHour: Float + errorEventsPerHour: Float + otherEventsPerHour: Float } -type Workspace implements Node { - """Unique identifier of the workspace""" +type Team { + """Unique identifier of the team""" id: ID! - """Parent account this workspace belongs to""" - accountID: ID! + """Organization this team belongs to""" + organizationID: ID! - """Human-readable name within the account""" + """Human-readable team name within the organization""" name: String! - """ - Primary purpose determining evaluation strategy. observability: performance - and reliability, security: threat detection, compliance: regulatory requirements. - """ - purpose: WorkspacePurpose! + """Optional description of the team's scope and responsibilities""" + description: String - """When the workspace was created""" + """When the team was created""" createdAt: Time! - """When the workspace was last updated""" + """When the team was last updated""" updatedAt: Time! - """Account this workspace belongs to""" - account: Account! - - """Teams assigned to manage this workspace""" - teams: [Team!] - - """Chat conversations in this workspace""" - conversations: [Conversation!] - - """Log event policies for this workspace""" - logEventPolicies: [LogEventPolicy!] + """Organization this team belongs to""" + organization: Organization! + members: [TeamMember!]! + services(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: ServiceOrder, where: ServiceWhereInput): ServiceConnection! } -"""A connection to a list of items.""" -type WorkspaceConnection { - """A list of edges.""" - edges: [WorkspaceEdge] - - """Information to aid in pagination.""" +type TeamConnection { + edges: [TeamEdge!]! pageInfo: PageInfo! - - """Identifies the total count of items in the connection.""" totalCount: Int! } -"""An edge in a connection.""" -type WorkspaceEdge { - """The item at the end of the edge.""" - node: Workspace +"""Team creation input.""" +input TeamCreateInput { + """Organization this team belongs to""" + organizationID: ID! + + """Human-readable team name within the organization""" + name: String! +} - """A cursor for use in pagination.""" +type TeamEdge { + node: Team! cursor: Cursor! } -"""Ordering options for Workspace connections""" -input WorkspaceOrder { - """The ordering direction.""" - direction: OrderDirection! = ASC +type TeamMember { + userID: String! +} - """The field by which to order Workspaces.""" - field: WorkspaceOrderField! +input TeamOrder { + direction: OrderDirection! = ASC + field: TeamOrderField! } -"""Properties by which Workspace connections can be ordered.""" -enum WorkspaceOrderField { +enum TeamOrderField { + ID NAME - PURPOSE + DESCRIPTION CREATED_AT UPDATED_AT } -"""WorkspacePurpose is enum for the field purpose""" -enum WorkspacePurpose { - observability - security - compliance +"""Team update input.""" +input TeamUpdateInput { + """Human-readable team name within the organization""" + name: String } -""" -WorkspaceWhereInput is used for filtering Workspace objects. -Input was generated by ent. -""" -input WorkspaceWhereInput { - not: WorkspaceWhereInput - and: [WorkspaceWhereInput!] - or: [WorkspaceWhereInput!] +input TeamWhereInput { + not: TeamWhereInput + and: [TeamWhereInput!] + or: [TeamWhereInput!] + + """"organization" edge predicates.""" + organization: OrganizationWhereInput + + """Whether the "organization" edge has at least one related row.""" + hasOrganization: Boolean - """id field predicates""" + """"id" field predicates.""" id: ID + + """"id" field predicates.""" idNEQ: ID + + """"id" field predicates.""" idIn: [ID!] + + """"id" field predicates.""" idNotIn: [ID!] + + """"id" field predicates.""" idGT: ID + + """"id" field predicates.""" idGTE: ID + + """"id" field predicates.""" idLT: ID + + """"id" field predicates.""" idLTE: ID - """account_id field predicates""" - accountID: ID - accountIDNEQ: ID - accountIDIn: [ID!] - accountIDNotIn: [ID!] + """"organization_id" field predicates.""" + organizationID: ID + + """"organization_id" field predicates.""" + organizationIDNEQ: ID + + """"organization_id" field predicates.""" + organizationIDIn: [ID!] + + """"organization_id" field predicates.""" + organizationIDNotIn: [ID!] - """name field predicates""" + """"name" field predicates.""" name: String + + """"name" field predicates.""" nameNEQ: String + + """"name" field predicates.""" nameIn: [String!] + + """"name" field predicates.""" nameNotIn: [String!] + + """"name" field predicates.""" nameGT: String + + """"name" field predicates.""" nameGTE: String + + """"name" field predicates.""" nameLT: String + + """"name" field predicates.""" nameLTE: String + + """"name" field predicates.""" nameContains: String + + """"name" field predicates.""" nameHasPrefix: String + + """"name" field predicates.""" nameHasSuffix: String + + """"name" field predicates.""" nameEqualFold: String + + """"name" field predicates.""" nameContainsFold: String - """purpose field predicates""" - purpose: WorkspacePurpose - purposeNEQ: WorkspacePurpose - purposeIn: [WorkspacePurpose!] - purposeNotIn: [WorkspacePurpose!] + """"description" field predicates.""" + description: String + + """"description" field predicates.""" + descriptionNEQ: String + + """"description" field predicates.""" + descriptionIn: [String!] + + """"description" field predicates.""" + descriptionNotIn: [String!] + + """"description" field predicates.""" + descriptionGT: String + + """"description" field predicates.""" + descriptionGTE: String + + """"description" field predicates.""" + descriptionLT: String + + """"description" field predicates.""" + descriptionLTE: String + + """"description" field predicates.""" + descriptionContains: String + + """"description" field predicates.""" + descriptionHasPrefix: String + + """"description" field predicates.""" + descriptionHasSuffix: String + + """"description" field predicates.""" + descriptionIsNil: Boolean + + """"description" field predicates.""" + descriptionNotNil: Boolean + + """"description" field predicates.""" + descriptionEqualFold: String + + """"description" field predicates.""" + descriptionContainsFold: String - """created_at field predicates""" + """"created_at" field predicates.""" createdAt: Time + + """"created_at" field predicates.""" createdAtNEQ: Time + + """"created_at" field predicates.""" createdAtIn: [Time!] + + """"created_at" field predicates.""" createdAtNotIn: [Time!] + + """"created_at" field predicates.""" createdAtGT: Time + + """"created_at" field predicates.""" createdAtGTE: Time + + """"created_at" field predicates.""" createdAtLT: Time + + """"created_at" field predicates.""" createdAtLTE: Time - """updated_at field predicates""" + """"updated_at" field predicates.""" updatedAt: Time + + """"updated_at" field predicates.""" updatedAtNEQ: Time + + """"updated_at" field predicates.""" updatedAtIn: [Time!] + + """"updated_at" field predicates.""" updatedAtNotIn: [Time!] + + """"updated_at" field predicates.""" updatedAtGT: Time + + """"updated_at" field predicates.""" updatedAtGTE: Time + + """"updated_at" field predicates.""" updatedAtLT: Time + + """"updated_at" field predicates.""" updatedAtLTE: Time +} - """account edge predicates""" - hasAccount: Boolean - hasAccountWith: [AccountWhereInput!] +""" +Names the concrete telemetry-emitting technology when it is recognizable. +""" +type TechnologyIdentity { + """Short grounded summary of the recognized technology identity.""" + summary: String! - """teams edge predicates""" - hasTeams: Boolean - hasTeamsWith: [TeamWhereInput!] + """Recognized technology name or `custom` / `unknown`.""" + technologyName: String! +} + +""" +Names the concrete telemetry-emitting technology when it is recognizable. +""" +input TechnologyIdentityWhereInput { + """Negated predicates.""" + not: TechnologyIdentityWhereInput - """conversations edge predicates""" - hasConversations: Boolean - hasConversationsWith: [ConversationWhereInput!] + """Predicates that must all match.""" + and: [TechnologyIdentityWhereInput!] - """log_event_policies edge predicates""" - hasLogEventPolicies: Boolean - hasLogEventPoliciesWith: [LogEventPolicyWhereInput!] + """Predicates where at least one must match.""" + or: [TechnologyIdentityWhereInput!] + + """Whether the technology_identity JSONB value is present.""" + present: Boolean + + """Short grounded summary of the recognized technology identity.""" + summary: String + + """Short grounded summary of the recognized technology identity.""" + summaryNEQ: String + + """Short grounded summary of the recognized technology identity.""" + summaryIn: [String!] + + """Short grounded summary of the recognized technology identity.""" + summaryNotIn: [String!] + + """Short grounded summary of the recognized technology identity.""" + summaryContains: String + + """Short grounded summary of the recognized technology identity.""" + summaryHasPrefix: String + + """Short grounded summary of the recognized technology identity.""" + summaryHasSuffix: String + + """Short grounded summary of the recognized technology identity.""" + summaryEqualFold: String + + """Short grounded summary of the recognized technology identity.""" + summaryContainsFold: String + + """Short grounded summary of the recognized technology identity.""" + hasSummary: Boolean + + """Recognized technology name or `custom` / `unknown`.""" + technologyName: String + + """Recognized technology name or `custom` / `unknown`.""" + technologyNameNEQ: String + + """Recognized technology name or `custom` / `unknown`.""" + technologyNameIn: [String!] + + """Recognized technology name or `custom` / `unknown`.""" + technologyNameNotIn: [String!] + + """Recognized technology name or `custom` / `unknown`.""" + technologyNameContains: String + + """Recognized technology name or `custom` / `unknown`.""" + technologyNameHasPrefix: String + + """Recognized technology name or `custom` / `unknown`.""" + technologyNameHasSuffix: String + + """Recognized technology name or `custom` / `unknown`.""" + technologyNameEqualFold: String + + """Recognized technology name or `custom` / `unknown`.""" + technologyNameContainsFold: String + + """Recognized technology name or `custom` / `unknown`.""" + hasTechnologyName: Boolean +} + +"""Source of log-volume observations.""" +enum TelemetrySource { + DATADOG + EDGE +} + +scalar Time + +"""Bucket granularity for returned time series.""" +enum TimeGranularity { + HOUR + DAY +} + +"""Concrete half-open time range.""" +type TimeRange { + start: Time! + end: Time! +} + +"""Concrete half-open time range input.""" +input TimeRangeInput { + start: Time! + end: Time! +} + +scalar UUID + +input ValidateDatadogApiKeyInput { + apiKey: String! + site: DatadogAccountSite! +} + +type ValidateDatadogApiKeyResult { + valid: Boolean! + error: String +} + +""" +The authenticated user making the request. +Returns null for organizationId if the user is not assigned to an organization. +""" +type Viewer { + """The user's unique identifier (WorkOS user ID).""" + id: String! + + """The user's email address.""" + email: String! + + """The user's role in the current organization.""" + role: String! + + """ + The organization ID the user is authenticated for, or null if not assigned to any organization. + """ + organizationId: UUID + + """Teams the user belongs to in the current organization.""" + teams: [Team!]! } diff --git a/internal/boundary/graphql/issue_service.go b/internal/boundary/graphql/issue_service.go new file mode 100644 index 00000000..1449a88f --- /dev/null +++ b/internal/boundary/graphql/issue_service.go @@ -0,0 +1,92 @@ +package graphql + +import ( + "context" + + "github.com/usetero/cli/internal/boundary/graphql/gen" + "github.com/usetero/cli/internal/domain" + "github.com/usetero/cli/internal/log" +) + +// maxIssues bounds the issue-list read for the chat agent. +const maxIssues = 50 + +// Issues provides access to the account's active issues. +type Issues interface { + GetSummary(ctx context.Context) (domain.IssueSummary, error) + List(ctx context.Context) ([]domain.Issue, error) +} + +// IssueService reads issue aggregates from the control plane. +type IssueService struct { + client Client + scope log.Scope +} + +var _ Issues = (*IssueService)(nil) + +// NewIssueService creates a new issue service. +func NewIssueService(client Client, scope log.Scope) *IssueService { + return &IssueService{ + client: client, + scope: scope.Child("issues"), + } +} + +// GetSummary fetches the server-computed active-issue summary, including the +// per-priority breakdown. Counts are never aggregated locally. +func (s *IssueService) GetSummary(ctx context.Context) (domain.IssueSummary, error) { + s.scope.Debug("fetching issue summary from API") + resp, err := s.client.GetIssueSummary(ctx) + if err != nil { + s.scope.Error("failed to fetch issue summary", "error", err) + return domain.IssueSummary{}, err + } + + summary := domain.IssueSummary{ + Open: int64(resp.Issues.Summary.Count), + } + for _, bucket := range resp.Issues.Facets.Priorities.Buckets { + switch bucket.Value { + case gen.IssuePriorityHigh: + summary.HighCount = int64(bucket.Count) + case gen.IssuePriorityMedium: + summary.MediumCount = int64(bucket.Count) + case gen.IssuePriorityLow: + summary.LowCount = int64(bucket.Count) + } + } + + s.scope.Debug("fetched issue summary", + log.Int("open", int(summary.Open)), + log.Int("high", int(summary.HighCount))) + return summary, nil +} + +// List fetches the active issues with detail, highest priority first. +func (s *IssueService) List(ctx context.Context) ([]domain.Issue, error) { + s.scope.Debug("fetching issues from API") + resp, err := s.client.ListIssues(ctx, maxIssues) + if err != nil { + s.scope.Error("failed to fetch issues", "error", err) + return nil, err + } + + issues := make([]domain.Issue, 0, len(resp.Issues.Edges)) + for _, edge := range resp.Issues.Edges { + node := edge.Node + issue := domain.Issue{ + ID: node.Id, + DisplayID: node.DisplayID, + Title: node.Title, + Priority: domain.IssuePriority(node.Priority), + } + if node.Service != nil { + issue.ServiceName = node.Service.Name + } + issues = append(issues, issue) + } + + s.scope.Debug("fetched issues", log.Int("count", len(issues))) + return issues, nil +} diff --git a/internal/boundary/graphql/issue_service_test.go b/internal/boundary/graphql/issue_service_test.go new file mode 100644 index 00000000..1eee8936 --- /dev/null +++ b/internal/boundary/graphql/issue_service_test.go @@ -0,0 +1,67 @@ +package graphql_test + +import ( + "context" + "errors" + "testing" + + graphql "github.com/usetero/cli/internal/boundary/graphql" + "github.com/usetero/cli/internal/boundary/graphql/apitest" + "github.com/usetero/cli/internal/boundary/graphql/gen" + "github.com/usetero/cli/internal/log/logtest" +) + +func TestIssueService_GetSummary(t *testing.T) { + t.Parallel() + t.Run("maps server count and priority facet buckets", func(t *testing.T) { + t.Parallel() + mockClient := &apitest.MockClient{ + GetIssueSummaryFunc: func(ctx context.Context) (*gen.GetIssueSummaryResponse, error) { + return &gen.GetIssueSummaryResponse{ + Issues: gen.GetIssueSummaryIssuesIssueConnection{ + TotalCount: 9, + Summary: gen.GetIssueSummaryIssuesIssueConnectionSummaryIssueSummary{Count: 9}, + Facets: gen.GetIssueSummaryIssuesIssueConnectionFacetsIssueFacets{ + Priorities: gen.GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacet{ + Buckets: []gen.GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacetBucketsIssuePriorityFacetBucket{ + {Value: gen.IssuePriorityHigh, Count: 4}, + {Value: gen.IssuePriorityMedium, Count: 3}, + {Value: gen.IssuePriorityLow, Count: 2}, + }, + }, + }, + }, + }, nil + }, + } + + svc := graphql.NewIssueService(mockClient, logtest.NewScope(t)) + summary, err := svc.GetSummary(context.Background()) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if summary.Open != 9 { + t.Errorf("Open = %d, want 9", summary.Open) + } + if summary.HighCount != 4 || summary.MediumCount != 3 || summary.LowCount != 2 { + t.Errorf("priority counts = %+v, want high=4 medium=3 low=2", summary) + } + }) + + t.Run("propagates client errors", func(t *testing.T) { + t.Parallel() + mockClient := &apitest.MockClient{ + GetIssueSummaryFunc: func(ctx context.Context) (*gen.GetIssueSummaryResponse, error) { + return nil, errors.New("network error") + }, + } + + svc := graphql.NewIssueService(mockClient, logtest.NewScope(t)) + _, err := svc.GetSummary(context.Background()) + + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} diff --git a/internal/boundary/graphql/message_service.go b/internal/boundary/graphql/message_service.go deleted file mode 100644 index 9ad6e05f..00000000 --- a/internal/boundary/graphql/message_service.go +++ /dev/null @@ -1,166 +0,0 @@ -package graphql - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/usetero/cli/internal/boundary/graphql/gen" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log" -) - -// Messages persists messages to the control plane for durability. -// This is separate from the Chat API - it only handles persistence, -// not inference. -type Messages interface { - CreateMessage(ctx context.Context, msg *domain.Message) error -} - -// MessageService handles message persistence via GraphQL. -type MessageService struct { - client Client - scope log.Scope -} - -var _ Messages = (*MessageService)(nil) - -// NewMessageService creates a new message service. -func NewMessageService(client Client, scope log.Scope) *MessageService { - return &MessageService{ - client: client, - scope: scope.Child("messages"), - } -} - -// CreateMessage persists a message to the control plane. -func (s *MessageService) CreateMessage(ctx context.Context, msg *domain.Message) error { - s.scope.Debug("persisting message", - "id", msg.ID.String(), - "conversationID", msg.ConversationID.String(), - "role", msg.Role, - ) - - content, err := toContentBlockInputs(msg.Content) - if err != nil { - return fmt.Errorf("convert content blocks: %w", err) - } - - input := gen.CreateMessageInput{ - Id: ptr(msg.ID.String()), - ConversationID: msg.ConversationID.String(), - Role: toMessageRole(msg.Role), - Content: content, - Model: ptr(msg.Model), - StopReason: toStopReason(msg.StopReason), - } - - _, err = s.client.CreateMessage(ctx, input) - if err != nil { - return fmt.Errorf("create message: %w", err) - } - - return nil -} - -func toMessageRole(role domain.Role) gen.MessageRole { - switch role { - case domain.RoleUser: - return gen.MessageRoleUser - case domain.RoleAssistant: - return gen.MessageRoleAssistant - default: - return gen.MessageRoleUser - } -} - -func toStopReason(reason string) *gen.MessageStopReason { - switch reason { - case "end_turn": - return ptr(gen.MessageStopReasonEndTurn) - case "tool_use": - return ptr(gen.MessageStopReasonToolUse) - default: - return nil // Don't send stopReason for user messages - } -} - -func toContentBlockInputs(blocks []domain.Block) ([]gen.ContentBlockInput, error) { - inputs := make([]gen.ContentBlockInput, 0, len(blocks)) - - for _, block := range blocks { - input, err := toContentBlockInput(block) - if err != nil { - return nil, err - } - inputs = append(inputs, input) - } - - return inputs, nil -} - -func toContentBlockInput(block domain.Block) (gen.ContentBlockInput, error) { - switch block.Type { - case domain.BlockTypeText: - return gen.ContentBlockInput{ - Type: gen.ContentBlockTypeText, - Text: &gen.TextBlockInput{ - Content: block.Text.Content, - }, - }, nil - - case domain.BlockTypeThinking: - return gen.ContentBlockInput{ - Type: gen.ContentBlockTypeThinking, - Thinking: &gen.ThinkingBlockInput{ - Content: block.Thinking.Content, - }, - }, nil - - case domain.BlockTypeToolUse: - input, err := rawJSONToMap(block.ToolUse.Input) - if err != nil { - return gen.ContentBlockInput{}, fmt.Errorf("tool_use block: %w", err) - } - return gen.ContentBlockInput{ - Type: gen.ContentBlockTypeToolUse, - ToolUse: &gen.ToolUseInput{ - Id: block.ToolUse.ID, - Name: block.ToolUse.Name, - Input: input, - }, - }, nil - - case domain.BlockTypeToolResult: - var content *map[string]any - if block.ToolResult.Content != nil { - content = &block.ToolResult.Content - } - toolResult := &gen.ToolResultInput{ - ToolUseId: block.ToolResult.ToolUseID, - IsError: block.ToolResult.IsError, - Content: content, - } - if block.ToolResult.Error != "" { - toolResult.Error = ptr(block.ToolResult.Error) - } - return gen.ContentBlockInput{ - Type: gen.ContentBlockTypeToolResult, - ToolResult: toolResult, - }, nil - - default: - return gen.ContentBlockInput{}, fmt.Errorf("unknown block type: %s", block.Type) - } -} - -func rawJSONToMap(data json.RawMessage) (map[string]any, error) { - if len(data) == 0 { - return nil, nil - } - var m map[string]any - if err := json.Unmarshal(data, &m); err != nil { - return nil, err - } - return m, nil -} diff --git a/internal/boundary/graphql/message_service_test.go b/internal/boundary/graphql/message_service_test.go deleted file mode 100644 index 51ee88fd..00000000 --- a/internal/boundary/graphql/message_service_test.go +++ /dev/null @@ -1,285 +0,0 @@ -package graphql_test - -import ( - "context" - "encoding/json" - "testing" - - graphql "github.com/usetero/cli/internal/boundary/graphql" - "github.com/usetero/cli/internal/boundary/graphql/apitest" - "github.com/usetero/cli/internal/boundary/graphql/gen" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log/logtest" -) - -func TestMessageService_CreateMessage(t *testing.T) { - t.Parallel() - - t.Run("sends text block with correct content", func(t *testing.T) { - t.Parallel() - - var captured gen.CreateMessageInput - mockClient := &apitest.MockClient{ - CreateMessageFunc: func(ctx context.Context, input gen.CreateMessageInput) (*gen.CreateMessageResponse, error) { - captured = input - return &gen.CreateMessageResponse{}, nil - }, - } - - svc := graphql.NewMessageService(mockClient, logtest.NewScope(t)) - - msg := &domain.Message{ - ID: "msg-123", - ConversationID: "conv-456", - Role: domain.RoleUser, - Content: []domain.Block{ - domain.NewTextBlock("Hello, world!"), - }, - } - - err := svc.CreateMessage(context.Background(), msg) - if err != nil { - t.Fatalf("CreateMessage() error = %v", err) - } - - if captured.Id == nil || *captured.Id != "msg-123" { - t.Errorf("Id = %v, want %q", captured.Id, "msg-123") - } - if captured.ConversationID != "conv-456" { - t.Errorf("ConversationID = %q, want %q", captured.ConversationID, "conv-456") - } - if captured.Role != gen.MessageRoleUser { - t.Errorf("Role = %v, want %v", captured.Role, gen.MessageRoleUser) - } - if len(captured.Content) != 1 { - t.Fatalf("Content length = %d, want 1", len(captured.Content)) - } - if captured.Content[0].Type != gen.ContentBlockTypeText { - t.Errorf("Content[0].Type = %v, want %v", captured.Content[0].Type, gen.ContentBlockTypeText) - } - if captured.Content[0].Text.Content != "Hello, world!" { - t.Errorf("Content[0].Text.Content = %q, want %q", captured.Content[0].Text.Content, "Hello, world!") - } - }) - - t.Run("sends assistant message with model and stop reason", func(t *testing.T) { - t.Parallel() - - var captured gen.CreateMessageInput - mockClient := &apitest.MockClient{ - CreateMessageFunc: func(ctx context.Context, input gen.CreateMessageInput) (*gen.CreateMessageResponse, error) { - captured = input - return &gen.CreateMessageResponse{}, nil - }, - } - - svc := graphql.NewMessageService(mockClient, logtest.NewScope(t)) - - msg := &domain.Message{ - ID: "msg-123", - ConversationID: "conv-456", - Role: domain.RoleAssistant, - Model: "claude-3", - StopReason: "end_turn", - Content: []domain.Block{ - domain.NewTextBlock("Hello!"), - }, - } - - err := svc.CreateMessage(context.Background(), msg) - if err != nil { - t.Fatalf("CreateMessage() error = %v", err) - } - - if captured.Role != gen.MessageRoleAssistant { - t.Errorf("Role = %v, want %v", captured.Role, gen.MessageRoleAssistant) - } - if captured.Model == nil || *captured.Model != "claude-3" { - t.Errorf("Model = %v, want %q", captured.Model, "claude-3") - } - if captured.StopReason == nil || *captured.StopReason != gen.MessageStopReasonEndTurn { - t.Errorf("StopReason = %v, want %v", captured.StopReason, gen.MessageStopReasonEndTurn) - } - }) - - t.Run("sends tool_use block with raw input", func(t *testing.T) { - t.Parallel() - - var captured gen.CreateMessageInput - mockClient := &apitest.MockClient{ - CreateMessageFunc: func(ctx context.Context, input gen.CreateMessageInput) (*gen.CreateMessageResponse, error) { - captured = input - return &gen.CreateMessageResponse{}, nil - }, - } - - svc := graphql.NewMessageService(mockClient, logtest.NewScope(t)) - - msg := &domain.Message{ - ID: "msg-123", - ConversationID: "conv-456", - Role: domain.RoleAssistant, - Content: []domain.Block{ - { - Type: domain.BlockTypeToolUse, - ToolUse: &domain.ToolUse{ - ID: "tool-1", - Name: "query", - Input: json.RawMessage(`{"sql": "SELECT * FROM logs"}`), - }, - }, - }, - } - - err := svc.CreateMessage(context.Background(), msg) - if err != nil { - t.Fatalf("CreateMessage() error = %v", err) - } - - if len(captured.Content) != 1 { - t.Fatalf("Content length = %d, want 1", len(captured.Content)) - } - block := captured.Content[0] - if block.Type != gen.ContentBlockTypeToolUse { - t.Errorf("Type = %v, want %v", block.Type, gen.ContentBlockTypeToolUse) - } - if block.ToolUse.Id != "tool-1" { - t.Errorf("ToolUse.Id = %q, want %q", block.ToolUse.Id, "tool-1") - } - if block.ToolUse.Name != "query" { - t.Errorf("ToolUse.Name = %q, want %q", block.ToolUse.Name, "query") - } - sql, ok := block.ToolUse.Input["sql"].(string) - if !ok || sql != "SELECT * FROM logs" { - t.Errorf("ToolUse.Input[sql] = %v, want %q", block.ToolUse.Input["sql"], "SELECT * FROM logs") - } - }) - - t.Run("rejects unknown block types", func(t *testing.T) { - t.Parallel() - - mockClient := &apitest.MockClient{ - CreateMessageFunc: func(ctx context.Context, input gen.CreateMessageInput) (*gen.CreateMessageResponse, error) { - t.Error("CreateMessage should not be called") - return &gen.CreateMessageResponse{}, nil - }, - } - - svc := graphql.NewMessageService(mockClient, logtest.NewScope(t)) - - msg := &domain.Message{ - ID: "msg-123", - ConversationID: "conv-456", - Role: domain.RoleAssistant, - Content: []domain.Block{ - {Type: "unknown_type"}, - }, - } - - err := svc.CreateMessage(context.Background(), msg) - if err == nil { - t.Fatal("CreateMessage() expected error for unknown block type, got nil") - } - }) - - t.Run("sends tool_result with raw content", func(t *testing.T) { - t.Parallel() - - var captured gen.CreateMessageInput - mockClient := &apitest.MockClient{ - CreateMessageFunc: func(ctx context.Context, input gen.CreateMessageInput) (*gen.CreateMessageResponse, error) { - captured = input - return &gen.CreateMessageResponse{}, nil - }, - } - - svc := graphql.NewMessageService(mockClient, logtest.NewScope(t)) - - msg := &domain.Message{ - ID: "msg-123", - ConversationID: "conv-456", - Role: domain.RoleUser, - Content: []domain.Block{ - { - Type: domain.BlockTypeToolResult, - ToolResult: &domain.ToolResult{ - ToolUseID: "tool-1", - Content: map[string]any{ - "columns": []any{"id", "name"}, - "rows": []any{[]any{"1", "foo"}}, - }, - }, - }, - }, - } - - err := svc.CreateMessage(context.Background(), msg) - if err != nil { - t.Fatalf("CreateMessage() error = %v", err) - } - - if len(captured.Content) != 1 { - t.Fatalf("Content length = %d, want 1", len(captured.Content)) - } - block := captured.Content[0] - if block.Type != gen.ContentBlockTypeToolResult { - t.Errorf("Type = %v, want %v", block.Type, gen.ContentBlockTypeToolResult) - } - if block.ToolResult.ToolUseId != "tool-1" { - t.Errorf("ToolResult.ToolUseId = %q, want %q", block.ToolResult.ToolUseId, "tool-1") - } - if block.ToolResult.Content == nil { - t.Fatal("ToolResult.Content is nil") - } - cols, ok := (*block.ToolResult.Content)["columns"].([]any) - if !ok || len(cols) != 2 { - t.Errorf("ToolResult.Content[columns] = %v, want [id, name]", (*block.ToolResult.Content)["columns"]) - } - }) - - t.Run("sends tool_result error without content", func(t *testing.T) { - t.Parallel() - - var captured gen.CreateMessageInput - mockClient := &apitest.MockClient{ - CreateMessageFunc: func(ctx context.Context, input gen.CreateMessageInput) (*gen.CreateMessageResponse, error) { - captured = input - return &gen.CreateMessageResponse{}, nil - }, - } - - svc := graphql.NewMessageService(mockClient, logtest.NewScope(t)) - - msg := &domain.Message{ - ID: "msg-123", - ConversationID: "conv-456", - Role: domain.RoleUser, - Content: []domain.Block{ - { - Type: domain.BlockTypeToolResult, - ToolResult: &domain.ToolResult{ - ToolUseID: "tool-1", - IsError: true, - Error: "something went wrong", - }, - }, - }, - } - - err := svc.CreateMessage(context.Background(), msg) - if err != nil { - t.Fatalf("CreateMessage() error = %v", err) - } - - if len(captured.Content) != 1 { - t.Fatalf("Content length = %d, want 1", len(captured.Content)) - } - block := captured.Content[0] - if !block.ToolResult.IsError { - t.Error("ToolResult.IsError = false, want true") - } - if block.ToolResult.Error == nil || *block.ToolResult.Error != "something went wrong" { - t.Errorf("ToolResult.Error = %v, want %q", block.ToolResult.Error, "something went wrong") - } - }) -} diff --git a/internal/boundary/graphql/organization_service.go b/internal/boundary/graphql/organization_service.go index 03066e0c..c256058d 100644 --- a/internal/boundary/graphql/organization_service.go +++ b/internal/boundary/graphql/organization_service.go @@ -38,11 +38,10 @@ func NewOrganizationService(client Client, scope log.Scope) *OrganizationService } } -// OrganizationBootstrapResult contains the organization, account, and workspace created during bootstrap. +// OrganizationBootstrapResult contains the organization and account created during bootstrap. type OrganizationBootstrapResult struct { Organization *domain.Organization Account *domain.Account - Workspace *domain.Workspace } // List fetches all organizations for the user. @@ -71,8 +70,7 @@ func (s *OrganizationService) List(ctx context.Context) ([]domain.Organization, // Create creates a new organization with bootstrapped account and workspace. func (s *OrganizationService) Create(ctx context.Context, input CreateOrganizationInput) (*OrganizationBootstrapResult, error) { s.scope.Debug("creating organization with bootstrap via API", "id", input.ID.String(), "name", input.Name) - genInput := gen.CreateOrganizationInput{ - Id: ptr(input.ID.String()), + genInput := gen.OrganizationCreateInput{ Name: input.Name, } @@ -93,15 +91,9 @@ func (s *OrganizationService) Create(ctx context.Context, input CreateOrganizati Name: resp.CreateOrganizationAndBootstrap.Account.Name, } - workspace := &domain.Workspace{ - ID: domain.WorkspaceID(resp.CreateOrganizationAndBootstrap.Workspace.Id), - Name: resp.CreateOrganizationAndBootstrap.Workspace.Name, - } - result := &OrganizationBootstrapResult{ Organization: org, Account: account, - Workspace: workspace, } s.scope.Debug("created organization via API", "id", org.ID, "name", org.Name, "accountID", account.ID) diff --git a/internal/boundary/graphql/organization_service_test.go b/internal/boundary/graphql/organization_service_test.go index 7cf35db8..4cc68b7b 100644 --- a/internal/boundary/graphql/organization_service_test.go +++ b/internal/boundary/graphql/organization_service_test.go @@ -20,13 +20,13 @@ func TestOrganizationService_List(t *testing.T) { ListOrganizationsFunc: func(ctx context.Context) (*gen.ListOrganizationsResponse, error) { return &gen.ListOrganizationsResponse{ Organizations: gen.ListOrganizationsOrganizationsOrganizationConnection{ - Edges: []*gen.ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdge{ - {Node: &gen.ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdgeNodeOrganization{ + Edges: []gen.ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdge{ + {Node: gen.ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdgeNodeOrganization{ Id: "org-1", Name: "Acme Corp", WorkosOrganizationID: "org_workos_123", }}, - {Node: &gen.ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdgeNodeOrganization{ + {Node: gen.ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdgeNodeOrganization{ Id: "org-2", Name: "Beta Inc", WorkosOrganizationID: "org_workos_456", @@ -60,7 +60,7 @@ func TestOrganizationService_List(t *testing.T) { ListOrganizationsFunc: func(ctx context.Context) (*gen.ListOrganizationsResponse, error) { return &gen.ListOrganizationsResponse{ Organizations: gen.ListOrganizationsOrganizationsOrganizationConnection{ - Edges: []*gen.ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdge{}, + Edges: []gen.ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdge{}, }, }, nil }, @@ -101,9 +101,9 @@ func TestOrganizationService_Create(t *testing.T) { t.Parallel() t.Run("creates organization and returns bootstrap result", func(t *testing.T) { t.Parallel() - var capturedInput gen.CreateOrganizationInput + var capturedInput gen.OrganizationCreateInput mockClient := &apitest.MockClient{ - CreateOrganizationAndBootstrapFunc: func(ctx context.Context, input gen.CreateOrganizationInput) (*gen.CreateOrganizationAndBootstrapResponse, error) { + CreateOrganizationAndBootstrapFunc: func(ctx context.Context, input gen.OrganizationCreateInput) (*gen.CreateOrganizationAndBootstrapResponse, error) { capturedInput = input return &gen.CreateOrganizationAndBootstrapResponse{ CreateOrganizationAndBootstrap: gen.CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResult{ @@ -116,10 +116,6 @@ func TestOrganizationService_Create(t *testing.T) { Id: "acc-new", Name: "Default Account", }, - Workspace: gen.CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResultWorkspace{ - Id: "ws-new", - Name: "Default Workspace", - }, }, }, nil }, @@ -138,12 +134,6 @@ func TestOrganizationService_Create(t *testing.T) { if result.Account.ID != "acc-new" || result.Account.Name != "Default Account" { t.Errorf("account = %+v, want ID=acc-new, Name=Default Account", result.Account) } - if result.Workspace.ID != "ws-new" || result.Workspace.Name != "Default Workspace" { - t.Errorf("workspace = %+v, want ID=ws-new, Name=Default Workspace", result.Workspace) - } - if capturedInput.Id == nil || *capturedInput.Id != testID.String() { - t.Errorf("input.Id = %v, want %q", capturedInput.Id, testID.String()) - } if capturedInput.Name != "New Org" { t.Errorf("input.Name = %q, want %q", capturedInput.Name, "New Org") } @@ -152,7 +142,7 @@ func TestOrganizationService_Create(t *testing.T) { t.Run("propagates client errors", func(t *testing.T) { t.Parallel() mockClient := &apitest.MockClient{ - CreateOrganizationAndBootstrapFunc: func(ctx context.Context, input gen.CreateOrganizationInput) (*gen.CreateOrganizationAndBootstrapResponse, error) { + CreateOrganizationAndBootstrapFunc: func(ctx context.Context, input gen.OrganizationCreateInput) (*gen.CreateOrganizationAndBootstrapResponse, error) { return nil, errors.New("validation error") }, } diff --git a/internal/boundary/graphql/policy_service.go b/internal/boundary/graphql/policy_service.go index edc67220..cdc77b88 100644 --- a/internal/boundary/graphql/policy_service.go +++ b/internal/boundary/graphql/policy_service.go @@ -31,35 +31,17 @@ func NewPolicyService(client Client, scope log.Scope) *PolicyService { } // ApprovePolicy approves a log event policy. -func (s *PolicyService) ApprovePolicy(ctx context.Context, id string) error { - s.scope.Debug("approving policy via API", "id", id) - - _, err := s.client.ApproveLogEventPolicy(ctx, id) - if err != nil { - s.scope.Error("failed to approve policy", "error", err, "id", id) - if classified := classifyError(err); classified != nil { - return fmt.Errorf("approve policy %s: %w", id, classified) - } - return err - } - - s.scope.Debug("approved policy via API", "id", id) - return nil +// +// TODO(drop-powersync): the policy lifecycle moved to the Issue model in the +// control plane (createLogEventPolicy / ignoreIssue). Re-wire as an inline +// mutation in the writes step (task #5). +func (s *PolicyService) ApprovePolicy(_ context.Context, id string) error { + return fmt.Errorf("approve policy %s: not wired — moved to the issue model", id) } // DismissPolicy dismisses a log event policy. -func (s *PolicyService) DismissPolicy(ctx context.Context, id string) error { - s.scope.Debug("dismissing policy via API", "id", id) - - _, err := s.client.DismissLogEventPolicy(ctx, id) - if err != nil { - s.scope.Error("failed to dismiss policy", "error", err, "id", id) - if classified := classifyError(err); classified != nil { - return fmt.Errorf("dismiss policy %s: %w", id, classified) - } - return err - } - - s.scope.Debug("dismissed policy via API", "id", id) - return nil +// +// TODO(drop-powersync): see ApprovePolicy — moved to the Issue model. +func (s *PolicyService) DismissPolicy(_ context.Context, id string) error { + return fmt.Errorf("dismiss policy %s: not wired — moved to the issue model", id) } diff --git a/internal/boundary/graphql/ptr.go b/internal/boundary/graphql/ptr.go deleted file mode 100644 index 87edd517..00000000 --- a/internal/boundary/graphql/ptr.go +++ /dev/null @@ -1,16 +0,0 @@ -package graphql - -// ptr returns a pointer to the given value. -// Useful for setting optional fields in generated GraphQL inputs. -func ptr[T any](v T) *T { - return &v -} - -// deref safely dereferences a pointer, returning the zero value if nil. -func deref[T any](p *T) T { - if p == nil { - var zero T - return zero - } - return *p -} diff --git a/internal/boundary/graphql/services.go b/internal/boundary/graphql/services.go index 5bfc8c46..1dca5779 100644 --- a/internal/boundary/graphql/services.go +++ b/internal/boundary/graphql/services.go @@ -15,12 +15,13 @@ type ServiceSet struct { scope log.Scope Organizations Organizations Accounts Accounts - Workspaces Workspaces DatadogAccounts DatadogAccounts - Conversations Conversations - Messages Messages Services Services Policies Policies + Issues Issues + Checks Checks + EdgeInstances EdgeInstances + Status Status } // NewServiceSet creates ServiceSet with an internally-managed client. @@ -46,12 +47,13 @@ func newServiceSetWithScope(client Client, scope log.Scope) ServiceSet { scope: scope, Organizations: NewOrganizationService(client, scope), Accounts: NewAccountService(client, scope), - Workspaces: NewWorkspaceService(client, scope), DatadogAccounts: NewDatadogAccountService(client, scope), - Conversations: NewConversationService(client, scope), - Messages: NewMessageService(client, scope), Services: NewServiceService(client, scope), Policies: NewPolicyService(client, scope), + Issues: NewIssueService(client, scope), + Checks: NewCheckService(client, scope), + EdgeInstances: NewEdgeInstanceService(client, scope), + Status: NewStatusService(client, scope), } } diff --git a/internal/boundary/graphql/status_service.go b/internal/boundary/graphql/status_service.go new file mode 100644 index 00000000..f79dfdbd --- /dev/null +++ b/internal/boundary/graphql/status_service.go @@ -0,0 +1,158 @@ +package graphql + +import ( + "context" + + "github.com/usetero/cli/internal/boundary/graphql/gen" + "github.com/usetero/cli/internal/domain" + "github.com/usetero/cli/internal/log" +) + +// maxServiceStatuses bounds the services-list read for the data-plane surface. +const maxServiceStatuses = 50 + +// Status provides the account- and service-level status reads that back the +// data-plane status surfaces. All aggregates are computed by the control plane. +type Status interface { + GetAccountSummary(ctx context.Context) (domain.AccountSummary, error) + ListServiceStatuses(ctx context.Context) ([]domain.ServiceStatus, error) + ListServiceLogEvents(ctx context.Context, serviceID string) ([]domain.LogEventStatus, error) +} + +// StatusService reads control-plane status projections. +type StatusService struct { + client Client + scope log.Scope +} + +var _ Status = (*StatusService)(nil) + +// NewStatusService creates a new status service. +func NewStatusService(client Client, scope log.Scope) *StatusService { + return &StatusService{ + client: client, + scope: scope.Child("status"), + } +} + +// GetAccountSummary fetches the account-level status summary. Returns a zero +// summary (ServiceCount 0) when no Datadog account status exists yet. +func (s *StatusService) GetAccountSummary(ctx context.Context) (domain.AccountSummary, error) { + resp, err := s.client.GetAccountStatusSummary(ctx) + if err != nil { + s.scope.Error("failed to fetch account status summary", "error", err) + return domain.AccountSummary{}, err + } + + if len(resp.DatadogAccounts.Edges) == 0 { + return domain.AccountSummary{}, nil + } + status := resp.DatadogAccounts.Edges[0].Node.Status + if status == nil { + return domain.AccountSummary{}, nil + } + + cov := status.Coverage + cur := status.Current + summary := domain.AccountSummary{ + ReadyForUse: status.Readiness.ReadyForUse, + Health: serviceHealth(status.Health), + ServiceCount: int64(cov.LogServiceCount), + ActiveServices: int64(cov.LogActiveServices), + OkServices: int64(cov.OkServices), + DisabledServices: int64(cov.DisabledServices), + InactiveServices: int64(cov.InactiveServices), + EventCount: int64(cov.LogEventCount), + AnalyzedCount: int64(cov.LogEventAnalyzedCount), + // Service-level ground truth. + TotalServiceVolumePerHour: cur.Services.EventsPerHour, + TotalServiceCostPerHour: cur.Services.VolumeUsdPerHour, + // Discovered log-event throughput. + TotalVolumePerHour: cur.Totals.EventsPerHour, + TotalBytesPerHour: cur.Totals.BytesPerHour, + TotalCostPerHour: cur.Totals.TotalUsdPerHour, + } + return summary, nil +} + +// ListServiceStatuses fetches enabled services with their list-status summary. +func (s *StatusService) ListServiceStatuses(ctx context.Context) ([]domain.ServiceStatus, error) { + resp, err := s.client.ListServiceStatuses(ctx, maxServiceStatuses) + if err != nil { + s.scope.Error("failed to fetch service statuses", "error", err) + return nil, err + } + + statuses := make([]domain.ServiceStatus, 0, len(resp.Services.Edges)) + for _, edge := range resp.Services.Edges { + node := edge.Node + svc := domain.ServiceStatus{ID: node.Id, Name: node.Name, Health: domain.ServiceHealthInactive} + if node.StatusSummary != nil { + sum := node.StatusSummary + cur := sum.Current + sev := cur.Severity + svc.Health = serviceHealth(sum.Health) + svc.LogEventCount = int64(sum.LogEventCount) + svc.LogEventAnalyzedCount = int64(sum.LogEventAnalyzedCount) + svc.ServiceVolumePerHour = cur.EventsPerHour + svc.LogEventVolumePerHour = cur.EventsPerHour + svc.LogEventBytesPerHour = cur.BytesPerHour + svc.ServiceCostPerHourVolumeUSD = cur.TotalUsdPerHour + svc.LogEventCostPerHourUSD = cur.TotalUsdPerHour + svc.ServiceDebugVolumePerHour = sev.DebugEventsPerHour + svc.ServiceInfoVolumePerHour = sev.InfoEventsPerHour + svc.ServiceWarnVolumePerHour = sev.WarnEventsPerHour + svc.ServiceErrorVolumePerHour = sev.ErrorEventsPerHour + svc.ServiceOtherVolumePerHour = sev.OtherEventsPerHour + } + statuses = append(statuses, svc) + } + + s.scope.Debug("fetched service statuses", log.Int("count", len(statuses))) + return statuses, nil +} + +// ListServiceLogEvents fetches the log events for a single service. +func (s *StatusService) ListServiceLogEvents(ctx context.Context, serviceID string) ([]domain.LogEventStatus, error) { + resp, err := s.client.ListServiceLogEvents(ctx, serviceID, 25) + if err != nil { + s.scope.Error("failed to fetch service log events", "error", err, "serviceID", serviceID) + return nil, err + } + + events := make([]domain.LogEventStatus, 0, len(resp.LogEvents.Edges)) + for _, edge := range resp.LogEvents.Edges { + node := edge.Node + name := node.Name + if node.DisplayName != nil && *node.DisplayName != "" { + name = *node.DisplayName + } + event := domain.LogEventStatus{Name: name} + if node.Status != nil { + cur := node.Status.Current + event.VolumePerHour = cur.EventsPerHour + event.BytesPerHour = cur.BytesPerHour + event.CostPerHourUSD = cur.TotalUsdPerHour + } + events = append(events, event) + } + + s.scope.Debug("fetched service log events", log.Int("count", len(events)), log.String("serviceID", serviceID)) + return events, nil +} + +// serviceHealth maps a control-plane StatusHealth to the domain health enum. +func serviceHealth(h gen.StatusHealth) domain.ServiceHealth { + switch h { + case gen.StatusHealthOk: + return domain.ServiceHealthOK + case gen.StatusHealthError: + return domain.ServiceHealthError + case gen.StatusHealthDisabled: + return domain.ServiceHealthDisabled + case gen.StatusHealthInactive: + return domain.ServiceHealthInactive + default: + return domain.ServiceHealthInactive + } +} diff --git a/internal/boundary/graphql/workspace.go b/internal/boundary/graphql/workspace.go deleted file mode 100644 index 266a19bd..00000000 --- a/internal/boundary/graphql/workspace.go +++ /dev/null @@ -1,52 +0,0 @@ -package graphql - -import ( - "context" - - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log" -) - -// Workspaces provides access to workspaces. -type Workspaces interface { - List(ctx context.Context, accountID domain.AccountID) ([]domain.Workspace, error) -} - -// WorkspaceService handles workspace-related API operations. -type WorkspaceService struct { - client Client - scope log.Scope -} - -// Ensure WorkspaceService implements Workspaces. -var _ Workspaces = (*WorkspaceService)(nil) - -// NewWorkspaceService creates a new workspace service. -func NewWorkspaceService(client Client, scope log.Scope) *WorkspaceService { - return &WorkspaceService{ - client: client, - scope: scope.Child("workspaces"), - } -} - -// List fetches all workspaces for an account. -func (s *WorkspaceService) List(ctx context.Context, accountID domain.AccountID) ([]domain.Workspace, error) { - s.scope.Debug("fetching workspaces from API", "accountID", accountID) - resp, err := s.client.ListWorkspaces(ctx, accountID.String()) - if err != nil { - s.scope.Error("failed to fetch workspaces", "error", err, "accountID", accountID) - return nil, err - } - - // Convert GraphQL response to domain model - workspaces := make([]domain.Workspace, len(resp.Workspaces.Edges)) - for i, edge := range resp.Workspaces.Edges { - workspaces[i] = domain.Workspace{ - ID: domain.WorkspaceID(edge.Node.Id), - Name: edge.Node.Name, - } - } - - s.scope.Debug("fetched workspaces from API", "count", len(workspaces)) - return workspaces, nil -} diff --git a/internal/boundary/powersync/apitest/mock_client.go b/internal/boundary/powersync/apitest/mock_client.go deleted file mode 100644 index 37d31bf7..00000000 --- a/internal/boundary/powersync/apitest/mock_client.go +++ /dev/null @@ -1,69 +0,0 @@ -// Package apitest provides test doubles for the powersync/api package. -package apitest - -import ( - "context" - - psapi "github.com/usetero/cli/internal/boundary/powersync" -) - -// MockClient is a test double for psapi.Client. -type MockClient struct { - // SyncStreamFunc is called when SyncStream is invoked. - SyncStreamFunc func(ctx context.Context, req *psapi.SyncStreamRequest, handler psapi.LineHandler) error - - // GetWriteCheckpointFunc is called when GetWriteCheckpoint is invoked. - GetWriteCheckpointFunc func(ctx context.Context, clientID string) (string, error) - - // Token is the current token set via SetToken. - Token string - - // SyncStreamCalls records the number of times SyncStream was called. - SyncStreamCalls int - - // GetWriteCheckpointCalls records the number of times GetWriteCheckpoint was called. - GetWriteCheckpointCalls int -} - -// Ensure MockClient implements psapi.Client. -var _ psapi.Client = (*MockClient)(nil) - -// NewMockClient creates a new MockClient with sensible defaults. -func NewMockClient() *MockClient { - return &MockClient{ - SyncStreamFunc: func(ctx context.Context, req *psapi.SyncStreamRequest, handler psapi.LineHandler) error { - <-ctx.Done() - return ctx.Err() - }, - } -} - -// SyncStream implements psapi.Client. -func (m *MockClient) SyncStream(ctx context.Context, req *psapi.SyncStreamRequest, handler psapi.LineHandler) error { - m.SyncStreamCalls++ - if m.SyncStreamFunc != nil { - return m.SyncStreamFunc(ctx, req, handler) - } - return nil -} - -// GetWriteCheckpoint implements psapi.Client. -func (m *MockClient) GetWriteCheckpoint(ctx context.Context, clientID string) (string, error) { - m.GetWriteCheckpointCalls++ - if m.GetWriteCheckpointFunc != nil { - return m.GetWriteCheckpointFunc(ctx, clientID) - } - return "1", nil -} - -// SetToken implements psapi.Client. -func (m *MockClient) SetToken(token string) { - m.Token = token -} - -// NewMockClientFactory returns a client factory that always returns the given mock. -func NewMockClientFactory(mock *MockClient) func(endpoint string) psapi.Client { - return func(endpoint string) psapi.Client { - return mock - } -} diff --git a/internal/boundary/powersync/client.go b/internal/boundary/powersync/client.go deleted file mode 100644 index 0e6ebdfd..00000000 --- a/internal/boundary/powersync/client.go +++ /dev/null @@ -1,251 +0,0 @@ -// Package api provides HTTP client access to the PowerSync service. -package powersync - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" -) - -// Client provides HTTP access to the PowerSync service. -type Client interface { - SyncStream(ctx context.Context, req *SyncStreamRequest, handler LineHandler) error - GetWriteCheckpoint(ctx context.Context, clientID string) (string, error) - SetToken(token string) -} - -// HTTPDoer is the interface for making HTTP requests. -type HTTPDoer interface { - Do(req *http.Request) (*http.Response, error) -} - -// NewClient creates a new PowerSync client. -func NewClient(endpoint string) Client { - return &httpClient{ - endpoint: endpoint, - http: http.DefaultClient, - } -} - -// httpClient is the concrete implementation of Client. -type httpClient struct { - endpoint string - token string - http HTTPDoer -} - -// SetToken updates the authentication token. -func (c *httpClient) SetToken(token string) { - c.token = token -} - -// SetHTTPDoer sets a custom HTTP client (for testing). -func (c *httpClient) SetHTTPDoer(doer HTTPDoer) { - c.http = doer -} - -// LineHandler is called for each line received from the sync stream. -type LineHandler func(line []byte) error - -// SyncStream opens a streaming connection to the sync endpoint. -// It calls handler for each line received until the stream ends, -// context is cancelled, or handler returns an error. -func (c *httpClient) SyncStream(ctx context.Context, req *SyncStreamRequest, handler LineHandler) error { - u, err := url.Parse(c.endpoint) - if err != nil { - return fmt.Errorf("parse endpoint: %w", err) - } - u.Path = "/sync/stream" - - body, err := json.Marshal(req) - if err != nil { - return fmt.Errorf("marshal request: %w", err) - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), nil) - if err != nil { - return fmt.Errorf("create request: %w", err) - } - httpReq.Header.Set("Authorization", "Bearer "+c.token) - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Accept", "application/x-ndjson") - httpReq.Body = io.NopCloser(bytes.NewReader(body)) - httpReq.ContentLength = int64(len(body)) - - resp, err := c.http.Do(httpReq) - if err != nil { - return &Error{ - Kind: ErrorKindTransient, - Message: "connection failed", - Err: err, - } - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) - return &Error{ - Kind: classifyHTTPStatus(resp.StatusCode), - StatusCode: resp.StatusCode, - Message: string(respBody), - } - } - - scanner := bufio.NewScanner(resp.Body) - scanner.Buffer(make([]byte, 64*1024), 16*1024*1024) - - for scanner.Scan() { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - line := scanner.Bytes() - if len(line) == 0 { - continue - } - - if err := handler(line); err != nil { - return err - } - } - - if ctx.Err() != nil { - return ctx.Err() - } - - if err := scanner.Err(); err != nil { - return fmt.Errorf("read stream: %w", err) - } - - return nil -} - -// GetWriteCheckpoint fetches a write checkpoint from the server. -// The checkpoint represents the server's acknowledgment of uploaded changes. -func (c *httpClient) GetWriteCheckpoint(ctx context.Context, clientID string) (string, error) { - u, err := url.Parse(c.endpoint) - if err != nil { - return "", fmt.Errorf("parse endpoint: %w", err) - } - u.Path = "/write-checkpoint2.json" - u.RawQuery = "client_id=" + url.QueryEscape(clientID) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) - if err != nil { - return "", fmt.Errorf("create request: %w", err) - } - req.Header.Set("Authorization", "Bearer "+c.token) - - resp, err := c.http.Do(req) - if err != nil { - return "", fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) - return "", fmt.Errorf("server returned %d: %s", resp.StatusCode, string(body)) - } - - var result struct { - WriteCheckpoint string `json:"write_checkpoint"` - } - - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", fmt.Errorf("parse response: %w", err) - } - - return result.WriteCheckpoint, nil -} - -// SyncStreamRequest is the request body for the sync stream endpoint. -type SyncStreamRequest struct { - Buckets []BucketRequest `json:"buckets"` - IncludeChecksum bool `json:"include_checksum"` - RawData bool `json:"raw_data"` - BinaryData bool `json:"binary_data"` - ClientID string `json:"client_id"` - Parameters map[string]any `json:"parameters,omitempty"` - Streams *StreamSubscription `json:"streams,omitempty"` - AppMetadata json.RawMessage `json:"app_metadata,omitempty"` -} - -// BucketRequest specifies a bucket to sync and the last known checkpoint. -type BucketRequest struct { - Name string `json:"name"` - After string `json:"after"` -} - -// StreamSubscription defines stream subscription preferences. -type StreamSubscription struct { - IncludeDefaults bool `json:"include_defaults"` - Subscriptions []RequestedStreamSubscription `json:"subscriptions"` -} - -// RequestedStreamSubscription is a request to subscribe to a stream. -type RequestedStreamSubscription struct { - Stream string `json:"stream"` - Parameters string `json:"parameters,omitempty"` - OverridePriority *int `json:"override_priority,omitempty"` -} - -// ErrorKind classifies client errors. -type ErrorKind int - -const ( - ErrorKindTransient ErrorKind = iota // Retry with backoff - ErrorKindAuth // Refresh token and retry - ErrorKindPermanent // Don't retry -) - -// Error represents an error from the PowerSync service. -type Error struct { - Kind ErrorKind - StatusCode int - Message string - Err error -} - -func (e *Error) Error() string { - if e.StatusCode > 0 { - return fmt.Sprintf("powersync api: %d: %s", e.StatusCode, e.Message) - } - if e.Err != nil { - return fmt.Sprintf("powersync api: %v", e.Err) - } - return fmt.Sprintf("powersync api: %s", e.Message) -} - -func (e *Error) Unwrap() error { - return e.Err -} - -func (e *Error) IsAuth() bool { - return e.Kind == ErrorKindAuth -} - -func (e *Error) IsTransient() bool { - return e.Kind == ErrorKindTransient -} - -func (e *Error) IsPermanent() bool { - return e.Kind == ErrorKindPermanent -} - -func classifyHTTPStatus(statusCode int) ErrorKind { - switch { - case statusCode == 401 || statusCode == 403: - return ErrorKindAuth - case statusCode >= 500 || statusCode == 429: - return ErrorKindTransient - default: - return ErrorKindPermanent - } -} diff --git a/internal/boundary/powersync/indexes.go b/internal/boundary/powersync/indexes.go deleted file mode 100644 index d8e866ef..00000000 --- a/internal/boundary/powersync/indexes.go +++ /dev/null @@ -1,82 +0,0 @@ -package powersync - -// clientIndexes defines indexes for client-side query performance. -// These are not part of the server schema — they optimize local SQLite queries. -// The map key is the table name, the value is a list of indexes to create. -var clientIndexes = map[string][]SchemaIndex{ - "services": { - {Name: "name", Columns: []SchemaIndexColumn{ - {Name: "name", Ascending: true, Type: "text"}, - }}, - }, - - "log_events": { - {Name: "service_id", Columns: []SchemaIndexColumn{ - {Name: "service_id", Ascending: true, Type: "text"}, - }}, - }, - - // Filtered by status, category, objectivity, risk_level in AI-generated queries. - // Joined on log_event_id. - "log_event_policy_statuses_cache": { - {Name: "log_event_id", Columns: []SchemaIndexColumn{ - {Name: "log_event_id", Ascending: true, Type: "text"}, - }}, - {Name: "category_status", Columns: []SchemaIndexColumn{ - {Name: "category", Ascending: true, Type: "text"}, - {Name: "status", Ascending: true, Type: "text"}, - }}, - }, - - "log_event_statuses_cache": { - {Name: "log_event_id", Columns: []SchemaIndexColumn{ - {Name: "log_event_id", Ascending: true, Type: "text"}, - }}, - {Name: "service_id", Columns: []SchemaIndexColumn{ - {Name: "service_id", Ascending: true, Type: "text"}, - }}, - }, - - "service_statuses_cache": { - {Name: "service_id", Columns: []SchemaIndexColumn{ - {Name: "service_id", Ascending: true, Type: "text"}, - }}, - }, - - "log_event_policies": { - {Name: "log_event_id", Columns: []SchemaIndexColumn{ - {Name: "log_event_id", Ascending: true, Type: "text"}, - }}, - }, - - "messages": { - {Name: "conversation_id", Columns: []SchemaIndexColumn{ - {Name: "conversation_id", Ascending: true, Type: "text"}, - }}, - }, - - "conversations": { - {Name: "account_id", Columns: []SchemaIndexColumn{ - {Name: "account_id", Ascending: true, Type: "text"}, - }}, - }, - - "datadog_account_statuses_cache": { - {Name: "datadog_account_id", Columns: []SchemaIndexColumn{ - {Name: "datadog_account_id", Ascending: true, Type: "text"}, - }}, - }, -} - -// applyClientIndexes merges client-side indexes into fetched schema tables. -// Tables without indexes get an empty slice so JSON encodes as [] not null. -func applyClientIndexes(tables []SchemaTable) []SchemaTable { - for i, table := range tables { - if indexes, ok := clientIndexes[table.Name]; ok { - tables[i].Indexes = indexes - } else { - tables[i].Indexes = []SchemaIndex{} - } - } - return tables -} diff --git a/internal/boundary/powersync/schema.go b/internal/boundary/powersync/schema.go deleted file mode 100644 index 840f648c..00000000 --- a/internal/boundary/powersync/schema.go +++ /dev/null @@ -1,261 +0,0 @@ -package powersync - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "sort" - "strings" -) - -// SQLite type constants from PowerSync schema API. -const ( - sqliteTypeText = 2 - sqliteTypeInteger = 4 - sqliteTypeReal = 8 -) - -// SchemaTable represents a table in the PowerSync schema. -type SchemaTable struct { - Name string `json:"name"` - Columns []SchemaColumn `json:"columns"` - Indexes []SchemaIndex `json:"indexes"` -} - -// SchemaColumn represents a column in a PowerSync table. -type SchemaColumn struct { - Name string `json:"name"` - Type string `json:"type"` // "text", "integer", "real" -} - -// SchemaIndex represents an index on a PowerSync table. -type SchemaIndex struct { - Name string `json:"name"` - Columns []SchemaIndexColumn `json:"columns"` -} - -// SchemaIndexColumn represents a column in an index. -type SchemaIndexColumn struct { - Name string `json:"name"` - Ascending bool `json:"ascending"` - Type string `json:"type"` -} - -// schemaResponse is the response from /api/admin/v1/schema. -type schemaResponse struct { - Data struct { - Connections []struct { - Schemas []struct { - Name string `json:"name"` - Tables []struct { - Name string `json:"name"` - Columns []struct { - Name string `json:"name"` - SQLiteType int `json:"sqlite_type"` - PgType string `json:"pg_type"` - } `json:"columns"` - } `json:"tables"` - } `json:"schemas"` - } `json:"connections"` - } `json:"data"` -} - -// syncRulesResponse is the response from /api/sync-rules/v1/current. -type syncRulesResponse struct { - Data struct { - Current struct { - BucketDefinitions []struct { - DataQueries []struct { - Table struct { - TablePattern string `json:"tablePattern"` - } `json:"table"` - Columns []string `json:"columns"` - } `json:"data_queries"` - } `json:"bucket_definitions"` - } `json:"current"` - } `json:"data"` -} - -// columnType holds type information for a column. -type columnType struct { - sqliteType int - pgType string -} - -// FetchSchemaJSON fetches the schema from the PowerSync service and returns it as JSON. -// The returned JSON is in the format expected by powersync_replace_schema(). -func FetchSchemaJSON(ctx context.Context, endpoint, token string) (string, error) { - // Fetch column types from schema API - columnTypes, err := fetchColumnTypes(ctx, endpoint, token) - if err != nil { - return "", fmt.Errorf("fetch column types: %w", err) - } - - // Fetch synced tables from sync rules API - tables, err := fetchSyncedTables(ctx, endpoint, token, columnTypes) - if err != nil { - return "", fmt.Errorf("fetch synced tables: %w", err) - } - - // Apply client-side indexes for query performance - tables = applyClientIndexes(tables) - - // Build schema JSON - schema := struct { - Tables []SchemaTable `json:"tables"` - }{ - Tables: tables, - } - - data, err := json.Marshal(schema) - if err != nil { - return "", fmt.Errorf("marshal schema: %w", err) - } - - return string(data), nil -} - -// fetchColumnTypes fetches column type information from the schema API. -func fetchColumnTypes(ctx context.Context, endpoint, token string) (map[string]map[string]columnType, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint+"/api/admin/v1/schema", strings.NewReader("{}")) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("Content-Type", "application/json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("schema API returned %d: %s", resp.StatusCode, body) - } - - var result schemaResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - - // Build column type map - columnTypes := make(map[string]map[string]columnType) - for _, conn := range result.Data.Connections { - for _, schema := range conn.Schemas { - for _, table := range schema.Tables { - if columnTypes[table.Name] == nil { - columnTypes[table.Name] = make(map[string]columnType) - } - for _, col := range table.Columns { - columnTypes[table.Name][col.Name] = columnType{ - sqliteType: col.SQLiteType, - pgType: col.PgType, - } - } - } - } - } - - return columnTypes, nil -} - -// fetchSyncedTables fetches the synced tables from the sync rules API. -func fetchSyncedTables(ctx context.Context, endpoint, token string, columnTypes map[string]map[string]columnType) ([]SchemaTable, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"/api/sync-rules/v1/current", nil) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+token) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("sync-rules API returned %d: %s", resp.StatusCode, body) - } - - var result syncRulesResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - - // Collect unique tables and their columns - tableColumns := make(map[string][]string) - for _, bucket := range result.Data.Current.BucketDefinitions { - for _, query := range bucket.DataQueries { - tableName := query.Table.TablePattern - existing := tableColumns[tableName] - for _, col := range query.Columns { - if !contains(existing, col) { - existing = append(existing, col) - } - } - tableColumns[tableName] = existing - } - } - - // Build schema tables - var tables []SchemaTable - for tableName, columns := range tableColumns { - // Sort columns: id first, then alphabetically - sort.Slice(columns, func(i, j int) bool { - if columns[i] == "id" { - return true - } - if columns[j] == "id" { - return false - } - return columns[i] < columns[j] - }) - - var schemaColumns []SchemaColumn - for _, colName := range columns { - colType := columnTypes[tableName][colName] - schemaColumns = append(schemaColumns, SchemaColumn{ - Name: colName, - Type: toSQLiteTypeName(colType.sqliteType), - }) - } - - tables = append(tables, SchemaTable{ - Name: tableName, - Columns: schemaColumns, - }) - } - - // Sort tables by name for deterministic output - sort.Slice(tables, func(i, j int) bool { - return tables[i].Name < tables[j].Name - }) - - return tables, nil -} - -// toSQLiteTypeName converts a SQLite type constant to a string. -func toSQLiteTypeName(sqliteType int) string { - switch sqliteType { - case sqliteTypeInteger: - return "integer" - case sqliteTypeReal: - return "real" - default: - return "text" - } -} - -func contains(slice []string, s string) bool { - for _, item := range slice { - if item == s { - return true - } - } - return false -} diff --git a/internal/cmd/debug.go b/internal/cmd/debug.go index 8448f2c2..f35a8488 100644 --- a/internal/cmd/debug.go +++ b/internal/cmd/debug.go @@ -8,7 +8,6 @@ import ( "github.com/usetero/cli/internal/config" "github.com/usetero/cli/internal/log" "github.com/usetero/cli/internal/preferences" - "github.com/usetero/cli/internal/sqlite" "github.com/usetero/cli/internal/styles" ) @@ -162,7 +161,6 @@ func newDebugPrefsCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Comma fmt.Println(s.Title.Render("Org Preferences")) fmt.Println(s.Help.Render("Path: " + orgPrefsPath)) printPref("Account ID", orgPrefs.GetDefaultAccountID().String()) - printPref("Workspace ID", orgPrefs.GetDefaultWorkspaceID().String()) } return nil @@ -258,13 +256,6 @@ func newDebugPathsCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Comma if err == nil { baseDir, _ := orgCfg.BaseDir() fmt.Println(kv(s, "Base Dir", baseDir)) - - orgPrefs := preferences.NewOrgService(orgCfg, scope) - if accountID := orgPrefs.GetDefaultAccountID(); accountID != "" { - storage := sqlite.NewStorageService(orgCfg) - dbPath, _ := storage.DatabasePath(accountID.String()) - fmt.Println(kv(s, "Database", dbPath)) - } } } diff --git a/internal/cmd/dependencies.go b/internal/cmd/dependencies.go index db7ad988..b04e2fa8 100644 --- a/internal/cmd/dependencies.go +++ b/internal/cmd/dependencies.go @@ -19,7 +19,6 @@ func newAuthService(cliConfig *config.CLIConfig, scope log.Scope) *auth.Service cliConfig.WorkOSClientID, cliConfig.APIEndpoint, cliConfig.PowerSyncEndpoint, - cliConfig.ChatEndpoint, ) return auth.NewService(workosClient, tokenStore, scope) } diff --git a/internal/cmd/internal.go b/internal/cmd/internal.go index 9b7d17b8..58ff3c1e 100644 --- a/internal/cmd/internal.go +++ b/internal/cmd/internal.go @@ -17,7 +17,6 @@ func NewInternalCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command } internalCmd.AddCommand(NewInternalInspectCmd(scope, cliConfig)) - internalCmd.AddCommand(NewInternalPowerSyncCmd(scope, cliConfig)) return internalCmd } diff --git a/internal/cmd/internal_powersync.go b/internal/cmd/internal_powersync.go deleted file mode 100644 index 02b121ba..00000000 --- a/internal/cmd/internal_powersync.go +++ /dev/null @@ -1,146 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "os" - "os/signal" - "path/filepath" - "syscall" - "time" - - "github.com/spf13/cobra" - "github.com/usetero/cli/internal/config" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/powersync" - "github.com/usetero/cli/internal/preferences" - "github.com/usetero/cli/internal/sqlite" -) - -func NewInternalPowerSyncCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command { - scope = scope.Child("powersync") - - cmd := &cobra.Command{ - Use: "powersync", - Short: "PowerSync operational commands", - } - cmd.AddCommand(newInternalPowerSyncCaptureCmd(scope, cliConfig)) - cmd.AddCommand(newInternalPowerSyncSanitizeFixtureCmd(scope)) - return cmd -} - -func newInternalPowerSyncCaptureCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command { - var ( - accountID string - output string - duration time.Duration - maxBytes int64 - ) - - cmd := &cobra.Command{ - Use: "capture", - Short: "Capture raw PowerSync NDJSON stream lines to a fixture file", - RunE: func(cmd *cobra.Command, _ []string) error { - if output == "" { - return fmt.Errorf("--output is required") - } - if duration <= 0 { - return fmt.Errorf("--duration must be > 0") - } - if maxBytes <= 0 { - return fmt.Errorf("--max-bytes must be > 0") - } - - env := cliConfig.Environment() - - resolvedOutput, err := resolveCaptureOutputPath(env, output) - if err != nil { - return err - } - - orgCfg, err := config.LoadOrgPreferences(env, config.ActiveOrgID(env)) - if err != nil { - return fmt.Errorf("load org preferences: %w", err) - } - orgPrefs := preferences.NewOrgService(orgCfg, scope) - - if accountID == "" { - accountID = orgPrefs.GetDefaultAccountID().String() - } - if accountID == "" { - return fmt.Errorf("no account configured; pass --account-id or complete onboarding first") - } - - authService := newAuthService(cliConfig, scope) - - storage := sqlite.NewStorageService(orgCfg) - dbPath, err := storage.DatabasePath(accountID) - if err != nil { - return fmt.Errorf("resolve database path: %w", err) - } - db, err := sqlite.Open(cmd.Context(), dbPath) - if err != nil { - return fmt.Errorf("open database: %w", err) - } - defer db.Close() - - capture, err := powersync.NewNDJSONStreamCapture(resolvedOutput, maxBytes, scope) - if err != nil { - return fmt.Errorf("create stream capture: %w", err) - } - - syncer := powersync.NewSyncer( - cliConfig.PowerSyncEndpoint, - authService, - scope, - powersync.WithStreamCapture(capture), - ) - - ctx, cancel := context.WithCancel(cmd.Context()) - defer cancel() - - if err := syncer.Start(ctx, db, accountID, nil); err != nil { - return fmt.Errorf("start syncer: %w", err) - } - defer syncer.Stop() - - fmt.Printf("Capturing PowerSync stream for account %s\n", accountID) - fmt.Printf("Output: %s\n", resolvedOutput) - fmt.Printf("Duration: %s\n", duration) - fmt.Printf("Max bytes: %d\n", maxBytes) - - sigCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) - defer stop() - - timer := time.NewTimer(duration) - defer timer.Stop() - - select { - case <-timer.C: - fmt.Println("Capture complete") - case <-sigCtx.Done(): - fmt.Println("Capture interrupted") - } - - return nil - }, - } - - cmd.Flags().StringVar(&accountID, "account-id", "", "Account ID to sync (defaults to org preference)") - cmd.Flags().StringVar(&output, "output", "", "Output path for NDJSON fixture (required)") - cmd.Flags().DurationVar(&duration, "duration", 90*time.Second, "Capture duration") - cmd.Flags().Int64Var(&maxBytes, "max-bytes", 25*1024*1024, "Maximum capture file size in bytes") - - return cmd -} - -func resolveCaptureOutputPath(env, output string) (string, error) { - if filepath.IsAbs(output) { - return output, nil - } - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("resolve home directory: %w", err) - } - return filepath.Join(homeDir, ".tero", "environments", env, output), nil -} diff --git a/internal/cmd/internal_powersync_sanitize.go b/internal/cmd/internal_powersync_sanitize.go deleted file mode 100644 index 5b5bc21f..00000000 --- a/internal/cmd/internal_powersync_sanitize.go +++ /dev/null @@ -1,230 +0,0 @@ -package cmd - -import ( - "bufio" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "os" - "path/filepath" - "regexp" - "sort" - - "github.com/spf13/cobra" - "github.com/usetero/cli/internal/log" -) - -var digitsOnly = regexp.MustCompile(`^\d+$`) - -var preserveStringByKey = map[string]struct{}{ - "op": {}, - "op_id": {}, - "after": {}, - "next_after": {}, - "last_op_id": {}, - "write_checkpoint": {}, -} - -func newInternalPowerSyncSanitizeFixtureCmd(scope log.Scope) *cobra.Command { - var ( - input string - output string - maxLines int - ) - - cmd := &cobra.Command{ - Use: "sanitize-fixture", - Short: "Sanitize raw PowerSync NDJSON into commit-safe fixture data", - RunE: func(cmd *cobra.Command, _ []string) error { - if input == "" { - return fmt.Errorf("--input is required") - } - if output == "" { - return fmt.Errorf("--output is required") - } - if maxLines < 0 { - return fmt.Errorf("--max-lines must be >= 0") - } - - if !filepath.IsAbs(input) { - input = filepath.Clean(input) - } - if !filepath.IsAbs(output) { - output = filepath.Clean(output) - } - - lines, err := sanitizeFixtureFile(input, output, maxLines) - if err != nil { - return err - } - scope.Info("sanitized powersync fixture", "input", input, "output", output, "lines", lines) - fmt.Printf("Sanitized %d lines\nInput: %s\nOutput: %s\n", lines, input, output) - return nil - }, - } - - cmd.Flags().StringVar(&input, "input", "", "Path to raw NDJSON fixture (required)") - cmd.Flags().StringVar(&output, "output", "", "Path to sanitized NDJSON fixture (required)") - cmd.Flags().IntVar(&maxLines, "max-lines", 0, "Maximum number of lines to sanitize (0 = all)") - - return cmd -} - -func sanitizeFixtureFile(inputPath, outputPath string, maxLines int) (int, error) { - in, err := os.Open(inputPath) - if err != nil { - return 0, fmt.Errorf("open input fixture: %w", err) - } - defer in.Close() - - if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { - return 0, fmt.Errorf("create output directory: %w", err) - } - out, err := os.OpenFile(outputPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) - if err != nil { - return 0, fmt.Errorf("open output fixture: %w", err) - } - defer out.Close() - - scanner := bufio.NewScanner(in) - scanner.Buffer(make([]byte, 64*1024), 64*1024*1024) - writer := bufio.NewWriter(out) - defer writer.Flush() - - s := newSanitizer() - written := 0 - - for scanner.Scan() { - line := scanner.Text() - if line == "" { - continue - } - sanitized, err := s.sanitizeLine(line) - if err != nil { - return written, fmt.Errorf("sanitize line %d: %w", written+1, err) - } - if sanitized == "" { - continue - } - if _, err := writer.WriteString(sanitized); err != nil { - return written, fmt.Errorf("write output line %d: %w", written+1, err) - } - if err := writer.WriteByte('\n'); err != nil { - return written, fmt.Errorf("write newline %d: %w", written+1, err) - } - - written++ - if maxLines > 0 && written >= maxLines { - break - } - } - if err := scanner.Err(); err != nil { - return written, fmt.Errorf("read input fixture: %w", err) - } - if err := writer.Flush(); err != nil { - return written, fmt.Errorf("flush output fixture: %w", err) - } - return written, nil -} - -type fixtureSanitizer struct { - cache map[string]string -} - -func newSanitizer() *fixtureSanitizer { - return &fixtureSanitizer{cache: make(map[string]string)} -} - -func (s *fixtureSanitizer) sanitizeLine(line string) (string, error) { - var v any - if err := json.Unmarshal([]byte(line), &v); err != nil { - return "", fmt.Errorf("invalid json: %w", err) - } - if shouldDropReplayLine(v) { - return "", nil - } - clean := s.sanitizeValue("", v) - out, err := json.Marshal(clean) - if err != nil { - return "", fmt.Errorf("marshal sanitized line: %w", err) - } - return string(out), nil -} - -func shouldDropReplayLine(v any) bool { - m, ok := v.(map[string]any) - if !ok { - return false - } - _, hasData := m["data"] - _, hasCheckpoint := m["checkpoint"] - _, hasCheckpointComplete := m["checkpoint_complete"] - return !hasData && !hasCheckpoint && !hasCheckpointComplete -} - -func (s *fixtureSanitizer) sanitizeValue(key string, value any) any { - switch v := value.(type) { - case map[string]any: - keys := make([]string, 0, len(v)) - for k := range v { - keys = append(keys, k) - } - sort.Strings(keys) - out := make(map[string]any, len(v)) - for _, k := range keys { - out[k] = s.sanitizeValue(k, v[k]) - } - return out - case []any: - out := make([]any, len(v)) - for i := range v { - out[i] = s.sanitizeValue("", v[i]) - } - return out - case string: - return s.sanitizeString(key, v) - default: - return value - } -} - -func (s *fixtureSanitizer) sanitizeString(key, raw string) string { - if raw == "" { - return raw - } - if _, ok := preserveStringByKey[key]; ok { - return raw - } - if digitsOnly.MatchString(raw) { - return raw - } - - if maybeJSON(raw) { - var nested any - if err := json.Unmarshal([]byte(raw), &nested); err == nil { - nestedClean := s.sanitizeValue("", nested) - if out, err := json.Marshal(nestedClean); err == nil { - return string(out) - } - } - } - - if token, ok := s.cache[raw]; ok { - return token - } - - sum := sha256.Sum256([]byte(raw)) - token := "redacted_" + hex.EncodeToString(sum[:6]) - s.cache[raw] = token - return token -} - -func maybeJSON(s string) bool { - if len(s) < 2 { - return false - } - first := s[0] - last := s[len(s)-1] - return (first == '{' && last == '}') || (first == '[' && last == ']') -} diff --git a/internal/cmd/internal_powersync_sanitize_test.go b/internal/cmd/internal_powersync_sanitize_test.go deleted file mode 100644 index 8d1ef68d..00000000 --- a/internal/cmd/internal_powersync_sanitize_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package cmd - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestSanitizeLine_DeterministicAndSafe(t *testing.T) { - s := newSanitizer() - - line := `{"checkpoint":{"last_op_id":"42","buckets":[{"bucket":"29#account_data[\"acc-123\"]"}]},"data":{"op":"PUT","object_id":"user@example.com","data":"{\"account_id\":\"acc-123\",\"url\":\"https://api.usetero.com/v1\"}"}}` - got1, err := s.sanitizeLine(line) - if err != nil { - t.Fatalf("sanitizeLine() error = %v", err) - } - got2, err := s.sanitizeLine(line) - if err != nil { - t.Fatalf("sanitizeLine() second error = %v", err) - } - if got1 != got2 { - t.Fatalf("sanitize output must be deterministic\ngot1=%s\ngot2=%s", got1, got2) - } - - if !strings.Contains(got1, `"last_op_id":"42"`) { - t.Fatalf("expected preserved numeric checkpoint field, got: %s", got1) - } - if strings.Contains(got1, "user@example.com") { - t.Fatalf("expected sensitive value to be redacted, got: %s", got1) - } - if strings.Contains(got1, "api.usetero.com") { - t.Fatalf("expected nested JSON string content to be redacted, got: %s", got1) - } -} - -func TestSanitizeFixtureFile_MaxLines(t *testing.T) { - in := filepath.Join(t.TempDir(), "in.ndjson") - out := filepath.Join(t.TempDir(), "out.ndjson") - - content := strings.Join([]string{ - `{"token_expires_in":3600}`, - `{"checkpoint":{"last_op_id":"0","buckets":[]}}`, - `{"checkpoint":{"last_op_id":"1","buckets":[]}}`, - }, "\n") + "\n" - if err := os.WriteFile(in, []byte(content), 0o600); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - - n, err := sanitizeFixtureFile(in, out, 2) - if err != nil { - t.Fatalf("sanitizeFixtureFile() error = %v", err) - } - if n != 2 { - t.Fatalf("line count = %d, want 2", n) - } - - data, err := os.ReadFile(out) - if err != nil { - t.Fatalf("ReadFile() error = %v", err) - } - lines := strings.Split(strings.TrimSpace(string(data)), "\n") - if len(lines) != 2 { - t.Fatalf("output lines = %d, want 2", len(lines)) - } -} - -func TestSanitizeLine_DropsNonReplayMessages(t *testing.T) { - s := newSanitizer() - got, err := s.sanitizeLine(`{"token_expires_in":3600}`) - if err != nil { - t.Fatalf("sanitizeLine() error = %v", err) - } - if got != "" { - t.Fatalf("expected dropped line, got %q", got) - } -} - -func TestSanitizeLine_ArrayAndNestedJSONDeterministic(t *testing.T) { - s := newSanitizer() - - line := `{"data":{"op":"PUT","object_id":"user-123","tags":["env:prod","env:prod","service:payments"],"payload":"{\"owner\":\"user-123\",\"emails\":[\"a@example.com\",\"a@example.com\"],\"tokens\":[\"tok_abcdef\",\"tok_abcdef\"]}"}}` - got, err := s.sanitizeLine(line) - if err != nil { - t.Fatalf("sanitizeLine() error = %v", err) - } - - // Repeated sensitive values should map to the same token. - if strings.Count(got, "redacted_") < 3 { - t.Fatalf("expected multiple redactions, got: %s", got) - } - if strings.Contains(got, "a@example.com") || strings.Contains(got, "tok_abcdef") || strings.Contains(got, "user-123") { - t.Fatalf("expected sensitive values redacted, got: %s", got) - } - - got2, err := s.sanitizeLine(line) - if err != nil { - t.Fatalf("sanitizeLine() second error = %v", err) - } - if got != got2 { - t.Fatalf("non-deterministic output\ngot=%s\ngot2=%s", got, got2) - } -} diff --git a/internal/cmd/internal_powersync_test.go b/internal/cmd/internal_powersync_test.go deleted file mode 100644 index a4378905..00000000 --- a/internal/cmd/internal_powersync_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package cmd - -import ( - "path/filepath" - "testing" -) - -func TestResolveCaptureOutputPath_Absolute(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - abs := "/tmp/capture.ndjson" - - got, err := resolveCaptureOutputPath("prd", abs) - if err != nil { - t.Fatalf("resolveCaptureOutputPath() error = %v", err) - } - if got != abs { - t.Fatalf("path = %q, want %q", got, abs) - } -} - -func TestResolveCaptureOutputPath_Relative(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - got, err := resolveCaptureOutputPath("dev", filepath.Join("fixtures", "capture.ndjson")) - if err != nil { - t.Fatalf("resolveCaptureOutputPath() error = %v", err) - } - - want := filepath.Join(home, ".tero", "environments", "dev", "fixtures", "capture.ndjson") - if got != want { - t.Fatalf("path = %q, want %q", got, want) - } -} diff --git a/internal/cmd/reset.go b/internal/cmd/reset.go index 21a5f1f8..9a755509 100644 --- a/internal/cmd/reset.go +++ b/internal/cmd/reset.go @@ -10,7 +10,6 @@ import ( "github.com/usetero/cli/internal/domain" "github.com/usetero/cli/internal/log" "github.com/usetero/cli/internal/preferences" - "github.com/usetero/cli/internal/sqlite" "github.com/usetero/cli/internal/styles" ) @@ -42,7 +41,6 @@ func listOrgIDs(env string) ([]domain.OrganizationID, error) { func NewResetCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command { scope = scope.Child("reset") - var includeDB bool cmd := &cobra.Command{ Use: "reset", @@ -59,22 +57,6 @@ func NewResetCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command { return fmt.Errorf("failed to list orgs: %w", err) } - // Clear databases and preferences for all orgs - clearedDBs := 0 - if includeDB { - for _, orgID := range orgIDs { - // Clear database - orgCfg, err := config.Load(env, orgID) - if err == nil { - storage := sqlite.NewStorageService(orgCfg) - if err := storage.Clear(); err != nil { - return fmt.Errorf("failed to clear database for org %s: %w", orgID, err) - } - clearedDBs++ - } - } - } - // Clear org preferences for all orgs for _, orgID := range orgIDs { orgCfg, err := config.LoadOrgPreferences(env, orgID) @@ -104,17 +86,11 @@ func NewResetCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command { // Print results fmt.Println(s.Success.Render("✓ Reset complete")) - if includeDB { - fmt.Println(s.Help.Render(fmt.Sprintf("Cleared preferences, authentication, and %d database(s) for: %s", clearedDBs, env))) - } else { - fmt.Println(s.Help.Render("Cleared preferences and authentication for: " + env)) - } + fmt.Println(s.Help.Render("Cleared preferences and authentication for: " + env)) return nil }, } - cmd.Flags().BoolVar(&includeDB, "db", false, "Also delete the local SQLite database") - return cmd } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index fde5ef61..4fe37122 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -9,9 +9,7 @@ import ( "github.com/usetero/cli/internal/app" "github.com/usetero/cli/internal/config" "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/powersync" "github.com/usetero/cli/internal/preferences" - "github.com/usetero/cli/internal/sqlite" "github.com/usetero/cli/internal/styles" "github.com/usetero/cli/internal/tea/filter" ) @@ -27,6 +25,13 @@ func NewRootCmd(scope log.Scope, version string) *cobra.Command { rootCmd.AddCommand(NewResetCmd(scope, cliConfig)) rootCmd.AddCommand(NewInternalCmd(scope, cliConfig)) + // Product surfaces — direct GraphQL reads for the current account. + rootCmd.AddCommand(NewStatusCmd(scope, cliConfig)) + rootCmd.AddCommand(NewIssuesCmd(scope, cliConfig)) + rootCmd.AddCommand(NewChecksCmd(scope, cliConfig)) + rootCmd.AddCommand(NewServicesCmd(scope, cliConfig)) + rootCmd.AddCommand(NewEdgeCmd(scope, cliConfig)) + return rootCmd } @@ -67,15 +72,10 @@ Just run 'tero' to start an interactive chat session.`, authService := newAuthService(cliConfig, scope) services := newGraphQLServiceSet(cliConfig, authService, scope) - // Create storage for SQLite databases - storage := sqlite.NewStorageService(orgCfg) - - // Create PowerSync syncer - syncer := powersync.NewSyncer(cliConfig.PowerSyncEndpoint, authService, scope) - - // Create and run the TUI + // Create and run the TUI. The CLI is a thin GraphQL client: there is + // no local database or sync engine. p := tea.NewProgram( - app.New(ctx, cliConfig, theme, version, services, authService, userPrefs, orgPrefs, storage, syncer, scope), + app.New(ctx, cliConfig, theme, version, services, authService, userPrefs, orgPrefs, scope), tea.WithContext(ctx), tea.WithEnvironment(os.Environ()), tea.WithFilter(filter.Mouse), @@ -91,6 +91,7 @@ Just run 'tero' to start an interactive chat session.`, // Global flags with defaults from CLI config rootCmd.PersistentFlags().String("endpoint", cliConfig.APIEndpoint, "Tero control plane endpoint") rootCmd.PersistentFlags().BoolP("debug", "d", cliConfig.Debug, "Enable debug logging") + rootCmd.PersistentFlags().StringP("output", "o", "table", "Output format: table or json") return rootCmd } diff --git a/internal/cmd/surfaces.go b/internal/cmd/surfaces.go new file mode 100644 index 00000000..5fe2b697 --- /dev/null +++ b/internal/cmd/surfaces.go @@ -0,0 +1,326 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + + graphql "github.com/usetero/cli/internal/boundary/graphql" + "github.com/usetero/cli/internal/config" + "github.com/usetero/cli/internal/format" + "github.com/usetero/cli/internal/log" + "github.com/usetero/cli/internal/preferences" + "github.com/usetero/cli/internal/styles" +) + +// emit renders a command's result. When --output=json it writes data as +// indented JSON; otherwise it runs the table renderer. Every surface command +// routes through this so JSON support is uniform and free per command. +func emit(cmd *cobra.Command, data any, table func() error) error { + if format, _ := cmd.Flags().GetString("output"); strings.EqualFold(format, "json") { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(data) + } + return table() +} + +// accountServices resolves an authenticated, account-scoped service set from +// the active org's default account. Returns a helpful error when the user is +// not authenticated or has not finished onboarding. +func accountServices(ctx context.Context, cliConfig *config.CLIConfig, scope log.Scope) (graphql.ServiceSet, error) { + services, err := newAuthenticatedGraphQLServiceSet(ctx, cliConfig, scope) + if err != nil { + return graphql.ServiceSet{}, err + } + + env := cliConfig.Environment() + orgID := config.ActiveOrgID(env) + if orgID == "" { + return graphql.ServiceSet{}, fmt.Errorf("no organization selected — run 'tero' to complete onboarding") + } + orgCfg, err := config.LoadOrgPreferences(env, orgID) + if err != nil { + return graphql.ServiceSet{}, fmt.Errorf("load org preferences: %w", err) + } + accountID := preferences.NewOrgService(orgCfg, scope).GetDefaultAccountID() + if accountID == "" { + return graphql.ServiceSet{}, fmt.Errorf("no account configured — run 'tero' to complete onboarding") + } + return services.WithAccountID(accountID), nil +} + +// newTabWriter returns a tabwriter writing to stdout with padded columns. +func newTabWriter() *tabwriter.Writer { + return tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) +} + +func cost(p *float64) string { + if p == nil { + return "—" + } + return format.YearlyCost(*p) +} + +func rate(p *float64) string { + if p == nil { + return "—" + } + return format.Volume(*p) + "/hr" +} + +type issueOut struct { + ID string `json:"id"` + DisplayID string `json:"display_id"` + Priority string `json:"priority"` + Service string `json:"service,omitempty"` + Title string `json:"title"` +} + +// NewIssuesCmd lists the account's active issues. +func NewIssuesCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command { + scope = scope.Child("issues") + return &cobra.Command{ + Use: "issues", + Short: "List active issues for the current account", + RunE: func(cmd *cobra.Command, _ []string) error { + services, err := accountServices(cmd.Context(), cliConfig, scope) + if err != nil { + return err + } + issues, err := services.Issues.List(cmd.Context()) + if err != nil { + return fmt.Errorf("list issues: %w", err) + } + out := make([]issueOut, len(issues)) + for i, is := range issues { + out[i] = issueOut{ID: is.ID, DisplayID: is.DisplayID, Priority: string(is.Priority), Service: is.ServiceName, Title: is.Title} + } + return emit(cmd, out, func() error { + if len(issues) == 0 { + fmt.Println(styles.DetectTheme().Styles.Help.Render("No active issues.")) + return nil + } + w := newTabWriter() + fmt.Fprintln(w, "PRIORITY\tID\tSERVICE\tTITLE") + for _, i := range issues { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + i.Priority, dashIfEmpty(i.DisplayID), dashIfEmpty(i.ServiceName), i.Title) + } + return w.Flush() + }) + }, + } +} + +type serviceOut struct { + Name string `json:"name"` + Health string `json:"health"` + LogEvents int64 `json:"log_events"` + EventsPerHour *float64 `json:"events_per_hour,omitempty"` + CostPerHourUSD *float64 `json:"cost_per_hour_usd,omitempty"` +} + +// NewServicesCmd lists enabled services and their status. +func NewServicesCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command { + scope = scope.Child("services") + return &cobra.Command{ + Use: "services", + Short: "List enabled services for the current account", + RunE: func(cmd *cobra.Command, _ []string) error { + services, err := accountServices(cmd.Context(), cliConfig, scope) + if err != nil { + return err + } + statuses, err := services.Status.ListServiceStatuses(cmd.Context()) + if err != nil { + return fmt.Errorf("list services: %w", err) + } + out := make([]serviceOut, len(statuses)) + for i, svc := range statuses { + out[i] = serviceOut{ + Name: svc.Name, Health: string(svc.Health), LogEvents: svc.LogEventCount, + EventsPerHour: svc.ServiceVolumePerHour, CostPerHourUSD: svc.ServiceCostPerHourVolumeUSD, + } + } + return emit(cmd, out, func() error { + if len(statuses) == 0 { + fmt.Println(styles.DetectTheme().Styles.Help.Render("No enabled services.")) + return nil + } + w := newTabWriter() + fmt.Fprintln(w, "SERVICE\tHEALTH\tLOG EVENTS\tVOLUME\tCOST/YR") + for _, svc := range statuses { + fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s\n", + svc.Name, svc.Health, svc.LogEventCount, rate(svc.ServiceVolumePerHour), cost(svc.ServiceCostPerHourVolumeUSD)) + } + return w.Flush() + }) + }, + } +} + +type checkOut struct { + Name string `json:"name"` + Domain string `json:"domain"` + OpenFindings int64 `json:"open_findings"` + ActiveIssues int64 `json:"active_issues"` + CostPerHourUSD *float64 `json:"cost_per_hour_usd,omitempty"` +} + +// NewChecksCmd lists product checks and their posture. +func NewChecksCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command { + scope = scope.Child("checks") + return &cobra.Command{ + Use: "checks", + Short: "List product checks and their posture for the current account", + RunE: func(cmd *cobra.Command, _ []string) error { + services, err := accountServices(cmd.Context(), cliConfig, scope) + if err != nil { + return err + } + catalog, err := services.Checks.List(cmd.Context()) + if err != nil { + return fmt.Errorf("list checks: %w", err) + } + out := make([]checkOut, len(catalog.Checks)) + for i, c := range catalog.Checks { + out[i] = checkOut{ + Name: c.Name, Domain: string(c.Domain), OpenFindings: c.OpenFindingCount, + ActiveIssues: c.ActiveIssueCount, CostPerHourUSD: c.CurrentCostPerHour, + } + } + return emit(cmd, out, func() error { + if len(catalog.Checks) == 0 { + fmt.Println(styles.DetectTheme().Styles.Help.Render("No checks.")) + return nil + } + w := newTabWriter() + fmt.Fprintln(w, "CHECK\tDOMAIN\tOPEN FINDINGS\tACTIVE ISSUES\tCOST/YR") + for _, c := range catalog.Checks { + fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%s\n", + c.Name, c.Domain, c.OpenFindingCount, c.ActiveIssueCount, cost(c.CurrentCostPerHour)) + } + return w.Flush() + }) + }, + } +} + +type statusOut struct { + Health string `json:"health"` + ReadyForUse bool `json:"ready_for_use"` + ActiveServices int64 `json:"active_services"` + TotalServices int64 `json:"total_services"` + LogEvents int64 `json:"log_events"` + AnalyzedEvents int64 `json:"analyzed_events"` + EventsPerHour *float64 `json:"events_per_hour,omitempty"` + CostPerHourUSD *float64 `json:"cost_per_hour_usd,omitempty"` + OpenIssues int64 `json:"open_issues"` + HighIssues int64 `json:"high_issues"` + MediumIssues int64 `json:"medium_issues"` + LowIssues int64 `json:"low_issues"` +} + +// NewStatusCmd prints the account-level status summary. +func NewStatusCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command { + scope = scope.Child("status") + return &cobra.Command{ + Use: "status", + Short: "Show the current account's overall status", + RunE: func(cmd *cobra.Command, _ []string) error { + services, err := accountServices(cmd.Context(), cliConfig, scope) + if err != nil { + return err + } + summary, err := services.Status.GetAccountSummary(cmd.Context()) + if err != nil { + return fmt.Errorf("account status: %w", err) + } + issues, err := services.Issues.GetSummary(cmd.Context()) + if err != nil { + return fmt.Errorf("issue summary: %w", err) + } + out := statusOut{ + Health: string(summary.Health), ReadyForUse: summary.ReadyForUse, + ActiveServices: summary.ActiveServices, TotalServices: summary.ServiceCount, + LogEvents: summary.EventCount, AnalyzedEvents: summary.AnalyzedCount, + EventsPerHour: summary.TotalVolumePerHour, CostPerHourUSD: summary.TotalCostPerHour, + OpenIssues: issues.Open, HighIssues: issues.HighCount, MediumIssues: issues.MediumCount, LowIssues: issues.LowCount, + } + return emit(cmd, out, func() error { + s := styles.DetectTheme().Styles + fmt.Println(s.Title.Render("Account Status")) + w := newTabWriter() + fmt.Fprintf(w, "Health\t%s\n", summary.Health) + fmt.Fprintf(w, "Ready for use\t%t\n", summary.ReadyForUse) + fmt.Fprintf(w, "Services\t%d active / %d total\n", summary.ActiveServices, summary.ServiceCount) + fmt.Fprintf(w, "Log events\t%d (%d analyzed)\n", summary.EventCount, summary.AnalyzedCount) + fmt.Fprintf(w, "Volume\t%s\n", rate(summary.TotalVolumePerHour)) + fmt.Fprintf(w, "Cost\t%s\n", cost(summary.TotalCostPerHour)) + fmt.Fprintf(w, "Open issues\t%d (%d high, %d medium, %d low)\n", + issues.Open, issues.HighCount, issues.MediumCount, issues.LowCount) + return w.Flush() + }) + }, + } +} + +type edgeOut struct { + Service string `json:"service"` + Namespace string `json:"namespace,omitempty"` + InstanceID string `json:"instance_id"` + LastSyncAt string `json:"last_sync_at"` +} + +// NewEdgeCmd lists the account's edge instances. +func NewEdgeCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command { + scope = scope.Child("edge") + return &cobra.Command{ + Use: "edge", + Short: "List edge instances for the current account", + RunE: func(cmd *cobra.Command, _ []string) error { + services, err := accountServices(cmd.Context(), cliConfig, scope) + if err != nil { + return err + } + fleet, err := services.EdgeInstances.List(cmd.Context()) + if err != nil { + return fmt.Errorf("list edge instances: %w", err) + } + out := make([]edgeOut, len(fleet.Instances)) + for i, inst := range fleet.Instances { + out[i] = edgeOut{ + Service: inst.ServiceName, Namespace: inst.ServiceNamespace, + InstanceID: inst.InstanceID, LastSyncAt: inst.LastSyncAt.Format(time.RFC3339), + } + } + return emit(cmd, out, func() error { + if len(fleet.Instances) == 0 { + fmt.Println(styles.DetectTheme().Styles.Help.Render("No edge instances registered.")) + return nil + } + w := newTabWriter() + fmt.Fprintln(w, "SERVICE\tNAMESPACE\tINSTANCE\tLAST SYNC") + for _, inst := range fleet.Instances { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + inst.ServiceName, dashIfEmpty(inst.ServiceNamespace), inst.InstanceID, inst.LastSyncAt.Format("2006-01-02 15:04")) + } + return w.Flush() + }) + }, + } +} + +func dashIfEmpty(s string) string { + if s == "" { + return "—" + } + return s +} diff --git a/internal/config/cli.go b/internal/config/cli.go index 0e5a3c1f..0c98eaaf 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -8,7 +8,6 @@ import ( type environmentDefaults struct { APIEndpoint string PowerSyncEndpoint string - ChatEndpoint string WorkOSClientID string } @@ -18,19 +17,16 @@ var environments = map[string]environmentDefaults{ "local": { APIEndpoint: "http://localhost:18081", PowerSyncEndpoint: "http://localhost:18084", - ChatEndpoint: "http://localhost:18083", WorkOSClientID: "client_01JQCC2CJMTB8AY2JRMZXFY9R1", }, "dev": { APIEndpoint: "https://api.usetero.dev", PowerSyncEndpoint: "https://powersync.usetero.dev", - ChatEndpoint: "https://chat.usetero.dev", WorkOSClientID: "client_01JQCC2CJMTB8AY2JRMZXFY9R1", }, "prd": { APIEndpoint: "https://api.usetero.com", PowerSyncEndpoint: "https://powersync.usetero.com", - ChatEndpoint: "https://chat.usetero.com", WorkOSClientID: "client_01JQCC2D06JF9ASFA6GRHMFA3N", }, } @@ -43,12 +39,10 @@ type CLIConfig struct { // APIEndpoint is the Tero control plane GraphQL endpoint APIEndpoint string - // PowerSyncEndpoint is the PowerSync service endpoint for local-first sync + // PowerSyncEndpoint is retained only as a token audience for auth + // compatibility; the CLI no longer syncs. PowerSyncEndpoint string - // ChatEndpoint is the Chat API endpoint for message streaming - ChatEndpoint string - // WorkOSClientID is the WorkOS OAuth client ID for authentication WorkOSClientID string @@ -73,7 +67,6 @@ func LoadCLIConfig() *CLIConfig { Env: env, APIEndpoint: getEnvOrDefault("TERO_API_ENDPOINT", defaults.APIEndpoint), PowerSyncEndpoint: getEnvOrDefault("TERO_POWERSYNC_ENDPOINT", defaults.PowerSyncEndpoint), - ChatEndpoint: getEnvOrDefault("TERO_CHAT_ENDPOINT", defaults.ChatEndpoint), WorkOSClientID: getEnvOrDefault("WORKOS_CLIENT_ID", defaults.WorkOSClientID), Debug: os.Getenv("TERO_DEBUG") == "true" || os.Getenv("TERO_DEBUG") == "1", } diff --git a/internal/config/cli_test.go b/internal/config/cli_test.go index 65dd6e6c..812453b0 100644 --- a/internal/config/cli_test.go +++ b/internal/config/cli_test.go @@ -26,9 +26,6 @@ func TestLoadCLIConfig_DefaultsToPrd(t *testing.T) { if cfg.PowerSyncEndpoint != "https://powersync.usetero.com" { t.Errorf("PowerSyncEndpoint = %q, want production", cfg.PowerSyncEndpoint) } - if cfg.ChatEndpoint != "https://chat.usetero.com" { - t.Errorf("ChatEndpoint = %q, want production", cfg.ChatEndpoint) - } if cfg.WorkOSClientID != "client_01JQCC2D06JF9ASFA6GRHMFA3N" { t.Errorf("WorkOSClientID = %q, want production client", cfg.WorkOSClientID) } @@ -52,9 +49,6 @@ func TestLoadCLIConfig_Local(t *testing.T) { if cfg.PowerSyncEndpoint != "http://localhost:18084" { t.Errorf("PowerSyncEndpoint = %q, want localhost", cfg.PowerSyncEndpoint) } - if cfg.ChatEndpoint != "http://localhost:18083" { - t.Errorf("ChatEndpoint = %q, want localhost", cfg.ChatEndpoint) - } if cfg.WorkOSClientID != "client_01JQCC2CJMTB8AY2JRMZXFY9R1" { t.Errorf("WorkOSClientID = %q, want local/dev client", cfg.WorkOSClientID) } @@ -78,9 +72,6 @@ func TestLoadCLIConfig_Dev(t *testing.T) { if cfg.PowerSyncEndpoint != "https://powersync.usetero.dev" { t.Errorf("PowerSyncEndpoint = %q, want dev", cfg.PowerSyncEndpoint) } - if cfg.ChatEndpoint != "https://chat.usetero.dev" { - t.Errorf("ChatEndpoint = %q, want dev", cfg.ChatEndpoint) - } } func TestLoadCLIConfig_Prd(t *testing.T) { diff --git a/internal/core/bootstrap/completion.go b/internal/core/bootstrap/completion.go index 3498fd16..cee49b2c 100644 --- a/internal/core/bootstrap/completion.go +++ b/internal/core/bootstrap/completion.go @@ -5,24 +5,23 @@ import ( "github.com/usetero/cli/internal/domain" ) -// Completion is the required onboarding payload for entering chat. +// Completion is the required onboarding payload for entering chat. The account +// is the working context; there is no workspace. type Completion struct { - User *auth.User - Org domain.Organization - Account domain.Account - Workspace domain.Workspace + User *auth.User + Org domain.Organization + Account domain.Account } // CompleteOnboarding validates bootstrap state and returns completion payload. func CompleteOnboarding(state State) (Completion, bool) { - if state.User == nil || state.Org == nil || state.Account == nil || state.Workspace == nil { + if state.User == nil || state.Org == nil || state.Account == nil { return Completion{}, false } return Completion{ - User: state.User, - Org: *state.Org, - Account: *state.Account, - Workspace: *state.Workspace, + User: state.User, + Org: *state.Org, + Account: *state.Account, }, true } diff --git a/internal/core/bootstrap/completion_test.go b/internal/core/bootstrap/completion_test.go index 806b8f53..49b6e85a 100644 --- a/internal/core/bootstrap/completion_test.go +++ b/internal/core/bootstrap/completion_test.go @@ -13,13 +13,11 @@ func TestCompleteOnboarding(t *testing.T) { user := &auth.User{ID: "user-1"} org := &domain.Organization{ID: "org-1", Name: "Org 1"} account := &domain.Account{ID: "acc-1", Name: "Account 1"} - workspace := &domain.Workspace{ID: "ws-1", Name: "Workspace 1"} got, ok := CompleteOnboarding(State{ - User: user, - Org: org, - Account: account, - Workspace: workspace, + User: user, + Org: org, + Account: account, }) if !ok { t.Fatal("expected completion payload") @@ -33,29 +31,24 @@ func TestCompleteOnboarding(t *testing.T) { if got.Account.ID != "acc-1" { t.Fatalf("account = %#v, want acc-1", got.Account) } - if got.Workspace.ID != "ws-1" { - t.Fatalf("workspace = %#v, want ws-1", got.Workspace) - } } func TestCompleteOnboardingMissingRequirements(t *testing.T) { t.Parallel() base := State{ - User: &auth.User{ID: "user-1"}, - Org: &domain.Organization{ID: "org-1"}, - Account: &domain.Account{ID: "acc-1"}, - Workspace: &domain.Workspace{ID: "ws-1"}, + User: &auth.User{ID: "user-1"}, + Org: &domain.Organization{ID: "org-1"}, + Account: &domain.Account{ID: "acc-1"}, } cases := []struct { name string state State }{ - {name: "missing user", state: State{Org: base.Org, Account: base.Account, Workspace: base.Workspace}}, - {name: "missing org", state: State{User: base.User, Account: base.Account, Workspace: base.Workspace}}, - {name: "missing account", state: State{User: base.User, Org: base.Org, Workspace: base.Workspace}}, - {name: "missing workspace", state: State{User: base.User, Org: base.Org, Account: base.Account}}, + {name: "missing user", state: State{Org: base.Org, Account: base.Account}}, + {name: "missing org", state: State{User: base.User, Account: base.Account}}, + {name: "missing account", state: State{User: base.User, Org: base.Org}}, } for _, tt := range cases { diff --git a/internal/core/bootstrap/event_adapter.go b/internal/core/bootstrap/event_adapter.go index f31b869f..1a5e2ded 100644 --- a/internal/core/bootstrap/event_adapter.go +++ b/internal/core/bootstrap/event_adapter.go @@ -38,10 +38,6 @@ func EventFromMessage(msg Message) (Event, bool) { return Event{Kind: EventDatadogAccountCreated, DatadogAccountID: msg.DatadogAccountID}, true case DatadogDiscoveryComplete: return Event{Kind: EventDatadogDiscoveryDone}, true - case WorkspaceSelected: - return Event{Kind: EventWorkspaceSelected, Workspace: msg.Workspace}, true - case SyncComplete: - return Event{Kind: EventSyncComplete}, true default: return Event{}, false } diff --git a/internal/core/bootstrap/event_adapter_test.go b/internal/core/bootstrap/event_adapter_test.go index b47f1f01..4bb34722 100644 --- a/internal/core/bootstrap/event_adapter_test.go +++ b/internal/core/bootstrap/event_adapter_test.go @@ -17,7 +17,7 @@ func TestEventFromMessage(t *testing.T) { {name: "authenticated", msg: Authenticated{}, kind: EventAuthenticated}, {name: "org selected", msg: OrgSelected{}, kind: EventOrgSelected}, {name: "runtime ready", msg: RuntimeReady{}, kind: EventRuntimeReady}, - {name: "sync complete", msg: SyncComplete{}, kind: EventSyncComplete}, + {name: "datadog discovery complete", msg: DatadogDiscoveryComplete{}, kind: EventDatadogDiscoveryDone}, } for _, tt := range tests { diff --git a/internal/core/bootstrap/events.go b/internal/core/bootstrap/events.go index ce0c28de..3e7a726b 100644 --- a/internal/core/bootstrap/events.go +++ b/internal/core/bootstrap/events.go @@ -25,8 +25,6 @@ const ( EventDatadogAPIKeyEntered EventKind = "datadog_apikey_entered" EventDatadogAccountCreated EventKind = "datadog_account_created" EventDatadogDiscoveryDone EventKind = "datadog_discovery_done" - EventWorkspaceSelected EventKind = "workspace_selected" - EventSyncComplete EventKind = "sync_complete" ) // Event is the canonical transition input consumed by the bootstrap engine. @@ -40,7 +38,6 @@ type Event struct { Site domain.DatadogSite APIKey string DatadogAccountID domain.DatadogAccountID - Workspace domain.Workspace } // TransitionKind is the deterministic output shape for ApplyEvent. @@ -94,8 +91,8 @@ func ApplyEvent(state State, event Event) Transition { nextState, next := ApplyRuntimeReady(state, event.Org, event.Account) return Transition{Kind: TransitionAdvance, State: nextState, Next: next} case EventDatadogReady: - nextState, next := ApplyDatadogReady(state) - return Transition{Kind: TransitionAdvance, State: nextState, Next: next} + // Datadog already configured: onboarding is complete (no workspace step). + return completeOrNoop(state) case EventDatadogNeeded: nextState, next := ApplyDatadogNeeded(state) return Transition{Kind: TransitionAdvance, State: nextState, Next: next} @@ -109,18 +106,20 @@ func ApplyEvent(state State, event Event) Transition { nextState, next := ApplyDatadogAccountCreated(state, event.DatadogAccountID) return Transition{Kind: TransitionAdvance, State: nextState, Next: next} case EventDatadogDiscoveryDone: - nextState, next := ApplyDatadogDiscoveryComplete(state) - return Transition{Kind: TransitionAdvance, State: nextState, Next: next} - case EventWorkspaceSelected: - nextState, next := ApplyWorkspaceSelected(state, event.Workspace) - return Transition{Kind: TransitionAdvance, State: nextState, Next: next} - case EventSyncComplete: - completion, ok := CompleteOnboarding(state) - if !ok { - return Transition{Kind: TransitionNoop, State: state} - } - return Transition{Kind: TransitionComplete, State: state, Completion: completion} + // Datadog discovery finished: onboarding is complete. The account is the + // working context; there is no workspace step. + return completeOrNoop(state) default: return Transition{Kind: TransitionNoop, State: state} } } + +// completeOrNoop completes onboarding when the required state is present, +// otherwise no-ops. +func completeOrNoop(state State) Transition { + completion, ok := CompleteOnboarding(state) + if !ok { + return Transition{Kind: TransitionNoop, State: state} + } + return Transition{Kind: TransitionComplete, State: state, Completion: completion} +} diff --git a/internal/core/bootstrap/events_test.go b/internal/core/bootstrap/events_test.go index 9cdcf2e0..3a4f514a 100644 --- a/internal/core/bootstrap/events_test.go +++ b/internal/core/bootstrap/events_test.go @@ -25,31 +25,46 @@ func TestApplyEventAuthenticated(t *testing.T) { } } -func TestApplyEventSyncComplete(t *testing.T) { +func TestApplyEventDatadogDiscoveryDoneCompletes(t *testing.T) { t.Parallel() + // Datadog discovery is the terminal onboarding step; the account is the + // working context (no workspace). state := State{ - User: &auth.User{ID: "user-1"}, - Org: &domain.Organization{ID: "org-1"}, - Account: &domain.Account{ID: "acc-1"}, - Workspace: &domain.Workspace{ID: "ws-1"}, + User: &auth.User{ID: "user-1"}, + Org: &domain.Organization{ID: "org-1"}, + Account: &domain.Account{ID: "acc-1"}, } - got := ApplyEvent(state, Event{Kind: EventSyncComplete}) + got := ApplyEvent(state, Event{Kind: EventDatadogDiscoveryDone}) if got.Kind != TransitionComplete { t.Fatalf("kind = %q, want %q", got.Kind, TransitionComplete) } if got.Completion.User == nil || got.Completion.User.ID != "user-1" { t.Fatalf("completion user = %#v", got.Completion.User) } - if got.Completion.Org.ID != "org-1" || got.Completion.Account.ID != "acc-1" || got.Completion.Workspace.ID != "ws-1" { + if got.Completion.Org.ID != "org-1" || got.Completion.Account.ID != "acc-1" { t.Fatalf("unexpected completion payload: %#v", got.Completion) } } -func TestApplyEventSyncCompleteMissingStateNoops(t *testing.T) { +func TestApplyEventDatadogReadyCompletes(t *testing.T) { t.Parallel() - got := ApplyEvent(State{User: &auth.User{ID: "user-1"}}, Event{Kind: EventSyncComplete}) + state := State{ + User: &auth.User{ID: "user-1"}, + Org: &domain.Organization{ID: "org-1"}, + Account: &domain.Account{ID: "acc-1"}, + } + got := ApplyEvent(state, Event{Kind: EventDatadogReady}) + if got.Kind != TransitionComplete { + t.Fatalf("kind = %q, want %q", got.Kind, TransitionComplete) + } +} + +func TestApplyEventDatadogDiscoveryDoneMissingStateNoops(t *testing.T) { + t.Parallel() + + got := ApplyEvent(State{User: &auth.User{ID: "user-1"}}, Event{Kind: EventDatadogDiscoveryDone}) if got.Kind != TransitionNoop { t.Fatalf("kind = %q, want %q", got.Kind, TransitionNoop) } diff --git a/internal/core/bootstrap/gate_requirements.go b/internal/core/bootstrap/gate_requirements.go index c66e5545..ab327886 100644 --- a/internal/core/bootstrap/gate_requirements.go +++ b/internal/core/bootstrap/gate_requirements.go @@ -5,7 +5,7 @@ func RequirementForGate(gate Gate) GateRequirement { switch gate { case GateAccountSelect, GateAccountCreate: return GateRequirement{NeedsOrg: true} - case GateRuntimeInit, GateDatadogCheck, GateWorkspaceSelect: + case GateRuntimeInit, GateDatadogCheck: return GateRequirement{NeedsOrg: true, NeedsAccount: true} case GateDatadogAPIKey: return GateRequirement{NeedsOrg: true, NeedsAccount: true, NeedsDDSite: true} @@ -13,8 +13,6 @@ func RequirementForGate(gate Gate) GateRequirement { return GateRequirement{NeedsOrg: true, NeedsAccount: true, NeedsDDSite: true, NeedsDDAPIKey: true} case GateDatadogDiscovery: return GateRequirement{NeedsOrg: true, NeedsAccount: true, NeedsDDAccount: true} - case GateSync: - return GateRequirement{NeedsOrg: true, NeedsAccount: true, NeedsWorkspace: true} default: return GateRequirement{} } diff --git a/internal/core/bootstrap/gate_requirements_test.go b/internal/core/bootstrap/gate_requirements_test.go index 2cea9d04..bb3bd1fa 100644 --- a/internal/core/bootstrap/gate_requirements_test.go +++ b/internal/core/bootstrap/gate_requirements_test.go @@ -15,7 +15,6 @@ func TestRequirementForGate(t *testing.T) { {gate: GateDatadogAPIKey, want: GateRequirement{NeedsOrg: true, NeedsAccount: true, NeedsDDSite: true}}, {gate: GateDatadogAppKey, want: GateRequirement{NeedsOrg: true, NeedsAccount: true, NeedsDDSite: true, NeedsDDAPIKey: true}}, {gate: GateDatadogDiscovery, want: GateRequirement{NeedsOrg: true, NeedsAccount: true, NeedsDDAccount: true}}, - {gate: GateSync, want: GateRequirement{NeedsOrg: true, NeedsAccount: true, NeedsWorkspace: true}}, } for _, tc := range cases { diff --git a/internal/core/bootstrap/gates.go b/internal/core/bootstrap/gates.go index 1d385744..bf69b017 100644 --- a/internal/core/bootstrap/gates.go +++ b/internal/core/bootstrap/gates.go @@ -17,8 +17,6 @@ const ( GateDatadogAPIKey Gate = "datadog_api_key" GateDatadogAppKey Gate = "datadog_app_key" GateDatadogDiscovery Gate = "datadog_discovery" - GateWorkspaceSelect Gate = "workspace_select" - GateSync Gate = "sync" ) func (g Gate) String() string { return string(g) } diff --git a/internal/core/bootstrap/messages.go b/internal/core/bootstrap/messages.go index 4248666f..a349c8fe 100644 --- a/internal/core/bootstrap/messages.go +++ b/internal/core/bootstrap/messages.go @@ -70,31 +70,23 @@ type DatadogAccountCreated struct { type DatadogDiscoveryComplete struct{} -type WorkspaceSelected struct { - Workspace domain.Workspace -} - -type SyncComplete struct{} - type OnboardingComplete struct { - User *auth.User - Org domain.Organization - Account domain.Account - Workspace domain.Workspace + User *auth.User + Org domain.Organization + Account domain.Account } type PreflightState struct { - Outcome PreflightOutcome - HasValidAuth bool - Role string - ActiveOrgID domain.OrganizationID - DefaultAccountID domain.AccountID - DefaultWorkspaceID domain.WorkspaceID - Org *domain.Organization - Account *domain.Account - Workspace *domain.Workspace - HasDatadog bool - Error string + Outcome PreflightOutcome + HasValidAuth bool + Role string + ActiveOrgID domain.OrganizationID + DefaultAccountID domain.AccountID + User *auth.User + Org *domain.Organization + Account *domain.Account + HasDatadog bool + Error string } type PreflightResolved struct { @@ -117,6 +109,4 @@ func (DatadogAPIKeyEntered) bootstrapMessage() {} func (DatadogAccountCreated) bootstrapMessage() {} func (DatadogDiscoveryComplete) bootstrapMessage() { } -func (WorkspaceSelected) bootstrapMessage() {} -func (SyncComplete) bootstrapMessage() {} func (PreflightResolved) bootstrapMessage() {} diff --git a/internal/core/bootstrap/requirements.go b/internal/core/bootstrap/requirements.go index 6989d098..4bc612a6 100644 --- a/internal/core/bootstrap/requirements.go +++ b/internal/core/bootstrap/requirements.go @@ -7,7 +7,6 @@ type GateRequirement struct { NeedsDDSite bool NeedsDDAPIKey bool NeedsDDAccount bool - NeedsWorkspace bool } // RewindGate returns the earliest gate that satisfies the missing requirement. @@ -28,8 +27,5 @@ func RewindGate(target Gate, req GateRequirement, state State) Gate { if req.NeedsDDAccount && state.DDAccount == "" { return GateDatadogCheck } - if req.NeedsWorkspace && state.Workspace == nil { - return GateWorkspaceSelect - } return target } diff --git a/internal/core/bootstrap/requirements_test.go b/internal/core/bootstrap/requirements_test.go index d22a85b6..fa4a16b5 100644 --- a/internal/core/bootstrap/requirements_test.go +++ b/internal/core/bootstrap/requirements_test.go @@ -38,11 +38,11 @@ func TestRewindGate(t *testing.T) { want: GateDatadogAPIKey, }, { - name: "sync requirement rewinds to workspace when workspace missing", - target: GateSync, - req: GateRequirement{NeedsOrg: true, NeedsAccount: true, NeedsWorkspace: true}, + name: "datadog discovery rewinds to datadog check when dd account missing", + target: GateDatadogDiscovery, + req: GateRequirement{NeedsOrg: true, NeedsAccount: true, NeedsDDAccount: true}, state: State{Org: &domain.Organization{ID: "org-1"}, Account: &domain.Account{ID: "acc-1"}}, - want: GateWorkspaceSelect, + want: GateDatadogCheck, }, } diff --git a/internal/core/bootstrap/state.go b/internal/core/bootstrap/state.go index f81e1422..31706895 100644 --- a/internal/core/bootstrap/state.go +++ b/internal/core/bootstrap/state.go @@ -10,7 +10,6 @@ type State struct { User *auth.User Org *domain.Organization Account *domain.Account - Workspace *domain.Workspace DDSite domain.DatadogSite DDAPIKey string DDAccount domain.DatadogAccountID @@ -26,6 +25,9 @@ func ApplyPreflight(state State, resolved PreflightState) (State, Gate) { HasAccount: resolved.Account != nil, }) + if resolved.User != nil { + state.User = resolved.User + } if resolved.Org != nil { state.Org = resolved.Org } @@ -89,10 +91,6 @@ func ApplyRuntimeReady(state State, org domain.Organization, account domain.Acco return state, GateDatadogCheck } -func ApplyDatadogReady(state State) (State, Gate) { - return state, GateWorkspaceSelect -} - func ApplyDatadogNeeded(state State) (State, Gate) { return state, GateDatadogRegion } @@ -112,18 +110,8 @@ func ApplyDatadogAccountCreated(state State, datadogAccountID domain.DatadogAcco return state, GateDatadogDiscovery } -func ApplyDatadogDiscoveryComplete(state State) (State, Gate) { - return state, GateWorkspaceSelect -} - -func ApplyWorkspaceSelected(state State, workspace domain.Workspace) (State, Gate) { - state.Workspace = &workspace - return state, GateSync -} - func clearAccountScopedState(state State) State { state.Account = nil - state.Workspace = nil state.DDSite = "" state.DDAPIKey = "" state.DDAccount = "" diff --git a/internal/core/bootstrap/transitions_test.go b/internal/core/bootstrap/transitions_test.go index 88cca266..ad024840 100644 --- a/internal/core/bootstrap/transitions_test.go +++ b/internal/core/bootstrap/transitions_test.go @@ -48,19 +48,6 @@ func TestApplyDatadogTransitions(t *testing.T) { } } -func TestApplyWorkspaceSelected(t *testing.T) { - t.Parallel() - - workspace := domain.Workspace{ID: "ws-1", Name: "Workspace 1"} - state, next := ApplyWorkspaceSelected(State{}, workspace) - if next != GateSync { - t.Fatalf("next gate = %q, want %q", next, GateSync) - } - if state.Workspace == nil || state.Workspace.ID != workspace.ID { - t.Fatalf("workspace not applied: %#v", state.Workspace) - } -} - func TestApplyOrgSelectedClearsAccountScopedState(t *testing.T) { t.Parallel() @@ -68,7 +55,6 @@ func TestApplyOrgSelectedClearsAccountScopedState(t *testing.T) { state, next := ApplyOrgSelected(State{ Org: &domain.Organization{ID: "org-1"}, Account: &domain.Account{ID: "acc-1"}, - Workspace: &domain.Workspace{ID: "ws-1"}, DDSite: "US1", DDAPIKey: "api-key", DDAccount: "dd-1", @@ -79,7 +65,7 @@ func TestApplyOrgSelectedClearsAccountScopedState(t *testing.T) { if state.Org == nil || state.Org.ID != org.ID { t.Fatalf("org not applied: %#v", state.Org) } - if state.Account != nil || state.Workspace != nil || state.DDSite != "" || state.DDAPIKey != "" || state.DDAccount != "" { + if state.Account != nil || state.DDSite != "" || state.DDAPIKey != "" || state.DDAccount != "" { t.Fatalf("account-scoped state not cleared: %#v", state) } } @@ -91,7 +77,6 @@ func TestApplyAccountSelectedClearsScopedStateBeforeSettingAccount(t *testing.T) account := domain.Account{ID: "acc-2", Name: "Account 2"} state, next := ApplyAccountSelected(State{ Account: &domain.Account{ID: "acc-1"}, - Workspace: &domain.Workspace{ID: "ws-1"}, DDSite: "US1", DDAPIKey: "api-key", DDAccount: "dd-1", @@ -102,7 +87,7 @@ func TestApplyAccountSelectedClearsScopedStateBeforeSettingAccount(t *testing.T) if state.Account == nil || state.Account.ID != account.ID { t.Fatalf("account not applied: %#v", state.Account) } - if state.Workspace != nil || state.DDSite != "" || state.DDAPIKey != "" || state.DDAccount != "" { + if state.DDSite != "" || state.DDAPIKey != "" || state.DDAccount != "" { t.Fatalf("account-scoped state not reset: %#v", state) } } diff --git a/internal/core/chat/accumulator.go b/internal/core/chat/accumulator.go deleted file mode 100644 index d255915f..00000000 --- a/internal/core/chat/accumulator.go +++ /dev/null @@ -1,207 +0,0 @@ -package chat - -import ( - "encoding/json" - "fmt" - "sort" - - "github.com/usetero/cli/internal/domain" -) - -// accumulator builds a domain.Message from a stream of protocol events. -type accumulator struct { - model string - stopReason string - blocks []domain.Block - current *domain.Block - nextIndex int - title string - contextWindow int - inputTokens int - outputTokens int - - openTools map[string]*toolAccumulator - openToolOrder []string - seenTools map[string]struct{} -} - -type toolAccumulator struct { - index int - id string - name string - input []byte -} - -func newAccumulator() *accumulator { - return &accumulator{ - openTools: make(map[string]*toolAccumulator), - seenTools: make(map[string]struct{}), - } -} - -func (a *accumulator) handle(e event) error { - if e.Done { - return nil - } - - switch e.Type { - case EventTypeMessageStart: - a.model = e.MessageStart.Model - a.contextWindow = *e.MessageStart.ContextWindow - return nil - - case EventTypeMessageStop: - if len(a.openTools) > 0 { - return fmt.Errorf("protocol error: message_stop with %d unfinished tool blocks", len(a.openTools)) - } - a.stopReason = e.MessageStop.StopReason - a.inputTokens = *e.MessageStop.InputTokens - a.outputTokens = *e.MessageStop.OutputTokens - a.finalizeCurrent() - return nil - - case EventTypeTextDelta: - a.handleTextDelta(*e.Text.Content) - return nil - - case EventTypeThinkingDelta: - a.handleThinkingDelta(*e.Thinking.Content) - return nil - - case EventTypeToolUse: - a.finalizeCurrent() - if _, exists := a.seenTools[e.ToolUse.ID]; exists { - return fmt.Errorf("protocol error: duplicate tool_use id %q", e.ToolUse.ID) - } - a.seenTools[e.ToolUse.ID] = struct{}{} - a.openTools[e.ToolUse.ID] = &toolAccumulator{ - index: a.nextIndex, - id: e.ToolUse.ID, - name: e.ToolUse.Name, - } - a.openToolOrder = append(a.openToolOrder, e.ToolUse.ID) - a.nextIndex++ - return nil - - case EventTypeToolInputDelta: - tool, ok := a.openTools[e.ToolUseID] - if !ok { - return fmt.Errorf("protocol error: tool_input_delta for unknown tool_use_id %q", e.ToolUseID) - } - tool.input = append(tool.input, e.ToolInputDelta...) - return nil - - case EventTypeContentBlockStop: - if e.ToolUseID == "" { - if len(a.openTools) > 0 && a.current == nil { - return fmt.Errorf("protocol error: content_block_stop missing tool_use_id for open tool block") - } - a.finalizeCurrent() - return nil - } - return a.finalizeTool(e.ToolUseID) - - case EventTypeMetadataUpdate: - if e.Metadata.Title != "" { - a.title = e.Metadata.Title - } - return nil - } - - return fmt.Errorf("protocol error: unhandled event type %q", e.Type) -} - -func (a *accumulator) handleTextDelta(delta string) { - if a.current == nil || a.current.Type != domain.BlockTypeText { - a.finalizeCurrent() - a.current = &domain.Block{ - Index: a.nextIndex, - Type: domain.BlockTypeText, - Text: &domain.TextBlock{Content: delta}, - } - a.nextIndex++ - return - } - a.current.Text.Content += delta -} - -func (a *accumulator) handleThinkingDelta(delta string) { - if a.current == nil || a.current.Type != domain.BlockTypeThinking { - a.finalizeCurrent() - a.current = &domain.Block{ - Index: a.nextIndex, - Type: domain.BlockTypeThinking, - Thinking: &domain.Thinking{Content: delta}, - } - a.nextIndex++ - return - } - a.current.Thinking.Content += delta -} - -func (a *accumulator) finalizeCurrent() { - if a.current == nil { - return - } - a.blocks = append(a.blocks, *a.current) - a.current = nil -} - -func (a *accumulator) finalizeTool(toolUseID string) error { - tool, ok := a.openTools[toolUseID] - if !ok { - return fmt.Errorf("protocol error: content_block_stop for unknown tool_use_id %q", toolUseID) - } - if !json.Valid(tool.input) { - return fmt.Errorf("protocol error: invalid JSON tool input for %q", toolUseID) - } - - a.blocks = append(a.blocks, domain.Block{ - Index: tool.index, - Type: domain.BlockTypeToolUse, - ToolUse: &domain.ToolUse{ - ID: tool.id, - Name: tool.name, - Input: json.RawMessage(append([]byte(nil), tool.input...)), - InputComplete: true, - }, - }) - delete(a.openTools, toolUseID) - return nil -} - -func (a *accumulator) message() *domain.Message { - content := make([]domain.Block, len(a.blocks)) - copy(content, a.blocks) - - if a.current != nil { - content = append(content, *a.current) - } - - for _, id := range a.openToolOrder { - tool, ok := a.openTools[id] - if !ok { - continue - } - content = append(content, domain.Block{ - Index: tool.index, - Type: domain.BlockTypeToolUse, - ToolUse: &domain.ToolUse{ - ID: tool.id, - Name: tool.name, - Input: json.RawMessage(append([]byte(nil), tool.input...)), - }, - }) - } - - sort.Slice(content, func(i, j int) bool { - return content[i].Index < content[j].Index - }) - - return &domain.Message{ - Role: domain.RoleAssistant, - Content: content, - Model: a.model, - StopReason: a.stopReason, - } -} diff --git a/internal/core/chat/accumulator_test.go b/internal/core/chat/accumulator_test.go deleted file mode 100644 index 07f695cc..00000000 --- a/internal/core/chat/accumulator_test.go +++ /dev/null @@ -1,138 +0,0 @@ -package chat - -import ( - "strings" - "testing" - - "github.com/usetero/cli/internal/domain" -) - -func TestAccumulator(t *testing.T) { - t.Parallel() - - t.Run("happy path text-only stream", func(t *testing.T) { - t.Parallel() - - acc := newAccumulator() - events := []event{ - {Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}}, - {Type: EventTypeTextDelta, Text: &textContent{Content: strPtr("Hello")}}, - {Type: EventTypeTextDelta, Text: &textContent{Content: strPtr(" world")}}, - {Type: EventTypeMessageStop, MessageStop: &messageStop{StopReason: "end_turn", InputTokens: intPtr(9), OutputTokens: intPtr(2)}}, - {Done: true}, - } - for _, e := range events { - if err := acc.handle(e); err != nil { - t.Fatalf("handle(%s) error = %v", e.Type, err) - } - } - - msg := acc.message() - if msg.Model != "claude-3" { - t.Fatalf("model = %q", msg.Model) - } - if msg.StopReason != "end_turn" { - t.Fatalf("stop_reason = %q", msg.StopReason) - } - if len(msg.Content) != 1 || msg.Content[0].Type != domain.BlockTypeText { - t.Fatalf("unexpected content: %#v", msg.Content) - } - if msg.Content[0].Text.Content != "Hello world" { - t.Fatalf("text = %q", msg.Content[0].Text.Content) - } - }) - - t.Run("single tool call", func(t *testing.T) { - t.Parallel() - - acc := newAccumulator() - events := []event{ - {Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}}, - {Type: EventTypeToolUse, ToolUse: &toolUseEvent{ID: "tool-1", Name: "query"}}, - {Type: EventTypeToolInputDelta, ToolUseID: "tool-1", ToolInputDelta: `{"sql":`}, - {Type: EventTypeToolInputDelta, ToolUseID: "tool-1", ToolInputDelta: `"SELECT 1"}`}, - {Type: EventTypeContentBlockStop, ToolUseID: "tool-1"}, - {Type: EventTypeMessageStop, MessageStop: &messageStop{StopReason: "tool_use", InputTokens: intPtr(10), OutputTokens: intPtr(2)}}, - {Done: true}, - } - for _, e := range events { - if err := acc.handle(e); err != nil { - t.Fatalf("handle(%s) error = %v", e.Type, err) - } - } - - msg := acc.message() - if len(msg.Content) != 1 || msg.Content[0].Type != domain.BlockTypeToolUse { - t.Fatalf("unexpected content: %#v", msg.Content) - } - if string(msg.Content[0].ToolUse.Input) != `{"sql":"SELECT 1"}` { - t.Fatalf("input = %s", string(msg.Content[0].ToolUse.Input)) - } - if !msg.Content[0].ToolUse.InputComplete { - t.Fatal("expected InputComplete=true") - } - }) - - t.Run("multiple interleaved tool calls", func(t *testing.T) { - t.Parallel() - - acc := newAccumulator() - events := []event{ - {Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}}, - {Type: EventTypeToolUse, ToolUse: &toolUseEvent{ID: "a", Name: "query"}}, - {Type: EventTypeToolUse, ToolUse: &toolUseEvent{ID: "b", Name: "query"}}, - {Type: EventTypeToolInputDelta, ToolUseID: "a", ToolInputDelta: `{"sql":"SELECT `}, - {Type: EventTypeToolInputDelta, ToolUseID: "b", ToolInputDelta: `{"sql":"SELECT `}, - {Type: EventTypeToolInputDelta, ToolUseID: "a", ToolInputDelta: `1"}`}, - {Type: EventTypeToolInputDelta, ToolUseID: "b", ToolInputDelta: `2"}`}, - {Type: EventTypeContentBlockStop, ToolUseID: "b"}, - {Type: EventTypeContentBlockStop, ToolUseID: "a"}, - {Type: EventTypeMessageStop, MessageStop: &messageStop{StopReason: "tool_use", InputTokens: intPtr(10), OutputTokens: intPtr(2)}}, - {Done: true}, - } - for _, e := range events { - if err := acc.handle(e); err != nil { - t.Fatalf("handle(%s) error = %v", e.Type, err) - } - } - - msg := acc.message() - if len(msg.Content) != 2 { - t.Fatalf("blocks = %d, want 2", len(msg.Content)) - } - if string(msg.Content[0].ToolUse.Input) != `{"sql":"SELECT 1"}` { - t.Fatalf("tool a input = %s", string(msg.Content[0].ToolUse.Input)) - } - if string(msg.Content[1].ToolUse.Input) != `{"sql":"SELECT 2"}` { - t.Fatalf("tool b input = %s", string(msg.Content[1].ToolUse.Input)) - } - }) - - t.Run("malformed ordering errors", func(t *testing.T) { - t.Parallel() - - acc := newAccumulator() - if err := acc.handle(event{Type: EventTypeToolInputDelta, ToolUseID: "missing", ToolInputDelta: "{}"}); err == nil { - t.Fatal("expected unknown tool error") - } - - acc = newAccumulator() - _ = acc.handle(event{Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}}) - if err := acc.handle(event{Type: EventTypeContentBlockStop, ToolUseID: "missing"}); err == nil { - t.Fatal("expected unknown stop id error") - } - }) - - t.Run("rejects invalid tool input JSON", func(t *testing.T) { - t.Parallel() - - acc := newAccumulator() - _ = acc.handle(event{Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}}) - _ = acc.handle(event{Type: EventTypeToolUse, ToolUse: &toolUseEvent{ID: "tool-1", Name: "query"}}) - _ = acc.handle(event{Type: EventTypeToolInputDelta, ToolUseID: "tool-1", ToolInputDelta: `{`}) - err := acc.handle(event{Type: EventTypeContentBlockStop, ToolUseID: "tool-1"}) - if err == nil || !strings.Contains(err.Error(), "invalid JSON") { - t.Fatalf("error = %v, want invalid JSON", err) - } - }) -} diff --git a/internal/core/chat/fuzz_test.go b/internal/core/chat/fuzz_test.go deleted file mode 100644 index 5d1d9540..00000000 --- a/internal/core/chat/fuzz_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package chat - -import ( - "fmt" - "strings" - "testing" -) - -func FuzzDecodeEventData(f *testing.F) { - f.Add([]byte(`{"chat_stream_version":"v2","type":"message_start","message_start":{"model":"claude-3","context_window":200000}}`)) - f.Add([]byte(`{"chat_stream_version":"v2","type":"tool_use","tool_use":{"id":"tool-1","name":"query"}}`)) - f.Add([]byte(`{"chat_stream_version":"v2","error":"internal error"}`)) - f.Add([]byte(`{"chat_stream_version":"v2","type":"unknown"}`)) - f.Add([]byte(`not json`)) - - f.Fuzz(func(t *testing.T, data []byte) { - e, err := decodeEventData(data) - if err == nil && e.Type == "" { - t.Fatalf("decodeEventData returned nil error with empty type for %q", string(data)) - } - }) -} - -func FuzzReducerApplySequence(f *testing.F) { - f.Add([]byte{0, 1, 6, 8}) - f.Add([]byte{0, 3, 4, 5, 6, 8}) - f.Add([]byte{0, 3, 3, 4, 4, 5, 5, 6, 8}) - f.Add([]byte{1, 8}) - - f.Fuzz(func(t *testing.T, ops []byte) { - r := newReducer("conv-fuzz") - for i, op := range ops { - seq := i + 1 - if op%16 == 0 && i > 0 { - seq = i // intentionally non-monotonic sometimes - } - - toolID := fmt.Sprintf("tool-%d", op%3) - var e event - switch op % 9 { - case 0: - e = event{ConversationID: "conv-fuzz", TurnID: "turn-fuzz", Seq: seq, Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}} - case 1: - e = event{ConversationID: "conv-fuzz", TurnID: "turn-fuzz", Seq: seq, Type: EventTypeTextDelta, Text: &textContent{Content: strPtr(strings.Repeat("x", int(op%5)))}} - case 2: - e = event{ConversationID: "conv-fuzz", TurnID: "turn-fuzz", Seq: seq, Type: EventTypeThinkingDelta, Thinking: &textContent{Content: strPtr(strings.Repeat("t", int(op%5)))}} - case 3: - e = event{ConversationID: "conv-fuzz", TurnID: "turn-fuzz", Seq: seq, Type: EventTypeToolUse, ToolUse: &toolUseEvent{ID: toolID, Name: "query"}} - case 4: - e = event{ConversationID: "conv-fuzz", TurnID: "turn-fuzz", Seq: seq, Type: EventTypeToolInputDelta, ToolUseID: toolID, ToolInputDelta: `{"k":1}`} - case 5: - e = event{ConversationID: "conv-fuzz", TurnID: "turn-fuzz", Seq: seq, Type: EventTypeContentBlockStop, ToolUseID: toolID} - case 6: - stopReason := "end_turn" - if op%2 == 0 { - stopReason = "tool_use" - } - e = event{ConversationID: "conv-fuzz", TurnID: "turn-fuzz", Seq: seq, Type: EventTypeMessageStop, MessageStop: &messageStop{StopReason: stopReason, InputTokens: intPtr(1), OutputTokens: intPtr(1)}} - case 7: - e = event{ConversationID: "conv-fuzz", TurnID: "turn-fuzz", Seq: seq, Type: EventTypeMetadataUpdate, Metadata: &metadata{Title: "fuzz"}} - default: - e = event{ConversationID: "conv-fuzz", TurnID: "turn-fuzz", Seq: seq, Done: true} - } - - _, _ = r.apply(e) - if op%11 == 0 { - _ = r.abortSnapshot("fuzz abort") - } - } - }) -} diff --git a/internal/core/chat/protocol.go b/internal/core/chat/protocol.go deleted file mode 100644 index edf75ea6..00000000 --- a/internal/core/chat/protocol.go +++ /dev/null @@ -1,98 +0,0 @@ -package chat - -import "encoding/json" - -const chatStreamVersionV2 = "v2" - -// EventType identifies the kind of SSE event from the Chat API. -type EventType string - -const ( - EventTypeMessageStart EventType = "message_start" - EventTypeTextDelta EventType = "text_delta" - EventTypeThinkingDelta EventType = "thinking_delta" - EventTypeToolUse EventType = "tool_use" - EventTypeToolInputDelta EventType = "tool_input_delta" - EventTypeContentBlockStop EventType = "content_block_stop" - EventTypeMessageStop EventType = "message_stop" - EventTypeMetadataUpdate EventType = "metadata_update" -) - -// event is a single event from the Chat API response stream. -// This is internal to the chat package. -type event struct { - ChatStreamVersion string `json:"chat_stream_version"` - ConversationID string `json:"conversation_id,omitempty"` - TurnID string `json:"turn_id,omitempty"` - Seq int `json:"seq,omitempty"` - Type EventType `json:"type"` - - Text *textContent `json:"text,omitempty"` - Thinking *textContent `json:"thinking,omitempty"` - - ToolUse *toolUseEvent `json:"tool_use,omitempty"` - ToolUseID string `json:"tool_use_id,omitempty"` - ToolInputDelta string `json:"tool_input_delta,omitempty"` - - MessageStart *messageStart `json:"message_start,omitempty"` - MessageStop *messageStop `json:"message_stop,omitempty"` - Metadata *metadata `json:"metadata,omitempty"` - - Done bool `json:"-"` -} - -type textContent struct { - Content *string `json:"content"` -} - -type toolUseEvent struct { - ID string `json:"id"` - Name string `json:"name"` -} - -type messageStart struct { - Model string `json:"model"` - ContextWindow *int `json:"context_window"` -} - -type messageStop struct { - StopReason string `json:"stop_reason"` - InputTokens *int `json:"input_tokens"` - OutputTokens *int `json:"output_tokens"` -} - -type metadata struct { - Title string `json:"title,omitempty"` -} - -// errorEnvelope represents an error frame from the stream. -type errorEnvelope struct { - Error any `json:"error"` -} - -func parseErrorMessage(raw json.RawMessage) (string, bool) { - var frame errorEnvelope - if err := json.Unmarshal(raw, &frame); err != nil { - return "", false - } - if frame.Error == nil { - return "", false - } - - switch v := frame.Error.(type) { - case string: - if v == "" { - return "", false - } - return v, true - case map[string]any: - if msg, _ := v["message"].(string); msg != "" { - return msg, true - } - if msg, _ := v["error"].(string); msg != "" { - return msg, true - } - } - - return "", false -} diff --git a/internal/core/chat/reducer.go b/internal/core/chat/reducer.go deleted file mode 100644 index 3e884fc4..00000000 --- a/internal/core/chat/reducer.go +++ /dev/null @@ -1,136 +0,0 @@ -package chat - -import ( - "fmt" -) - -type reducer struct { - acc *accumulator - conversationID string - turnID string - lastSeq int - terminal bool - started bool - stopped bool -} - -func newReducer(conversationID string) *reducer { - return &reducer{ - acc: newAccumulator(), - conversationID: conversationID, - } -} - -func (r *reducer) apply(e event) (*StreamSnapshot, error) { - if r.terminal { - return nil, fmt.Errorf("received event after terminal state") - } - - if e.ConversationID != "" && r.conversationID != "" && e.ConversationID != r.conversationID { - return nil, fmt.Errorf("conversation_id mismatch: got %q want %q", e.ConversationID, r.conversationID) - } - if e.ConversationID != "" { - r.conversationID = e.ConversationID - } - - if e.TurnID != "" { - if r.turnID == "" { - r.turnID = e.TurnID - } else if e.TurnID != r.turnID { - return nil, fmt.Errorf("turn_id mismatch: got %q want %q", e.TurnID, r.turnID) - } - } - - if e.Seq > 0 { - if r.lastSeq > 0 && e.Seq <= r.lastSeq { - return nil, fmt.Errorf("non-monotonic seq: got %d after %d", e.Seq, r.lastSeq) - } - r.lastSeq = e.Seq - } - - if e.Done { - if !r.stopped { - return nil, fmt.Errorf("protocol error: received [DONE] before message_stop") - } - if err := r.acc.handle(e); err != nil { - return nil, err - } - r.terminal = true - - status := StreamStatusCompleted - if r.acc.stopReason == "tool_use" { - status = StreamStatusToolUse - } - - return &StreamSnapshot{ - ConversationID: r.conversationID, - TurnID: r.turnID, - Seq: r.lastSeq, - Status: status, - Done: true, - Message: r.acc.message(), - Metadata: r.metadata(), - }, nil - } - - if !r.started { - if e.Type != EventTypeMessageStart { - return nil, fmt.Errorf("protocol error: first event must be message_start, got %q", e.Type) - } - r.started = true - } else { - if e.Type == EventTypeMessageStart { - return nil, fmt.Errorf("protocol error: duplicate message_start") - } - if r.stopped && e.Type != EventTypeMetadataUpdate { - return nil, fmt.Errorf("protocol error: event %q after message_stop", e.Type) - } - } - - if err := r.acc.handle(e); err != nil { - return nil, err - } - - if e.Type == EventTypeMessageStop { - r.stopped = true - } - - return &StreamSnapshot{ - ConversationID: r.conversationID, - TurnID: r.turnID, - Seq: r.lastSeq, - Status: StreamStatusStreaming, - Done: false, - Message: r.acc.message(), - Metadata: r.metadata(), - }, nil -} - -func (r *reducer) abortSnapshot(reason string) *StreamSnapshot { - if r.terminal { - return nil - } - r.terminal = true - return &StreamSnapshot{ - ConversationID: r.conversationID, - TurnID: r.turnID, - Seq: r.lastSeq, - Status: StreamStatusAborted, - AbortReason: reason, - Done: true, - Message: r.acc.message(), - Metadata: r.metadata(), - } -} - -func (r *reducer) metadata() *StreamMetadata { - if r.acc.title == "" && r.acc.contextWindow == 0 && r.acc.inputTokens == 0 && r.acc.outputTokens == 0 { - return nil - } - return &StreamMetadata{ - Title: r.acc.title, - ContextWindow: r.acc.contextWindow, - InputTokens: r.acc.inputTokens, - OutputTokens: r.acc.outputTokens, - } -} diff --git a/internal/core/chat/reducer_properties_test.go b/internal/core/chat/reducer_properties_test.go deleted file mode 100644 index eb230fe0..00000000 --- a/internal/core/chat/reducer_properties_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package chat - -import "testing" - -func TestReducerProperty_SequenceMustBeStrictlyIncreasing(t *testing.T) { - t.Parallel() - - for a := 1; a <= 4; a++ { - for b := 1; b <= 4; b++ { - for c := 1; c <= 4; c++ { - seqs := []int{a, b, c} - r := newReducer("conv-1") - _, _ = r.apply(event{ConversationID: "conv-1", TurnID: "turn-1", Seq: 0, Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}}) - - errAt := -1 - for i, s := range seqs { - _, err := r.apply(event{ConversationID: "conv-1", TurnID: "turn-1", Seq: s, Type: EventTypeMetadataUpdate, Metadata: &metadata{}}) - if err != nil { - errAt = i - break - } - } - - hasNonIncreasing := b <= a || c <= b - if hasNonIncreasing && errAt == -1 { - t.Fatalf("expected error for non-increasing sequence %v", seqs) - } - if !hasNonIncreasing && errAt != -1 { - t.Fatalf("unexpected error for increasing sequence %v", seqs) - } - } - } - } -} - -func TestReducerProperty_TurnIDMustStayStable(t *testing.T) { - t.Parallel() - - r := newReducer("conv-1") - if _, err := r.apply(event{ConversationID: "conv-1", TurnID: "turn-1", Seq: 1, Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}}); err != nil { - t.Fatalf("unexpected error on first turn event: %v", err) - } - if _, err := r.apply(event{ConversationID: "conv-1", TurnID: "turn-1", Seq: 2, Type: EventTypeMetadataUpdate, Metadata: &metadata{}}); err != nil { - t.Fatalf("unexpected error for same turn: %v", err) - } - if _, err := r.apply(event{ConversationID: "conv-1", TurnID: "turn-2", Seq: 3, Type: EventTypeMetadataUpdate, Metadata: &metadata{}}); err == nil { - t.Fatal("expected turn mismatch error, got nil") - } -} - -func TestReducerProperty_NoEventsAfterTerminal(t *testing.T) { - t.Parallel() - - r := newReducer("conv-1") - if _, err := r.apply(event{ConversationID: "conv-1", TurnID: "turn-1", Seq: 1, Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}}); err != nil { - t.Fatalf("unexpected error before done: %v", err) - } - if _, err := r.apply(event{ConversationID: "conv-1", TurnID: "turn-1", Seq: 2, Type: EventTypeMessageStop, MessageStop: &messageStop{StopReason: "end_turn", InputTokens: intPtr(10), OutputTokens: intPtr(2)}}); err != nil { - t.Fatalf("unexpected message_stop error: %v", err) - } - if _, err := r.apply(event{ConversationID: "conv-1", TurnID: "turn-1", Seq: 3, Done: true}); err != nil { - t.Fatalf("unexpected done error: %v", err) - } - if _, err := r.apply(event{ConversationID: "conv-1", TurnID: "turn-1", Seq: 4, Type: EventTypeMetadataUpdate, Metadata: &metadata{}}); err == nil { - t.Fatal("expected error for event after terminal, got nil") - } -} diff --git a/internal/core/chat/reducer_test.go b/internal/core/chat/reducer_test.go deleted file mode 100644 index 9787893f..00000000 --- a/internal/core/chat/reducer_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package chat - -import ( - "strings" - "testing" -) - -func TestReducer(t *testing.T) { - t.Parallel() - - t.Run("requires message_start first", func(t *testing.T) { - t.Parallel() - - r := newReducer("conv-1") - _, err := r.apply(event{Type: EventTypeTextDelta, Text: &textContent{Content: strPtr("x")}}) - if err == nil || !strings.Contains(err.Error(), "first event must be message_start") { - t.Fatalf("error = %v", err) - } - }) - - t.Run("builds completed snapshot with metadata", func(t *testing.T) { - t.Parallel() - - r := newReducer("conv-1") - events := []event{ - {ConversationID: "conv-1", TurnID: "turn-1", Seq: 1, Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}}, - {ConversationID: "conv-1", TurnID: "turn-1", Seq: 2, Type: EventTypeTextDelta, Text: &textContent{Content: strPtr("hello")}}, - {ConversationID: "conv-1", TurnID: "turn-1", Seq: 3, Type: EventTypeMessageStop, MessageStop: &messageStop{StopReason: "end_turn", InputTokens: intPtr(10), OutputTokens: intPtr(3)}}, - } - for _, e := range events { - if _, err := r.apply(e); err != nil { - t.Fatalf("apply(%s) error = %v", e.Type, err) - } - } - - snap, err := r.apply(event{ConversationID: "conv-1", TurnID: "turn-1", Seq: 4, Done: true}) - if err != nil { - t.Fatalf("apply(done) error = %v", err) - } - if !snap.Done || snap.Status != StreamStatusCompleted { - t.Fatalf("unexpected final snapshot: %#v", snap) - } - if snap.Metadata == nil || snap.Metadata.ContextWindow != 200000 { - t.Fatalf("metadata = %#v", snap.Metadata) - } - }) - - t.Run("maps tool_use stop reason to tool_use status", func(t *testing.T) { - t.Parallel() - - r := newReducer("conv-1") - _, _ = r.apply(event{Seq: 1, Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}}) - _, _ = r.apply(event{Seq: 2, Type: EventTypeMessageStop, MessageStop: &messageStop{StopReason: "tool_use", InputTokens: intPtr(10), OutputTokens: intPtr(2)}}) - snap, err := r.apply(event{Seq: 3, Done: true}) - if err != nil { - t.Fatalf("apply(done) error = %v", err) - } - if snap.Status != StreamStatusToolUse { - t.Fatalf("status = %q", snap.Status) - } - }) - - t.Run("rejects content after message_stop", func(t *testing.T) { - t.Parallel() - - r := newReducer("conv-1") - _, _ = r.apply(event{Seq: 1, Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}}) - _, _ = r.apply(event{Seq: 2, Type: EventTypeMessageStop, MessageStop: &messageStop{StopReason: "end_turn", InputTokens: intPtr(10), OutputTokens: intPtr(2)}}) - _, err := r.apply(event{Seq: 3, Type: EventTypeTextDelta, Text: &textContent{Content: strPtr("late")}}) - if err == nil || !strings.Contains(err.Error(), "after message_stop") { - t.Fatalf("error = %v", err) - } - }) - - t.Run("allows metadata_update after message_stop before done", func(t *testing.T) { - t.Parallel() - - r := newReducer("conv-1") - _, _ = r.apply(event{Seq: 1, Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}}) - _, _ = r.apply(event{Seq: 2, Type: EventTypeMessageStop, MessageStop: &messageStop{StopReason: "end_turn", InputTokens: intPtr(10), OutputTokens: intPtr(2)}}) - snap, err := r.apply(event{Seq: 3, Type: EventTypeMetadataUpdate, Metadata: &metadata{Title: "hello"}}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if snap == nil || snap.Metadata == nil || snap.Metadata.Title != "hello" { - t.Fatalf("expected metadata title on snapshot, got %#v", snap) - } - _, err = r.apply(event{Seq: 4, Done: true}) - if err != nil { - t.Fatalf("unexpected done error: %v", err) - } - }) - - t.Run("rejects done before message_stop", func(t *testing.T) { - t.Parallel() - - r := newReducer("conv-1") - _, _ = r.apply(event{Seq: 1, Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}}) - _, err := r.apply(event{Seq: 2, Done: true}) - if err == nil || !strings.Contains(err.Error(), "before message_stop") { - t.Fatalf("error = %v", err) - } - }) - - t.Run("rejects non-monotonic seq", func(t *testing.T) { - t.Parallel() - - r := newReducer("conv-1") - _, _ = r.apply(event{Seq: 2, Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}}) - _, err := r.apply(event{Seq: 2, Type: EventTypeMessageStop, MessageStop: &messageStop{StopReason: "end_turn", InputTokens: intPtr(10), OutputTokens: intPtr(2)}}) - if err == nil { - t.Fatal("expected non-monotonic seq error") - } - }) - - t.Run("abortSnapshot emits terminal aborted status", func(t *testing.T) { - t.Parallel() - - r := newReducer("conv-1") - _, _ = r.apply(event{TurnID: "turn-1", Seq: 1, Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}}) - snap := r.abortSnapshot("user_cancelled") - if snap == nil || snap.Status != StreamStatusAborted || !snap.Done { - t.Fatalf("unexpected abort snapshot: %#v", snap) - } - if again := r.abortSnapshot("user_cancelled"); again != nil { - t.Fatal("second abortSnapshot should return nil") - } - }) -} diff --git a/internal/core/chat/session.go b/internal/core/chat/session.go deleted file mode 100644 index b717d692..00000000 --- a/internal/core/chat/session.go +++ /dev/null @@ -1,160 +0,0 @@ -package chat - -import "github.com/usetero/cli/internal/domain" - -// Session owns in-memory message history for one active chat loop. -// Persistence is handled separately by callers. -type Session struct { - conversationID domain.ConversationID - history []domain.Message -} - -// NewSession creates a session with an initial history snapshot. -func NewSession(conversationID domain.ConversationID, initial []domain.Message) *Session { - return &Session{ - conversationID: conversationID, - history: cloneMessages(initial), - } -} - -// Messages returns a defensive copy of the current history. -func (s *Session) Messages() []domain.Message { - return cloneMessages(s.history) -} - -// AppendMessage appends a message to history. -func (s *Session) AppendMessage(message domain.Message) { - if message.ID == "" { - return - } - s.history = append(s.history, cloneMessage(message)) -} - -// AppendUserTextMessage appends a user text message and returns it. -func (s *Session) AppendUserTextMessage(messageID domain.MessageID, text string) domain.Message { - msg := domain.Message{ - ID: messageID, - ConversationID: s.conversationID, - Role: domain.RoleUser, - Content: []domain.Block{{ - Index: 0, - Type: domain.BlockTypeText, - Text: &domain.TextBlock{Content: text}, - }}, - } - s.history = append(s.history, msg) - return msg -} - -// AppendUserToolResultsMessage appends a user tool_result message and returns it. -func (s *Session) AppendUserToolResultsMessage(messageID domain.MessageID, results []domain.ToolResult) domain.Message { - msg := buildToolResultMessage(s.conversationID, messageID, results) - s.history = append(s.history, msg) - return msg -} - -// RecordAssistantMessage upserts an assistant message in history by message ID. -func (s *Session) RecordAssistantMessage(message domain.Message) { - if message.ID == "" { - return - } - message.Role = domain.RoleAssistant - for i := range s.history { - if s.history[i].ID == message.ID { - s.history[i] = cloneMessage(message) - return - } - } - s.history = append(s.history, cloneMessage(message)) -} - -// RemoveMessagesByID removes all messages whose IDs are in ids. -func (s *Session) RemoveMessagesByID(ids []domain.MessageID) { - if len(ids) == 0 || len(s.history) == 0 { - return - } - drop := make(map[domain.MessageID]struct{}, len(ids)) - for _, id := range ids { - drop[id] = struct{}{} - } - kept := s.history[:0] - for _, msg := range s.history { - if _, ok := drop[msg.ID]; ok { - continue - } - kept = append(kept, msg) - } - s.history = kept -} - -func buildToolResultMessage(conversationID domain.ConversationID, messageID domain.MessageID, results []domain.ToolResult) domain.Message { - blocks := make([]domain.Block, len(results)) - for i, result := range results { - r := result - blocks[i] = domain.Block{ - Index: i, - Type: domain.BlockTypeToolResult, - ToolResult: &r, - } - } - return domain.Message{ - ID: messageID, - ConversationID: conversationID, - Role: domain.RoleUser, - Content: blocks, - } -} - -func cloneMessages(in []domain.Message) []domain.Message { - out := make([]domain.Message, len(in)) - for i := range in { - out[i] = cloneMessage(in[i]) - } - return out -} - -func cloneMessage(in domain.Message) domain.Message { - out := in - if in.Content != nil { - out.Content = make([]domain.Block, len(in.Content)) - for i := range in.Content { - out.Content[i] = cloneBlock(in.Content[i]) - } - } - return out -} - -func cloneBlock(in domain.Block) domain.Block { - out := in - if in.Text != nil { - v := *in.Text - out.Text = &v - } - if in.Thinking != nil { - v := *in.Thinking - out.Thinking = &v - } - if in.ToolUse != nil { - v := *in.ToolUse - if in.ToolUse.Input != nil { - v.Input = append([]byte(nil), in.ToolUse.Input...) - } - out.ToolUse = &v - } - if in.ToolResult != nil { - v := *in.ToolResult - if in.ToolResult.Content != nil { - v.Content = cloneAnyMap(in.ToolResult.Content) - } - out.ToolResult = &v - } - return out -} - -func cloneAnyMap(in map[string]any) map[string]any { - out := make(map[string]any, len(in)) - for k, v := range in { - out[k] = v - } - return out -} diff --git a/internal/core/chat/session_test.go b/internal/core/chat/session_test.go deleted file mode 100644 index 2eb7b851..00000000 --- a/internal/core/chat/session_test.go +++ /dev/null @@ -1,135 +0,0 @@ -package chat - -import ( - "testing" - - "github.com/usetero/cli/internal/domain" -) - -func TestSession_RecordAssistantMessageUpsert(t *testing.T) { - t.Parallel() - - s := NewSession("conv-1", []domain.Message{{ - ID: "asst-1", - Role: domain.RoleAssistant, - Content: []domain.Block{{ - Type: domain.BlockTypeText, - Text: &domain.TextBlock{Content: "old"}, - }}, - }}) - - s.RecordAssistantMessage(domain.Message{ - ID: "asst-1", - Role: domain.RoleAssistant, - Content: []domain.Block{{ - Type: domain.BlockTypeText, - Text: &domain.TextBlock{Content: "new"}, - }}, - }) - - msgs := s.Messages() - if len(msgs) != 1 { - t.Fatalf("len(messages) = %d, want 1", len(msgs)) - } - if got := msgs[0].Content[0].Text.Content; got != "new" { - t.Fatalf("assistant content = %q, want %q", got, "new") - } -} - -func TestSession_AppendUserToolResultsMessage(t *testing.T) { - t.Parallel() - - s := NewSession("conv-1", nil) - s.AppendUserToolResultsMessage("tool-result-1", []domain.ToolResult{ - { - ToolUseID: "tool-a", - Content: map[string]any{"rows": []any{1, 2}}, - }, - { - ToolUseID: "tool-b", - IsError: true, - Error: "failed", - }, - }) - - msgs := s.Messages() - if len(msgs) != 1 { - t.Fatalf("len(messages) = %d, want 1", len(msgs)) - } - msg := msgs[0] - if msg.Role != domain.RoleUser { - t.Fatalf("role = %q, want %q", msg.Role, domain.RoleUser) - } - if len(msg.Content) != 2 { - t.Fatalf("len(content) = %d, want 2", len(msg.Content)) - } - if msg.Content[0].Type != domain.BlockTypeToolResult || msg.Content[0].ToolResult.ToolUseID != "tool-a" { - t.Fatalf("unexpected first block: %#v", msg.Content[0]) - } - if msg.Content[1].Type != domain.BlockTypeToolResult || msg.Content[1].ToolResult.ToolUseID != "tool-b" { - t.Fatalf("unexpected second block: %#v", msg.Content[1]) - } -} - -func TestSession_AppendUserTextMessage(t *testing.T) { - t.Parallel() - - s := NewSession("conv-1", nil) - s.AppendUserTextMessage("msg-1", "hello") - - msgs := s.Messages() - if len(msgs) != 1 { - t.Fatalf("len(messages) = %d, want 1", len(msgs)) - } - msg := msgs[0] - if msg.Role != domain.RoleUser { - t.Fatalf("role = %q, want %q", msg.Role, domain.RoleUser) - } - if len(msg.Content) != 1 || msg.Content[0].Type != domain.BlockTypeText || msg.Content[0].Text == nil { - t.Fatalf("unexpected content: %#v", msg.Content) - } - if msg.Content[0].Text.Content != "hello" { - t.Fatalf("text = %q, want %q", msg.Content[0].Text.Content, "hello") - } -} - -func TestSession_MessagesReturnsDefensiveCopy(t *testing.T) { - t.Parallel() - - s := NewSession("conv-1", []domain.Message{{ - ID: "msg-1", - Role: domain.RoleUser, - Content: []domain.Block{{ - Type: domain.BlockTypeText, - Text: &domain.TextBlock{Content: "hello"}, - }}, - }}) - - got := s.Messages() - got[0].Content[0].Text.Content = "mutated" - - again := s.Messages() - if again[0].Content[0].Text.Content != "hello" { - t.Fatalf("session history was mutated through returned slice") - } -} - -func TestSession_RemoveMessagesByID(t *testing.T) { - t.Parallel() - - s := NewSession("conv-1", []domain.Message{ - {ID: "a", Role: domain.RoleUser}, - {ID: "b", Role: domain.RoleAssistant}, - {ID: "c", Role: domain.RoleUser}, - }) - - s.RemoveMessagesByID([]domain.MessageID{"b", "c"}) - - msgs := s.Messages() - if len(msgs) != 1 { - t.Fatalf("len(messages) = %d, want 1", len(msgs)) - } - if msgs[0].ID != "a" { - t.Fatalf("remaining id = %q, want %q", msgs[0].ID, "a") - } -} diff --git a/internal/core/chat/stream_decode.go b/internal/core/chat/stream_decode.go deleted file mode 100644 index 5013f84f..00000000 --- a/internal/core/chat/stream_decode.go +++ /dev/null @@ -1,82 +0,0 @@ -package chat - -import ( - "bytes" - "encoding/json" - "fmt" - "io" -) - -func decodeEventData(data []byte) (event, error) { - var header struct { - ChatStreamVersion string `json:"chat_stream_version"` - Type EventType `json:"type"` - } - if err := json.Unmarshal(data, &header); err != nil { - return event{}, fmt.Errorf("parse event: %w", err) - } - if header.ChatStreamVersion != chatStreamVersionV2 { - return event{}, fmt.Errorf("protocol error: unsupported chat_stream_version %q", header.ChatStreamVersion) - } - if msg, ok := parseErrorMessage(data); ok { - return event{}, fmt.Errorf("server error: %s", msg) - } - - var e event - if err := strictUnmarshal(data, &e); err != nil { - return event{}, fmt.Errorf("parse event: %w", err) - } - if e.Type == "" { - return event{}, fmt.Errorf("protocol error: missing event type") - } - - switch e.Type { - case EventTypeMessageStart: - if e.MessageStart == nil || e.MessageStart.Model == "" || e.MessageStart.ContextWindow == nil { - return event{}, fmt.Errorf("protocol error: message_start missing required fields") - } - case EventTypeTextDelta: - if e.Text == nil || e.Text.Content == nil { - return event{}, fmt.Errorf("protocol error: text_delta missing content") - } - case EventTypeThinkingDelta: - if e.Thinking == nil || e.Thinking.Content == nil { - return event{}, fmt.Errorf("protocol error: thinking_delta missing content") - } - case EventTypeToolUse: - if e.ToolUse == nil || e.ToolUse.ID == "" || e.ToolUse.Name == "" { - return event{}, fmt.Errorf("protocol error: tool_use missing id/name") - } - case EventTypeToolInputDelta: - if e.ToolUseID == "" { - return event{}, fmt.Errorf("protocol error: tool_input_delta missing tool_use_id") - } - case EventTypeContentBlockStop: - // tool_use_id is optional and required only for tool blocks. - case EventTypeMessageStop: - if e.MessageStop == nil || e.MessageStop.StopReason == "" || e.MessageStop.InputTokens == nil || e.MessageStop.OutputTokens == nil { - return event{}, fmt.Errorf("protocol error: message_stop missing required fields") - } - case EventTypeMetadataUpdate: - if e.Metadata == nil || e.Metadata.Title == "" { - return event{}, fmt.Errorf("protocol error: metadata_update missing title") - } - default: - return event{}, fmt.Errorf("protocol error: unknown event type %q", e.Type) - } - - return e, nil -} - -func strictUnmarshal(data []byte, out any) error { - dec := json.NewDecoder(bytes.NewReader(data)) - dec.DisallowUnknownFields() - if err := dec.Decode(out); err != nil { - return err - } - var trailing struct{} - if err := dec.Decode(&trailing); err != io.EOF { - return fmt.Errorf("unexpected trailing JSON data") - } - return nil -} diff --git a/internal/core/chat/stream_machine.go b/internal/core/chat/stream_machine.go deleted file mode 100644 index 38663229..00000000 --- a/internal/core/chat/stream_machine.go +++ /dev/null @@ -1,68 +0,0 @@ -package chat - -import "github.com/usetero/cli/internal/domain" - -type StreamStatus string - -const ( - StreamStatusStreaming StreamStatus = "streaming" - StreamStatusCompleted StreamStatus = "completed" - StreamStatusToolUse StreamStatus = "tool_use" - StreamStatusAborted StreamStatus = "aborted" -) - -// StreamMetadata contains post-stream metadata from the Chat API. -type StreamMetadata struct { - Title string - ContextWindow int - InputTokens int - OutputTokens int -} - -// StreamSnapshot is a deterministic progress snapshot for one stream turn. -type StreamSnapshot struct { - ConversationID string - TurnID string - Seq int - Status StreamStatus - AbortReason string - Done bool - Message *domain.Message - Metadata *StreamMetadata -} - -// StreamMachine consumes SSE data payloads and emits typed stream snapshots. -type StreamMachine struct { - red *reducer -} - -func NewStreamMachine(conversationID string) *StreamMachine { - return &StreamMachine{red: newReducer(conversationID)} -} - -// ConsumeData parses and applies one non-[DONE] SSE data payload. -func (m *StreamMachine) ConsumeData(data []byte) (*StreamSnapshot, error) { - e, err := decodeEventData(data) - if err != nil { - return nil, err - } - return m.red.apply(e) -} - -// ConsumeDone applies terminal [DONE]. -func (m *StreamMachine) ConsumeDone() (*StreamSnapshot, error) { - return m.red.apply(event{Done: true}) -} - -// Abort emits a terminal aborted snapshot. -func (m *StreamMachine) Abort(reason string) *StreamSnapshot { - return m.red.abortSnapshot(reason) -} - -func (m *StreamMachine) Message() *domain.Message { - return m.red.acc.message() -} - -func (m *StreamMachine) Metadata() *StreamMetadata { - return m.red.metadata() -} diff --git a/internal/core/chat/stream_result.go b/internal/core/chat/stream_result.go deleted file mode 100644 index 2f660787..00000000 --- a/internal/core/chat/stream_result.go +++ /dev/null @@ -1,9 +0,0 @@ -package chat - -import "github.com/usetero/cli/internal/domain" - -// StreamResult captures the final stream outputs for a turn. -type StreamResult struct { - Message *domain.Message - Metadata *StreamMetadata // nil if no metadata_update event was received -} diff --git a/internal/core/chat/test_helpers_test.go b/internal/core/chat/test_helpers_test.go deleted file mode 100644 index 96b08708..00000000 --- a/internal/core/chat/test_helpers_test.go +++ /dev/null @@ -1,5 +0,0 @@ -package chat - -func intPtr(v int) *int { return &v } - -func strPtr(v string) *string { return &v } diff --git a/internal/domain/check.go b/internal/domain/check.go new file mode 100644 index 00000000..a15f87c3 --- /dev/null +++ b/internal/domain/check.go @@ -0,0 +1,43 @@ +package domain + +// CheckDomain is the lane a product check belongs to. +type CheckDomain string + +const ( + CheckDomainCost CheckDomain = "cost" + CheckDomainCompliance CheckDomain = "compliance" +) + +func (d CheckDomain) String() string { return string(d) } + +// Check is one code-defined product check with its account-scoped posture. +// Posture counts and cost totals are computed server-side from findings and +// issues. +type Check struct { + ID string + Name string + Domain CheckDomain + + OpenFindingCount int64 + PendingFindingCount int64 + EscalatedFindingCount int64 + ActiveIssueCount int64 + AffectedServiceCount int64 + + // CurrentCostPerHour is the current spend attributable to this check, or + // nil when unmeasured. + CurrentCostPerHour *float64 +} + +// CheckCatalog is the full set of product checks plus server-computed +// per-domain counts. +type CheckCatalog struct { + Total int64 + Checks []Check + ByDomain map[CheckDomain]int64 +} + +// DomainCount returns the number of checks in a domain. +func (c CheckCatalog) DomainCount(d CheckDomain) int64 { + return c.ByDomain[d] +} diff --git a/internal/domain/conversation.go b/internal/domain/conversation.go index e6e86136..a42c3da3 100644 --- a/internal/domain/conversation.go +++ b/internal/domain/conversation.go @@ -21,11 +21,10 @@ func (id ConversationID) String() string { // Conversation represents a chat conversation. type Conversation struct { - ID ConversationID `json:"id"` - WorkspaceID WorkspaceID `json:"workspace_id"` - Title string `json:"title"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID ConversationID `json:"id"` + Title string `json:"title"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // ContextSource indicates who added an entity to context. diff --git a/internal/domain/edge_instance.go b/internal/domain/edge_instance.go new file mode 100644 index 00000000..56996f46 --- /dev/null +++ b/internal/domain/edge_instance.go @@ -0,0 +1,32 @@ +package domain + +import "time" + +// EdgeInstance is one edge runtime registered for the account. +type EdgeInstance struct { + ID string + InstanceID string + ServiceName string + ServiceNamespace string + LastSyncAt time.Time +} + +// EdgeFleet is the set of edge instances for the active account. Total is the +// server-reported fleet size. +type EdgeFleet struct { + Total int64 + Instances []EdgeInstance +} + +// ConnectedCount returns the number of instances that synced within the given +// recency window relative to now. +func (f EdgeFleet) ConnectedCount(now time.Time, within time.Duration) int64 { + var connected int64 + cutoff := now.Add(-within) + for _, inst := range f.Instances { + if inst.LastSyncAt.After(cutoff) { + connected++ + } + } + return connected +} diff --git a/internal/domain/issue.go b/internal/domain/issue.go new file mode 100644 index 00000000..16085071 --- /dev/null +++ b/internal/domain/issue.go @@ -0,0 +1,36 @@ +package domain + +// IssuePriority is how much attention a kept finding deserves. +type IssuePriority string + +const ( + IssuePriorityLow IssuePriority = "low" + IssuePriorityMedium IssuePriority = "medium" + IssuePriorityHigh IssuePriority = "high" +) + +func (p IssuePriority) String() string { return string(p) } + +// Issue is a single active issue with the detail the chat agent surfaces. +type Issue struct { + ID string + DisplayID string + Title string + Priority IssuePriority + ServiceName string + CostPerHour *float64 +} + +// IssueSummary is the server-computed aggregate state for active issues +// (issues whose closedAt and ignoredAt are both nil). The control plane +// computes the count and per-priority breakdown; the CLI never aggregates +// issue rows locally. +type IssueSummary struct { + // Open is the total number of active issues. + Open int64 + + // Per-priority counts of active issues. + HighCount int64 + MediumCount int64 + LowCount int64 +} diff --git a/internal/domain/service_status.go b/internal/domain/service_status.go index e99466d6..29fc8301 100644 --- a/internal/domain/service_status.go +++ b/internal/domain/service_status.go @@ -6,6 +6,7 @@ type ServiceHealth string const ( ServiceHealthDisabled ServiceHealth = "DISABLED" ServiceHealthInactive ServiceHealth = "INACTIVE" + ServiceHealthError ServiceHealth = "ERROR" ServiceHealthOK ServiceHealth = "OK" ) @@ -14,6 +15,7 @@ func (s ServiceHealth) String() string { return string(s) } // ServiceStatus mirrors service_statuses_cache. All columns included; // callers pick what they need. type ServiceStatus struct { + ID string Name string Health ServiceHealth diff --git a/internal/domain/tools/approvepolicy.go b/internal/domain/tools/approvepolicy.go deleted file mode 100644 index e99c01a8..00000000 --- a/internal/domain/tools/approvepolicy.go +++ /dev/null @@ -1,20 +0,0 @@ -package tools - -// ApprovePolicyInput is the input schema for the approve_policy tool. -type ApprovePolicyInput struct { - PolicyID string `json:"policy_id"` -} - -// ApprovePolicyResult is the typed output of an approve_policy tool execution. -type ApprovePolicyResult struct { - PolicyID string `json:"policy_id"` - Status string `json:"status"` -} - -// ToMap serializes the result for the GraphQL API. -func (r ApprovePolicyResult) ToMap() map[string]any { - return map[string]any{ - "policy_id": r.PolicyID, - "status": r.Status, - } -} diff --git a/internal/domain/workspace.go b/internal/domain/workspace.go deleted file mode 100644 index 2c84cae8..00000000 --- a/internal/domain/workspace.go +++ /dev/null @@ -1,20 +0,0 @@ -package domain - -import "github.com/google/uuid" - -// WorkspaceID is a unique identifier for a workspace. -type WorkspaceID string - -// NewWorkspaceID generates a new unique WorkspaceID. -func NewWorkspaceID() WorkspaceID { return WorkspaceID(uuid.New().String()) } - -func (id WorkspaceID) String() string { return string(id) } - -// Workspace represents a workspace within an account. -type Workspace struct { - ID WorkspaceID `json:"id"` - Name string `json:"name"` -} - -// FilterValue returns the string used for filtering/searching. -func (w Workspace) FilterValue() string { return w.Name } diff --git a/internal/integration/hermetic/uploader_flow_integration_test.go b/internal/integration/hermetic/uploader_flow_integration_test.go deleted file mode 100644 index 0dcacc77..00000000 --- a/internal/integration/hermetic/uploader_flow_integration_test.go +++ /dev/null @@ -1,89 +0,0 @@ -//go:build integration - -package hermetic_test - -import ( - "context" - "testing" - "time" - - "github.com/google/uuid" - graphql "github.com/usetero/cli/internal/boundary/graphql" - graphapitest "github.com/usetero/cli/internal/boundary/graphql/apitest" - psapitest "github.com/usetero/cli/internal/boundary/powersync/apitest" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log/logtest" - psdb "github.com/usetero/cli/internal/powersync/db" - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/powersync/powersynctest" - "github.com/usetero/cli/internal/upload" -) - -func TestIntegration_UploaderHermeticFlow(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - ctx := context.Background() - - _, err := database.Exec(ctx, "INSERT INTO ps_buckets (name, last_op, target_op) VALUES ('$local', 0, 0)") - if err != nil { - t.Fatalf("setup local bucket: %v", err) - } - - convID := uuid.New().String() - dbtest.InsertCrudEntry(t, database, 1, nil, `{"op":"PUT","type":"conversations","id":"`+convID+`","data":{"workspace_id":"ws-1","title":"Integration"}}`) - - var createdConversationID domain.ConversationID - conversations := &graphapitest.MockConversations{ - CreateFunc: func(ctx context.Context, input graphql.CreateConversationInput) (*domain.Conversation, error) { - createdConversationID = domain.ConversationID(input.ID.String()) - return &domain.Conversation{ID: domain.ConversationID(input.ID.String())}, nil - }, - } - - uploader := upload.New( - database, - psapitest.NewMockClient(), - powersynctest.NewMockTokenRefresher("token"), - upload.MutationDeps{ - Conversations: conversations, - Messages: graphapitest.NewMockMessages(), - Services: graphapitest.NewMockAPIServiceServices(), - Policies: graphapitest.NewMockPolicies(), - }, - logtest.NewScope(t), - ) - - runCtx, cancel := context.WithCancel(ctx) - done := make(chan error, 1) - go func() { - done <- uploader.Run(runCtx) - }() - - select { - case event := <-uploader.Events(): - if _, ok := event.(upload.SyncingEvent); !ok { - t.Fatalf("expected SyncingEvent, got %T", event) - } - case <-time.After(2 * time.Second): - t.Fatal("timeout waiting for sync event") - } - - cancel() - if err := <-done; err != nil && err != context.Canceled { - t.Fatalf("uploader returned error: %v", err) - } - - if createdConversationID == "" { - t.Fatal("expected conversation mutation to be called") - } - - queue := psdb.NewCrudQueue(database) - entry, err := queue.GetNextEntry(ctx) - if err != nil { - t.Fatalf("GetNextEntry() error = %v", err) - } - if entry != nil { - t.Fatal("expected CRUD queue to be empty after upload") - } -} diff --git a/internal/integration/live/powersync/syncer_live_test.go b/internal/integration/live/powersync/syncer_live_test.go deleted file mode 100644 index 0d4def8d..00000000 --- a/internal/integration/live/powersync/syncer_live_test.go +++ /dev/null @@ -1,152 +0,0 @@ -//go:build integration_live - -package powersynclive_test - -import ( - "context" - "testing" - "time" - - "github.com/usetero/cli/internal/auth" - graphql "github.com/usetero/cli/internal/boundary/graphql" - "github.com/usetero/cli/internal/config" - "github.com/usetero/cli/internal/keyring" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync" - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/preferences" - "github.com/usetero/cli/internal/sqlite" - "github.com/usetero/cli/internal/workos" -) - -// Live integration tests run against non-production services. -// -// Prerequisites: -// 1. task auth:login -// 2. task run (complete onboarding to set default account) -// 3. task test:integration:live -func TestIntegrationLive_Syncer(t *testing.T) { - cliConfig := config.LoadCLIConfig() - logger := logtest.NewScope(t) - env := cliConfig.Environment() - - t.Logf("Environment: %s", env) - t.Logf("API Endpoint: %s", cliConfig.APIEndpoint) - t.Logf("PowerSync Endpoint: %s", cliConfig.PowerSyncEndpoint) - - storage := keyring.New(env) - oauthProvider := workos.NewClient(cliConfig.WorkOSClientID, cliConfig.ChatEndpoint, cliConfig.PowerSyncEndpoint) - authSvc := auth.NewService(oauthProvider, storage, logger) - - if _, err := authSvc.GetAccessToken(context.Background()); err != nil { - t.Fatalf("failed to get access token: %v (run: task auth:login)", err) - } - - orgCfg, err := config.LoadOrgPreferences(env, config.ActiveOrgID(env)) - if err != nil { - t.Fatalf("org preferences not found: %v (run: task run)", err) - } - orgPrefs := preferences.NewOrgService(orgCfg, logger) - accountID := orgPrefs.GetDefaultAccountID() - if accountID == "" { - t.Fatalf("no default account (run: task run)") - } - - services := graphql.NewServiceSet(cliConfig.APIEndpoint+"/graphql", authSvc, logger).WithAccountID(accountID) - - account, err := services.Accounts.Get(context.Background(), accountID) - if err != nil { - t.Fatalf("failed to fetch account: %v", err) - } - if account == nil { - t.Fatalf("account %s not found", accountID) - } - - t.Logf("Account: %s (%s)", account.Name, account.ID) - - t.Run("connects and syncs data", func(t *testing.T) { - database := dbtest.OpenTestDB(t) - syncer := powersync.NewSyncer(cliConfig.PowerSyncEndpoint, authSvc, logtest.NewScope(t)) - - ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) - defer cancel() - - firstSyncDone := make(chan struct{}) - err := syncer.Start(ctx, database, accountID.String(), func() { - close(firstSyncDone) - }) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - defer syncer.Stop() - - ticker := time.NewTicker(1 * time.Second) - defer ticker.Stop() - - lastState := "" - for { - select { - case <-firstSyncDone: - t.Log("first sync completed") - goto done - case <-ticker.C: - state := syncer.State() - summary := summarizeState(state) - if summary != lastState { - t.Logf("State: %s", summary) - if errState, ok := state.(*powersync.Error); ok { - t.Fatalf("sync error: %v", errState.Err) - } - lastState = summary - } - case <-ctx.Done(): - t.Fatalf("timeout waiting for first sync, last state: %s", summarizeState(syncer.State())) - } - } - done: - - buckets, err := countBuckets(database) - if err != nil { - t.Fatalf("countBuckets() error = %v", err) - } - t.Logf("Synced %d buckets", buckets) - - if buckets == 0 { - t.Fatal("no buckets synced") - } - if !syncer.IsReady() { - t.Error("IsReady() should be true after first sync") - } - }) -} - -func countBuckets(db sqlite.DB) (int64, error) { - var count int64 - err := db.QueryRow(context.Background(), "SELECT COUNT(*) FROM ps_buckets").Scan(&count) - return count, err -} - -func summarizeState(state powersync.State) string { - switch s := state.(type) { - case *powersync.Disconnected: - return "disconnected" - case *powersync.Connecting: - return "connecting" - case *powersync.Syncing: - if s.Progress != nil { - return "syncing " + s.Progress.String() - } - return "syncing" - case *powersync.Ready: - return "ready" - case *powersync.Reconnecting: - if s.Degraded { - return "reconnecting (degraded)" - } - return "reconnecting" - case *powersync.Error: - return "error: " + s.Err.Error() - default: - return "unknown" - } -} diff --git a/internal/integration/live/upload/uploader_live_test.go b/internal/integration/live/upload/uploader_live_test.go deleted file mode 100644 index c0dd0f6e..00000000 --- a/internal/integration/live/upload/uploader_live_test.go +++ /dev/null @@ -1,169 +0,0 @@ -//go:build integration_live - -package uploadlive_test - -import ( - "context" - "testing" - "time" - - "github.com/usetero/cli/internal/auth" - graphql "github.com/usetero/cli/internal/boundary/graphql" - psapi "github.com/usetero/cli/internal/boundary/powersync" - "github.com/usetero/cli/internal/config" - "github.com/usetero/cli/internal/keyring" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync" - psdb "github.com/usetero/cli/internal/powersync/db" - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/preferences" - "github.com/usetero/cli/internal/upload" - "github.com/usetero/cli/internal/workos" -) - -// Live integration tests run against non-production services. -// -// Prerequisites: -// 1. task auth:login -// 2. task run (complete onboarding to set default account/workspace) -// 3. task test:integration:live -func TestIntegrationLive_Upload(t *testing.T) { - ctx := context.Background() - logger := logtest.NewScope(t) - - cliConfig := config.LoadCLIConfig() - env := cliConfig.Environment() - - t.Logf("Environment: %s", env) - t.Logf("API Endpoint: %s", cliConfig.APIEndpoint) - t.Logf("PowerSync Endpoint: %s", cliConfig.PowerSyncEndpoint) - - storage := keyring.New(env) - oauthProvider := workos.NewClient(cliConfig.WorkOSClientID, cliConfig.ChatEndpoint, cliConfig.PowerSyncEndpoint) - authSvc := auth.NewService(oauthProvider, storage, logger) - - if _, err := authSvc.GetAccessToken(ctx); err != nil { - t.Fatalf("failed to get access token: %v (run: task auth:login)", err) - } - - orgCfg, err := config.LoadOrgPreferences(env, config.ActiveOrgID(env)) - if err != nil { - t.Fatalf("org preferences not found: %v (run: task run)", err) - } - orgPrefs := preferences.NewOrgService(orgCfg, logger) - - accountID := orgPrefs.GetDefaultAccountID() - if accountID == "" { - t.Fatalf("no default account (run: task run)") - } - - workspaceID := orgPrefs.GetDefaultWorkspaceID() - if workspaceID == "" { - t.Fatalf("no default workspace (run: task run)") - } - - t.Logf("Account ID: %s", accountID) - t.Logf("Workspace ID: %s", workspaceID) - - services := graphql.NewServiceSet(cliConfig.APIEndpoint+"/graphql", authSvc, logger).WithAccountID(accountID) - - t.Run("mutation round-trip maintains healthy database", func(t *testing.T) { - database := dbtest.OpenTestDB(t) - syncer := powersync.NewSyncer(cliConfig.PowerSyncEndpoint, authSvc, logger) - - syncCtx, syncCancel := context.WithTimeout(ctx, 90*time.Second) - defer syncCancel() - - firstSyncDone := make(chan struct{}) - err := syncer.Start(syncCtx, database, accountID.String(), func() { - close(firstSyncDone) - }) - if err != nil { - t.Fatalf("failed to start sync: %v", err) - } - defer syncer.Stop() - - t.Log("waiting for initial sync") - select { - case <-firstSyncDone: - t.Log("initial sync complete") - case <-syncCtx.Done(): - t.Fatalf("timeout waiting for initial sync") - } - - queue := psdb.NewCrudQueue(database) - if err := queue.CheckHealth(ctx); err != nil { - t.Fatalf("database unhealthy before mutation: %v", err) - } - - uploader := upload.New( - database, - psapi.NewClient(cliConfig.PowerSyncEndpoint), - authSvc, - upload.MutationDeps{ - Conversations: services.Conversations, - Messages: services.Messages, - Services: services.Services, - Policies: services.Policies, - }, - logger, - upload.WithBatchCompletedHook(func(hookCtx context.Context) error { - return syncer.NotifyUploadCompleted(hookCtx) - }), - ) - - uploadCtx, uploadCancel := context.WithCancel(ctx) - defer uploadCancel() - - uploadDone := make(chan error, 1) - go func() { - uploadDone <- uploader.Run(uploadCtx) - }() - - t.Log("creating conversation") - convID, err := database.Conversations().Create(ctx, accountID, workspaceID) - if err != nil { - t.Fatalf("failed to create conversation: %v", err) - } - t.Logf("created conversation: %s", convID) - - t.Log("creating message") - msgID, err := database.Messages().CreateUserMessage(ctx, accountID, convID, "Hello from integration live test") - if err != nil { - t.Fatalf("failed to create message: %v", err) - } - t.Logf("created message: %s", msgID) - - t.Log("waiting for CRUD queue to drain") - drainTimeout := time.After(45 * time.Second) - ticker := time.NewTicker(200 * time.Millisecond) - defer ticker.Stop() - - for { - select { - case <-drainTimeout: - hasPending, _ := queue.HasPendingUploads(ctx) - t.Fatalf("timeout waiting for CRUD queue to drain (hasPending=%v)", hasPending) - case <-ticker.C: - hasPending, err := queue.HasPendingUploads(ctx) - if err != nil { - t.Fatalf("failed to check pending uploads: %v", err) - } - if !hasPending { - t.Log("CRUD queue drained") - goto drained - } - } - } - drained: - - uploadCancel() - if err := <-uploadDone; err != nil && err != context.Canceled { - t.Fatalf("uploader stopped with error: %v", err) - } - - if err := queue.CheckHealth(ctx); err != nil { - t.Fatalf("database unhealthy after mutation: %v", err) - } - }) -} diff --git a/internal/powersync/db/batch.go b/internal/powersync/db/batch.go deleted file mode 100644 index 38955afd..00000000 --- a/internal/powersync/db/batch.go +++ /dev/null @@ -1,45 +0,0 @@ -package db - -import ( - "context" - "fmt" - - "github.com/usetero/cli/internal/sqlite" -) - -// CompleteBatch finalizes a CRUD upload by atomically: -// 1. Deleting uploaded entries from ps_crud -// 2. Setting target_op to the write checkpoint -// -// This must be called after uploading entries and fetching a write checkpoint -// from the server. The checkpoint signals to sync that uploads are complete. -func CompleteBatch(ctx context.Context, db sqlite.DB, lastEntryID int64, checkpoint string) error { - return db.WithTx(ctx, func(tx *sqlite.Tx) error { - // Delete all uploaded entries - if _, err := tx.Exec(ctx, "DELETE FROM ps_crud WHERE id <= ?", lastEntryID); err != nil { - return fmt.Errorf("delete crud entries: %w", err) - } - - // Set target_op to the checkpoint - // This tells sync_local that we're waiting for this checkpoint from the server - if _, err := tx.Exec(ctx, - "UPDATE ps_buckets SET target_op = CAST(? AS INTEGER) WHERE name = '$local'", - checkpoint, - ); err != nil { - return fmt.Errorf("update target_op: %w", err) - } - - return nil - }) -} - -// GetClientID returns the PowerSync client ID for this database. -// This ID is used when fetching write checkpoints from the server. -func GetClientID(ctx context.Context, db sqlite.DB) (string, error) { - var clientID string - err := db.QueryRow(ctx, "SELECT powersync_client_id()").Scan(&clientID) - if err != nil { - return "", fmt.Errorf("get client id: %w", err) - } - return clientID, nil -} diff --git a/internal/powersync/db/batch_test.go b/internal/powersync/db/batch_test.go deleted file mode 100644 index 20cc0c75..00000000 --- a/internal/powersync/db/batch_test.go +++ /dev/null @@ -1,157 +0,0 @@ -package db_test - -import ( - "context" - "testing" - - "github.com/usetero/cli/internal/powersync/db" - "github.com/usetero/cli/internal/powersync/db/dbtest" -) - -func TestCompleteBatch(t *testing.T) { - t.Parallel() - - t.Run("deletes crud entries up to lastEntryID", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - ctx := context.Background() - - // Insert some crud entries - dbtest.InsertCrudEntry(t, database, 1, nil, `{"op":"PUT","type":"messages","id":"msg-1","data":{}}`) - dbtest.InsertCrudEntry(t, database, 2, nil, `{"op":"PUT","type":"messages","id":"msg-2","data":{}}`) - dbtest.InsertCrudEntry(t, database, 3, nil, `{"op":"PUT","type":"messages","id":"msg-3","data":{}}`) - - // Set up $local bucket - _, err := database.Exec(ctx, "INSERT INTO ps_buckets (name, last_op, target_op) VALUES ('$local', 0, 0)") - if err != nil { - t.Fatalf("setup bucket: %v", err) - } - - // Complete batch for entries 1 and 2 - err = db.CompleteBatch(ctx, database, 2, "100") - if err != nil { - t.Fatalf("CompleteBatch() error = %v", err) - } - - // Verify entries 1 and 2 are deleted, 3 remains - var count int - err = database.QueryRow(ctx, "SELECT COUNT(*) FROM ps_crud").Scan(&count) - if err != nil { - t.Fatalf("count crud: %v", err) - } - if count != 1 { - t.Errorf("expected 1 remaining entry, got %d", count) - } - - var remainingID int64 - err = database.QueryRow(ctx, "SELECT id FROM ps_crud").Scan(&remainingID) - if err != nil { - t.Fatalf("get remaining: %v", err) - } - if remainingID != 3 { - t.Errorf("expected entry 3 to remain, got %d", remainingID) - } - }) - - t.Run("updates target_op to checkpoint", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - ctx := context.Background() - - // Set up $local bucket - _, err := database.Exec(ctx, "INSERT INTO ps_buckets (name, last_op, target_op) VALUES ('$local', 0, 0)") - if err != nil { - t.Fatalf("setup bucket: %v", err) - } - - err = db.CompleteBatch(ctx, database, 0, "42") - if err != nil { - t.Fatalf("CompleteBatch() error = %v", err) - } - - var targetOp int64 - err = database.QueryRow(ctx, "SELECT target_op FROM ps_buckets WHERE name = '$local'").Scan(&targetOp) - if err != nil { - t.Fatalf("get target_op: %v", err) - } - if targetOp != 42 { - t.Errorf("target_op = %d, want 42", targetOp) - } - }) - - t.Run("is atomic - rolls back on error", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - ctx := context.Background() - - // Insert a crud entry - dbtest.InsertCrudEntry(t, database, 1, nil, `{"op":"PUT","type":"messages","id":"msg-1","data":{}}`) - - // Don't create $local bucket - update should fail - err := db.CompleteBatch(ctx, database, 1, "100") - - // Should succeed (UPDATE affects 0 rows but doesn't error) - // This is actually fine - if there's no $local bucket, sync hasn't started - if err != nil { - t.Fatalf("CompleteBatch() error = %v", err) - } - - // Crud entry should still be deleted - var count int - err = database.QueryRow(ctx, "SELECT COUNT(*) FROM ps_crud").Scan(&count) - if err != nil { - t.Fatalf("count crud: %v", err) - } - if count != 0 { - t.Errorf("expected 0 entries, got %d", count) - } - }) -} - -func TestGetClientID(t *testing.T) { - t.Parallel() - - t.Run("returns client ID from powersync extension", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - ctx := context.Background() - - clientID, err := db.GetClientID(ctx, database) - if err != nil { - t.Fatalf("GetClientID() error = %v", err) - } - - // Client ID should be a non-empty UUID-like string - if clientID == "" { - t.Error("GetClientID() returned empty string") - } - if len(clientID) < 32 { - t.Errorf("GetClientID() = %q, expected UUID-like string", clientID) - } - }) - - t.Run("returns same ID for same database", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - ctx := context.Background() - - id1, err := db.GetClientID(ctx, database) - if err != nil { - t.Fatalf("first GetClientID() error = %v", err) - } - - id2, err := db.GetClientID(ctx, database) - if err != nil { - t.Fatalf("second GetClientID() error = %v", err) - } - - if id1 != id2 { - t.Errorf("client IDs differ: %q vs %q", id1, id2) - } - }) -} diff --git a/internal/powersync/db/crud.go b/internal/powersync/db/crud.go deleted file mode 100644 index 1e971a9d..00000000 --- a/internal/powersync/db/crud.go +++ /dev/null @@ -1,221 +0,0 @@ -// Package db provides PowerSync local database operations. -package db - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - - "github.com/usetero/cli/internal/sqlite" -) - -// Op represents a CRUD operation type. -type Op string - -// CRUD operation types. -const ( - OpPut Op = "PUT" // Insert or replace - OpPatch Op = "PATCH" // Update - OpDelete Op = "DELETE" // Delete -) - -// CrudEntry represents a single entry in the ps_crud upload queue. -type CrudEntry struct { - // ID is the auto-incrementing client-side id. - ID int64 - // TxID is the transaction id. All operations in the same transaction share this. - TxID *int64 - // Op is the operation type: PUT, PATCH, or DELETE. - Op Op - // Table is the table name. - Table sqlite.Table - // RowID is the ID of the affected row. - RowID string - // Data contains the row data (for PUT/PATCH operations). - Data map[string]any - // Old contains previous values (for tables with trackPreviousValues enabled). - Old map[string]any - // Metadata contains client-side metadata (if trackMetadata was enabled). - Metadata *string -} - -// crudRow is the raw row from ps_crud. -type crudRow struct { - ID int64 `json:"id"` - TxID *int64 `json:"tx_id"` - Data string `json:"data"` -} - -// crudData is the JSON structure inside the data column. -type crudData struct { - Op string `json:"op"` - Type string `json:"type"` - ID string `json:"id"` - Data map[string]any `json:"data,omitempty"` - Old map[string]any `json:"old,omitempty"` - Metadata *string `json:"metadata,omitempty"` -} - -// CrudQueue provides access to the PowerSync CRUD upload queue. -type CrudQueue struct { - db sqlite.DB -} - -// NewCrudQueue creates a new CRUD queue accessor. -func NewCrudQueue(db sqlite.DB) *CrudQueue { - return &CrudQueue{ - db: db, - } -} - -// HasPendingUploads returns true if there are entries waiting to be uploaded. -func (q *CrudQueue) HasPendingUploads(ctx context.Context) (bool, error) { - var count int64 - err := q.db.QueryRow(ctx, "SELECT COUNT(*) FROM ps_crud").Scan(&count) - if err != nil { - return false, fmt.Errorf("check pending uploads: %w", err) - } - return count > 0, nil -} - -// GetNextEntry returns the next CRUD entry to process, or nil if the queue is empty. -func (q *CrudQueue) GetNextEntry(ctx context.Context) (*CrudEntry, error) { - var row crudRow - err := q.db.QueryRow(ctx, "SELECT id, tx_id, data FROM ps_crud ORDER BY id LIMIT 1").Scan( - &row.ID, &row.TxID, &row.Data, - ) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } - return nil, fmt.Errorf("get next crud entry: %w", err) - } - - return q.parseEntry(row) -} - -// GetNextTransaction returns all CRUD entries for the next transaction. -// If entries exist without a transaction ID, returns just the first entry. -func (q *CrudQueue) GetNextTransaction(ctx context.Context) ([]CrudEntry, error) { - // First, get the minimum ID to find where to start - first, err := q.GetNextEntry(ctx) - if err != nil || first == nil { - return nil, err - } - - // If no transaction ID, return just this entry - if first.TxID == nil { - return []CrudEntry{*first}, nil - } - - // Get all entries with the same transaction ID - rows, err := q.db.Query(ctx, - "SELECT id, tx_id, data FROM ps_crud WHERE tx_id = ? ORDER BY id", - *first.TxID, - ) - if err != nil { - return nil, fmt.Errorf("get transaction entries: %w", err) - } - defer rows.Close() - - var entries []CrudEntry - for rows.Next() { - var row crudRow - if err := rows.Scan(&row.ID, &row.TxID, &row.Data); err != nil { - return nil, fmt.Errorf("scan crud row: %w", err) - } - entry, err := q.parseEntry(row) - if err != nil { - return nil, err - } - entries = append(entries, *entry) - } - - return entries, rows.Err() -} - -// GetAllEntries returns all pending CRUD entries in order. -func (q *CrudQueue) GetAllEntries(ctx context.Context) ([]*CrudEntry, error) { - rows, err := q.db.Query(ctx, "SELECT id, tx_id, data FROM ps_crud ORDER BY id") - if err != nil { - return nil, fmt.Errorf("query crud entries: %w", err) - } - defer rows.Close() - - var entries []*CrudEntry - for rows.Next() { - var row crudRow - if err := rows.Scan(&row.ID, &row.TxID, &row.Data); err != nil { - return nil, fmt.Errorf("scan crud row: %w", err) - } - entry, err := q.parseEntry(row) - if err != nil { - return nil, err - } - entries = append(entries, entry) - } - - return entries, rows.Err() -} - -// ErrDatabaseCorrupt indicates the database is in an inconsistent state -// from a crash during migration or write. The database should be deleted -// and re-synced from the server. -var ErrDatabaseCorrupt = fmt.Errorf("database corrupt") - -// CheckHealth verifies the database is in a consistent state. -// Returns ErrDatabaseCorrupt if corruption is detected from a crash. -func (q *CrudQueue) CheckHealth(ctx context.Context) error { - // Check 1: ps_tx must have a row with id=1 - // This is required for CRUD operations. Missing if crash during migration. - var txCount int - err := q.db.QueryRow(ctx, "SELECT COUNT(*) FROM ps_tx WHERE id = 1").Scan(&txCount) - if err != nil { - return fmt.Errorf("check ps_tx: %w", err) - } - if txCount == 0 { - return fmt.Errorf("%w: ps_tx missing required row", ErrDatabaseCorrupt) - } - - // Check 2: $local bucket should not be stuck - // Stuck = target_op > last_op with empty ps_crud (crash during upload) - hasPending, err := q.HasPendingUploads(ctx) - if err != nil { - return fmt.Errorf("check pending uploads: %w", err) - } - if !hasPending { - var targetOp, lastOp int64 - err = q.db.QueryRow(ctx, - "SELECT target_op, last_op FROM ps_buckets WHERE name = '$local'", - ).Scan(&targetOp, &lastOp) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("check local bucket: %w", err) - } - if err == nil && targetOp > lastOp { - return fmt.Errorf("%w: $local bucket stuck", ErrDatabaseCorrupt) - } - } - - return nil -} - -// parseEntry converts a raw database row into a CrudEntry. -func (q *CrudQueue) parseEntry(row crudRow) (*CrudEntry, error) { - var data crudData - if err := json.Unmarshal([]byte(row.Data), &data); err != nil { - return nil, fmt.Errorf("parse crud data: %w", err) - } - - return &CrudEntry{ - ID: row.ID, - TxID: row.TxID, - Op: Op(data.Op), - Table: sqlite.Table(data.Type), - RowID: data.ID, - Data: data.Data, - Old: data.Old, - Metadata: data.Metadata, - }, nil -} diff --git a/internal/powersync/db/crud_test.go b/internal/powersync/db/crud_test.go deleted file mode 100644 index 9f02b8ca..00000000 --- a/internal/powersync/db/crud_test.go +++ /dev/null @@ -1,311 +0,0 @@ -package db_test - -import ( - "context" - "errors" - "testing" - - "github.com/usetero/cli/internal/powersync/db" - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/sqlite" -) - -func TestCrudQueue_GetNextEntry(t *testing.T) { - t.Parallel() - - t.Run("returns nil when queue is empty", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - queue := db.NewCrudQueue(database) - - entry, err := queue.GetNextEntry(context.Background()) - if err != nil { - t.Fatalf("GetNextEntry() error = %v", err) - } - if entry != nil { - t.Errorf("GetNextEntry() = %v, want nil", entry) - } - }) - - t.Run("returns entry with parsed data", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - dbtest.InsertCrudEntry(t, database, 1, nil, `{"op":"PUT","type":"messages","id":"msg-1","data":{"content":"hello"}}`) - - queue := db.NewCrudQueue(database) - entry, err := queue.GetNextEntry(context.Background()) - if err != nil { - t.Fatalf("GetNextEntry() error = %v", err) - } - - if entry == nil { - t.Fatal("GetNextEntry() = nil, want entry") - } - if entry.ID != 1 { - t.Errorf("entry.ID = %d, want 1", entry.ID) - } - if entry.Op != db.OpPut { - t.Errorf("entry.Op = %q, want PUT", entry.Op) - } - if entry.Table != sqlite.TableMessages { - t.Errorf("entry.Table = %q, want messages", entry.Table) - } - if entry.RowID != "msg-1" { - t.Errorf("entry.RowID = %q, want msg-1", entry.RowID) - } - if entry.Data["content"] != "hello" { - t.Errorf("entry.Data[content] = %v, want hello", entry.Data["content"]) - } - }) - - t.Run("returns entries in order", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - dbtest.InsertCrudEntry(t, database, 1, nil, `{"op":"PUT","type":"messages","id":"first","data":{}}`) - dbtest.InsertCrudEntry(t, database, 2, nil, `{"op":"PUT","type":"messages","id":"second","data":{}}`) - - queue := db.NewCrudQueue(database) - - entry, _ := queue.GetNextEntry(context.Background()) - if entry.RowID != "first" { - t.Errorf("first entry.RowID = %q, want first", entry.RowID) - } - }) - - t.Run("returns error on malformed JSON", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - dbtest.InsertCrudEntry(t, database, 1, nil, `not valid json`) - - queue := db.NewCrudQueue(database) - - _, err := queue.GetNextEntry(context.Background()) - if err == nil { - t.Error("GetNextEntry() should return error for malformed JSON") - } - }) -} - -func TestCrudQueue_GetAllEntries(t *testing.T) { - t.Parallel() - - t.Run("returns empty slice when queue is empty", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - queue := db.NewCrudQueue(database) - - entries, err := queue.GetAllEntries(context.Background()) - if err != nil { - t.Fatalf("GetAllEntries() error = %v", err) - } - if len(entries) != 0 { - t.Errorf("GetAllEntries() = %d entries, want 0", len(entries)) - } - }) - - t.Run("returns all entries in order", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - dbtest.InsertCrudEntry(t, database, 1, nil, `{"op":"PUT","type":"messages","id":"first","data":{}}`) - dbtest.InsertCrudEntry(t, database, 2, nil, `{"op":"PATCH","type":"messages","id":"second","data":{}}`) - dbtest.InsertCrudEntry(t, database, 3, nil, `{"op":"DELETE","type":"messages","id":"third","data":{}}`) - - queue := db.NewCrudQueue(database) - - entries, err := queue.GetAllEntries(context.Background()) - if err != nil { - t.Fatalf("GetAllEntries() error = %v", err) - } - if len(entries) != 3 { - t.Fatalf("GetAllEntries() = %d entries, want 3", len(entries)) - } - - if entries[0].RowID != "first" { - t.Errorf("entries[0].RowID = %q, want first", entries[0].RowID) - } - if entries[1].RowID != "second" { - t.Errorf("entries[1].RowID = %q, want second", entries[1].RowID) - } - if entries[2].RowID != "third" { - t.Errorf("entries[2].RowID = %q, want third", entries[2].RowID) - } - }) -} - -func TestCrudQueue_HasPendingUploads(t *testing.T) { - t.Parallel() - - t.Run("returns false when empty", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - queue := db.NewCrudQueue(database) - - has, err := queue.HasPendingUploads(context.Background()) - if err != nil { - t.Fatalf("HasPendingUploads() error = %v", err) - } - if has { - t.Error("HasPendingUploads() = true, want false") - } - }) - - t.Run("returns true when entries exist", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - dbtest.InsertCrudEntry(t, database, 1, nil, `{"op":"PUT","type":"messages","id":"msg-1","data":{}}`) - - queue := db.NewCrudQueue(database) - - has, err := queue.HasPendingUploads(context.Background()) - if err != nil { - t.Fatalf("HasPendingUploads() error = %v", err) - } - if !has { - t.Error("HasPendingUploads() = false, want true") - } - }) -} - -func TestCrudQueue_CheckHealth(t *testing.T) { - t.Parallel() - - t.Run("healthy database passes", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - ctx := context.Background() - - queue := db.NewCrudQueue(database) - err := queue.CheckHealth(ctx) - if err != nil { - t.Fatalf("CheckHealth() error = %v", err) - } - }) - - t.Run("missing ps_tx row returns corrupt error", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - ctx := context.Background() - - // Delete the required row - _, err := database.Exec(ctx, "DELETE FROM ps_tx") - if err != nil { - t.Fatalf("delete ps_tx: %v", err) - } - - queue := db.NewCrudQueue(database) - err = queue.CheckHealth(ctx) - if err == nil { - t.Fatal("CheckHealth() should return error") - } - if !errors.Is(err, db.ErrDatabaseCorrupt) { - t.Errorf("CheckHealth() error = %v, want ErrDatabaseCorrupt", err) - } - }) - - t.Run("stuck local bucket returns corrupt error", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - ctx := context.Background() - - // Set up $local bucket in stuck state (target_op > last_op with empty ps_crud) - _, err := database.Exec(ctx, "INSERT INTO ps_buckets (name, last_op, target_op) VALUES ('$local', 0, 5)") - if err != nil { - t.Fatalf("setup bucket: %v", err) - } - - queue := db.NewCrudQueue(database) - err = queue.CheckHealth(ctx) - if err == nil { - t.Fatal("CheckHealth() should return error") - } - if !errors.Is(err, db.ErrDatabaseCorrupt) { - t.Errorf("CheckHealth() error = %v, want ErrDatabaseCorrupt", err) - } - }) - - t.Run("local bucket with pending data is healthy", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - ctx := context.Background() - - // Set up $local bucket with target_op > last_op, but with actual pending data - _, err := database.Exec(ctx, "INSERT INTO ps_buckets (name, last_op, target_op) VALUES ('$local', 0, 5)") - if err != nil { - t.Fatalf("setup bucket: %v", err) - } - dbtest.InsertCrudEntry(t, database, 1, nil, `{"op":"PUT","type":"messages","id":"msg-1","data":{}}`) - - queue := db.NewCrudQueue(database) - err = queue.CheckHealth(ctx) - if err != nil { - t.Fatalf("CheckHealth() error = %v", err) - } - }) - - t.Run("no local bucket is healthy", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - ctx := context.Background() - - queue := db.NewCrudQueue(database) - err := queue.CheckHealth(ctx) - if err != nil { - t.Fatalf("CheckHealth() error = %v", err) - } - }) -} - -func TestCrudQueue_GetNextTransaction(t *testing.T) { - t.Parallel() - - t.Run("returns single entry when no tx_id", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - dbtest.InsertCrudEntry(t, database, 1, nil, `{"op":"PUT","type":"messages","id":"msg-1","data":{}}`) - dbtest.InsertCrudEntry(t, database, 2, nil, `{"op":"PUT","type":"messages","id":"msg-2","data":{}}`) - - queue := db.NewCrudQueue(database) - - entries, err := queue.GetNextTransaction(context.Background()) - if err != nil { - t.Fatalf("GetNextTransaction() error = %v", err) - } - if len(entries) != 1 { - t.Errorf("GetNextTransaction() returned %d entries, want 1", len(entries)) - } - }) - - t.Run("returns all entries with same tx_id", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - txID := int64(100) - dbtest.InsertCrudEntry(t, database, 1, &txID, `{"op":"PUT","type":"conversations","id":"conv-1","data":{}}`) - dbtest.InsertCrudEntry(t, database, 2, &txID, `{"op":"PUT","type":"messages","id":"msg-1","data":{}}`) - dbtest.InsertCrudEntry(t, database, 3, nil, `{"op":"PUT","type":"messages","id":"msg-2","data":{}}`) - - queue := db.NewCrudQueue(database) - - entries, err := queue.GetNextTransaction(context.Background()) - if err != nil { - t.Fatalf("GetNextTransaction() error = %v", err) - } - if len(entries) != 2 { - t.Errorf("GetNextTransaction() returned %d entries, want 2", len(entries)) - } - }) -} diff --git a/internal/powersync/db/dbtest/db.go b/internal/powersync/db/dbtest/db.go deleted file mode 100644 index 7aad353b..00000000 --- a/internal/powersync/db/dbtest/db.go +++ /dev/null @@ -1,39 +0,0 @@ -// Package dbtest provides test utilities for the powersync/db package. -package dbtest - -import ( - "context" - "testing" - - "github.com/usetero/cli/internal/powersync/extension" - "github.com/usetero/cli/internal/sqlite" - "github.com/usetero/cli/internal/sqlite/sqlitetest" -) - -// OpenTestDB creates a temporary SQLite database with the PowerSync extension -// loaded and schema initialized. Ready for testing. -// The database is automatically closed when the test completes. -func OpenTestDB(t *testing.T) sqlite.DB { - t.Helper() - - // Extension is registered via extension.init() - ctx := context.Background() - db := sqlitetest.OpenBareDB(t) - - if err := extension.ApplySchema(ctx, db); err != nil { - t.Fatalf("ApplySchema() error = %v", err) - } - - return db -} - -// InsertCrudEntry inserts a test entry into the ps_crud table. -func InsertCrudEntry(t *testing.T, db sqlite.DB, id int64, txID *int64, data string) { - t.Helper() - - ctx := context.Background() - _, err := db.Exec(ctx, "INSERT INTO ps_crud (id, tx_id, data) VALUES (?, ?, ?)", id, txID, data) - if err != nil { - t.Fatalf("InsertCrudEntry() error = %v", err) - } -} diff --git a/internal/powersync/doc.go b/internal/powersync/doc.go deleted file mode 100644 index 9fdb5a26..00000000 --- a/internal/powersync/doc.go +++ /dev/null @@ -1,26 +0,0 @@ -// Package powersync provides background sync using PowerSync. -// -// It takes a sqlite.DB, loads the PowerSync extension, and keeps the -// database in sync with the server via HTTP streaming. -// -// You don't query through powersync - just use sqlite directly. -// -// Internal architecture: -// - syncer.go: public API, lifecycle wiring, dependencies -// - syncer_run.go: retry/backoff loop and token refresh paths -// - syncer_stream.go: session and stream processing -// - syncer_instructions.go: extension instruction application -// - syncer_controlplane.go: serialized extension control-plane calls -// - stream_capture.go: optional raw NDJSON stream capture utilities -// -// Optional fixture capture: -// - Use `tero internal powersync capture` to record raw stream fixtures. -// - Use `task internal:powersync:capture` as a wrapper in dev/prd. -// -// Subpackages: -// - api: HTTP client for PowerSync service -// - extension: SQLite extension interface and wire types -// - db: Local database operations (CRUD queue, batch completion) -// -//go:generate go run ./extension/generate -package powersync diff --git a/internal/powersync/extension/controller.go b/internal/powersync/extension/controller.go deleted file mode 100644 index 34b52a65..00000000 --- a/internal/powersync/extension/controller.go +++ /dev/null @@ -1,175 +0,0 @@ -package extension - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - - "github.com/usetero/cli/internal/sqlite" -) - -// ControlOp represents an operation for powersync_control. -type ControlOp string - -const ( - // OpStart starts a sync stream. Payload: StartRequest (JSON). - OpStart ControlOp = "start" - // OpStop stops the current sync stream. Payload: none. - OpStop ControlOp = "stop" - // OpLineText forwards a JSON line from the sync service. Payload: string. - OpLineText ControlOp = "line_text" - // OpLineBinary forwards a BSON line from the sync service. Payload: []byte. - OpLineBinary ControlOp = "line_binary" - // OpRefreshedToken notifies that the auth token was refreshed. Payload: none. - OpRefreshedToken ControlOp = "refreshed_token" - // OpCompletedUpload notifies that CRUD upload completed. Payload: none. - OpCompletedUpload ControlOp = "completed_upload" - // OpUpdateSubscriptions updates stream subscriptions. Payload: JSON array. - OpUpdateSubscriptions ControlOp = "update_subscriptions" -) - -// ConnectionEvent represents a connection state change. -type ConnectionEvent string - -const ( - // ConnectionEstablished indicates the sync stream connection was established. - ConnectionEstablished ConnectionEvent = "established" - // ConnectionEnded indicates the sync stream connection ended. - ConnectionEnded ConnectionEvent = "end" -) - -// StartRequest is the payload for OpStart. -type StartRequest struct { - // Parameters are bucket parameters for the sync request. - Parameters map[string]any `json:"parameters,omitempty"` - // Schema defines the tables to sync. - Schema json.RawMessage `json:"schema,omitempty"` - // IncludeDefaults whether to request default streams. - IncludeDefaults bool `json:"include_defaults"` - // ActiveStreams are currently active stream subscriptions. - ActiveStreams []StreamKey `json:"active_streams,omitempty"` -} - -// StreamKey identifies a stream subscription. -type StreamKey struct { - Name string `json:"name"` - Parameters string `json:"parameters,omitempty"` -} - -// Controller wraps the powersync_control SQLite function with type safety. -// It holds a dedicated database connection to ensure all operations use the -// same connection, since the PowerSync extension maintains per-connection state. -type Controller struct { - db sqlite.DB - conn *sql.Conn // dedicated connection for state consistency -} - -// NewController creates a new PowerSync controller. -// Call Close() when done to release the dedicated connection. -func NewController(db sqlite.DB) *Controller { - return &Controller{db: db} -} - -// Close releases the dedicated connection. -func (c *Controller) Close() error { - if c.conn != nil { - err := c.conn.Close() - c.conn = nil - return err - } - return nil -} - -// Control sends a control command and returns the resulting instructions. -// The powersync_control function always expects 2 arguments (op, payload). -// All operations use a dedicated connection to ensure state consistency. -func (c *Controller) Control(ctx context.Context, op ControlOp, payload any) ([]Instruction, error) { - // Lazily acquire a dedicated connection on first use. - // This connection is held for the lifetime of the Controller to ensure - // all powersync_control calls see the same extension state. - if c.conn == nil { - conn, err := c.db.Raw().Conn(ctx) - if err != nil { - return nil, fmt.Errorf("acquire connection: %w", err) - } - c.conn = conn - } - - // Determine how to pass the payload to SQLite - var sqlPayload any - if payload == nil { - sqlPayload = nil // SQL NULL - } else { - switch p := payload.(type) { - case string: - sqlPayload = p - case []byte: - sqlPayload = p - default: - // Marshal structs/maps to JSON string - jsonBytes, err := json.Marshal(payload) - if err != nil { - return nil, fmt.Errorf("marshal payload: %w", err) - } - sqlPayload = string(jsonBytes) - } - } - - var result []byte - err := c.conn.QueryRowContext(ctx, "SELECT powersync_control(?, ?)", string(op), sqlPayload).Scan(&result) - if err != nil { - return nil, fmt.Errorf("powersync_control(%s): %w", op, err) - } - - if len(result) == 0 || string(result) == "null" { - return nil, nil - } - - var instructions []Instruction - if err := json.Unmarshal(result, &instructions); err != nil { - // Try single instruction - var single Instruction - if err := json.Unmarshal(result, &single); err != nil { - return nil, fmt.Errorf("unmarshal instructions: %w", err) - } - instructions = []Instruction{single} - } - - return instructions, nil -} - -// Start begins a sync stream with the given parameters. -func (c *Controller) Start(ctx context.Context, req StartRequest) ([]Instruction, error) { - return c.Control(ctx, OpStart, req) -} - -// Stop stops the current sync stream. -func (c *Controller) Stop(ctx context.Context) ([]Instruction, error) { - return c.Control(ctx, OpStop, nil) -} - -// SendTextLine forwards a JSON line from the sync service. -func (c *Controller) SendTextLine(ctx context.Context, line string) ([]Instruction, error) { - return c.Control(ctx, OpLineText, line) -} - -// SendBinaryLine forwards a BSON line from the sync service. -func (c *Controller) SendBinaryLine(ctx context.Context, data []byte) ([]Instruction, error) { - return c.Control(ctx, OpLineBinary, data) -} - -// NotifyConnection notifies of a connection state change. -func (c *Controller) NotifyConnection(ctx context.Context, event ConnectionEvent) ([]Instruction, error) { - return c.Control(ctx, "connection", string(event)) -} - -// NotifyTokenRefreshed notifies that the auth token was refreshed. -func (c *Controller) NotifyTokenRefreshed(ctx context.Context) ([]Instruction, error) { - return c.Control(ctx, OpRefreshedToken, nil) -} - -// NotifyUploadCompleted notifies that CRUD upload completed. -func (c *Controller) NotifyUploadCompleted(ctx context.Context) ([]Instruction, error) { - return c.Control(ctx, OpCompletedUpload, nil) -} diff --git a/internal/powersync/extension/controller_correctness_test.go b/internal/powersync/extension/controller_correctness_test.go deleted file mode 100644 index c2a98a40..00000000 --- a/internal/powersync/extension/controller_correctness_test.go +++ /dev/null @@ -1,163 +0,0 @@ -//go:build correctness - -package extension_test - -import ( - "bufio" - "context" - "fmt" - "os" - "strconv" - "strings" - "testing" - - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/powersync/extension" - "github.com/usetero/cli/internal/sqlite" -) - -const ( - envPowerSyncFixturePath = "TERO_POWERSYNC_FIXTURE_PATH" - envPowerSyncFixtureMaxLines = "TERO_POWERSYNC_FIXTURE_MAX_LINES" - defaultFixturePath = "testdata/dev-sanitized.ndjson" -) - -type replayDigest struct { - BucketCount int64 - SumLastOp int64 - SumTargetOp int64 - MaxLastOp int64 - MaxTargetOp int64 - OplogCount int64 - OplogMaxRow int64 -} - -func TestCorrectness_PowerSync_ControllerFixtureReplayDeterministic(t *testing.T) { - fixturePath := os.Getenv(envPowerSyncFixturePath) - if fixturePath == "" { - fixturePath = defaultFixturePath - } - - maxLines, err := fixtureMaxLinesFromEnv() - if err != nil { - t.Fatalf("invalid %s: %v", envPowerSyncFixtureMaxLines, err) - } - - var baseline replayDigest - var baselineLines int - for i := range 2 { - database := dbtest.OpenTestDB(t) - lines, err := replayFixture(context.Background(), database, fixturePath, maxLines) - if err != nil { - t.Fatalf("replay run %d failed: %v", i+1, err) - } - if lines == 0 { - t.Fatalf("replay run %d applied zero lines", i+1) - } - - digest, err := snapshotDigest(context.Background(), database) - if err != nil { - t.Fatalf("snapshot digest run %d: %v", i+1, err) - } - - if i == 0 { - baseline = digest - baselineLines = lines - continue - } - - if lines != baselineLines { - t.Fatalf("line count mismatch: run1=%d run2=%d", baselineLines, lines) - } - if digest != baseline { - t.Fatalf("digest mismatch:\nrun1=%+v\nrun2=%+v", baseline, digest) - } - } -} - -func replayFixture(ctx context.Context, database sqlite.DB, fixturePath string, maxLines int) (int, error) { - controller := extension.NewController(database) - defer controller.Close() - - if _, err := controller.Start(ctx, extension.StartRequest{IncludeDefaults: true}); err != nil { - return 0, fmt.Errorf("start: %w", err) - } - if _, err := controller.NotifyConnection(ctx, extension.ConnectionEstablished); err != nil { - return 0, fmt.Errorf("notify connection established: %w", err) - } - - f, err := os.Open(fixturePath) - if err != nil { - return 0, fmt.Errorf("open fixture: %w", err) - } - defer f.Close() - - scanner := bufio.NewScanner(f) - scanner.Buffer(make([]byte, 64*1024), 64*1024*1024) - - lineNo := 0 - for scanner.Scan() { - line := scanner.Text() - if line == "" { - continue - } - lineNo++ - if _, err := controller.SendTextLine(ctx, line); err != nil { - return lineNo, fmt.Errorf("line %d: %w", lineNo, err) - } - if maxLines > 0 && lineNo >= maxLines { - break - } - } - if err := scanner.Err(); err != nil { - return lineNo, fmt.Errorf("read fixture: %w", err) - } - - if _, err := controller.NotifyConnection(ctx, extension.ConnectionEnded); err != nil && !isNoActiveIterationErr(err) { - return lineNo, fmt.Errorf("notify connection ended: %w", err) - } - return lineNo, nil -} - -func snapshotDigest(ctx context.Context, database sqlite.DB) (replayDigest, error) { - var d replayDigest - if err := database.QueryRow(ctx, ` - SELECT - COUNT(*), - COALESCE(SUM(last_op), 0), - COALESCE(SUM(target_op), 0), - COALESCE(MAX(last_op), 0), - COALESCE(MAX(target_op), 0) - FROM ps_buckets - `).Scan(&d.BucketCount, &d.SumLastOp, &d.SumTargetOp, &d.MaxLastOp, &d.MaxTargetOp); err != nil { - return replayDigest{}, err - } - if err := database.QueryRow(ctx, ` - SELECT - COUNT(*), - COALESCE(MAX(rowid), 0) - FROM ps_oplog - `).Scan(&d.OplogCount, &d.OplogMaxRow); err != nil { - return replayDigest{}, err - } - return d, nil -} - -func fixtureMaxLinesFromEnv() (int, error) { - raw := os.Getenv(envPowerSyncFixtureMaxLines) - if raw == "" { - return 0, nil - } - n, err := strconv.Atoi(raw) - if err != nil { - return 0, err - } - if n <= 0 { - return 0, fmt.Errorf("must be > 0") - } - return n, nil -} - -func isNoActiveIterationErr(err error) bool { - return strings.Contains(err.Error(), "No iteration is active") -} diff --git a/internal/powersync/extension/controller_test.go b/internal/powersync/extension/controller_test.go deleted file mode 100644 index c76c485e..00000000 --- a/internal/powersync/extension/controller_test.go +++ /dev/null @@ -1,299 +0,0 @@ -package extension_test - -import ( - "bufio" - "context" - "os" - "path/filepath" - "testing" - - "github.com/usetero/cli/internal/powersync/db" - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/powersync/extension" -) - -func TestController_Start(t *testing.T) { - t.Parallel() - - t.Run("returns EstablishSyncStream instruction", func(t *testing.T) { - t.Parallel() - - ctx := context.Background() - db := dbtest.OpenTestDB(t) - controller := extension.NewController(db) - - instructions, err := controller.Start(ctx, extension.StartRequest{ - IncludeDefaults: true, - }) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - if len(instructions) == 0 { - t.Fatal("expected at least one instruction") - } - - var found bool - for _, inst := range instructions { - if inst.Type == extension.InstructionEstablishSyncStream { - found = true - if inst.Request == nil { - t.Error("EstablishSyncStream should have a Request") - } - } - } - if !found { - t.Errorf("expected EstablishSyncStream instruction, got: %v", instructionTypes(instructions)) - } - }) -} - -func TestController_Stop(t *testing.T) { - t.Parallel() - - t.Run("stops without error", func(t *testing.T) { - t.Parallel() - - ctx := context.Background() - db := dbtest.OpenTestDB(t) - controller := extension.NewController(db) - - _, err := controller.Start(ctx, extension.StartRequest{IncludeDefaults: true}) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - _, err = controller.Stop(ctx) - if err != nil { - t.Errorf("Stop() error = %v", err) - } - }) -} - -func TestController_NotifyConnection(t *testing.T) { - t.Parallel() - - t.Run("accepts established event", func(t *testing.T) { - t.Parallel() - - ctx := context.Background() - db := dbtest.OpenTestDB(t) - controller := extension.NewController(db) - - _, _ = controller.Start(ctx, extension.StartRequest{IncludeDefaults: true}) - - _, err := controller.NotifyConnection(ctx, extension.ConnectionEstablished) - if err != nil { - t.Errorf("NotifyConnection(established) error = %v", err) - } - }) - - t.Run("accepts end event", func(t *testing.T) { - t.Parallel() - - ctx := context.Background() - db := dbtest.OpenTestDB(t) - controller := extension.NewController(db) - - _, _ = controller.Start(ctx, extension.StartRequest{IncludeDefaults: true}) - - _, err := controller.NotifyConnection(ctx, extension.ConnectionEnded) - if err != nil { - t.Errorf("NotifyConnection(end) error = %v", err) - } - }) -} - -func TestController_SendTextLine(t *testing.T) { - t.Parallel() - - t.Run("processes checkpoint line", func(t *testing.T) { - t.Parallel() - - ctx := context.Background() - db := dbtest.OpenTestDB(t) - controller := extension.NewController(db) - - _, _ = controller.Start(ctx, extension.StartRequest{IncludeDefaults: true}) - _, _ = controller.NotifyConnection(ctx, extension.ConnectionEstablished) - - line := `{"checkpoint":{"last_op_id":"0","buckets":[]}}` - _, err := controller.SendTextLine(ctx, line) - if err != nil { - t.Errorf("SendTextLine() error = %v", err) - } - }) -} - -func TestController_NotifyTokenRefreshed(t *testing.T) { - t.Parallel() - - t.Run("notifies without error", func(t *testing.T) { - t.Parallel() - - ctx := context.Background() - db := dbtest.OpenTestDB(t) - controller := extension.NewController(db) - - _, _ = controller.Start(ctx, extension.StartRequest{IncludeDefaults: true}) - - _, err := controller.NotifyTokenRefreshed(ctx) - if err != nil { - t.Errorf("NotifyTokenRefreshed() error = %v", err) - } - }) -} - -func TestController_NotifyUploadCompleted_AcceptsCompletedBatchProtocol(t *testing.T) { - t.Parallel() - - ctx := context.Background() - database := dbtest.OpenTestDB(t) - controller := extension.NewController(database) - defer controller.Close() - - _, err := controller.Start(ctx, extension.StartRequest{IncludeDefaults: true}) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - _, err = controller.NotifyConnection(ctx, extension.ConnectionEstablished) - if err != nil { - t.Fatalf("NotifyConnection(established) error = %v", err) - } - - // Simulate a completed upload batch that is waiting on server checkpoint 42. - _, err = database.Exec(ctx, "INSERT INTO ps_buckets (name, last_op, target_op) VALUES ('$local', 0, 0)") - if err != nil { - t.Fatalf("setup bucket: %v", err) - } - dbtest.InsertCrudEntry(t, database, 1, nil, `{"op":"PUT","type":"messages","id":"m1","data":{}}`) - if err := db.CompleteBatch(ctx, database, 1, "42"); err != nil { - t.Fatalf("CompleteBatch() error = %v", err) - } - - var beforeLast, beforeTarget int64 - if err := database.QueryRow(ctx, "SELECT last_op, target_op FROM ps_buckets WHERE name = '$local'").Scan(&beforeLast, &beforeTarget); err != nil { - t.Fatalf("read before checkpoint: %v", err) - } - - _, err = controller.NotifyUploadCompleted(ctx) - if err != nil { - t.Fatalf("NotifyUploadCompleted() error = %v", err) - } - - // Feed server checkpoint line acknowledging op 42. - _, err = controller.SendTextLine(ctx, `{"checkpoint":{"last_op_id":"42","buckets":[]}}`) - if err != nil { - t.Fatalf("SendTextLine(checkpoint) error = %v", err) - } - - var afterLast, afterTarget int64 - if err := database.QueryRow(ctx, "SELECT last_op, target_op FROM ps_buckets WHERE name = '$local'").Scan(&afterLast, &afterTarget); err != nil { - t.Fatalf("read after checkpoint: %v", err) - } - - t.Logf("before: last_op=%d target_op=%d after: last_op=%d target_op=%d", beforeLast, beforeTarget, afterLast, afterTarget) - - if afterLast < beforeLast { - t.Fatalf("last_op regressed: before=%d after=%d", beforeLast, afterLast) - } - if afterTarget < beforeTarget { - t.Fatalf("target_op regressed: before=%d after=%d", beforeTarget, afterTarget) - } - // The synthetic checkpoint line above is only validated for protocol acceptance. - // The native extension controls checkpoint application details internally. -} - -func TestController_ReplaysCheckpointFixtureLines(t *testing.T) { - t.Parallel() - - ctx := context.Background() - database := dbtest.OpenTestDB(t) - controller := extension.NewController(database) - defer controller.Close() - - _, err := controller.Start(ctx, extension.StartRequest{IncludeDefaults: true}) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - _, err = controller.NotifyConnection(ctx, extension.ConnectionEstablished) - if err != nil { - t.Fatalf("NotifyConnection(established) error = %v", err) - } - - path := filepath.Join("testdata", "checkpoint_lines.ndjson") - f, err := os.Open(path) - if err != nil { - t.Fatalf("open fixture %s: %v", path, err) - } - defer f.Close() - - scanner := bufio.NewScanner(f) - lineNo := 0 - for scanner.Scan() { - lineNo++ - line := scanner.Text() - if line == "" { - continue - } - if _, err := controller.SendTextLine(ctx, line); err != nil { - t.Fatalf("SendTextLine(line %d) error = %v", lineNo, err) - } - } - if err := scanner.Err(); err != nil { - t.Fatalf("read fixture: %v", err) - } -} - -func instructionTypes(instructions []extension.Instruction) []extension.InstructionType { - types := make([]extension.InstructionType, len(instructions)) - for i, inst := range instructions { - types[i] = inst.Type - } - return types -} - -func TestController_ConnectionStateConsistency(t *testing.T) { - t.Parallel() - - t.Run("maintains state across multiple operations", func(t *testing.T) { - t.Parallel() - - // This test verifies the fix for the "No iteration is active" bug. - // The PowerSync extension maintains per-connection state: - // - Start() begins an iteration on a connection - // - NotifyConnection/SendTextLine must use the SAME connection - // If connection pooling gives us different connections, we get the error. - - ctx := context.Background() - db := dbtest.OpenTestDB(t) - controller := extension.NewController(db) - defer controller.Close() - - // Start an iteration - _, err := controller.Start(ctx, extension.StartRequest{IncludeDefaults: true}) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - // These must use the same connection as Start(), or we get - // "No iteration is active" error - _, err = controller.NotifyConnection(ctx, extension.ConnectionEstablished) - if err != nil { - t.Fatalf("NotifyConnection(established) error = %v", err) - } - - // Send a line - requires iteration to be active - line := `{"token_expires_in":3600}` - _, err = controller.SendTextLine(ctx, line) - if err != nil { - t.Fatalf("SendTextLine() error = %v", err) - } - - // End the connection - _, err = controller.NotifyConnection(ctx, extension.ConnectionEnded) - if err != nil { - t.Fatalf("NotifyConnection(end) error = %v", err) - } - }) -} diff --git a/internal/powersync/extension/extension.go b/internal/powersync/extension/extension.go deleted file mode 100644 index 29e31231..00000000 --- a/internal/powersync/extension/extension.go +++ /dev/null @@ -1,112 +0,0 @@ -package extension - -import ( - "context" - "embed" - "fmt" - "os" - "path/filepath" - "runtime" - - "github.com/usetero/cli/internal/sqlite" -) - -//go:embed extensions/*.dylib extensions/*.so -var embeddedExtensions embed.FS - -//go:embed schema.json -var schemaJSON string - -func init() { - if err := Register(); err != nil { - panic(fmt.Sprintf("powersync extension: %v", err)) - } -} - -// Register configures the SQLite driver to load the PowerSync -// extension on every new connection. This is called automatically via init(). -func Register() error { - extPath, err := Path() - if err != nil { - return fmt.Errorf("get extension path: %w", err) - } - sqlite.SetExtensionPath(extPath) - return nil -} - -// SchemaJSON returns the embedded PowerSync schema. -func SchemaJSON() string { - return schemaJSON -} - -// ApplySchema applies the PowerSync schema to the database. -// This should be called once after opening the database. -func ApplySchema(ctx context.Context, db sqlite.DB) error { - if _, err := db.Exec(ctx, "SELECT powersync_replace_schema(?)", SchemaJSON()); err != nil { - return fmt.Errorf("apply schema: %w", err) - } - return nil -} - -// Path returns the path to the PowerSync extension for the current platform. -// The extension is extracted from the embedded binary to a temporary location. -func Path() (string, error) { - filename, err := extensionFilename() - if err != nil { - return "", err - } - - // Read embedded extension - data, err := embeddedExtensions.ReadFile("extensions/" + filename) - if err != nil { - return "", fmt.Errorf("read embedded extension: %w", err) - } - - // Write to temp directory - // We use a consistent path so we don't create new files on every run - tmpDir := filepath.Join(os.TempDir(), "tero-powersync") - if err := os.MkdirAll(tmpDir, 0o755); err != nil { - return "", fmt.Errorf("create temp directory: %w", err) - } - - path := filepath.Join(tmpDir, filename) - - // Check if already extracted with correct size - if info, err := os.Stat(path); err == nil && info.Size() == int64(len(data)) { - return path, nil - } - - // Write to temp file, then rename for atomicity - tmpPath := path + ".tmp" - if err := os.WriteFile(tmpPath, data, 0o755); err != nil { - return "", fmt.Errorf("write extension: %w", err) - } - - if err := os.Rename(tmpPath, path); err != nil { - os.Remove(tmpPath) - return "", fmt.Errorf("rename extension: %w", err) - } - - return path, nil -} - -// extensionFilename returns the platform-specific extension filename. -func extensionFilename() (string, error) { - switch runtime.GOOS { - case "darwin": - switch runtime.GOARCH { - case "arm64": - return "libpowersync_aarch64.macos.dylib", nil - case "amd64": - return "libpowersync_x64.macos.dylib", nil - } - case "linux": - switch runtime.GOARCH { - case "arm64": - return "libpowersync_aarch64.linux.so", nil - case "amd64": - return "libpowersync_x64.linux.so", nil - } - } - return "", fmt.Errorf("unsupported platform: %s/%s", runtime.GOOS, runtime.GOARCH) -} diff --git a/internal/powersync/extension/extensions/libpowersync_aarch64.linux.so b/internal/powersync/extension/extensions/libpowersync_aarch64.linux.so deleted file mode 100644 index 5efd28f0..00000000 Binary files a/internal/powersync/extension/extensions/libpowersync_aarch64.linux.so and /dev/null differ diff --git a/internal/powersync/extension/extensions/libpowersync_aarch64.macos.dylib b/internal/powersync/extension/extensions/libpowersync_aarch64.macos.dylib deleted file mode 100644 index ce216445..00000000 Binary files a/internal/powersync/extension/extensions/libpowersync_aarch64.macos.dylib and /dev/null differ diff --git a/internal/powersync/extension/extensions/libpowersync_x64.linux.so b/internal/powersync/extension/extensions/libpowersync_x64.linux.so deleted file mode 100644 index db66c36a..00000000 Binary files a/internal/powersync/extension/extensions/libpowersync_x64.linux.so and /dev/null differ diff --git a/internal/powersync/extension/extensions/libpowersync_x64.macos.dylib b/internal/powersync/extension/extensions/libpowersync_x64.macos.dylib deleted file mode 100644 index fa830b8b..00000000 Binary files a/internal/powersync/extension/extensions/libpowersync_x64.macos.dylib and /dev/null differ diff --git a/internal/powersync/extension/generate/main.go b/internal/powersync/extension/generate/main.go deleted file mode 100644 index 0cbf35ee..00000000 --- a/internal/powersync/extension/generate/main.go +++ /dev/null @@ -1,62 +0,0 @@ -// Package main generates the PowerSync schema from the PowerSync service. -// -// It fetches the schema from the admin API and writes schema.json for embedding. -// -// Usage: -// -// doppler run -- go generate ./internal/powersync -package main - -import ( - "context" - "fmt" - "os" - "path/filepath" - - psapi "github.com/usetero/cli/internal/boundary/powersync" - "github.com/usetero/cli/internal/config" -) - -func main() { - if err := run(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -func run() error { - cfg := config.LoadCLIConfig() - endpoint := cfg.PowerSyncEndpoint - token := os.Getenv("POWERSYNC_API_TOKEN") - - if token == "" { - return fmt.Errorf("POWERSYNC_API_TOKEN is required\nUse: doppler run -- go generate ./internal/powersync") - } - - ctx := context.Background() - - fmt.Println("Fetching schema from PowerSync service...") - - schemaJSON, err := psapi.FetchSchemaJSON(ctx, endpoint, token) - if err != nil { - return fmt.Errorf("fetch schema: %w", err) - } - - // Write schema.json to the extension directory (where it's embedded from) - outputDir := mustGetwd() - schemaPath := filepath.Join(outputDir, "extension", "schema.json") - if err := os.WriteFile(schemaPath, []byte(schemaJSON), 0o644); err != nil { - return fmt.Errorf("write schema.json: %w", err) - } - - fmt.Printf("Wrote %s\n", schemaPath) - return nil -} - -func mustGetwd() string { - dir, err := os.Getwd() - if err != nil { - panic(err) - } - return dir -} diff --git a/internal/powersync/extension/protocol.go b/internal/powersync/extension/protocol.go deleted file mode 100644 index 9700fa80..00000000 --- a/internal/powersync/extension/protocol.go +++ /dev/null @@ -1,179 +0,0 @@ -// Package extension provides the interface to the PowerSync SQLite extension. -package extension - -import ( - "bytes" - "encoding/json" - - psapi "github.com/usetero/cli/internal/boundary/powersync" -) - -// InstructionType represents a PowerSync instruction type. -type InstructionType string - -// InstructionType constants for Instruction.Type. -const ( - InstructionEstablishSyncStream InstructionType = "EstablishSyncStream" - InstructionFetchCredentials InstructionType = "FetchCredentials" - InstructionCloseSyncStream InstructionType = "CloseSyncStream" - InstructionFlushFileSystem InstructionType = "FlushFileSystem" - InstructionDidCompleteSync InstructionType = "DidCompleteSync" - InstructionUpdateSyncStatus InstructionType = "UpdateSyncStatus" - InstructionLogLine InstructionType = "LogLine" -) - -// Instruction is a command returned by powersync_control. -// The extension returns instructions as tagged enums: {"InstructionType": {fields...}} -type Instruction struct { - Type InstructionType - // Fields vary by type - Request *psapi.SyncStreamRequest - DidExpire *bool - HideDisconnect *bool - SyncStatus *SyncStatus - Severity string - Line string -} - -// unmarshalStrict unmarshals JSON with DisallowUnknownFields to catch payload mismatches. -func unmarshalStrict(data []byte, v any) error { - dec := json.NewDecoder(bytes.NewReader(data)) - dec.DisallowUnknownFields() - return dec.Decode(v) -} - -// UnmarshalJSON handles the serde-style tagged enum format from the extension. -// Example: {"EstablishSyncStream": {"request": {...}}} -func (i *Instruction) UnmarshalJSON(data []byte) error { - // Parse as map to get the variant name (first key) - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - // Should have exactly one key - the instruction type - for typ, payload := range raw { - i.Type = InstructionType(typ) - - // Parse the payload based on type using strict unmarshaling - switch i.Type { - case InstructionEstablishSyncStream: - var p struct { - Request *psapi.SyncStreamRequest `json:"request"` - } - if err := unmarshalStrict(payload, &p); err != nil { - return err - } - i.Request = p.Request - - case InstructionFetchCredentials: - var p struct { - DidExpire *bool `json:"did_expire"` - } - if err := unmarshalStrict(payload, &p); err != nil { - return err - } - i.DidExpire = p.DidExpire - - case InstructionCloseSyncStream: - var p struct { - HideDisconnect *bool `json:"hide_disconnect"` - } - if err := unmarshalStrict(payload, &p); err != nil { - return err - } - i.HideDisconnect = p.HideDisconnect - - case InstructionUpdateSyncStatus: - // Payload is wrapped: {"status": {...}} - var wrapper struct { - Status SyncStatus `json:"status"` - } - if err := unmarshalStrict(payload, &wrapper); err != nil { - return err - } - i.SyncStatus = &wrapper.Status - - case InstructionLogLine: - var p struct { - Severity string `json:"severity"` - Line string `json:"line"` - } - if err := unmarshalStrict(payload, &p); err != nil { - return err - } - i.Severity = p.Severity - i.Line = p.Line - - default: - // Unknown type - store raw for debugging but don't fail - } - - // Only process the first key - break - } - - return nil -} - -// SyncStatus represents the detailed sync state from UpdateSyncStatus instructions. -type SyncStatus struct { - Connected bool `json:"connected"` - Connecting bool `json:"connecting"` - PriorityStatus []PriorityStatus `json:"priority_status"` - Downloading *DownloadProgress `json:"downloading"` - Streams []StreamStatus `json:"streams"` -} - -// PriorityStatus represents sync status for a specific priority level. -type PriorityStatus struct { - Priority int `json:"priority"` - LastSyncedAt *int `json:"last_synced_at"` - HasSynced *bool `json:"has_synced"` -} - -// StreamStatus represents the status of a sync stream subscription. -type StreamStatus struct { - Name string `json:"name"` - Parameters *string `json:"parameters"` - Priority int `json:"priority"` - Active bool `json:"active"` - IsDefault bool `json:"is_default"` - HasExplicitSubscription bool `json:"has_explicit_subscription"` - ExpiresAt *int `json:"expires_at"` - LastSyncedAt *int `json:"last_synced_at"` - Progress *StreamProgress `json:"progress"` -} - -// StreamProgress represents download progress for a stream. -type StreamProgress struct { - Total int `json:"total"` - Downloaded int `json:"downloaded"` -} - -// DownloadProgress represents the current download progress. -type DownloadProgress struct { - Buckets map[string]BucketProgress `json:"buckets"` -} - -// BucketProgress represents progress for a single bucket. -type BucketProgress struct { - Priority int `json:"priority"` - AtLast int `json:"at_last"` - SinceLast int `json:"since_last"` - TargetCount int `json:"target_count"` -} - -// TotalProgress returns the total progress across all buckets. -// Returns (downloaded, total) counts. -func (d *DownloadProgress) TotalProgress() (int, int) { - if d == nil { - return 0, 0 - } - var downloaded, total int - for _, b := range d.Buckets { - downloaded += b.SinceLast - total += b.TargetCount - } - return downloaded, total -} diff --git a/internal/powersync/extension/protocol_test.go b/internal/powersync/extension/protocol_test.go deleted file mode 100644 index eadd2bee..00000000 --- a/internal/powersync/extension/protocol_test.go +++ /dev/null @@ -1,184 +0,0 @@ -package extension_test - -import ( - "encoding/json" - "testing" - - "github.com/usetero/cli/internal/powersync/extension" -) - -func TestInstruction_UnmarshalJSON(t *testing.T) { - t.Parallel() - - t.Run("parses EstablishSyncStream", func(t *testing.T) { - t.Parallel() - - data := `{"EstablishSyncStream": {"request": {"buckets": [], "client_id": "abc123"}}}` - - var inst extension.Instruction - if err := json.Unmarshal([]byte(data), &inst); err != nil { - t.Fatalf("Unmarshal error = %v", err) - } - - if inst.Type != extension.InstructionEstablishSyncStream { - t.Errorf("Type = %q, want %q", inst.Type, extension.InstructionEstablishSyncStream) - } - if inst.Request == nil { - t.Fatal("Request is nil") - } - if inst.Request.ClientID != "abc123" { - t.Errorf("Request.ClientID = %q, want %q", inst.Request.ClientID, "abc123") - } - }) - - t.Run("parses FetchCredentials", func(t *testing.T) { - t.Parallel() - - data := `{"FetchCredentials": {"did_expire": true}}` - - var inst extension.Instruction - if err := json.Unmarshal([]byte(data), &inst); err != nil { - t.Fatalf("Unmarshal error = %v", err) - } - - if inst.Type != extension.InstructionFetchCredentials { - t.Errorf("Type = %q, want %q", inst.Type, extension.InstructionFetchCredentials) - } - if inst.DidExpire == nil || !*inst.DidExpire { - t.Error("DidExpire should be true") - } - }) - - t.Run("parses CloseSyncStream", func(t *testing.T) { - t.Parallel() - - data := `{"CloseSyncStream": {"hide_disconnect": false}}` - - var inst extension.Instruction - if err := json.Unmarshal([]byte(data), &inst); err != nil { - t.Fatalf("Unmarshal error = %v", err) - } - - if inst.Type != extension.InstructionCloseSyncStream { - t.Errorf("Type = %q, want %q", inst.Type, extension.InstructionCloseSyncStream) - } - if inst.HideDisconnect == nil || *inst.HideDisconnect { - t.Error("HideDisconnect should be false") - } - }) - - t.Run("parses UpdateSyncStatus with realistic payload", func(t *testing.T) { - t.Parallel() - - // Realistic payload from the actual PowerSync extension - data := `{"UpdateSyncStatus": {"status": {"connected": true, "connecting": false, "priority_status": [], "downloading": {"buckets": {"prio_3": {"priority": 3, "at_last": 0, "since_last": 1000, "target_count": 32920}}}, "streams": [{"name": "account_data", "parameters": null, "priority": 3, "active": true, "is_default": true, "has_explicit_subscription": false, "expires_at": null, "last_synced_at": null, "progress": {"total": 32920, "downloaded": 1000}}]}}}` - - var inst extension.Instruction - if err := json.Unmarshal([]byte(data), &inst); err != nil { - t.Fatalf("Unmarshal error = %v", err) - } - - if inst.Type != extension.InstructionUpdateSyncStatus { - t.Errorf("Type = %q, want %q", inst.Type, extension.InstructionUpdateSyncStatus) - } - if inst.SyncStatus == nil { - t.Fatal("SyncStatus is nil") - } - if !inst.SyncStatus.Connected { - t.Error("SyncStatus.Connected should be true") - } - if inst.SyncStatus.Downloading == nil { - t.Fatal("SyncStatus.Downloading is nil") - } - downloaded, total := inst.SyncStatus.Downloading.TotalProgress() - if downloaded != 1000 || total != 32920 { - t.Errorf("TotalProgress() = (%d, %d), want (1000, 32920)", downloaded, total) - } - }) - - t.Run("parses UpdateSyncStatus after sync complete", func(t *testing.T) { - t.Parallel() - - // Status after sync completes - no downloading field - data := `{"UpdateSyncStatus": {"status": {"connected": true, "connecting": false, "priority_status": [{"priority": 2147483647, "last_synced_at": 1738443993, "has_synced": true}], "downloading": null, "streams": []}}}` - - var inst extension.Instruction - if err := json.Unmarshal([]byte(data), &inst); err != nil { - t.Fatalf("Unmarshal error = %v", err) - } - - if inst.SyncStatus == nil { - t.Fatal("SyncStatus is nil") - } - if inst.SyncStatus.Downloading != nil { - t.Error("SyncStatus.Downloading should be nil after sync complete") - } - if len(inst.SyncStatus.PriorityStatus) != 1 { - t.Errorf("PriorityStatus length = %d, want 1", len(inst.SyncStatus.PriorityStatus)) - } - }) - - t.Run("parses LogLine", func(t *testing.T) { - t.Parallel() - - data := `{"LogLine": {"severity": "info", "line": "test message"}}` - - var inst extension.Instruction - if err := json.Unmarshal([]byte(data), &inst); err != nil { - t.Fatalf("Unmarshal error = %v", err) - } - - if inst.Type != extension.InstructionLogLine { - t.Errorf("Type = %q, want %q", inst.Type, extension.InstructionLogLine) - } - if inst.Severity != "info" { - t.Errorf("Severity = %q, want %q", inst.Severity, "info") - } - if inst.Line != "test message" { - t.Errorf("Line = %q, want %q", inst.Line, "test message") - } - }) - - t.Run("parses DidCompleteSync", func(t *testing.T) { - t.Parallel() - - data := `{"DidCompleteSync": {}}` - - var inst extension.Instruction - if err := json.Unmarshal([]byte(data), &inst); err != nil { - t.Fatalf("Unmarshal error = %v", err) - } - - if inst.Type != extension.InstructionDidCompleteSync { - t.Errorf("Type = %q, want %q", inst.Type, extension.InstructionDidCompleteSync) - } - }) - - t.Run("parses unknown instruction type", func(t *testing.T) { - t.Parallel() - - data := `{"SomeNewInstruction": {"foo": "bar"}}` - - var inst extension.Instruction - if err := json.Unmarshal([]byte(data), &inst); err != nil { - t.Fatalf("Unmarshal error = %v", err) - } - - if inst.Type != "SomeNewInstruction" { - t.Errorf("Type = %q, want %q", inst.Type, "SomeNewInstruction") - } - }) - - t.Run("rejects unknown fields in known instruction types", func(t *testing.T) { - t.Parallel() - - // This should fail because "unknown_field" is not expected - data := `{"FetchCredentials": {"did_expire": true, "unknown_field": "value"}}` - - var inst extension.Instruction - err := json.Unmarshal([]byte(data), &inst) - if err == nil { - t.Fatal("Expected error for unknown field, got nil") - } - }) -} diff --git a/internal/powersync/extension/schema.json b/internal/powersync/extension/schema.json deleted file mode 100644 index cabe3801..00000000 --- a/internal/powersync/extension/schema.json +++ /dev/null @@ -1 +0,0 @@ -{"tables":[{"name":"conversation_contexts","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"added_by","type":"text"},{"name":"conversation_id","type":"text"},{"name":"created_at","type":"text"},{"name":"entity_id","type":"text"},{"name":"entity_type","type":"text"}],"indexes":[]},{"name":"conversations","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"created_at","type":"text"},{"name":"title","type":"text"},{"name":"user_id","type":"text"},{"name":"view_id","type":"text"},{"name":"workspace_id","type":"text"}],"indexes":[{"name":"account_id","columns":[{"name":"account_id","ascending":true,"type":"text"}]}]},{"name":"datadog_account_statuses_cache","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"datadog_account_id","type":"text"},{"name":"disabled_services","type":"integer"},{"name":"estimated_bytes_reduction_per_hour","type":"real"},{"name":"estimated_cost_reduction_per_hour_bytes_usd","type":"real"},{"name":"estimated_cost_reduction_per_hour_usd","type":"real"},{"name":"estimated_cost_reduction_per_hour_volume_usd","type":"real"},{"name":"estimated_volume_reduction_per_hour","type":"real"},{"name":"health","type":"text"},{"name":"inactive_services","type":"integer"},{"name":"log_active_services","type":"integer"},{"name":"log_event_analyzed_count","type":"integer"},{"name":"log_event_bytes_per_hour","type":"real"},{"name":"log_event_cost_per_hour_bytes_usd","type":"real"},{"name":"log_event_cost_per_hour_usd","type":"real"},{"name":"log_event_cost_per_hour_volume_usd","type":"real"},{"name":"log_event_count","type":"integer"},{"name":"log_event_volume_per_hour","type":"real"},{"name":"log_service_count","type":"integer"},{"name":"observed_bytes_per_hour_after","type":"real"},{"name":"observed_bytes_per_hour_before","type":"real"},{"name":"observed_cost_per_hour_after_bytes_usd","type":"real"},{"name":"observed_cost_per_hour_after_usd","type":"real"},{"name":"observed_cost_per_hour_after_volume_usd","type":"real"},{"name":"observed_cost_per_hour_before_bytes_usd","type":"real"},{"name":"observed_cost_per_hour_before_usd","type":"real"},{"name":"observed_cost_per_hour_before_volume_usd","type":"real"},{"name":"observed_volume_per_hour_after","type":"real"},{"name":"observed_volume_per_hour_before","type":"real"},{"name":"ok_services","type":"integer"},{"name":"policy_approved_count","type":"integer"},{"name":"policy_dismissed_count","type":"integer"},{"name":"policy_pending_count","type":"integer"},{"name":"policy_pending_critical_count","type":"integer"},{"name":"policy_pending_high_count","type":"integer"},{"name":"policy_pending_low_count","type":"integer"},{"name":"policy_pending_medium_count","type":"integer"},{"name":"ready_for_use","type":"integer"},{"name":"service_cost_per_hour_volume_usd","type":"real"},{"name":"service_volume_per_hour","type":"real"}],"indexes":[{"name":"datadog_account_id","columns":[{"name":"datadog_account_id","ascending":true,"type":"text"}]}]},{"name":"datadog_accounts","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"cost_per_gb_ingested","type":"real"},{"name":"created_at","type":"text"},{"name":"name","type":"text"},{"name":"site","type":"text"}],"indexes":[]},{"name":"datadog_log_indexes","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"cost_per_million_events_indexed","type":"real"},{"name":"created_at","type":"text"},{"name":"datadog_account_id","type":"text"},{"name":"name","type":"text"}],"indexes":[]},{"name":"log_event_fields","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"baseline_avg_bytes","type":"real"},{"name":"created_at","type":"text"},{"name":"field_path","type":"text"},{"name":"log_event_id","type":"text"},{"name":"value_distribution","type":"text"}],"indexes":[]},{"name":"log_event_policies","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"action","type":"text"},{"name":"analysis","type":"text"},{"name":"approved_at","type":"text"},{"name":"approved_baseline_avg_bytes","type":"real"},{"name":"approved_baseline_volume_per_hour","type":"real"},{"name":"approved_by","type":"text"},{"name":"category","type":"text"},{"name":"category_type","type":"text"},{"name":"created_at","type":"text"},{"name":"dismissed_at","type":"text"},{"name":"dismissed_by","type":"text"},{"name":"log_event_id","type":"text"},{"name":"severity","type":"text"},{"name":"subjective","type":"integer"},{"name":"workspace_id","type":"text"}],"indexes":[{"name":"log_event_id","columns":[{"name":"log_event_id","ascending":true,"type":"text"}]}]},{"name":"log_event_policy_category_statuses_cache","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"action","type":"text"},{"name":"approved_count","type":"integer"},{"name":"boundary","type":"text"},{"name":"category","type":"text"},{"name":"category_type","type":"text"},{"name":"dismissed_count","type":"integer"},{"name":"display_name","type":"text"},{"name":"estimated_bytes_reduction_per_hour","type":"real"},{"name":"estimated_cost_reduction_per_hour_bytes_usd","type":"real"},{"name":"estimated_cost_reduction_per_hour_usd","type":"real"},{"name":"estimated_cost_reduction_per_hour_volume_usd","type":"real"},{"name":"estimated_volume_reduction_per_hour","type":"real"},{"name":"events_with_volumes","type":"integer"},{"name":"pending_count","type":"integer"},{"name":"policy_pending_critical_count","type":"integer"},{"name":"policy_pending_high_count","type":"integer"},{"name":"policy_pending_low_count","type":"integer"},{"name":"policy_pending_medium_count","type":"integer"},{"name":"principle","type":"text"},{"name":"subjective","type":"integer"},{"name":"total_event_count","type":"integer"}],"indexes":[]},{"name":"log_event_policy_statuses_cache","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"action","type":"text"},{"name":"approved_at","type":"text"},{"name":"bytes_per_hour","type":"real"},{"name":"category","type":"text"},{"name":"category_type","type":"text"},{"name":"created_at","type":"text"},{"name":"dismissed_at","type":"text"},{"name":"estimated_bytes_reduction_per_hour","type":"real"},{"name":"estimated_cost_reduction_per_hour_bytes_usd","type":"real"},{"name":"estimated_cost_reduction_per_hour_usd","type":"real"},{"name":"estimated_cost_reduction_per_hour_volume_usd","type":"real"},{"name":"estimated_volume_reduction_per_hour","type":"real"},{"name":"log_event_id","type":"text"},{"name":"log_event_name","type":"text"},{"name":"policy_id","type":"text"},{"name":"service_id","type":"text"},{"name":"service_name","type":"text"},{"name":"severity","type":"text"},{"name":"status","type":"text"},{"name":"subjective","type":"integer"},{"name":"survival_rate","type":"real"},{"name":"volume_per_hour","type":"real"},{"name":"workspace_id","type":"text"}],"indexes":[{"name":"log_event_id","columns":[{"name":"log_event_id","ascending":true,"type":"text"}]},{"name":"category_status","columns":[{"name":"category","ascending":true,"type":"text"},{"name":"status","ascending":true,"type":"text"}]}]},{"name":"log_event_statuses_cache","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"approved_policy_count","type":"integer"},{"name":"bytes_per_hour","type":"real"},{"name":"cost_per_hour_bytes_usd","type":"real"},{"name":"cost_per_hour_usd","type":"real"},{"name":"cost_per_hour_volume_usd","type":"real"},{"name":"dismissed_policy_count","type":"integer"},{"name":"estimated_bytes_reduction_per_hour","type":"real"},{"name":"estimated_cost_reduction_per_hour_bytes_usd","type":"real"},{"name":"estimated_cost_reduction_per_hour_usd","type":"real"},{"name":"estimated_cost_reduction_per_hour_volume_usd","type":"real"},{"name":"estimated_volume_reduction_per_hour","type":"real"},{"name":"has_been_analyzed","type":"integer"},{"name":"has_volumes","type":"integer"},{"name":"log_event_id","type":"text"},{"name":"observed_bytes_per_hour_after","type":"real"},{"name":"observed_bytes_per_hour_before","type":"real"},{"name":"observed_cost_per_hour_after_bytes_usd","type":"real"},{"name":"observed_cost_per_hour_after_usd","type":"real"},{"name":"observed_cost_per_hour_after_volume_usd","type":"real"},{"name":"observed_cost_per_hour_before_bytes_usd","type":"real"},{"name":"observed_cost_per_hour_before_usd","type":"real"},{"name":"observed_cost_per_hour_before_volume_usd","type":"real"},{"name":"observed_volume_per_hour_after","type":"real"},{"name":"observed_volume_per_hour_before","type":"real"},{"name":"pending_policy_count","type":"integer"},{"name":"policy_count","type":"integer"},{"name":"policy_pending_critical_count","type":"integer"},{"name":"policy_pending_high_count","type":"integer"},{"name":"policy_pending_low_count","type":"integer"},{"name":"policy_pending_medium_count","type":"integer"},{"name":"service_id","type":"text"},{"name":"volume_per_hour","type":"real"}],"indexes":[{"name":"log_event_id","columns":[{"name":"log_event_id","ascending":true,"type":"text"}]},{"name":"service_id","columns":[{"name":"service_id","ascending":true,"type":"text"}]}]},{"name":"log_events","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"baseline_avg_bytes","type":"real"},{"name":"baseline_volume_per_hour","type":"real"},{"name":"created_at","type":"text"},{"name":"description","type":"text"},{"name":"event_nature","type":"text"},{"name":"examples","type":"text"},{"name":"matchers","type":"text"},{"name":"name","type":"text"},{"name":"service_id","type":"text"},{"name":"severity","type":"text"},{"name":"signal_purpose","type":"text"}],"indexes":[{"name":"service_id","columns":[{"name":"service_id","ascending":true,"type":"text"}]}]},{"name":"messages","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"content","type":"text"},{"name":"conversation_id","type":"text"},{"name":"created_at","type":"text"},{"name":"model","type":"text"},{"name":"role","type":"text"},{"name":"stop_reason","type":"text"}],"indexes":[{"name":"conversation_id","columns":[{"name":"conversation_id","ascending":true,"type":"text"}]}]},{"name":"service_statuses_cache","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"datadog_account_id","type":"text"},{"name":"estimated_bytes_reduction_per_hour","type":"real"},{"name":"estimated_cost_reduction_per_hour_bytes_usd","type":"real"},{"name":"estimated_cost_reduction_per_hour_usd","type":"real"},{"name":"estimated_cost_reduction_per_hour_volume_usd","type":"real"},{"name":"estimated_volume_reduction_per_hour","type":"real"},{"name":"health","type":"text"},{"name":"log_event_analyzed_count","type":"integer"},{"name":"log_event_bytes_per_hour","type":"real"},{"name":"log_event_cost_per_hour_bytes_usd","type":"real"},{"name":"log_event_cost_per_hour_usd","type":"real"},{"name":"log_event_cost_per_hour_volume_usd","type":"real"},{"name":"log_event_count","type":"integer"},{"name":"log_event_volume_per_hour","type":"real"},{"name":"observed_bytes_per_hour_after","type":"real"},{"name":"observed_bytes_per_hour_before","type":"real"},{"name":"observed_cost_per_hour_after_bytes_usd","type":"real"},{"name":"observed_cost_per_hour_after_usd","type":"real"},{"name":"observed_cost_per_hour_after_volume_usd","type":"real"},{"name":"observed_cost_per_hour_before_bytes_usd","type":"real"},{"name":"observed_cost_per_hour_before_usd","type":"real"},{"name":"observed_cost_per_hour_before_volume_usd","type":"real"},{"name":"observed_volume_per_hour_after","type":"real"},{"name":"observed_volume_per_hour_before","type":"real"},{"name":"policy_approved_count","type":"integer"},{"name":"policy_dismissed_count","type":"integer"},{"name":"policy_pending_count","type":"integer"},{"name":"policy_pending_critical_count","type":"integer"},{"name":"policy_pending_high_count","type":"integer"},{"name":"policy_pending_low_count","type":"integer"},{"name":"policy_pending_medium_count","type":"integer"},{"name":"service_cost_per_hour_volume_usd","type":"real"},{"name":"service_debug_volume_per_hour","type":"real"},{"name":"service_error_volume_per_hour","type":"real"},{"name":"service_id","type":"text"},{"name":"service_info_volume_per_hour","type":"real"},{"name":"service_other_volume_per_hour","type":"real"},{"name":"service_volume_per_hour","type":"real"},{"name":"service_warn_volume_per_hour","type":"real"}],"indexes":[{"name":"service_id","columns":[{"name":"service_id","ascending":true,"type":"text"}]}]},{"name":"services","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"created_at","type":"text"},{"name":"description","type":"text"},{"name":"enabled","type":"integer"},{"name":"initial_weekly_log_count","type":"integer"},{"name":"name","type":"text"}],"indexes":[{"name":"name","columns":[{"name":"name","ascending":true,"type":"text"}]}]},{"name":"teams","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"created_at","type":"text"},{"name":"name","type":"text"},{"name":"workspace_id","type":"text"}],"indexes":[]},{"name":"view_favorites","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"created_at","type":"text"},{"name":"user_id","type":"text"},{"name":"view_id","type":"text"}],"indexes":[]},{"name":"views","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"conversation_id","type":"text"},{"name":"created_at","type":"text"},{"name":"created_by","type":"text"},{"name":"entity_type","type":"text"},{"name":"forked_from_id","type":"text"},{"name":"message_id","type":"text"},{"name":"query","type":"text"}],"indexes":[]},{"name":"workspaces","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"created_at","type":"text"},{"name":"name","type":"text"},{"name":"purpose","type":"text"}],"indexes":[]}]} \ No newline at end of file diff --git a/internal/powersync/extension/testdata/checkpoint_lines.ndjson b/internal/powersync/extension/testdata/checkpoint_lines.ndjson deleted file mode 100644 index 0f7fd628..00000000 --- a/internal/powersync/extension/testdata/checkpoint_lines.ndjson +++ /dev/null @@ -1,3 +0,0 @@ -{"token_expires_in":3600} -{"checkpoint":{"last_op_id":"0","buckets":[]}} -{"checkpoint":{"last_op_id":"42","buckets":[]}} diff --git a/internal/powersync/extension/testdata/dev-sanitized.ndjson b/internal/powersync/extension/testdata/dev-sanitized.ndjson deleted file mode 100644 index 1980c1bd..00000000 --- a/internal/powersync/extension/testdata/dev-sanitized.ndjson +++ /dev/null @@ -1,2 +0,0 @@ -{"checkpoint":{"buckets":[{"bucket":"redacted_9d6690cd17d4","checksum":-484225811,"count":50463,"priority":3,"subscriptions":[{"default":0}]}],"last_op_id":"17975363","streams":[{"errors":[],"is_default":true,"name":"redacted_1148223160fd"}],"write_checkpoint":"170"}} -{"checkpoint_complete":{"last_op_id":"17975363"}} diff --git a/internal/powersync/powersynctest/db.go b/internal/powersync/powersynctest/db.go deleted file mode 100644 index bb161db2..00000000 --- a/internal/powersync/powersynctest/db.go +++ /dev/null @@ -1,28 +0,0 @@ -// Package powersynctest provides test utilities for the powersync package. -// -// For database test helpers, use powersync/db/dbtest. -// For mock API clients, use powersync/api/apitest. -package powersynctest - -import ( - "io" - "log/slog" - - "github.com/usetero/cli/internal/boundary/powersync/apitest" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/powersync" -) - -// NewSyncerWithMockClient creates a Syncer with a mock client for testing. -func NewSyncerWithMockClient(endpoint string, tokenRefresher powersync.TokenRefresher, mock *apitest.MockClient) powersync.Syncer { - return powersync.NewSyncer( - endpoint, - tokenRefresher, - discardScope(), - powersync.WithClientFactory(apitest.NewMockClientFactory(mock)), - ) -} - -func discardScope() log.Scope { - return log.RootScope(log.Wrap(slog.New(slog.NewTextHandler(io.Discard, nil)))) -} diff --git a/internal/powersync/powersynctest/mock_syncer.go b/internal/powersync/powersynctest/mock_syncer.go deleted file mode 100644 index 8632462e..00000000 --- a/internal/powersync/powersynctest/mock_syncer.go +++ /dev/null @@ -1,58 +0,0 @@ -package powersynctest - -import ( - "context" - - "github.com/usetero/cli/internal/powersync" - "github.com/usetero/cli/internal/sqlite" -) - -// MockSyncer is a test double for powersync.Syncer. -type MockSyncer struct { - StartFunc func(ctx context.Context, db sqlite.DB, accountID string, onFirstSync func()) error - StopFunc func() - StateFunc func() powersync.State - IsReadyFunc func() bool - NotifyUploadCompletedFunc func(ctx context.Context) error -} - -var _ powersync.Syncer = (*MockSyncer)(nil) - -// NewMockSyncer creates a MockSyncer with sensible defaults. -func NewMockSyncer() *MockSyncer { - return &MockSyncer{} -} - -func (m *MockSyncer) Start(ctx context.Context, db sqlite.DB, accountID string, onFirstSync func()) error { - if m.StartFunc != nil { - return m.StartFunc(ctx, db, accountID, onFirstSync) - } - return nil -} - -func (m *MockSyncer) Stop() { - if m.StopFunc != nil { - m.StopFunc() - } -} - -func (m *MockSyncer) State() powersync.State { - if m.StateFunc != nil { - return m.StateFunc() - } - return powersync.NewDisconnected() -} - -func (m *MockSyncer) IsReady() bool { - if m.IsReadyFunc != nil { - return m.IsReadyFunc() - } - return false -} - -func (m *MockSyncer) NotifyUploadCompleted(ctx context.Context) error { - if m.NotifyUploadCompletedFunc != nil { - return m.NotifyUploadCompletedFunc(ctx) - } - return nil -} diff --git a/internal/powersync/powersynctest/mock_token_refresher.go b/internal/powersync/powersynctest/mock_token_refresher.go deleted file mode 100644 index 00a43654..00000000 --- a/internal/powersync/powersynctest/mock_token_refresher.go +++ /dev/null @@ -1,53 +0,0 @@ -package powersynctest - -import ( - "context" - - "github.com/usetero/cli/internal/powersync" -) - -// Ensure MockTokenRefresher implements powersync.TokenRefresher. -var _ powersync.TokenRefresher = (*MockTokenRefresher)(nil) - -// MockTokenRefresher is a test double for powersync.TokenRefresher. -type MockTokenRefresher struct { - // GetAccessTokenFunc is called when GetAccessToken is invoked. - GetAccessTokenFunc func(ctx context.Context) (string, error) - - // ForceRefreshAccessTokenFunc is called when ForceRefreshAccessToken is invoked. - ForceRefreshAccessTokenFunc func(ctx context.Context) (string, error) - - // Calls records the number of times GetAccessToken was called. - Calls int - - // ForceRefreshCalls records the number of times ForceRefreshAccessToken was called. - ForceRefreshCalls int -} - -// GetAccessToken implements powersync.TokenRefresher. -func (m *MockTokenRefresher) GetAccessToken(ctx context.Context) (string, error) { - m.Calls++ - if m.GetAccessTokenFunc != nil { - return m.GetAccessTokenFunc(ctx) - } - return "mock-token", nil -} - -// ForceRefreshAccessToken implements powersync.TokenRefresher. -func (m *MockTokenRefresher) ForceRefreshAccessToken(ctx context.Context) (string, error) { - m.ForceRefreshCalls++ - if m.ForceRefreshAccessTokenFunc != nil { - return m.ForceRefreshAccessTokenFunc(ctx) - } - // Default: delegate to GetAccessToken for backwards compatibility in tests - return m.GetAccessToken(ctx) -} - -// NewMockTokenRefresher creates a MockTokenRefresher that returns the given token. -func NewMockTokenRefresher(token string) *MockTokenRefresher { - return &MockTokenRefresher{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return token, nil - }, - } -} diff --git a/internal/powersync/state.go b/internal/powersync/state.go deleted file mode 100644 index a1056e4b..00000000 --- a/internal/powersync/state.go +++ /dev/null @@ -1,98 +0,0 @@ -package powersync - -import ( - "fmt" -) - -// State represents the current syncer state. -// Use a type switch to handle each phase: -// -// switch s := syncer.State().(type) { -// case *powersync.Disconnected: -// case *powersync.Connecting: -// case *powersync.Syncing: -// case *powersync.Ready: -// case *powersync.Reconnecting: -// case *powersync.Error: -// } -type State interface { - state() // marker method -} - -// Disconnected means the syncer is not running. -type Disconnected struct{} - -func (*Disconnected) state() {} - -func NewDisconnected() *Disconnected { - return &Disconnected{} -} - -// Connecting means the syncer is establishing its first connection. -type Connecting struct{} - -func (*Connecting) state() {} - -func NewConnecting() *Connecting { - return &Connecting{} -} - -// Syncing means the syncer is actively downloading data. -type Syncing struct { - Progress *Progress // nil until progress is known -} - -func (*Syncing) state() {} - -func NewSyncing() *Syncing { - return &Syncing{} -} - -func (s *Syncing) WithProgress(downloaded, total int) *Syncing { - return &Syncing{ - Progress: &Progress{Downloaded: downloaded, Total: total}, - } -} - -// Ready means the initial sync is complete and data is fresh. -type Ready struct{} - -func (*Ready) state() {} - -func NewReady() *Ready { - return &Ready{} -} - -// Reconnecting means the syncer lost its connection and is retrying. -// Degraded is true after repeated consecutive failures. -type Reconnecting struct { - Degraded bool -} - -func (*Reconnecting) state() {} - -func NewReconnecting(degraded bool) *Reconnecting { - return &Reconnecting{Degraded: degraded} -} - -// Error means a fatal error occurred and syncing stopped. -type Error struct { - Err error -} - -func (*Error) state() {} - -func NewError(err error) *Error { - return &Error{Err: err} -} - -// Progress represents download progress. -type Progress struct { - Downloaded int - Total int -} - -// String returns a human-readable progress string like "50/100". -func (p *Progress) String() string { - return fmt.Sprintf("%d/%d", p.Downloaded, p.Total) -} diff --git a/internal/powersync/state_test.go b/internal/powersync/state_test.go deleted file mode 100644 index 04660380..00000000 --- a/internal/powersync/state_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package powersync_test - -import ( - "testing" - - "github.com/usetero/cli/internal/powersync" -) - -func TestSyncing_WithProgress(t *testing.T) { - t.Parallel() - - t.Run("sets progress", func(t *testing.T) { - t.Parallel() - - state := powersync.NewSyncing().WithProgress(50, 100) - - if state.Progress == nil { - t.Fatal("Progress should not be nil") - } - if state.Progress.Downloaded != 50 { - t.Errorf("Downloaded = %d, want 50", state.Progress.Downloaded) - } - if state.Progress.Total != 100 { - t.Errorf("Total = %d, want 100", state.Progress.Total) - } - }) -} - -func TestProgress_String(t *testing.T) { - t.Parallel() - - p := &powersync.Progress{Downloaded: 50, Total: 100} - want := "50/100" - if got := p.String(); got != want { - t.Errorf("String() = %q, want %q", got, want) - } -} - -func TestReconnecting(t *testing.T) { - t.Parallel() - - t.Run("not degraded", func(t *testing.T) { - t.Parallel() - - state := powersync.NewReconnecting(false) - if state.Degraded { - t.Error("expected Degraded to be false") - } - }) - - t.Run("degraded", func(t *testing.T) { - t.Parallel() - - state := powersync.NewReconnecting(true) - if !state.Degraded { - t.Error("expected Degraded to be true") - } - }) -} diff --git a/internal/powersync/stream_capture.go b/internal/powersync/stream_capture.go deleted file mode 100644 index 15268bc1..00000000 --- a/internal/powersync/stream_capture.go +++ /dev/null @@ -1,126 +0,0 @@ -package powersync - -import ( - "bufio" - "os" - "path/filepath" - "sync" - - "github.com/usetero/cli/internal/log" -) - -const defaultCaptureMaxBytes int64 = 25 * 1024 * 1024 - -// StreamCapture receives raw NDJSON sync-stream lines. -// -// Implementations must be best-effort and never panic. -type StreamCapture interface { - CaptureLine(line []byte) - Close() error -} - -type ndjsonStreamCapture struct { - path string - maxBytes int64 - scope log.Scope - - mu sync.Mutex - file *os.File - writer *bufio.Writer - written int64 - disabled bool -} - -var _ StreamCapture = (*ndjsonStreamCapture)(nil) - -// NewNDJSONStreamCapture creates a best-effort raw line capture sink. -// -// The capture file is created with permissions 0600, and parent directories -// are created with permissions 0700. -func NewNDJSONStreamCapture(path string, maxBytes int64, scope log.Scope) (StreamCapture, error) { - if maxBytes <= 0 { - maxBytes = defaultCaptureMaxBytes - } - - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return nil, err - } - - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) - if err != nil { - return nil, err - } - - info, err := f.Stat() - if err != nil { - _ = f.Close() - return nil, err - } - - return &ndjsonStreamCapture{ - path: path, - maxBytes: maxBytes, - scope: scope.Child("streamcapture"), - file: f, - writer: bufio.NewWriter(f), - written: info.Size(), - }, nil -} - -func (c *ndjsonStreamCapture) CaptureLine(line []byte) { - if len(line) == 0 { - return - } - - c.mu.Lock() - defer c.mu.Unlock() - - if c.disabled || c.writer == nil { - return - } - - writeLen := int64(len(line) + 1) - if c.written+writeLen > c.maxBytes { - c.scope.Warn("disabled stream capture after reaching size limit", "path", c.path, "max_bytes", c.maxBytes) - c.disabled = true - return - } - - if _, err := c.writer.Write(line); err != nil { - c.scope.Error("disabled stream capture after write failure", "path", c.path, "error", err) - c.disabled = true - return - } - if err := c.writer.WriteByte('\n'); err != nil { - c.scope.Error("disabled stream capture after write failure", "path", c.path, "error", err) - c.disabled = true - return - } - if err := c.writer.Flush(); err != nil { - c.scope.Error("disabled stream capture after flush failure", "path", c.path, "error", err) - c.disabled = true - return - } - - c.written += writeLen -} - -func (c *ndjsonStreamCapture) Close() error { - c.mu.Lock() - defer c.mu.Unlock() - - if c.writer == nil { - return nil - } - - if err := c.writer.Flush(); err != nil { - _ = c.file.Close() - c.writer = nil - c.file = nil - return err - } - err := c.file.Close() - c.writer = nil - c.file = nil - return err -} diff --git a/internal/powersync/stream_capture_test.go b/internal/powersync/stream_capture_test.go deleted file mode 100644 index 9a3e5e75..00000000 --- a/internal/powersync/stream_capture_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package powersync - -import ( - "os" - "path/filepath" - "testing" - - "github.com/usetero/cli/internal/log/logtest" -) - -func TestNDJSONStreamCapture_WritesLines(t *testing.T) { - t.Parallel() - - path := filepath.Join(t.TempDir(), "capture.ndjson") - capture, err := NewNDJSONStreamCapture(path, 1024, logtest.NewScope(t)) - if err != nil { - t.Fatalf("NewNDJSONStreamCapture() error = %v", err) - } - defer func() { _ = capture.Close() }() - - capture.CaptureLine([]byte(`{"op":"put"}`)) - capture.CaptureLine([]byte(`{"op":"remove"}`)) - - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("ReadFile() error = %v", err) - } - got := string(data) - want := "{\"op\":\"put\"}\n{\"op\":\"remove\"}\n" - if got != want { - t.Fatalf("capture file = %q, want %q", got, want) - } -} - -func TestNDJSONStreamCapture_StopsAtMaxBytes(t *testing.T) { - t.Parallel() - - path := filepath.Join(t.TempDir(), "capture.ndjson") - capture, err := NewNDJSONStreamCapture(path, 8, logtest.NewScope(t)) - if err != nil { - t.Fatalf("NewNDJSONStreamCapture() error = %v", err) - } - defer func() { _ = capture.Close() }() - - capture.CaptureLine([]byte("abc")) // 4 bytes with newline - capture.CaptureLine([]byte("def")) // 4 bytes with newline - capture.CaptureLine([]byte("ghi")) // should be ignored - - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("ReadFile() error = %v", err) - } - got := string(data) - want := "abc\ndef\n" - if got != want { - t.Fatalf("capture file = %q, want %q", got, want) - } -} diff --git a/internal/powersync/syncer.go b/internal/powersync/syncer.go deleted file mode 100644 index d006ebf1..00000000 --- a/internal/powersync/syncer.go +++ /dev/null @@ -1,219 +0,0 @@ -package powersync - -import ( - "context" - "errors" - "fmt" - "sync" - "sync/atomic" - - psapi "github.com/usetero/cli/internal/boundary/powersync" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/powersync/db" - "github.com/usetero/cli/internal/powersync/extension" - "github.com/usetero/cli/internal/sqlite" -) - -// TokenRefresher provides access tokens for authentication. -type TokenRefresher interface { - GetAccessToken(ctx context.Context) (string, error) - // ForceRefreshAccessToken refreshes the token unconditionally, bypassing - // local expiration checks. Used when the server rejects a token the - // client still considers valid (e.g. clock skew). - ForceRefreshAccessToken(ctx context.Context) (string, error) -} - -// Syncer manages PowerSync synchronization. -type Syncer interface { - Start(ctx context.Context, db sqlite.DB, accountID string, onFirstSync func()) error - Stop() - State() State - IsReady() bool - NotifyUploadCompleted(ctx context.Context) error -} - -// ControlPlane wraps extension control operations used by the syncer. -type ControlPlane interface { - Start(ctx context.Context, req extension.StartRequest) ([]extension.Instruction, error) - SendTextLine(ctx context.Context, line string) ([]extension.Instruction, error) - NotifyConnection(ctx context.Context, event extension.ConnectionEvent) ([]extension.Instruction, error) - NotifyTokenRefreshed(ctx context.Context) ([]extension.Instruction, error) - NotifyUploadCompleted(ctx context.Context) ([]extension.Instruction, error) - Close() error -} - -var _ Syncer = (*syncer)(nil) -var _ ControlPlane = (*extension.Controller)(nil) - -// syncer implements Syncer. -type syncer struct { - endpoint string - tokenRefresher TokenRefresher - scope log.Scope - clientFactory func(endpoint string) psapi.Client - controlPlaneFn func(db sqlite.DB) ControlPlane - streamCapture StreamCapture - - database sqlite.DB - accountID string - control ControlPlane - client psapi.Client - onFirstSync func() - controlMu sync.Mutex - - // State - protected by atomic operations - state atomic.Pointer[stateWrapper] - - cancel context.CancelFunc - done chan struct{} -} - -// stateWrapper wraps State to allow atomic.Pointer usage. -type stateWrapper struct { - state State -} - -// SyncerOption configures a Syncer. -type SyncerOption func(*syncer) - -// WithClientFactory sets a custom client factory (for testing). -func WithClientFactory(factory func(endpoint string) psapi.Client) SyncerOption { - return func(s *syncer) { - s.clientFactory = factory - } -} - -// WithControlPlaneFactory sets a custom control plane factory (for testing). -func WithControlPlaneFactory(factory func(db sqlite.DB) ControlPlane) SyncerOption { - return func(s *syncer) { - s.controlPlaneFn = factory - } -} - -// WithStreamCapture sets a best-effort raw sync-stream capture sink. -func WithStreamCapture(capture StreamCapture) SyncerOption { - return func(s *syncer) { - s.streamCapture = capture - } -} - -// NewSyncer creates a new Syncer. Call Start() when you have a database ready. -func NewSyncer(endpoint string, tokenRefresher TokenRefresher, scope log.Scope, opts ...SyncerOption) Syncer { - s := &syncer{ - endpoint: endpoint, - tokenRefresher: tokenRefresher, - scope: scope.Child("powersync"), - clientFactory: psapi.NewClient, - controlPlaneFn: func(db sqlite.DB) ControlPlane { - return extension.NewController(db) - }, - } - for _, opt := range opts { - opt(s) - } - s.setState(NewDisconnected()) - return s -} - -// Start begins syncing. The onFirstSync callback fires once when initial sync completes. -func (s *syncer) Start(ctx context.Context, database sqlite.DB, accountID string, onFirstSync func()) error { - if accountID == "" { - return fmt.Errorf("powersync: accountID is required") - } - if s.cancel != nil { - return fmt.Errorf("already started") - } - - token, err := s.tokenRefresher.GetAccessToken(ctx) - if err != nil { - return fmt.Errorf("get initial token: %w", err) - } - - if err := extension.ApplySchema(ctx, database); err != nil { - return err - } - - // Check database health before starting sync. - // A crash during migration or write can leave the database in an - // inconsistent state. If corrupt, return an error so caller can reset. - queue := db.NewCrudQueue(database) - if err := queue.CheckHealth(ctx); err != nil { - return err - } - - s.database = database - s.accountID = accountID - s.onFirstSync = onFirstSync - s.control = s.controlPlaneFn(database) - s.client = s.clientFactory(s.endpoint) - s.client.SetToken(token) - s.done = make(chan struct{}) - s.setState(NewConnecting()) - - ctx, s.cancel = context.WithCancel(ctx) - go s.run(ctx) - - s.scope.Info("sync started", log.String("accountID", accountID)) - return nil -} - -// Stop shuts down syncing. -func (s *syncer) Stop() { - if s.cancel != nil { - s.cancel() - <-s.done - s.cancel = nil - } - s.controlMu.Lock() - if s.control != nil { - _ = s.control.Close() - s.control = nil - } - s.controlMu.Unlock() - - if s.streamCapture != nil { - if err := s.streamCapture.Close(); err != nil { - s.scope.Warn("failed to close stream capture", "error", err) - } - s.streamCapture = nil - } - - s.client = nil - s.database = nil - s.done = nil - s.setState(NewDisconnected()) - s.scope.Info("sync stopped") -} - -// State returns the current syncer state. -func (s *syncer) State() State { - if w := s.state.Load(); w != nil { - return w.state - } - return NewDisconnected() -} - -// IsReady returns true if initial sync is complete. -func (s *syncer) IsReady() bool { - _, ok := s.State().(*Ready) - return ok -} - -func (s *syncer) NotifyUploadCompleted(ctx context.Context) error { - instructions, err := s.controlPlaneNotifyUploadCompleted(ctx) - if errors.Is(err, errControlPlaneUnavailable) { - // Upload completion can legitimately race with shutdown or run before start. - return nil - } - if err != nil { - return fmt.Errorf("notify upload completed: %w", err) - } - if _, err := s.applyInstructions(ctx, instructions); err != nil { - return fmt.Errorf("apply upload completion instructions: %w", err) - } - return nil -} - -func (s *syncer) setState(state State) { - s.state.Store(&stateWrapper{state: state}) -} diff --git a/internal/powersync/syncer_controlplane.go b/internal/powersync/syncer_controlplane.go deleted file mode 100644 index 3a076294..00000000 --- a/internal/powersync/syncer_controlplane.go +++ /dev/null @@ -1,69 +0,0 @@ -package powersync - -import ( - "context" - "errors" - - "github.com/usetero/cli/internal/powersync/extension" -) - -var errControlPlaneUnavailable = errors.New("control plane unavailable") - -func (s *syncer) withControlPlaneLocked(fn func(c ControlPlane) error) error { - s.controlMu.Lock() - defer s.controlMu.Unlock() - if s.control == nil { - return errControlPlaneUnavailable - } - return fn(s.control) -} - -func (s *syncer) controlPlaneStart(ctx context.Context, req extension.StartRequest) ([]extension.Instruction, error) { - var instructions []extension.Instruction - err := s.withControlPlaneLocked(func(c ControlPlane) error { - var err error - instructions, err = c.Start(ctx, req) - return err - }) - return instructions, err -} - -func (s *syncer) controlPlaneSendTextLine(ctx context.Context, line string) ([]extension.Instruction, error) { - var instructions []extension.Instruction - err := s.withControlPlaneLocked(func(c ControlPlane) error { - var err error - instructions, err = c.SendTextLine(ctx, line) - return err - }) - return instructions, err -} - -func (s *syncer) controlPlaneNotifyConnection(ctx context.Context, event extension.ConnectionEvent) ([]extension.Instruction, error) { - var instructions []extension.Instruction - err := s.withControlPlaneLocked(func(c ControlPlane) error { - var err error - instructions, err = c.NotifyConnection(ctx, event) - return err - }) - return instructions, err -} - -func (s *syncer) controlPlaneNotifyTokenRefreshed(ctx context.Context) ([]extension.Instruction, error) { - var instructions []extension.Instruction - err := s.withControlPlaneLocked(func(c ControlPlane) error { - var err error - instructions, err = c.NotifyTokenRefreshed(ctx) - return err - }) - return instructions, err -} - -func (s *syncer) controlPlaneNotifyUploadCompleted(ctx context.Context) ([]extension.Instruction, error) { - var instructions []extension.Instruction - err := s.withControlPlaneLocked(func(c ControlPlane) error { - var err error - instructions, err = c.NotifyUploadCompleted(ctx) - return err - }) - return instructions, err -} diff --git a/internal/powersync/syncer_controlplane_test.go b/internal/powersync/syncer_controlplane_test.go deleted file mode 100644 index dc205170..00000000 --- a/internal/powersync/syncer_controlplane_test.go +++ /dev/null @@ -1,243 +0,0 @@ -package powersync - -import ( - "context" - "errors" - "fmt" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/extension" -) - -func TestSyncer_ControlPlaneAccessIsSerialized(t *testing.T) { - t.Parallel() - - cp := &blockingControlPlane{} - s := &syncer{ - control: cp, - scope: logtest.NewScope(t), - } - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - var wg sync.WaitGroup - for i := 0; i < 20; i++ { - wg.Add(2) - go func() { - defer wg.Done() - _ = s.processLine(ctx, []byte(`{"token_expires_in":3600}`)) - }() - go func() { - defer wg.Done() - _ = s.NotifyUploadCompleted(ctx) - }() - } - wg.Wait() - - if got := cp.maxInFlight.Load(); got > 1 { - t.Fatalf("control plane operations overlapped, max_in_flight=%d", got) - } -} - -func TestSyncer_NotifyUploadCompleted_ContextCancellationPropagates(t *testing.T) { - t.Parallel() - - cp := &contextAwareControlPlane{} - s := &syncer{ - control: cp, - scope: logtest.NewScope(t), - } - - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) - defer cancel() - - err := s.NotifyUploadCompleted(ctx) - if !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("NotifyUploadCompleted() error = %v, want deadline exceeded", err) - } -} - -func TestSyncer_ProcessLine_ContextCancellationPropagates(t *testing.T) { - t.Parallel() - - cp := &contextAwareControlPlane{} - s := &syncer{ - control: cp, - scope: logtest.NewScope(t), - } - - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) - defer cancel() - - err := s.processLine(ctx, []byte(`{"token_expires_in":3600}`)) - if !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("processLine() error = %v, want deadline exceeded", err) - } -} - -func TestSyncer_NotifyUploadCompleted_ConcurrentWithStop_IsSafe(t *testing.T) { - t.Parallel() - - cp := &blockingControlPlane{} - s := &syncer{ - control: cp, - scope: logtest.NewScope(t), - } - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - var wg sync.WaitGroup - for i := 0; i < 50; i++ { - wg.Add(1) - go func() { - defer wg.Done() - _ = s.NotifyUploadCompleted(ctx) - }() - } - - // Stop concurrently while notifications are in flight. - s.Stop() - wg.Wait() -} - -func TestSyncer_ProcessLine_ControlPlaneUnavailable(t *testing.T) { - t.Parallel() - - s := &syncer{ - scope: logtest.NewScope(t), - } - - err := s.processLine(context.Background(), []byte(`{"token_expires_in":3600}`)) - if err == nil { - t.Fatal("expected error, got nil") - } - if !errors.Is(err, errControlPlaneUnavailable) { - t.Fatalf("processLine() error = %v, want errControlPlaneUnavailable", err) - } -} - -type blockingControlPlane struct { - inFlight atomic.Int32 - maxInFlight atomic.Int32 -} - -func (b *blockingControlPlane) recordCall() { - current := b.inFlight.Add(1) - for { - max := b.maxInFlight.Load() - if current <= max { - break - } - if b.maxInFlight.CompareAndSwap(max, current) { - break - } - } - time.Sleep(2 * time.Millisecond) - b.inFlight.Add(-1) -} - -func (b *blockingControlPlane) Start(context.Context, extension.StartRequest) ([]extension.Instruction, error) { - b.recordCall() - return nil, nil -} - -func (b *blockingControlPlane) SendTextLine(context.Context, string) ([]extension.Instruction, error) { - b.recordCall() - return nil, nil -} - -func (b *blockingControlPlane) NotifyConnection(context.Context, extension.ConnectionEvent) ([]extension.Instruction, error) { - b.recordCall() - return nil, nil -} - -func (b *blockingControlPlane) NotifyTokenRefreshed(context.Context) ([]extension.Instruction, error) { - b.recordCall() - return nil, nil -} - -func (b *blockingControlPlane) NotifyUploadCompleted(context.Context) ([]extension.Instruction, error) { - b.recordCall() - return nil, nil -} - -func (b *blockingControlPlane) Close() error { - return nil -} - -type contextAwareControlPlane struct{} - -func (*contextAwareControlPlane) Start(context.Context, extension.StartRequest) ([]extension.Instruction, error) { - return nil, nil -} - -func (*contextAwareControlPlane) SendTextLine(ctx context.Context, _ string) ([]extension.Instruction, error) { - <-ctx.Done() - return nil, ctx.Err() -} - -func (*contextAwareControlPlane) NotifyConnection(context.Context, extension.ConnectionEvent) ([]extension.Instruction, error) { - return nil, nil -} - -func (*contextAwareControlPlane) NotifyTokenRefreshed(context.Context) ([]extension.Instruction, error) { - return nil, nil -} - -func (*contextAwareControlPlane) NotifyUploadCompleted(ctx context.Context) ([]extension.Instruction, error) { - <-ctx.Done() - return nil, ctx.Err() -} - -func (*contextAwareControlPlane) Close() error { - return nil -} - -func TestSyncer_NotifyUploadCompleted_PropagatesControlPlaneError(t *testing.T) { - t.Parallel() - - cp := &errorControlPlane{err: fmt.Errorf("boom")} - s := &syncer{ - control: cp, - scope: logtest.NewScope(t), - } - - err := s.NotifyUploadCompleted(context.Background()) - if err == nil { - t.Fatal("expected error, got nil") - } -} - -type errorControlPlane struct { - err error -} - -func (e *errorControlPlane) Start(context.Context, extension.StartRequest) ([]extension.Instruction, error) { - return nil, nil -} - -func (e *errorControlPlane) SendTextLine(context.Context, string) ([]extension.Instruction, error) { - return nil, nil -} - -func (e *errorControlPlane) NotifyConnection(context.Context, extension.ConnectionEvent) ([]extension.Instruction, error) { - return nil, nil -} - -func (e *errorControlPlane) NotifyTokenRefreshed(context.Context) ([]extension.Instruction, error) { - return nil, nil -} - -func (e *errorControlPlane) NotifyUploadCompleted(context.Context) ([]extension.Instruction, error) { - return nil, e.err -} - -func (e *errorControlPlane) Close() error { - return nil -} diff --git a/internal/powersync/syncer_instructions.go b/internal/powersync/syncer_instructions.go deleted file mode 100644 index f7b76ca6..00000000 --- a/internal/powersync/syncer_instructions.go +++ /dev/null @@ -1,57 +0,0 @@ -package powersync - -import ( - "context" - "fmt" - - "github.com/usetero/cli/internal/powersync/extension" -) - -type streamAction int - -const ( - streamActionContinue streamAction = iota - streamActionClose -) - -func (s *syncer) applyInstructions(ctx context.Context, instructions []extension.Instruction) (streamAction, error) { - for _, inst := range instructions { - switch inst.Type { - case extension.InstructionDidCompleteSync: - s.scope.Debug("sync complete") - s.setState(NewReady()) - s.fireFirstSync() - - case extension.InstructionUpdateSyncStatus: - if inst.SyncStatus != nil && inst.SyncStatus.Downloading != nil { - downloaded, total := inst.SyncStatus.Downloading.TotalProgress() - s.setState(NewSyncing().WithProgress(downloaded, total)) - s.scope.Debug("sync progress", "downloaded", downloaded, "total", total) - } - - case extension.InstructionFetchCredentials: - s.scope.Debug("received FetchCredentials", "didExpire", inst.DidExpire) - if err := s.refreshToken(ctx); err != nil { - return streamActionContinue, err - } - - case extension.InstructionCloseSyncStream: - s.scope.Debug("received CloseSyncStream") - return streamActionClose, nil - - case extension.InstructionFlushFileSystem: - // Native SQLite handles durability on commit. Treat this as a best-effort - // checkpoint request to reduce WAL growth and honor the extension signal. - if _, err := s.database.Exec(ctx, "PRAGMA wal_checkpoint(PASSIVE)"); err != nil { - return streamActionContinue, fmt.Errorf("flush file system: %w", err) - } - - case extension.InstructionLogLine: - s.scope.Debug("powersync", "severity", inst.Severity, "line", inst.Line) - default: - // Other instruction types not expected during line processing. - } - } - - return streamActionContinue, nil -} diff --git a/internal/powersync/syncer_instructions_test.go b/internal/powersync/syncer_instructions_test.go deleted file mode 100644 index 68a50b12..00000000 --- a/internal/powersync/syncer_instructions_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package powersync - -import ( - "context" - "fmt" - "testing" - - "github.com/usetero/cli/internal/boundary/powersync/apitest" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/powersync/extension" -) - -func TestSyncer_ApplyInstructions_CloseSyncStream(t *testing.T) { - t.Parallel() - - s := &syncer{ - scope: logtest.NewScope(t), - } - - action, err := s.applyInstructions(context.Background(), []extension.Instruction{ - {Type: extension.InstructionCloseSyncStream}, - }) - if err != nil { - t.Fatalf("applyInstructions() error = %v", err) - } - if action != streamActionClose { - t.Fatalf("action = %v, want %v", action, streamActionClose) - } -} - -func TestSyncer_ApplyInstructions_FlushFileSystem(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - s := &syncer{ - database: db, - scope: logtest.NewScope(t), - } - - action, err := s.applyInstructions(context.Background(), []extension.Instruction{ - {Type: extension.InstructionFlushFileSystem}, - }) - if err != nil { - t.Fatalf("applyInstructions() error = %v", err) - } - if action != streamActionContinue { - t.Fatalf("action = %v, want %v", action, streamActionContinue) - } -} - -func TestSyncer_ApplyInstructions_FetchCredentialsThenClose(t *testing.T) { - t.Parallel() - - mockClient := apitest.NewMockClient() - ctrl := &stubController{} - s := &syncer{ - tokenRefresher: &stubTokenRefresher{token: "new-token"}, - client: mockClient, - control: ctrl, - scope: logtest.NewScope(t), - } - - action, err := s.applyInstructions(context.Background(), []extension.Instruction{ - {Type: extension.InstructionFetchCredentials}, - {Type: extension.InstructionCloseSyncStream}, - }) - if err != nil { - t.Fatalf("applyInstructions() error = %v", err) - } - if action != streamActionClose { - t.Fatalf("action = %v, want %v", action, streamActionClose) - } - if mockClient.Token != "new-token" { - t.Fatalf("token = %q, want %q", mockClient.Token, "new-token") - } - if ctrl.notifyTokenRefreshedCalls != 1 { - t.Fatalf("NotifyTokenRefreshed calls = %d, want 1", ctrl.notifyTokenRefreshedCalls) - } -} - -func TestSyncer_ApplyInstructions_FetchCredentialsError(t *testing.T) { - t.Parallel() - - mockClient := apitest.NewMockClient() - ctrl := &stubController{ - notifyTokenRefreshedErr: fmt.Errorf("notify failed"), - } - s := &syncer{ - tokenRefresher: &stubTokenRefresher{token: "new-token"}, - client: mockClient, - control: ctrl, - scope: logtest.NewScope(t), - } - - _, err := s.applyInstructions(context.Background(), []extension.Instruction{ - {Type: extension.InstructionFetchCredentials}, - {Type: extension.InstructionCloseSyncStream}, - }) - if err == nil { - t.Fatal("expected error, got nil") - } - if ctrl.notifyTokenRefreshedCalls != 1 { - t.Fatalf("NotifyTokenRefreshed calls = %d, want 1", ctrl.notifyTokenRefreshedCalls) - } -} - -type stubController struct { - notifyTokenRefreshedCalls int - notifyTokenRefreshedErr error -} - -func (s *stubController) Start(context.Context, extension.StartRequest) ([]extension.Instruction, error) { - return nil, nil -} - -func (s *stubController) SendTextLine(context.Context, string) ([]extension.Instruction, error) { - return nil, nil -} - -func (s *stubController) NotifyConnection(context.Context, extension.ConnectionEvent) ([]extension.Instruction, error) { - return nil, nil -} - -func (s *stubController) NotifyTokenRefreshed(context.Context) ([]extension.Instruction, error) { - s.notifyTokenRefreshedCalls++ - return nil, s.notifyTokenRefreshedErr -} - -func (s *stubController) NotifyUploadCompleted(context.Context) ([]extension.Instruction, error) { - return nil, nil -} - -func (s *stubController) Close() error { - return nil -} - -type stubTokenRefresher struct { - token string - err error -} - -func (s *stubTokenRefresher) GetAccessToken(context.Context) (string, error) { - if s.err != nil { - return "", s.err - } - return s.token, nil -} - -func (s *stubTokenRefresher) ForceRefreshAccessToken(context.Context) (string, error) { - if s.err != nil { - return "", s.err - } - return s.token, nil -} diff --git a/internal/powersync/syncer_lifecycle_test.go b/internal/powersync/syncer_lifecycle_test.go deleted file mode 100644 index 7761f6d3..00000000 --- a/internal/powersync/syncer_lifecycle_test.go +++ /dev/null @@ -1,309 +0,0 @@ -package powersync_test - -import ( - "context" - "testing" - "time" - - psapi "github.com/usetero/cli/internal/boundary/powersync" - "github.com/usetero/cli/internal/boundary/powersync/apitest" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync" - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/powersync/powersynctest" -) - -type closeCountCapture struct { - closeCalls int -} - -func (c *closeCountCapture) CaptureLine([]byte) {} -func (c *closeCountCapture) Close() error { - c.closeCalls++ - return nil -} - -func TestNewSyncer(t *testing.T) { - t.Parallel() - - t.Run("initial state is disconnected", func(t *testing.T) { - t.Parallel() - - syncer := powersync.NewSyncer("https://example.com", nil, logtest.NewScope(t)) - - if _, ok := syncer.State().(*powersync.Disconnected); !ok { - t.Errorf("State() = %T, want *Disconnected", syncer.State()) - } - }) - - t.Run("IsReady is false initially", func(t *testing.T) { - t.Parallel() - - syncer := powersync.NewSyncer("https://example.com", nil, logtest.NewScope(t)) - - if syncer.IsReady() { - t.Error("IsReady() should be false before Start") - } - }) -} - -func TestSyncer_Start(t *testing.T) { - t.Parallel() - - t.Run("returns error on empty accountID", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - syncer := powersync.NewSyncer( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - logtest.NewScope(t), - ) - - err := syncer.Start(context.Background(), db, "", nil) - if err == nil { - t.Error("expected error for empty accountID") - } - }) - - t.Run("returns error if already started", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - syncer := powersynctest.NewSyncerWithMockClient( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - apitest.NewMockClient(), - ) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := syncer.Start(ctx, db, "account-123", nil) - if err != nil { - t.Fatalf("first Start() error = %v", err) - } - defer syncer.Stop() - - err = syncer.Start(ctx, db, "account-123", nil) - if err == nil { - t.Error("expected error on second Start()") - } - }) - - t.Run("transitions to syncing when stream connects", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - started := make(chan struct{}) - mock := apitest.NewMockClient() - mock.SyncStreamFunc = func(ctx context.Context, req *psapi.SyncStreamRequest, handler psapi.LineHandler) error { - close(started) - <-ctx.Done() - return ctx.Err() - } - - syncer := powersynctest.NewSyncerWithMockClient( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - mock, - ) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := syncer.Start(ctx, db, "account-123", nil) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - defer syncer.Stop() - - <-started - - state := syncer.State() - _, isSyncing := state.(*powersync.Syncing) - _, isConnecting := state.(*powersync.Connecting) - if !isSyncing && !isConnecting { - t.Errorf("State() = %T, want *Syncing or *Connecting", state) - } - }) - - t.Run("processes lines from stream", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - - handlerCalled := make(chan struct{}) - mock := apitest.NewMockClient() - mock.SyncStreamFunc = func(ctx context.Context, req *psapi.SyncStreamRequest, handler psapi.LineHandler) error { - if err := handler([]byte(`{"token_expires_in":3600}`)); err != nil { - return err - } - close(handlerCalled) - <-ctx.Done() - return ctx.Err() - } - - syncer := powersynctest.NewSyncerWithMockClient( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - mock, - ) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := syncer.Start(ctx, db, "account-123", nil) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - defer syncer.Stop() - - select { - case <-handlerCalled: - case <-time.After(5 * time.Second): - t.Fatal("timeout waiting for stream line to be processed") - } - }) - - t.Run("does not panic with nil onFirstSync callback", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - syncer := powersynctest.NewSyncerWithMockClient( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - apitest.NewMockClient(), - ) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := syncer.Start(ctx, db, "account-123", nil) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - syncer.Stop() - }) -} - -func TestSyncer_Stop(t *testing.T) { - t.Parallel() - - t.Run("transitions to disconnected", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - syncer := powersynctest.NewSyncerWithMockClient( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - apitest.NewMockClient(), - ) - - ctx := context.Background() - err := syncer.Start(ctx, db, "account-123", nil) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - syncer.Stop() - - if _, ok := syncer.State().(*powersync.Disconnected); !ok { - t.Errorf("State() = %T, want *Disconnected", syncer.State()) - } - }) - - t.Run("IsReady is false after stop", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - syncer := powersynctest.NewSyncerWithMockClient( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - apitest.NewMockClient(), - ) - - ctx := context.Background() - _ = syncer.Start(ctx, db, "account-123", nil) - syncer.Stop() - - if syncer.IsReady() { - t.Error("IsReady() should be false after Stop") - } - }) - - t.Run("is safe to call multiple times", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - syncer := powersynctest.NewSyncerWithMockClient( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - apitest.NewMockClient(), - ) - - ctx := context.Background() - _ = syncer.Start(ctx, db, "account-123", nil) - - syncer.Stop() - syncer.Stop() - syncer.Stop() - }) - - t.Run("closes configured stream capture only once", func(t *testing.T) { - t.Parallel() - - capture := &closeCountCapture{} - syncer := powersync.NewSyncer( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - logtest.NewScope(t), - powersync.WithStreamCapture(capture), - ) - - syncer.Stop() - syncer.Stop() - - if capture.closeCalls != 1 { - t.Fatalf("capture close calls = %d, want 1", capture.closeCalls) - } - }) -} - -func TestSyncer_NotifyUploadCompleted(t *testing.T) { - t.Parallel() - - t.Run("is safe before start", func(t *testing.T) { - t.Parallel() - - syncer := powersync.NewSyncer( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - logtest.NewScope(t), - ) - - if err := syncer.NotifyUploadCompleted(context.Background()); err != nil { - t.Fatalf("NotifyUploadCompleted() error = %v", err) - } - }) - - t.Run("is safe after stop", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - syncer := powersynctest.NewSyncerWithMockClient( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - apitest.NewMockClient(), - ) - - if err := syncer.Start(context.Background(), db, "account-123", nil); err != nil { - t.Fatalf("Start() error = %v", err) - } - syncer.Stop() - - if err := syncer.NotifyUploadCompleted(context.Background()); err != nil { - t.Fatalf("NotifyUploadCompleted() error = %v", err) - } - }) -} diff --git a/internal/powersync/syncer_retry_test.go b/internal/powersync/syncer_retry_test.go deleted file mode 100644 index 5d60a47f..00000000 --- a/internal/powersync/syncer_retry_test.go +++ /dev/null @@ -1,327 +0,0 @@ -package powersync_test - -import ( - "context" - "fmt" - "sync/atomic" - "testing" - "time" - - psapi "github.com/usetero/cli/internal/boundary/powersync" - "github.com/usetero/cli/internal/boundary/powersync/apitest" - "github.com/usetero/cli/internal/powersync" - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/powersync/powersynctest" -) - -func TestSyncer_ErrorHandling(t *testing.T) { - t.Parallel() - - t.Run("force-refreshes token on 401 and retries", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - - var connectCalls atomic.Int32 - mock := apitest.NewMockClient() - mock.SyncStreamFunc = func(ctx context.Context, req *psapi.SyncStreamRequest, handler psapi.LineHandler) error { - n := connectCalls.Add(1) - if n == 1 { - return &psapi.Error{Kind: psapi.ErrorKindAuth, StatusCode: 401} - } - <-ctx.Done() - return ctx.Err() - } - - var forceRefreshed atomic.Bool - refresher := &powersynctest.MockTokenRefresher{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - if forceRefreshed.Load() { - return "new-token", nil - } - return "stale-token", nil - }, - ForceRefreshAccessTokenFunc: func(ctx context.Context) (string, error) { - forceRefreshed.Store(true) - return "new-token", nil - }, - } - - syncer := powersynctest.NewSyncerWithMockClient( - "https://example.com", - refresher, - mock, - ) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := syncer.Start(ctx, db, "account-123", nil) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - time.Sleep(500 * time.Millisecond) - syncer.Stop() - - if !forceRefreshed.Load() { - t.Error("expected ForceRefreshAccessToken to be called on 401") - } - if mock.Token != "new-token" { - t.Errorf("Token = %q, want %q", mock.Token, "new-token") - } - }) - - t.Run("auth errors are never fatal", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - - var connectCalls atomic.Int32 - mock := apitest.NewMockClient() - mock.SyncStreamFunc = func(ctx context.Context, req *psapi.SyncStreamRequest, handler psapi.LineHandler) error { - n := connectCalls.Add(1) - if n <= 5 { - return &psapi.Error{Kind: psapi.ErrorKindAuth, StatusCode: 401} - } - <-ctx.Done() - return ctx.Err() - } - - refresher := &powersynctest.MockTokenRefresher{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - ForceRefreshAccessTokenFunc: func(ctx context.Context) (string, error) { - return "new-token", nil - }, - } - - syncer := powersynctest.NewSyncerWithMockClient( - "https://example.com", - refresher, - mock, - ) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := syncer.Start(ctx, db, "account-123", nil) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - time.Sleep(500 * time.Millisecond) - - if _, ok := syncer.State().(*powersync.Error); ok { - t.Error("auth errors should never be fatal") - } - if connectCalls.Load() <= 5 { - t.Errorf("expected more than 5 connect calls, got %d", connectCalls.Load()) - } - - syncer.Stop() - }) - - t.Run("retries when token refresh fails", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - - mock := apitest.NewMockClient() - mock.SyncStreamFunc = func(ctx context.Context, req *psapi.SyncStreamRequest, handler psapi.LineHandler) error { - return &psapi.Error{Kind: psapi.ErrorKindAuth, StatusCode: 401} - } - - var refreshCalls atomic.Int32 - refresher := &powersynctest.MockTokenRefresher{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - ForceRefreshAccessTokenFunc: func(ctx context.Context) (string, error) { - n := refreshCalls.Add(1) - if n <= 2 { - return "", fmt.Errorf("network error") - } - return "new-token", nil - }, - } - - syncer := powersynctest.NewSyncerWithMockClient( - "https://example.com", - refresher, - mock, - ) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := syncer.Start(ctx, db, "account-123", nil) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - time.Sleep(4 * time.Second) - - if _, ok := syncer.State().(*powersync.Error); ok { - t.Error("refresh failures should not be fatal") - } - if refreshCalls.Load() < 3 { - t.Errorf("expected at least 3 refresh calls, got %d", refreshCalls.Load()) - } - - syncer.Stop() - }) - - t.Run("transitions to error state on permanent error", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - mock := apitest.NewMockClient() - mock.SyncStreamFunc = func(ctx context.Context, req *psapi.SyncStreamRequest, handler psapi.LineHandler) error { - return &psapi.Error{Kind: psapi.ErrorKindPermanent, StatusCode: 400, Message: "bad request"} - } - - syncer := powersynctest.NewSyncerWithMockClient( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - mock, - ) - - ctx := context.Background() - err := syncer.Start(ctx, db, "account-123", nil) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - time.Sleep(100 * time.Millisecond) - - errState, ok := syncer.State().(*powersync.Error) - if !ok { - t.Fatalf("State() = %T, want *Error", syncer.State()) - } - if errState.Err == nil { - t.Error("Error.Err should not be nil") - } - }) - - t.Run("retries on non-API error with backoff", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - - var connectCalls atomic.Int32 - mock := apitest.NewMockClient() - mock.SyncStreamFunc = func(ctx context.Context, req *psapi.SyncStreamRequest, handler psapi.LineHandler) error { - n := connectCalls.Add(1) - if n == 1 { - return fmt.Errorf("powersync_control: invalid state: No iteration is active") - } - <-ctx.Done() - return ctx.Err() - } - - syncer := powersynctest.NewSyncerWithMockClient( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - mock, - ) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := syncer.Start(ctx, db, "account-123", nil) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - time.Sleep(2 * time.Second) - syncer.Stop() - - if connectCalls.Load() < 2 { - t.Errorf("expected at least 2 connect calls, got %d", connectCalls.Load()) - } - - if _, ok := syncer.State().(*powersync.Error); ok { - t.Error("non-API errors should be retried, not fatal") - } - if _, ok := syncer.State().(*powersync.Reconnecting); ok { - t.Error("expected recovery, not reconnecting") - } - }) - - t.Run("marks degraded after repeated failures", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - - mock := apitest.NewMockClient() - mock.SyncStreamFunc = func(ctx context.Context, req *psapi.SyncStreamRequest, handler psapi.LineHandler) error { - return fmt.Errorf("powersync_control: invalid state: No iteration is active") - } - - syncer := powersynctest.NewSyncerWithMockClient( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - mock, - ) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := syncer.Start(ctx, db, "account-123", nil) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - time.Sleep(8 * time.Second) - - state, ok := syncer.State().(*powersync.Reconnecting) - if !ok { - t.Fatalf("State() = %T, want *Reconnecting", syncer.State()) - } - if !state.Degraded { - t.Error("expected Degraded to be true after repeated failures") - } - - syncer.Stop() - }) - - t.Run("retries on transient error with backoff", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - - var connectCalls atomic.Int32 - mock := apitest.NewMockClient() - mock.SyncStreamFunc = func(ctx context.Context, req *psapi.SyncStreamRequest, handler psapi.LineHandler) error { - n := connectCalls.Add(1) - if n == 1 { - return &psapi.Error{Kind: psapi.ErrorKindTransient, StatusCode: 503} - } - <-ctx.Done() - return ctx.Err() - } - - syncer := powersynctest.NewSyncerWithMockClient( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - mock, - ) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := syncer.Start(ctx, db, "account-123", nil) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - time.Sleep(2 * time.Second) - syncer.Stop() - - if connectCalls.Load() < 2 { - t.Errorf("expected at least 2 connect calls, got %d", connectCalls.Load()) - } - }) -} diff --git a/internal/powersync/syncer_run.go b/internal/powersync/syncer_run.go deleted file mode 100644 index d5be3c49..00000000 --- a/internal/powersync/syncer_run.go +++ /dev/null @@ -1,111 +0,0 @@ -package powersync - -import ( - "context" - "errors" - "fmt" - "time" - - psapi "github.com/usetero/cli/internal/boundary/powersync" - "github.com/usetero/cli/internal/log" -) - -const ( - initialRetryDelay = 1 * time.Second - maxRetryDelay = 10 * time.Second - errorStateAfter = 3 // show error state after this many consecutive failures -) - -// run is the main sync loop with retry logic. -func (s *syncer) run(ctx context.Context) { - defer close(s.done) - - retryDelay := initialRetryDelay - retries := 0 - - for { - if ctx.Err() != nil { - return - } - - err := s.runSession(ctx) - if err == nil { - retryDelay = initialRetryDelay - retries = 0 - continue - } - if ctx.Err() != nil { - return - } - - var clientErr *psapi.Error - if errors.As(err, &clientErr) { - if clientErr.IsAuth() { - s.scope.Debug("auth error, force-refreshing token") - s.setState(NewReconnecting(false)) - if err := s.forceRefreshToken(ctx); err != nil { - // Refresh failed — backoff and try again later. - // Don't give up. The server may be down, we'll recover when it's back. - retries++ - s.scope.Debug("token refresh failed, retrying", log.Duration("delay", retryDelay), log.Any("error", err)) - s.setState(NewReconnecting(retries >= errorStateAfter)) - s.wait(ctx, retryDelay) - retryDelay = min(retryDelay*2, maxRetryDelay) - } - continue - } - - if clientErr.IsPermanent() { - s.setError(err) - return - } - } - - // Transient API errors and non-API errors (e.g. extension state - // errors) are retried with backoff. runSession calls Start() which - // resets the extension state via tear_down(), so retry is safe. - retries++ - s.scope.Debug("transient error, retrying", log.Duration("delay", retryDelay), log.Int("attempt", retries), log.Any("error", err)) - s.setState(NewReconnecting(retries >= errorStateAfter)) - s.wait(ctx, retryDelay) - retryDelay = min(retryDelay*2, maxRetryDelay) - } -} - -func (s *syncer) refreshToken(ctx context.Context) error { - token, err := s.tokenRefresher.GetAccessToken(ctx) - if err != nil { - return err - } - s.client.SetToken(token) - if _, err := s.controlPlaneNotifyTokenRefreshed(ctx); err != nil { - return fmt.Errorf("notify token refreshed: %w", err) - } - return nil -} - -// forceRefreshToken unconditionally refreshes the token, bypassing local -// expiration checks. Used when the server has rejected the current token. -func (s *syncer) forceRefreshToken(ctx context.Context) error { - token, err := s.tokenRefresher.ForceRefreshAccessToken(ctx) - if err != nil { - return err - } - s.client.SetToken(token) - if _, err := s.controlPlaneNotifyTokenRefreshed(ctx); err != nil { - return fmt.Errorf("notify token refreshed: %w", err) - } - return nil -} - -func (s *syncer) setError(err error) { - s.setState(NewError(err)) - s.scope.Error("sync failed", log.Any("error", err)) -} - -func (s *syncer) wait(ctx context.Context, d time.Duration) { - select { - case <-ctx.Done(): - case <-time.After(d): - } -} diff --git a/internal/powersync/syncer_shutdown_test.go b/internal/powersync/syncer_shutdown_test.go deleted file mode 100644 index 9e2ac01b..00000000 --- a/internal/powersync/syncer_shutdown_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package powersync_test - -import ( - "context" - "testing" - "time" - - psapi "github.com/usetero/cli/internal/boundary/powersync" - "github.com/usetero/cli/internal/boundary/powersync/apitest" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync" - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/powersync/extension" - "github.com/usetero/cli/internal/powersync/powersynctest" - "github.com/usetero/cli/internal/sqlite" -) - -func TestSyncer_Stop_UnblocksBlockedControlPlaneCalls(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - cp := &cancelAwareControlPlane{} - mockClient := apitest.NewMockClient() - mockClient.SyncStreamFunc = func(ctx context.Context, req *psapi.SyncStreamRequest, handler psapi.LineHandler) error { - if err := handler([]byte(`{"token_expires_in":3600}`)); err != nil { - return err - } - <-ctx.Done() - return ctx.Err() - } - - s := powersync.NewSyncer( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - logtest.NewScope(t), - powersync.WithClientFactory(apitest.NewMockClientFactory(mockClient)), - powersync.WithControlPlaneFactory(func(sqlite.DB) powersync.ControlPlane { return cp }), - ) - - ctx := context.Background() - if err := s.Start(ctx, db, "account-123", nil); err != nil { - t.Fatalf("Start() error = %v", err) - } - - done := make(chan struct{}) - go func() { - s.Stop() - close(done) - }() - - select { - case <-done: - // Expected: Stop cancels context and blocked control-plane calls return. - case <-time.After(2 * time.Second): - t.Fatal("Stop() timed out while control-plane call was blocked") - } -} - -type cancelAwareControlPlane struct{} - -func (c *cancelAwareControlPlane) Start(context.Context, extension.StartRequest) ([]extension.Instruction, error) { - return []extension.Instruction{{Type: extension.InstructionEstablishSyncStream, Request: &psapi.SyncStreamRequest{}}}, nil -} - -func (c *cancelAwareControlPlane) SendTextLine(ctx context.Context, line string) ([]extension.Instruction, error) { - <-ctx.Done() - return nil, ctx.Err() -} - -func (c *cancelAwareControlPlane) NotifyConnection(context.Context, extension.ConnectionEvent) ([]extension.Instruction, error) { - return nil, nil -} - -func (c *cancelAwareControlPlane) NotifyTokenRefreshed(context.Context) ([]extension.Instruction, error) { - return nil, nil -} - -func (c *cancelAwareControlPlane) NotifyUploadCompleted(context.Context) ([]extension.Instruction, error) { - return nil, nil -} - -func (c *cancelAwareControlPlane) Close() error { - return nil -} - -var _ powersync.ControlPlane = (*cancelAwareControlPlane)(nil) diff --git a/internal/powersync/syncer_stream.go b/internal/powersync/syncer_stream.go deleted file mode 100644 index 019c6582..00000000 --- a/internal/powersync/syncer_stream.go +++ /dev/null @@ -1,129 +0,0 @@ -package powersync - -import ( - "context" - "errors" - "fmt" - - psapi "github.com/usetero/cli/internal/boundary/powersync" - "github.com/usetero/cli/internal/powersync/extension" -) - -var errCloseSyncStream = errors.New("close sync stream") - -// runSession runs one sync session: start control plane, connect stream, process lines. -func (s *syncer) runSession(ctx context.Context) error { - // Proactively refresh the token before connecting. This avoids a - // needless 401 round-trip when the stream dropped after hours and the - // token expired while we were connected. GetAccessToken checks the JWT - // exp claim and only hits the network if the token is actually expired. - // We only update the HTTP client here — no control plane notification, - // since the control plane hasn't started its iteration yet. - if token, err := s.tokenRefresher.GetAccessToken(ctx); err != nil { - return err - } else { - s.client.SetToken(token) - } - - instructions, err := s.controlPlaneStart(ctx, extension.StartRequest{ - IncludeDefaults: true, - Parameters: map[string]any{"account_id": s.accountID}, - }) - if err != nil { - return fmt.Errorf("control plane start: %w", err) - } - - for _, inst := range instructions { - if ctx.Err() != nil { - return ctx.Err() - } - - switch inst.Type { - case extension.InstructionEstablishSyncStream: - if err := s.runStream(ctx, inst.Request); err != nil { - return err - } - case extension.InstructionFetchCredentials: - if err := s.refreshToken(ctx); err != nil { - return err - } - default: - // Other instruction types are handled elsewhere or ignored at this level. - } - } - - return nil -} - -// runStream connects to the stream and processes lines until disconnect. -func (s *syncer) runStream(ctx context.Context, req *psapi.SyncStreamRequest) error { - if req == nil { - return fmt.Errorf("no sync request") - } - - s.scope.Debug("connecting stream") - s.setState(NewSyncing()) - - connected := false - - err := s.client.SyncStream(ctx, req, func(line []byte) error { - if !connected { - if _, err := s.controlPlaneNotifyConnection(ctx, extension.ConnectionEstablished); err != nil { - return fmt.Errorf("notify connected: %w", err) - } - connected = true - } - return s.processLine(ctx, line) - }) - - if connected { - _, _ = s.controlPlaneNotifyConnection(ctx, extension.ConnectionEnded) - } - - if err != nil { - if errors.Is(err, errCloseSyncStream) { - return nil - } - return fmt.Errorf("stream: %w", err) - } - return nil -} - -// processLine handles one line from the sync stream. -func (s *syncer) processLine(ctx context.Context, line []byte) error { - s.captureStreamLine(line) - - instructions, err := s.controlPlaneSendTextLine(ctx, string(line)) - if err != nil { - return fmt.Errorf("send line: %w", err) - } - - action, err := s.applyInstructions(ctx, instructions) - if err != nil { - return err - } - if action == streamActionClose { - return errCloseSyncStream - } - return nil -} - -func (s *syncer) captureStreamLine(line []byte) { - if s.streamCapture == nil { - return - } - defer func() { - if r := recover(); r != nil { - s.scope.Error("stream capture panicked; continuing sync", "panic", r) - } - }() - s.streamCapture.CaptureLine(line) -} - -func (s *syncer) fireFirstSync() { - if s.onFirstSync != nil { - s.scope.Info("sync connected") - s.onFirstSync() - s.onFirstSync = nil - } -} diff --git a/internal/powersync/syncer_stream_test.go b/internal/powersync/syncer_stream_test.go deleted file mode 100644 index 838793e3..00000000 --- a/internal/powersync/syncer_stream_test.go +++ /dev/null @@ -1,169 +0,0 @@ -package powersync - -import ( - "context" - "fmt" - "testing" - - psapi "github.com/usetero/cli/internal/boundary/powersync" - "github.com/usetero/cli/internal/boundary/powersync/apitest" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/extension" -) - -func TestSyncer_RunStream_CloseInstructionEndsStream(t *testing.T) { - t.Parallel() - - cp := &streamTestControlPlane{ - sendTextInstructions: []extension.Instruction{ - {Type: extension.InstructionCloseSyncStream}, - }, - } - mockClient := apitest.NewMockClient() - mockClient.SyncStreamFunc = func(ctx context.Context, req *psapi.SyncStreamRequest, handler psapi.LineHandler) error { - return handler([]byte(`{"x":1}`)) - } - - s := &syncer{ - client: mockClient, - control: cp, - scope: logtest.NewScope(t), - } - - err := s.runStream(context.Background(), &psapi.SyncStreamRequest{}) - if err != nil { - t.Fatalf("runStream() error = %v", err) - } - if cp.notifyConnectionCalls != 2 { - t.Fatalf("NotifyConnection calls = %d, want 2 (established + end)", cp.notifyConnectionCalls) - } -} - -func TestSyncer_ProcessLine_InstructionErrorPropagates(t *testing.T) { - t.Parallel() - - cp := &streamTestControlPlane{ - sendTextInstructions: []extension.Instruction{ - {Type: extension.InstructionFetchCredentials}, - }, - } - s := &syncer{ - tokenRefresher: &stubTokenRefresher{err: fmt.Errorf("token unavailable")}, - client: apitest.NewMockClient(), - control: cp, - scope: logtest.NewScope(t), - } - - err := s.processLine(context.Background(), []byte(`{"token_expires_in":3600}`)) - if err == nil { - t.Fatal("expected error, got nil") - } -} - -func TestSyncer_NotifyUploadCompleted_ForwardsToControlPlane(t *testing.T) { - t.Parallel() - - cp := &streamTestControlPlane{} - s := &syncer{ - control: cp, - scope: logtest.NewScope(t), - } - - if err := s.NotifyUploadCompleted(context.Background()); err != nil { - t.Fatalf("NotifyUploadCompleted() error = %v", err) - } - if cp.notifyUploadCompletedCalls != 1 { - t.Fatalf("NotifyUploadCompleted calls = %d, want 1", cp.notifyUploadCompletedCalls) - } -} - -func TestSyncer_NotifyUploadCompleted_AppliesReturnedInstructions(t *testing.T) { - t.Parallel() - - cp := &streamTestControlPlane{ - notifyUploadInstructions: []extension.Instruction{ - {Type: extension.InstructionFetchCredentials}, - }, - } - mockClient := apitest.NewMockClient() - s := &syncer{ - tokenRefresher: &stubTokenRefresher{token: "refreshed-token"}, - client: mockClient, - control: cp, - scope: logtest.NewScope(t), - } - - if err := s.NotifyUploadCompleted(context.Background()); err != nil { - t.Fatalf("NotifyUploadCompleted() error = %v", err) - } - if mockClient.Token != "refreshed-token" { - t.Fatalf("token = %q, want %q", mockClient.Token, "refreshed-token") - } - if cp.notifyTokenRefreshedCalls != 1 { - t.Fatalf("NotifyTokenRefreshed calls = %d, want 1", cp.notifyTokenRefreshedCalls) - } -} - -func TestSyncer_ProcessLine_CapturePanicIsolated(t *testing.T) { - t.Parallel() - - cp := &streamTestControlPlane{} - s := &syncer{ - control: cp, - scope: logtest.NewScope(t), - streamCapture: &panicCapture{ - panicValue: "boom", - }, - } - - if err := s.processLine(context.Background(), []byte(`{"token_expires_in":3600}`)); err != nil { - t.Fatalf("processLine() error = %v", err) - } -} - -type streamTestControlPlane struct { - sendTextInstructions []extension.Instruction - notifyUploadInstructions []extension.Instruction - notifyConnectionCalls int - notifyUploadCompletedCalls int - notifyTokenRefreshedCalls int -} - -type panicCapture struct { - panicValue any -} - -func (p *panicCapture) CaptureLine([]byte) { - panic(p.panicValue) -} - -func (p *panicCapture) Close() error { - return nil -} - -func (s *streamTestControlPlane) Start(context.Context, extension.StartRequest) ([]extension.Instruction, error) { - return nil, nil -} - -func (s *streamTestControlPlane) SendTextLine(context.Context, string) ([]extension.Instruction, error) { - return s.sendTextInstructions, nil -} - -func (s *streamTestControlPlane) NotifyConnection(context.Context, extension.ConnectionEvent) ([]extension.Instruction, error) { - s.notifyConnectionCalls++ - return nil, nil -} - -func (s *streamTestControlPlane) NotifyTokenRefreshed(context.Context) ([]extension.Instruction, error) { - s.notifyTokenRefreshedCalls++ - return nil, nil -} - -func (s *streamTestControlPlane) NotifyUploadCompleted(context.Context) ([]extension.Instruction, error) { - s.notifyUploadCompletedCalls++ - return s.notifyUploadInstructions, nil -} - -func (s *streamTestControlPlane) Close() error { - return nil -} diff --git a/internal/preferences/org.go b/internal/preferences/org.go index c0e49bf5..9140200b 100644 --- a/internal/preferences/org.go +++ b/internal/preferences/org.go @@ -7,8 +7,7 @@ import ( // Store keys for org preferences. const ( - keyDefaultAccountID = "default_account_id" - keyDefaultWorkspaceID = "default_workspace_id" + keyDefaultAccountID = "default_account_id" ) // OrgPreferences provides access to org-scoped preferences. @@ -16,10 +15,7 @@ const ( type OrgPreferences interface { GetDefaultAccountID() domain.AccountID SetDefaultAccountID(accountID domain.AccountID) error - GetDefaultWorkspaceID() domain.WorkspaceID - SetDefaultWorkspaceID(workspaceID domain.WorkspaceID) error ClearDefaultAccountID() error - ClearDefaultWorkspaceID() error Clear() error } @@ -48,24 +44,9 @@ func (s *OrgService) SetDefaultAccountID(accountID domain.AccountID) error { return s.store.Save() } -func (s *OrgService) GetDefaultWorkspaceID() domain.WorkspaceID { - return domain.WorkspaceID(s.store.Get(keyDefaultWorkspaceID)) -} - -func (s *OrgService) SetDefaultWorkspaceID(workspaceID domain.WorkspaceID) error { - s.store.Set(keyDefaultWorkspaceID, workspaceID.String()) - return s.store.Save() -} - -// ClearDefaultAccountID clears the default account and cascades to workspace. +// ClearDefaultAccountID clears the default account. func (s *OrgService) ClearDefaultAccountID() error { s.store.Set(keyDefaultAccountID, "") - s.store.Set(keyDefaultWorkspaceID, "") - return s.store.Save() -} - -func (s *OrgService) ClearDefaultWorkspaceID() error { - s.store.Set(keyDefaultWorkspaceID, "") return s.store.Save() } diff --git a/internal/preferences/preferencestest/mock_preferences.go b/internal/preferences/preferencestest/mock_preferences.go index 762f91c1..b1ef3bfb 100644 --- a/internal/preferences/preferencestest/mock_preferences.go +++ b/internal/preferences/preferencestest/mock_preferences.go @@ -71,13 +71,10 @@ func (m *MockUserPreferences) Clear() error { // MockOrgPreferences implements preferences.OrgPreferences for testing. type MockOrgPreferences struct { - GetDefaultAccountIDFunc func() domain.AccountID - SetDefaultAccountIDFunc func(accountID domain.AccountID) error - GetDefaultWorkspaceIDFunc func() domain.WorkspaceID - SetDefaultWorkspaceIDFunc func(workspaceID domain.WorkspaceID) error - ClearDefaultAccountIDFunc func() error - ClearDefaultWorkspaceIDFunc func() error - ClearFunc func() error + GetDefaultAccountIDFunc func() domain.AccountID + SetDefaultAccountIDFunc func(accountID domain.AccountID) error + ClearDefaultAccountIDFunc func() error + ClearFunc func() error } func NewMockOrgPreferences() *MockOrgPreferences { @@ -98,20 +95,6 @@ func (m *MockOrgPreferences) SetDefaultAccountID(accountID domain.AccountID) err return nil } -func (m *MockOrgPreferences) GetDefaultWorkspaceID() domain.WorkspaceID { - if m.GetDefaultWorkspaceIDFunc != nil { - return m.GetDefaultWorkspaceIDFunc() - } - return "" -} - -func (m *MockOrgPreferences) SetDefaultWorkspaceID(workspaceID domain.WorkspaceID) error { - if m.SetDefaultWorkspaceIDFunc != nil { - return m.SetDefaultWorkspaceIDFunc(workspaceID) - } - return nil -} - func (m *MockOrgPreferences) ClearDefaultAccountID() error { if m.ClearDefaultAccountIDFunc != nil { return m.ClearDefaultAccountIDFunc() @@ -119,13 +102,6 @@ func (m *MockOrgPreferences) ClearDefaultAccountID() error { return nil } -func (m *MockOrgPreferences) ClearDefaultWorkspaceID() error { - if m.ClearDefaultWorkspaceIDFunc != nil { - return m.ClearDefaultWorkspaceIDFunc() - } - return nil -} - func (m *MockOrgPreferences) Clear() error { if m.ClearFunc != nil { return m.ClearFunc() diff --git a/internal/sqlite/compliance_policies.go b/internal/sqlite/compliance_policies.go deleted file mode 100644 index 1b7e6333..00000000 --- a/internal/sqlite/compliance_policies.go +++ /dev/null @@ -1,82 +0,0 @@ -package sqlite - -import ( - "context" - "encoding/json" - - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/sqlite/gen" -) - -// CompliancePolicies provides type-safe access to compliance policies (PII, Secrets, PHI, Payment Data). -type CompliancePolicies interface { - ListPendingPoliciesByCategory(ctx context.Context, category domain.PolicyCategory, limit int64) ([]domain.CompliancePolicy, error) -} - -// compliancePoliciesImpl implements CompliancePolicies. -type compliancePoliciesImpl struct { - queries *gen.Queries -} - -// ListPendingPoliciesByCategory returns pending compliance policies for a specific category. -func (c *compliancePoliciesImpl) ListPendingPoliciesByCategory(ctx context.Context, category domain.PolicyCategory, limit int64) ([]domain.CompliancePolicy, error) { - catStr := string(category) - rows, err := c.queries.ListPendingCompliancePoliciesByCategory(ctx, gen.ListPendingCompliancePoliciesByCategoryParams{ - Category: &catStr, - Limit: limit, - }) - if err != nil { - return nil, WrapSQLiteError(err, "list pending compliance policies by category") - } - - result := make([]domain.CompliancePolicy, 0, len(rows)) - for _, row := range rows { - p := domain.CompliancePolicy{ - Category: category, - LogEventName: row.LogEventName, - ServiceName: row.ServiceName, - VolumePerHour: row.VolumePerHour, - AnyObserved: row.AnyObserved != 0, - } - - // Parse the analysis JSON to extract sensitive fields for this category. - if row.Analysis != "" { - fields := extractSensitiveFields(row.Analysis, category) - p.Fields = fields - } - - result = append(result, p) - } - return result, nil -} - -// extractSensitiveFields parses the analysis JSON and extracts the fields array for the given category. -func extractSensitiveFields(analysisJSON string, category domain.PolicyCategory) []domain.SensitiveField { - var envelope domain.ComplianceAnalysisEnvelope - if err := json.Unmarshal([]byte(analysisJSON), &envelope); err != nil { - return nil - } - - switch category { - case domain.CategoryPIILeakage: - if envelope.PIILeakage != nil { - return envelope.PIILeakage.Fields - } - case domain.CategorySecretsLeakage: - if envelope.SecretsLeakage != nil { - return envelope.SecretsLeakage.Fields - } - case domain.CategoryPHILeakage: - if envelope.PHILeakage != nil { - return envelope.PHILeakage.Fields - } - case domain.CategoryPaymentDataLeakage: - if envelope.PaymentDataLeakage != nil { - return envelope.PaymentDataLeakage.Fields - } - default: - return nil - } - - return nil -} diff --git a/internal/sqlite/conversations.go b/internal/sqlite/conversations.go deleted file mode 100644 index f91b3dd2..00000000 --- a/internal/sqlite/conversations.go +++ /dev/null @@ -1,76 +0,0 @@ -package sqlite - -import ( - "context" - "time" - - "github.com/google/uuid" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/sqlite/gen" -) - -// Conversations provides type-safe access to conversations. -type Conversations interface { - Count(ctx context.Context) (int64, error) - Create(ctx context.Context, accountID domain.AccountID, workspaceID domain.WorkspaceID) (domain.ConversationID, error) - UpdateTitle(ctx context.Context, id domain.ConversationID, title string) error - List(ctx context.Context, accountID domain.AccountID) ([]gen.Conversation, error) - Get(ctx context.Context, id domain.ConversationID) (gen.Conversation, error) -} - -// conversationsImpl implements Conversations. -type conversationsImpl struct { - read *gen.Queries // read pool — Count, List, Get - write *gen.Queries // write pool — Create, UpdateTitle -} - -// Count returns the total number of conversations. -func (c *conversationsImpl) Count(ctx context.Context) (int64, error) { - count, err := c.read.CountConversations(ctx) - if err != nil { - return 0, WrapSQLiteError(err, "count conversations") - } - return count, nil -} - -// Create creates a new conversation and returns its ID. -func (c *conversationsImpl) Create(ctx context.Context, accountID domain.AccountID, workspaceID domain.WorkspaceID) (domain.ConversationID, error) { - convID := uuid.New().String() - now := time.Now().UTC().Format(time.RFC3339) - accountIDStr := accountID.String() - workspaceIDStr := workspaceID.String() - - err := c.write.InsertConversation(ctx, gen.InsertConversationParams{ - ID: &convID, - AccountID: &accountIDStr, - WorkspaceID: &workspaceIDStr, - CreatedAt: &now, - }) - if err != nil { - return "", WrapSQLiteError(err, "insert conversation") - } - - return domain.ConversationID(convID), nil -} - -// UpdateTitle sets the title on a conversation. -func (c *conversationsImpl) UpdateTitle(ctx context.Context, id domain.ConversationID, title string) error { - idStr := id.String() - err := c.write.UpdateConversationTitle(ctx, gen.UpdateConversationTitleParams{ - Title: &title, - ID: &idStr, - }) - return WrapSQLiteError(err, "update conversation title") -} - -// List returns all conversations for an account. -func (c *conversationsImpl) List(ctx context.Context, accountID domain.AccountID) ([]gen.Conversation, error) { - accountIDStr := accountID.String() - return c.read.ListConversationsByAccount(ctx, &accountIDStr) -} - -// Get returns a conversation by ID. -func (c *conversationsImpl) Get(ctx context.Context, id domain.ConversationID) (gen.Conversation, error) { - idStr := id.String() - return c.read.GetConversation(ctx, &idStr) -} diff --git a/internal/sqlite/database.go b/internal/sqlite/database.go deleted file mode 100644 index 464d7510..00000000 --- a/internal/sqlite/database.go +++ /dev/null @@ -1,439 +0,0 @@ -// Package sqlite provides the local SQLite database for the CLI. -// This is the primary data layer - PowerSync keeps it in sync with the server, -// but you query it directly like any SQLite database. -package sqlite - -import ( - "context" - "database/sql" - "fmt" - "os" - "path/filepath" - "sync" - - "github.com/mattn/go-sqlite3" - "github.com/usetero/cli/internal/sqlite/gen" -) - -var ( - // extensionPath is set by powersync.RegisterExtension() to enable - // automatic extension loading on every new connection. - extensionPath string - extensionPathOnce sync.Once - driversOnce sync.Once -) - -const ( - writeDriverName = "sqlite3_tero_write" - readDriverName = "sqlite3_tero_read" -) - -// DB is the interface for application code. -// It provides type-safe access to domain data and raw query execution. -type DB interface { - // Domain entities - Conversations() Conversations - LogEventPolicies() LogEventPolicies - LogEventPolicyStatuses() LogEventPolicyStatuses - CompliancePolicies() CompliancePolicies - LogEvents() LogEvents - Messages() Messages - Services() Services - - // Aggregated statuses - DatadogAccountStatuses() DatadogAccountStatuses - LogEventPolicyCategoryStatuses() LogEventPolicyCategoryStatuses - LogEventStatuses() LogEventStatuses - ServiceStatuses() ServiceStatuses - - // Sync - PendingUploadCounts(ctx context.Context) (map[Table]int64, error) - - // Low-level - Query(ctx context.Context, sql string, args ...any) (*sql.Rows, error) - QueryRow(ctx context.Context, sql string, args ...any) *sql.Row - Exec(ctx context.Context, sql string, args ...any) (sql.Result, error) - WithTx(ctx context.Context, fn func(tx *Tx) error) error - Raw() *sql.DB // Write pool — for PowerSync controller and direct writes - ReadRaw() *sql.DB // Read pool — for query tool and direct reads - Close() error -} - -// database is the concrete implementation of DB. -type database struct { - db *sql.DB // write pool — WAL writer, PowerSync controller, mutations - readDB *sql.DB // read pool — query_only, all domain reads - path string -} - -// Ensure database implements DB. -var _ DB = (*database)(nil) - -// SetExtensionPath configures the PowerSync extension to be loaded on every -// new database connection. This must be called before Open() to have effect. -// Typically called once at startup by the powersync package. -func SetExtensionPath(path string) { - extensionPathOnce.Do(func() { - extensionPath = path - }) -} - -// Per-connection pragmas applied to every connection via driver hooks. -// These must be set per-connection because database/sql pools create -// connections on demand, and pragmas are connection-scoped. -var basePragmas = []string{ - "PRAGMA busy_timeout = 30000", // Wait up to 30s for locks instead of failing immediately - "PRAGMA synchronous = NORMAL", // Safe with WAL, avoids fsync on every commit - "PRAGMA cache_size = -51200", // 50MB page cache (negative = KB) - "PRAGMA temp_store = MEMORY", // Keep temp tables in memory - "PRAGMA recursive_triggers = TRUE", // Required by PowerSync extension for trigger chains -} - -// registerDrivers registers the write and read-only SQLite drivers exactly once. -// Both drivers load the PowerSync extension (if configured) and apply base pragmas -// on every new connection. The read driver additionally sets query_only = ON. -func registerDrivers() { - driversOnce.Do(func() { - sql.Register(writeDriverName, &sqlite3.SQLiteDriver{ - ConnectHook: func(conn *sqlite3.SQLiteConn) error { - if extensionPath != "" { - if err := conn.LoadExtension(extensionPath, "sqlite3_powersync_init"); err != nil { - return err - } - } - return execPragmas(conn, basePragmas) - }, - }) - - readPragmas := append(basePragmas, "PRAGMA query_only = ON") - sql.Register(readDriverName, &sqlite3.SQLiteDriver{ - ConnectHook: func(conn *sqlite3.SQLiteConn) error { - if extensionPath != "" { - if err := conn.LoadExtension(extensionPath, "sqlite3_powersync_init"); err != nil { - return err - } - } - return execPragmas(conn, readPragmas) - }, - }) - }) -} - -// execPragmas runs a list of PRAGMA statements on a raw SQLite connection. -func execPragmas(conn *sqlite3.SQLiteConn, pragmas []string) error { - for _, p := range pragmas { - if _, err := conn.Exec(p, nil); err != nil { - return fmt.Errorf("%s: %w", p, err) - } - } - return nil -} - -// Open opens a SQLite database at the given path with separate read and write -// connection pools. The write pool enables WAL mode for concurrent read access -// during writes. The read pool enforces query_only = ON via the driver hook. -// -// The database file and parent directories are created if they don't exist. -func Open(ctx context.Context, path string) (DB, error) { - // Ensure parent directory exists - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0700); err != nil { - return nil, fmt.Errorf("create database directory: %w", err) - } - - registerDrivers() - - // Open write pool - db, err := sql.Open(writeDriverName, path) - if err != nil { - return nil, fmt.Errorf("open write pool: %w", err) - } - if err := db.PingContext(ctx); err != nil { - db.Close() - return nil, fmt.Errorf("ping write pool: %w", err) - } - // Database-level pragmas (not per-connection — one exec is correct). - // WAL allows concurrent readers during writes — the core fix for query blocking during sync. - // journal_size_limit caps the WAL file at 6MB to prevent unbounded growth. - for _, p := range []string{ - "PRAGMA journal_mode = WAL", - "PRAGMA journal_size_limit = 6291456", - } { - if _, err := db.ExecContext(ctx, p); err != nil { - db.Close() - return nil, fmt.Errorf("%s: %w", p, err) - } - } - - // Open read pool — every connection has query_only = ON and busy_timeout - // set automatically by the driver hook. - readDB, err := sql.Open(readDriverName, path) - if err != nil { - db.Close() - return nil, fmt.Errorf("open read pool: %w", err) - } - if err := readDB.PingContext(ctx); err != nil { - db.Close() - readDB.Close() - return nil, fmt.Errorf("ping read pool: %w", err) - } - - return &database{ - db: db, - readDB: readDB, - path: path, - }, nil -} - -// Close closes both the read and write connection pools. -func (d *database) Close() error { - readErr := d.readDB.Close() - writeErr := d.db.Close() - if writeErr != nil { - return writeErr - } - return readErr -} - -// Path returns the database file path. -func (d *database) Path() string { - return d.path -} - -// --------------------------------------------------------------------------- -// Raw pool access -// --------------------------------------------------------------------------- - -// Raw returns the write pool for direct access. -// Used by the PowerSync controller which needs write access. -func (d *database) Raw() *sql.DB { - return d.db -} - -// ReadRaw returns the read pool for direct access. -// Used by the query tool for user-initiated SQL queries. -func (d *database) ReadRaw() *sql.DB { - return d.readDB -} - -// --------------------------------------------------------------------------- -// Typed query access (sqlc generated) -// --------------------------------------------------------------------------- - -// ReadQueries returns a Queries instance backed by the read pool. -func (d *database) ReadQueries() *gen.Queries { - return gen.New(&timeoutDB{db: d.readDB}) -} - -// WriteQueries returns a Queries instance backed by the write pool. -func (d *database) WriteQueries() *gen.Queries { - return gen.New(&timeoutDB{db: d.db}) -} - -// --------------------------------------------------------------------------- -// Domain entity factories -// --------------------------------------------------------------------------- - -// Messages returns type-safe message operations. -// Uses both pools: reads from readDB, writes (create/update) from writeDB. -func (d *database) Messages() Messages { - return &messagesImpl{read: d.ReadQueries(), write: d.WriteQueries()} -} - -// Conversations returns type-safe conversation operations. -// Uses both pools: reads from readDB, writes (create/update) from writeDB. -func (d *database) Conversations() Conversations { - return &conversationsImpl{read: d.ReadQueries(), write: d.WriteQueries()} -} - -// DatadogAccountStatuses returns type-safe Datadog account status operations. -func (d *database) DatadogAccountStatuses() DatadogAccountStatuses { - return &datadogAccountStatusesImpl{queries: d.ReadQueries()} -} - -// LogEventPolicyCategoryStatuses returns type-safe pre-computed policy category rollups. -func (d *database) LogEventPolicyCategoryStatuses() LogEventPolicyCategoryStatuses { - return &logEventPolicyCategoryStatusesImpl{queries: d.ReadQueries()} -} - -// LogEventStatuses returns type-safe log event status operations. -func (d *database) LogEventStatuses() LogEventStatuses { - return &logEventStatusesImpl{queries: d.ReadQueries()} -} - -// ServiceStatuses returns type-safe service status operations. -func (d *database) ServiceStatuses() ServiceStatuses { - return &serviceStatusesImpl{queries: d.ReadQueries()} -} - -// Services returns type-safe service operations. -func (d *database) Services() Services { - return &servicesImpl{read: d.ReadQueries(), write: d.WriteQueries()} -} - -// LogEvents returns type-safe log event operations. -func (d *database) LogEvents() LogEvents { - return &logEventsImpl{queries: d.ReadQueries()} -} - -// LogEventPolicies returns type-safe log event policy operations. -func (d *database) LogEventPolicies() LogEventPolicies { - return &logEventPoliciesImpl{read: d.ReadQueries(), write: d.WriteQueries()} -} - -// LogEventPolicyStatuses returns type-safe pre-computed policy status data. -func (d *database) LogEventPolicyStatuses() LogEventPolicyStatuses { - return &logEventPolicyStatusesImpl{queries: d.ReadQueries()} -} - -// CompliancePolicies returns type-safe compliance policy operations. -func (d *database) CompliancePolicies() CompliancePolicies { - return &compliancePoliciesImpl{queries: d.ReadQueries()} -} - -// --------------------------------------------------------------------------- -// Low-level query methods -// --------------------------------------------------------------------------- - -// Query executes a read query via the read pool. -// Note: withTimeout is not applied here because the caller iterates the -// returned *sql.Rows after this method returns — canceling the context -// before iteration causes "context canceled". The sqlc layer (via timeoutDB) -// handles timeouts correctly since iteration happens inside generated methods. -func (d *database) Query(ctx context.Context, query string, args ...any) (*sql.Rows, error) { - return d.readDB.QueryContext(ctx, query, args...) -} - -// QueryRow executes a query that returns at most one row via the read pool. -// Note: withTimeout is not applied here because the caller calls Scan() on -// the returned *sql.Row after this method returns — canceling the context -// before Scan causes "context canceled". The sqlc layer (via timeoutDB) -// handles timeouts correctly since Scan happens inside the generated method. -func (d *database) QueryRow(ctx context.Context, query string, args ...any) *sql.Row { - return d.readDB.QueryRowContext(ctx, query, args...) -} - -// Exec executes a statement via the write pool. -func (d *database) Exec(ctx context.Context, query string, args ...any) (sql.Result, error) { - ctx, cancel := withTimeout(ctx) - defer cancel() - return d.db.ExecContext(ctx, query, args...) -} - -// BeginTx starts a transaction on the write pool. -func (d *database) BeginTx(ctx context.Context, opts *sql.TxOptions) (*Tx, error) { - tx, err := d.db.BeginTx(ctx, opts) - if err != nil { - return nil, err - } - return &Tx{tx: tx}, nil -} - -// Count returns the number of rows in the given table via the read pool. -func (d *database) Count(ctx context.Context, table string) (int64, error) { - ctx, cancel := withTimeout(ctx) - defer cancel() - var count int64 - // Use quote identifier to prevent SQL injection - err := d.readDB.QueryRowContext(ctx, fmt.Sprintf("SELECT COUNT(*) FROM \"%s\"", table)).Scan(&count) - if err != nil { - return 0, fmt.Errorf("count %s: %w", table, err) - } - return count, nil -} - -// PendingUploadCounts returns pending upload counts grouped by entity table via the read pool. -func (d *database) PendingUploadCounts(ctx context.Context) (map[Table]int64, error) { - ctx, cancel := withTimeout(ctx) - defer cancel() - rows, err := d.readDB.QueryContext(ctx, - "SELECT json_extract(data, '$.type') AS entity, COUNT(*) AS cnt FROM ps_crud GROUP BY 1") - if err != nil { - return nil, fmt.Errorf("count pending uploads: %w", err) - } - defer rows.Close() - - counts := make(map[Table]int64) - for rows.Next() { - var entity string - var count int64 - if err := rows.Scan(&entity, &count); err != nil { - return nil, fmt.Errorf("scan pending upload count: %w", err) - } - counts[Table(entity)] = count - } - return counts, rows.Err() -} - -// LoadExtension loads a SQLite extension on the write pool. -func (d *database) LoadExtension(ctx context.Context, path, entryPoint string) error { - conn, err := d.db.Conn(ctx) - if err != nil { - return fmt.Errorf("get connection: %w", err) - } - defer conn.Close() - - return conn.Raw(func(driverConn any) error { - sqliteConn, ok := driverConn.(*sqlite3.SQLiteConn) - if !ok { - return fmt.Errorf("unexpected driver connection type: %T", driverConn) - } - return sqliteConn.LoadExtension(path, entryPoint) - }) -} - -// --------------------------------------------------------------------------- -// Transactions -// --------------------------------------------------------------------------- - -// Tx wraps a SQL transaction with convenience methods. -type Tx struct { - tx *sql.Tx -} - -// Exec executes a statement within the transaction. -func (t *Tx) Exec(ctx context.Context, query string, args ...any) (sql.Result, error) { - return t.tx.ExecContext(ctx, query, args...) -} - -// QueryRow executes a query that returns at most one row within the transaction. -func (t *Tx) QueryRow(ctx context.Context, query string, args ...any) *sql.Row { - return t.tx.QueryRowContext(ctx, query, args...) -} - -// Query executes a query within the transaction. -func (t *Tx) Query(ctx context.Context, query string, args ...any) (*sql.Rows, error) { - return t.tx.QueryContext(ctx, query, args...) -} - -// Commit commits the transaction. -func (t *Tx) Commit() error { - return t.tx.Commit() -} - -// Rollback aborts the transaction. -func (t *Tx) Rollback() error { - return t.tx.Rollback() -} - -// WithTx executes a function within a database transaction on the write pool. -// If the function returns an error, the transaction is rolled back. -// If the function succeeds, the transaction is committed. -func (d *database) WithTx(ctx context.Context, fn func(tx *Tx) error) error { - tx, err := d.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("begin transaction: %w", err) - } - - if fnErr := fn(&Tx{tx: tx}); fnErr != nil { - if rbErr := tx.Rollback(); rbErr != nil { - return fmt.Errorf("rollback failed: %w (original error: %w)", rbErr, fnErr) - } - return fnErr - } - - if err := tx.Commit(); err != nil { - return fmt.Errorf("commit transaction: %w", err) - } - return nil -} diff --git a/internal/sqlite/datadog_account_statuses.go b/internal/sqlite/datadog_account_statuses.go deleted file mode 100644 index 56545345..00000000 --- a/internal/sqlite/datadog_account_statuses.go +++ /dev/null @@ -1,84 +0,0 @@ -package sqlite - -import ( - "context" - "fmt" - - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/sqlite/gen" -) - -// DatadogAccountStatuses provides access to Datadog account status data. -type DatadogAccountStatuses interface { - GetSummary(ctx context.Context) (domain.AccountSummary, error) -} - -// datadogAccountStatusesImpl implements DatadogAccountStatuses. -type datadogAccountStatusesImpl struct { - queries *gen.Queries -} - -// GetSummary returns aggregated status across all Datadog accounts. -func (d *datadogAccountStatusesImpl) GetSummary(ctx context.Context) (domain.AccountSummary, error) { - row, err := d.queries.GetAccountSummary(ctx) - if err != nil { - return domain.AccountSummary{}, WrapSQLiteError(err, "get account summary") - } - - return domain.AccountSummary{ - ReadyForUse: row.ReadyForUse != 0, - - // Health - Health: domain.ServiceHealth(fmt.Sprint(row.Health)), - - // Services - ServiceCount: row.ServiceCount, - ActiveServices: row.ActiveServices, - OkServices: row.OkServices, - DisabledServices: row.DisabledServices, - InactiveServices: row.InactiveServices, - - // Events - EventCount: row.EventCount, - AnalyzedCount: row.AnalyzedCount, - - // Policies - PendingPolicyCount: row.PendingPolicyCount, - ApprovedPolicyCount: row.ApprovedPolicyCount, - DismissedPolicyCount: row.DismissedPolicyCount, - PolicyPendingCriticalCount: row.PolicyPendingCriticalCount, - PolicyPendingHighCount: row.PolicyPendingHighCount, - PolicyPendingMediumCount: row.PolicyPendingMediumCount, - PolicyPendingLowCount: row.PolicyPendingLowCount, - - // Estimated savings - EstimatedCostPerHour: row.EstimatedCostPerHour, - EstimatedCostPerHourBytes: row.EstimatedCostPerHourBytes, - EstimatedCostPerHourVolume: row.EstimatedCostPerHourVolume, - EstimatedVolumePerHour: row.EstimatedVolumePerHour, - EstimatedBytesPerHour: row.EstimatedBytesPerHour, - - // Observed impact - ObservedCostBefore: row.ObservedCostBefore, - ObservedCostBeforeBytes: row.ObservedCostBeforeBytes, - ObservedCostBeforeVolume: row.ObservedCostBeforeVolume, - ObservedCostAfter: row.ObservedCostAfter, - ObservedCostAfterBytes: row.ObservedCostAfterBytes, - ObservedCostAfterVolume: row.ObservedCostAfterVolume, - ObservedVolumeBefore: row.ObservedVolumeBefore, - ObservedVolumeAfter: row.ObservedVolumeAfter, - ObservedBytesBefore: row.ObservedBytesBefore, - ObservedBytesAfter: row.ObservedBytesAfter, - - // Totals - TotalCostPerHour: row.TotalCostPerHour, - TotalCostPerHourBytes: row.TotalCostPerHourBytes, - TotalCostPerHourVolume: row.TotalCostPerHourVolume, - TotalVolumePerHour: row.TotalVolumePerHour, - TotalBytesPerHour: row.TotalBytesPerHour, - - // Service-level throughput - TotalServiceVolumePerHour: row.TotalServiceVolumePerHour, - TotalServiceCostPerHour: row.TotalServiceCostPerHour, - }, nil -} diff --git a/internal/sqlite/db_test.go b/internal/sqlite/db_test.go deleted file mode 100644 index 0ffe3485..00000000 --- a/internal/sqlite/db_test.go +++ /dev/null @@ -1,237 +0,0 @@ -package sqlite - -import ( - "context" - "os" - "path/filepath" - "testing" -) - -// asDatabase casts DB interface to *database for testing internal methods. -func asDatabase(t *testing.T, db DB) *database { - t.Helper() - d, ok := db.(*database) - if !ok { - t.Fatal("expected db to be *database") - } - return d -} - -func TestDB_Count(t *testing.T) { - t.Parallel() - - t.Run("returns zero for empty table", func(t *testing.T) { - t.Parallel() - ctx := context.Background() - - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "test.sqlite") - db, err := Open(ctx, dbPath) - if err != nil { - t.Fatalf("Open() error = %v", err) - } - defer db.Close() - - _, err = db.Exec(ctx, "CREATE TABLE items (id INTEGER PRIMARY KEY)") - if err != nil { - t.Fatalf("CREATE TABLE error = %v", err) - } - - count, err := asDatabase(t, db).Count(ctx, "items") - if err != nil { - t.Fatalf("Count() error = %v", err) - } - - if count != 0 { - t.Errorf("Count() = %d, want 0", count) - } - }) - - t.Run("returns correct count", func(t *testing.T) { - t.Parallel() - ctx := context.Background() - - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "test.sqlite") - db, err := Open(ctx, dbPath) - if err != nil { - t.Fatalf("Open() error = %v", err) - } - defer db.Close() - - _, err = db.Exec(ctx, "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)") - if err != nil { - t.Fatalf("CREATE TABLE error = %v", err) - } - - _, err = db.Exec(ctx, "INSERT INTO items (name) VALUES ('a'), ('b'), ('c')") - if err != nil { - t.Fatalf("INSERT error = %v", err) - } - - count, err := asDatabase(t, db).Count(ctx, "items") - if err != nil { - t.Fatalf("Count() error = %v", err) - } - - if count != 3 { - t.Errorf("Count() = %d, want 3", count) - } - }) - - t.Run("returns error for nonexistent table", func(t *testing.T) { - t.Parallel() - ctx := context.Background() - - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "test.sqlite") - db, err := Open(ctx, dbPath) - if err != nil { - t.Fatalf("Open() error = %v", err) - } - defer db.Close() - - _, err = asDatabase(t, db).Count(ctx, "nonexistent") - if err == nil { - t.Error("expected error for nonexistent table, got nil") - } - }) -} - -func TestDB_Path(t *testing.T) { - t.Parallel() - - t.Run("returns the database path", func(t *testing.T) { - t.Parallel() - ctx := context.Background() - - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "mydb.sqlite") - db, err := Open(ctx, dbPath) - if err != nil { - t.Fatalf("Open() error = %v", err) - } - defer db.Close() - - d := asDatabase(t, db) - if d.Path() != dbPath { - t.Errorf("Path() = %q, want %q", d.Path(), dbPath) - } - }) -} - -func TestDB_Queries(t *testing.T) { - t.Parallel() - - t.Run("returns non-nil ReadQueries", func(t *testing.T) { - t.Parallel() - ctx := context.Background() - - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "test.sqlite") - db, err := Open(ctx, dbPath) - if err != nil { - t.Fatalf("Open() error = %v", err) - } - defer db.Close() - - q := asDatabase(t, db).ReadQueries() - if q == nil { - t.Error("ReadQueries() returned nil") - } - }) - - t.Run("returns non-nil WriteQueries", func(t *testing.T) { - t.Parallel() - ctx := context.Background() - - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "test.sqlite") - db, err := Open(ctx, dbPath) - if err != nil { - t.Fatalf("Open() error = %v", err) - } - defer db.Close() - - q := asDatabase(t, db).WriteQueries() - if q == nil { - t.Error("WriteQueries() returned nil") - } - }) -} - -func TestOpen(t *testing.T) { - t.Parallel() - - t.Run("creates parent directories if they do not exist", func(t *testing.T) { - t.Parallel() - ctx := context.Background() - - // Arrange: create a temp dir and a nested path that doesn't exist - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "nested", "dirs", "test.sqlite") - - // Verify the parent doesn't exist yet - parentDir := filepath.Dir(dbPath) - if _, err := os.Stat(parentDir); !os.IsNotExist(err) { - t.Fatalf("expected parent dir to not exist, got err: %v", err) - } - - // Act - db, err := Open(ctx, dbPath) - if err != nil { - t.Fatalf("Open() error = %v", err) - } - defer db.Close() - - // Assert: parent directory was created - info, err := os.Stat(parentDir) - if err != nil { - t.Fatalf("expected parent dir to exist, got err: %v", err) - } - if !info.IsDir() { - t.Error("expected parent to be a directory") - } - - // Assert: directory has correct permissions (0700) - perm := info.Mode().Perm() - if perm != 0700 { - t.Errorf("expected permissions 0700, got %o", perm) - } - }) - - t.Run("opens existing database", func(t *testing.T) { - t.Parallel() - ctx := context.Background() - - // Arrange: create a database first - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "existing.sqlite") - - db1, err := Open(ctx, dbPath) - if err != nil { - t.Fatalf("first Open() error = %v", err) - } - - // Create a table to verify it's a real database - _, err = db1.Exec(ctx, "CREATE TABLE test (id INTEGER PRIMARY KEY)") - if err != nil { - t.Fatalf("CREATE TABLE error = %v", err) - } - db1.Close() - - // Act: open the same database again - db2, err := Open(ctx, dbPath) - if err != nil { - t.Fatalf("second Open() error = %v", err) - } - defer db2.Close() - - // Assert: table exists - var count int64 - err = db2.QueryRow(ctx, "SELECT COUNT(*) FROM test").Scan(&count) - if err != nil { - t.Errorf("expected table to exist, got err: %v", err) - } - }) -} diff --git a/internal/sqlite/doc.go b/internal/sqlite/doc.go deleted file mode 100644 index 9f620d54..00000000 --- a/internal/sqlite/doc.go +++ /dev/null @@ -1,16 +0,0 @@ -// Package sqlite provides the local SQLite database for the CLI. -// -// This is the primary data layer. PowerSync keeps it in sync with the server, -// but you query it directly like any SQLite database. -// -// # Code Generation -// -// The schema types are generated from the PowerSync service. To regenerate: -// -// doppler run -- go generate ./internal/sqlite -// -// This fetches the current schema and sync rules from the PowerSync API -// and generates Go types for each synced table. -package sqlite - -//go:generate go run ./generate diff --git a/internal/sqlite/error.go b/internal/sqlite/error.go deleted file mode 100644 index 04a939d7..00000000 --- a/internal/sqlite/error.go +++ /dev/null @@ -1,53 +0,0 @@ -package sqlite - -import ( - "errors" - "fmt" - - "github.com/mattn/go-sqlite3" -) - -// SQLiteError wraps a sqlite3.Error with additional context. -// It provides structured access to error codes for debugging. -type SQLiteError struct { - Code int // Primary error code - ExtendedCode int // Extended error code with more detail - Message string // Error message from sqlite3_errmsg() - Op string // Operation that failed (e.g., "insert message") -} - -func (e *SQLiteError) Error() string { - if e.Op != "" { - return fmt.Sprintf("%s: %s (code=%d, extended=%d)", e.Op, e.Message, e.Code, e.ExtendedCode) - } - return fmt.Sprintf("%s (code=%d, extended=%d)", e.Message, e.Code, e.ExtendedCode) -} - -// Unwrap returns nil since this is the root error. -func (e *SQLiteError) Unwrap() error { - return nil -} - -// WrapSQLiteError wraps an error with SQLite-specific details if available. -// If the error is not a sqlite3.Error, it returns a generic wrapped error. -func WrapSQLiteError(err error, op string) error { - if err == nil { - return nil - } - - var sqliteErr sqlite3.Error - if errors.As(err, &sqliteErr) { - return &SQLiteError{ - Code: int(sqliteErr.Code), - ExtendedCode: int(sqliteErr.ExtendedCode), - Message: err.Error(), - Op: op, - } - } - - // Not a SQLite error, wrap with context - if op != "" { - return fmt.Errorf("%s: %w", op, err) - } - return err -} diff --git a/internal/sqlite/gen/compliance_policies.sql.go b/internal/sqlite/gen/compliance_policies.sql.go deleted file mode 100644 index b326536c..00000000 --- a/internal/sqlite/gen/compliance_policies.sql.go +++ /dev/null @@ -1,117 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: compliance_policies.sql - -package gen - -import ( - "context" -) - -const countObservedPoliciesByComplianceCategory = `-- name: CountObservedPoliciesByComplianceCategory :many -SELECT - leps.category, - CAST(SUM(CASE WHEN COALESCE(( - SELECT MAX(CASE json_extract(f.value, '$.observed') WHEN 1 THEN 1 ELSE 0 END) - FROM json_each(json_extract(lep.analysis, '$.' || leps.category || '.fields')) f - ), 0) = 1 THEN 1 ELSE 0 END) AS INTEGER) AS observed_count -FROM log_event_policy_statuses_cache leps -LEFT JOIN log_event_policies lep ON lep.id = leps.policy_id -WHERE leps.category_type = 'compliance' AND leps.status = 'PENDING' -GROUP BY leps.category -` - -type CountObservedPoliciesByComplianceCategoryRow struct { - Category *string - ObservedCount int64 -} - -// Returns, per compliance category, how many pending policies have observed (leaking) data. -func (q *Queries) CountObservedPoliciesByComplianceCategory(ctx context.Context) ([]CountObservedPoliciesByComplianceCategoryRow, error) { - rows, err := q.db.QueryContext(ctx, countObservedPoliciesByComplianceCategory) - if err != nil { - return nil, err - } - defer rows.Close() - var items []CountObservedPoliciesByComplianceCategoryRow - for rows.Next() { - var i CountObservedPoliciesByComplianceCategoryRow - if err := rows.Scan(&i.Category, &i.ObservedCount); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listPendingCompliancePoliciesByCategory = `-- name: ListPendingCompliancePoliciesByCategory :many - -SELECT - COALESCE(s.name, '') AS service_name, - COALESCE(le.name, '') AS log_event_name, - COALESCE(lep.analysis, '') AS analysis, - les.volume_per_hour, - CAST(COALESCE(( - SELECT MAX(CASE json_extract(f.value, '$.observed') WHEN 1 THEN 1 ELSE 0 END) - FROM json_each(json_extract(lep.analysis, '$.' || ?1 || '.fields')) f - ), 0) AS INTEGER) AS any_observed -FROM log_event_policy_statuses_cache leps -JOIN log_events le ON le.id = leps.log_event_id -JOIN services s ON s.id = le.service_id -LEFT JOIN log_event_policies lep ON lep.id = leps.policy_id -LEFT JOIN log_event_statuses_cache les ON les.log_event_id = leps.log_event_id -WHERE leps.category = ?1 AND leps.status = 'PENDING' -ORDER BY any_observed DESC, les.volume_per_hour DESC -LIMIT ?2 -` - -type ListPendingCompliancePoliciesByCategoryParams struct { - Category *string - Limit int64 -} - -type ListPendingCompliancePoliciesByCategoryRow struct { - ServiceName string - LogEventName string - Analysis string - VolumePerHour *float64 - AnyObserved int64 -} - -// Compliance policy queries for PII, Secrets, PHI, and Payment Data leakage. -// Returns pending compliance policies for a specific category, sorted by observed then volume. -func (q *Queries) ListPendingCompliancePoliciesByCategory(ctx context.Context, arg ListPendingCompliancePoliciesByCategoryParams) ([]ListPendingCompliancePoliciesByCategoryRow, error) { - rows, err := q.db.QueryContext(ctx, listPendingCompliancePoliciesByCategory, arg.Category, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - var items []ListPendingCompliancePoliciesByCategoryRow - for rows.Next() { - var i ListPendingCompliancePoliciesByCategoryRow - if err := rows.Scan( - &i.ServiceName, - &i.LogEventName, - &i.Analysis, - &i.VolumePerHour, - &i.AnyObserved, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/internal/sqlite/gen/conversations.sql.go b/internal/sqlite/gen/conversations.sql.go deleted file mode 100644 index d72179a4..00000000 --- a/internal/sqlite/gen/conversations.sql.go +++ /dev/null @@ -1,135 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: conversations.sql - -package gen - -import ( - "context" -) - -const countConversations = `-- name: CountConversations :one -SELECT COUNT(*) FROM conversations -` - -func (q *Queries) CountConversations(ctx context.Context) (int64, error) { - row := q.db.QueryRowContext(ctx, countConversations) - var count int64 - err := row.Scan(&count) - return count, err -} - -const getConversation = `-- name: GetConversation :one -SELECT id, account_id, created_at, title, user_id, view_id, workspace_id FROM conversations WHERE id = ? -` - -func (q *Queries) GetConversation(ctx context.Context, id *string) (Conversation, error) { - row := q.db.QueryRowContext(ctx, getConversation, id) - var i Conversation - err := row.Scan( - &i.ID, - &i.AccountID, - &i.CreatedAt, - &i.Title, - &i.UserID, - &i.ViewID, - &i.WorkspaceID, - ) - return i, err -} - -const getLatestConversationByAccount = `-- name: GetLatestConversationByAccount :one -SELECT id, account_id, created_at, title, user_id, view_id, workspace_id FROM conversations -WHERE account_id = ? -ORDER BY created_at DESC -LIMIT 1 -` - -func (q *Queries) GetLatestConversationByAccount(ctx context.Context, accountID *string) (Conversation, error) { - row := q.db.QueryRowContext(ctx, getLatestConversationByAccount, accountID) - var i Conversation - err := row.Scan( - &i.ID, - &i.AccountID, - &i.CreatedAt, - &i.Title, - &i.UserID, - &i.ViewID, - &i.WorkspaceID, - ) - return i, err -} - -const insertConversation = `-- name: InsertConversation :exec -INSERT INTO conversations (id, account_id, workspace_id, created_at) -VALUES (?, ?, ?, ?) -` - -type InsertConversationParams struct { - ID *string - AccountID *string - WorkspaceID *string - CreatedAt *string -} - -func (q *Queries) InsertConversation(ctx context.Context, arg InsertConversationParams) error { - _, err := q.db.ExecContext(ctx, insertConversation, - arg.ID, - arg.AccountID, - arg.WorkspaceID, - arg.CreatedAt, - ) - return err -} - -const listConversationsByAccount = `-- name: ListConversationsByAccount :many -SELECT id, account_id, created_at, title, user_id, view_id, workspace_id FROM conversations -WHERE account_id = ? -ORDER BY created_at DESC -` - -func (q *Queries) ListConversationsByAccount(ctx context.Context, accountID *string) ([]Conversation, error) { - rows, err := q.db.QueryContext(ctx, listConversationsByAccount, accountID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Conversation - for rows.Next() { - var i Conversation - if err := rows.Scan( - &i.ID, - &i.AccountID, - &i.CreatedAt, - &i.Title, - &i.UserID, - &i.ViewID, - &i.WorkspaceID, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const updateConversationTitle = `-- name: UpdateConversationTitle :exec -UPDATE conversations SET title = ? WHERE id = ? -` - -type UpdateConversationTitleParams struct { - Title *string - ID *string -} - -func (q *Queries) UpdateConversationTitle(ctx context.Context, arg UpdateConversationTitleParams) error { - _, err := q.db.ExecContext(ctx, updateConversationTitle, arg.Title, arg.ID) - return err -} diff --git a/internal/sqlite/gen/datadog_account_statuses.sql.go b/internal/sqlite/gen/datadog_account_statuses.sql.go deleted file mode 100644 index 36227698..00000000 --- a/internal/sqlite/gen/datadog_account_statuses.sql.go +++ /dev/null @@ -1,157 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: datadog_account_statuses.sql - -package gen - -import ( - "context" -) - -const getAccountSummary = `-- name: GetAccountSummary :one -SELECT - -- ready - CAST(COALESCE(MAX(ready_for_use), 0) AS INTEGER) AS ready_for_use, - - -- health - COALESCE(MAX(health), '') AS health, - - -- services - CAST(COALESCE(SUM(log_service_count), 0) AS INTEGER) AS service_count, - CAST(COALESCE(SUM(log_active_services), 0) AS INTEGER) AS active_services, - CAST(COALESCE(SUM(ok_services), 0) AS INTEGER) AS ok_services, - CAST(COALESCE(SUM(disabled_services), 0) AS INTEGER) AS disabled_services, - CAST(COALESCE(SUM(inactive_services), 0) AS INTEGER) AS inactive_services, - - -- events - CAST(COALESCE(SUM(log_event_count), 0) AS INTEGER) AS event_count, - CAST(COALESCE(SUM(log_event_analyzed_count), 0) AS INTEGER) AS analyzed_count, - - -- policies - CAST(COALESCE(SUM(policy_pending_count), 0) AS INTEGER) AS pending_policy_count, - CAST(COALESCE(SUM(policy_approved_count), 0) AS INTEGER) AS approved_policy_count, - CAST(COALESCE(SUM(policy_dismissed_count), 0) AS INTEGER) AS dismissed_policy_count, - CAST(COALESCE(SUM(policy_pending_critical_count), 0) AS INTEGER) AS policy_pending_critical_count, - CAST(COALESCE(SUM(policy_pending_high_count), 0) AS INTEGER) AS policy_pending_high_count, - CAST(COALESCE(SUM(policy_pending_medium_count), 0) AS INTEGER) AS policy_pending_medium_count, - CAST(COALESCE(SUM(policy_pending_low_count), 0) AS INTEGER) AS policy_pending_low_count, - - -- estimated savings - SUM(estimated_cost_reduction_per_hour_usd) AS estimated_cost_per_hour, - SUM(estimated_cost_reduction_per_hour_bytes_usd) AS estimated_cost_per_hour_bytes, - SUM(estimated_cost_reduction_per_hour_volume_usd) AS estimated_cost_per_hour_volume, - SUM(estimated_volume_reduction_per_hour) AS estimated_volume_per_hour, - SUM(estimated_bytes_reduction_per_hour) AS estimated_bytes_per_hour, - - -- observed impact - SUM(observed_cost_per_hour_before_usd) AS observed_cost_before, - SUM(observed_cost_per_hour_before_bytes_usd) AS observed_cost_before_bytes, - SUM(observed_cost_per_hour_before_volume_usd) AS observed_cost_before_volume, - SUM(observed_cost_per_hour_after_usd) AS observed_cost_after, - SUM(observed_cost_per_hour_after_bytes_usd) AS observed_cost_after_bytes, - SUM(observed_cost_per_hour_after_volume_usd) AS observed_cost_after_volume, - SUM(observed_volume_per_hour_before) AS observed_volume_before, - SUM(observed_volume_per_hour_after) AS observed_volume_after, - SUM(observed_bytes_per_hour_before) AS observed_bytes_before, - SUM(observed_bytes_per_hour_after) AS observed_bytes_after, - - -- totals - SUM(log_event_cost_per_hour_usd) AS total_cost_per_hour, - SUM(log_event_cost_per_hour_bytes_usd) AS total_cost_per_hour_bytes, - SUM(log_event_cost_per_hour_volume_usd) AS total_cost_per_hour_volume, - SUM(log_event_volume_per_hour) AS total_volume_per_hour, - SUM(log_event_bytes_per_hour) AS total_bytes_per_hour, - - -- service-level throughput - SUM(service_volume_per_hour) AS total_service_volume_per_hour, - SUM(service_cost_per_hour_volume_usd) AS total_service_cost_per_hour -FROM datadog_account_statuses_cache -` - -type GetAccountSummaryRow struct { - ReadyForUse int64 - Health interface{} - ServiceCount int64 - ActiveServices int64 - OkServices int64 - DisabledServices int64 - InactiveServices int64 - EventCount int64 - AnalyzedCount int64 - PendingPolicyCount int64 - ApprovedPolicyCount int64 - DismissedPolicyCount int64 - PolicyPendingCriticalCount int64 - PolicyPendingHighCount int64 - PolicyPendingMediumCount int64 - PolicyPendingLowCount int64 - EstimatedCostPerHour *float64 - EstimatedCostPerHourBytes *float64 - EstimatedCostPerHourVolume *float64 - EstimatedVolumePerHour *float64 - EstimatedBytesPerHour *float64 - ObservedCostBefore *float64 - ObservedCostBeforeBytes *float64 - ObservedCostBeforeVolume *float64 - ObservedCostAfter *float64 - ObservedCostAfterBytes *float64 - ObservedCostAfterVolume *float64 - ObservedVolumeBefore *float64 - ObservedVolumeAfter *float64 - ObservedBytesBefore *float64 - ObservedBytesAfter *float64 - TotalCostPerHour *float64 - TotalCostPerHourBytes *float64 - TotalCostPerHourVolume *float64 - TotalVolumePerHour *float64 - TotalBytesPerHour *float64 - TotalServiceVolumePerHour *float64 - TotalServiceCostPerHour *float64 -} - -func (q *Queries) GetAccountSummary(ctx context.Context) (GetAccountSummaryRow, error) { - row := q.db.QueryRowContext(ctx, getAccountSummary) - var i GetAccountSummaryRow - err := row.Scan( - &i.ReadyForUse, - &i.Health, - &i.ServiceCount, - &i.ActiveServices, - &i.OkServices, - &i.DisabledServices, - &i.InactiveServices, - &i.EventCount, - &i.AnalyzedCount, - &i.PendingPolicyCount, - &i.ApprovedPolicyCount, - &i.DismissedPolicyCount, - &i.PolicyPendingCriticalCount, - &i.PolicyPendingHighCount, - &i.PolicyPendingMediumCount, - &i.PolicyPendingLowCount, - &i.EstimatedCostPerHour, - &i.EstimatedCostPerHourBytes, - &i.EstimatedCostPerHourVolume, - &i.EstimatedVolumePerHour, - &i.EstimatedBytesPerHour, - &i.ObservedCostBefore, - &i.ObservedCostBeforeBytes, - &i.ObservedCostBeforeVolume, - &i.ObservedCostAfter, - &i.ObservedCostAfterBytes, - &i.ObservedCostAfterVolume, - &i.ObservedVolumeBefore, - &i.ObservedVolumeAfter, - &i.ObservedBytesBefore, - &i.ObservedBytesAfter, - &i.TotalCostPerHour, - &i.TotalCostPerHourBytes, - &i.TotalCostPerHourVolume, - &i.TotalVolumePerHour, - &i.TotalBytesPerHour, - &i.TotalServiceVolumePerHour, - &i.TotalServiceCostPerHour, - ) - return i, err -} diff --git a/internal/sqlite/gen/db.go b/internal/sqlite/gen/db.go deleted file mode 100644 index d577e39d..00000000 --- a/internal/sqlite/gen/db.go +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 - -package gen - -import ( - "context" - "database/sql" -) - -type DBTX interface { - ExecContext(context.Context, string, ...interface{}) (sql.Result, error) - PrepareContext(context.Context, string) (*sql.Stmt, error) - QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) - QueryRowContext(context.Context, string, ...interface{}) *sql.Row -} - -func New(db DBTX) *Queries { - return &Queries{db: db} -} - -type Queries struct { - db DBTX -} - -func (q *Queries) WithTx(tx *sql.Tx) *Queries { - return &Queries{ - db: tx, - } -} diff --git a/internal/sqlite/gen/log_event_policies.sql.go b/internal/sqlite/gen/log_event_policies.sql.go deleted file mode 100644 index e1548fc5..00000000 --- a/internal/sqlite/gen/log_event_policies.sql.go +++ /dev/null @@ -1,55 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: log_event_policies.sql - -package gen - -import ( - "context" -) - -const approveLogEventPolicy = `-- name: ApproveLogEventPolicy :exec -UPDATE log_event_policies -SET approved_at = ?, approved_by = ? -WHERE id = ? -` - -type ApproveLogEventPolicyParams struct { - ApprovedAt *string - ApprovedBy *string - ID *string -} - -func (q *Queries) ApproveLogEventPolicy(ctx context.Context, arg ApproveLogEventPolicyParams) error { - _, err := q.db.ExecContext(ctx, approveLogEventPolicy, arg.ApprovedAt, arg.ApprovedBy, arg.ID) - return err -} - -const countLogEventPolicies = `-- name: CountLogEventPolicies :one -SELECT COUNT(*) FROM log_event_policies -` - -func (q *Queries) CountLogEventPolicies(ctx context.Context) (int64, error) { - row := q.db.QueryRowContext(ctx, countLogEventPolicies) - var count int64 - err := row.Scan(&count) - return count, err -} - -const dismissLogEventPolicy = `-- name: DismissLogEventPolicy :exec -UPDATE log_event_policies -SET dismissed_at = ?, dismissed_by = ? -WHERE id = ? -` - -type DismissLogEventPolicyParams struct { - DismissedAt *string - DismissedBy *string - ID *string -} - -func (q *Queries) DismissLogEventPolicy(ctx context.Context, arg DismissLogEventPolicyParams) error { - _, err := q.db.ExecContext(ctx, dismissLogEventPolicy, arg.DismissedAt, arg.DismissedBy, arg.ID) - return err -} diff --git a/internal/sqlite/gen/log_event_policy_category_statuses.sql.go b/internal/sqlite/gen/log_event_policy_category_statuses.sql.go deleted file mode 100644 index 076d4a02..00000000 --- a/internal/sqlite/gen/log_event_policy_category_statuses.sql.go +++ /dev/null @@ -1,103 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: log_event_policy_category_statuses.sql - -package gen - -import ( - "context" -) - -const listCategoryStatusesByCostAndType = `-- name: ListCategoryStatusesByCostAndType :many -SELECT - COALESCE(category, '') AS category, - COALESCE(category_type, '') AS category_type, - COALESCE("action", '') AS policy_action, - COALESCE(display_name, '') AS display_name, - COALESCE(principle, '') AS principle, - CAST(COALESCE(pending_count, 0) AS INTEGER) AS pending_count, - CAST(COALESCE(approved_count, 0) AS INTEGER) AS approved_count, - CAST(COALESCE(dismissed_count, 0) AS INTEGER) AS dismissed_count, - estimated_volume_reduction_per_hour, - estimated_bytes_reduction_per_hour, - estimated_cost_reduction_per_hour_usd, - estimated_cost_reduction_per_hour_bytes_usd, - estimated_cost_reduction_per_hour_volume_usd, - CAST(COALESCE(events_with_volumes, 0) AS INTEGER) AS events_with_volumes, - CAST(COALESCE(total_event_count, 0) AS INTEGER) AS total_event_count, - CAST(COALESCE(policy_pending_critical_count, 0) AS INTEGER) AS policy_pending_critical_count, - CAST(COALESCE(policy_pending_high_count, 0) AS INTEGER) AS policy_pending_high_count, - CAST(COALESCE(policy_pending_medium_count, 0) AS INTEGER) AS policy_pending_medium_count, - CAST(COALESCE(policy_pending_low_count, 0) AS INTEGER) AS policy_pending_low_count -FROM log_event_policy_category_statuses_cache -WHERE category IS NOT NULL AND category != '' - AND category_type = ?1 -ORDER BY estimated_cost_reduction_per_hour_usd DESC NULLS LAST, pending_count DESC -` - -type ListCategoryStatusesByCostAndTypeRow struct { - Category string - CategoryType string - PolicyAction string - DisplayName string - Principle string - PendingCount int64 - ApprovedCount int64 - DismissedCount int64 - EstimatedVolumeReductionPerHour *float64 - EstimatedBytesReductionPerHour *float64 - EstimatedCostReductionPerHourUsd *float64 - EstimatedCostReductionPerHourBytesUsd *float64 - EstimatedCostReductionPerHourVolumeUsd *float64 - EventsWithVolumes int64 - TotalEventCount int64 - PolicyPendingCriticalCount int64 - PolicyPendingHighCount int64 - PolicyPendingMediumCount int64 - PolicyPendingLowCount int64 -} - -// Pre-computed per-category rollup filtered by category_type (waste or compliance). -func (q *Queries) ListCategoryStatusesByCostAndType(ctx context.Context, categoryType *string) ([]ListCategoryStatusesByCostAndTypeRow, error) { - rows, err := q.db.QueryContext(ctx, listCategoryStatusesByCostAndType, categoryType) - if err != nil { - return nil, err - } - defer rows.Close() - var items []ListCategoryStatusesByCostAndTypeRow - for rows.Next() { - var i ListCategoryStatusesByCostAndTypeRow - if err := rows.Scan( - &i.Category, - &i.CategoryType, - &i.PolicyAction, - &i.DisplayName, - &i.Principle, - &i.PendingCount, - &i.ApprovedCount, - &i.DismissedCount, - &i.EstimatedVolumeReductionPerHour, - &i.EstimatedBytesReductionPerHour, - &i.EstimatedCostReductionPerHourUsd, - &i.EstimatedCostReductionPerHourBytesUsd, - &i.EstimatedCostReductionPerHourVolumeUsd, - &i.EventsWithVolumes, - &i.TotalEventCount, - &i.PolicyPendingCriticalCount, - &i.PolicyPendingHighCount, - &i.PolicyPendingMediumCount, - &i.PolicyPendingLowCount, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/internal/sqlite/gen/log_event_policy_statuses.sql.go b/internal/sqlite/gen/log_event_policy_statuses.sql.go deleted file mode 100644 index b6bb64d1..00000000 --- a/internal/sqlite/gen/log_event_policy_statuses.sql.go +++ /dev/null @@ -1,142 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: log_event_policy_statuses.sql - -package gen - -import ( - "context" -) - -const countFixedPIIPolicies = `-- name: CountFixedPIIPolicies :one -SELECT CAST(COUNT(*) AS INTEGER) FROM log_event_policy_statuses_cache -WHERE category = 'pii_leakage' AND status = 'APPROVED' -` - -func (q *Queries) CountFixedPIIPolicies(ctx context.Context) (int64, error) { - row := q.db.QueryRowContext(ctx, countFixedPIIPolicies) - var column_1 int64 - err := row.Scan(&column_1) - return column_1, err -} - -const listPendingPIIPolicies = `-- name: ListPendingPIIPolicies :many -SELECT - COALESCE(service_name, '') AS service_name, - COALESCE(log_event_name, '') AS log_event_name, - COALESCE(lep.analysis, '') AS analysis, - volume_per_hour, - CAST(COALESCE(( - SELECT MAX(CASE json_extract(f.value, '$.observed') WHEN 1 THEN 1 ELSE 0 END) - FROM json_each(json_extract(lep.analysis, '$.pii_leakage.fields')) f - ), 0) AS INTEGER) AS any_observed -FROM log_event_policy_statuses_cache leps -LEFT JOIN log_event_policies lep ON lep.id = leps.policy_id -WHERE leps.category = 'pii_leakage' AND leps.status = 'PENDING' -ORDER BY any_observed DESC, leps.volume_per_hour DESC -` - -type ListPendingPIIPoliciesRow struct { - ServiceName string - LogEventName string - Analysis string - VolumePerHour *float64 - AnyObserved int64 -} - -func (q *Queries) ListPendingPIIPolicies(ctx context.Context) ([]ListPendingPIIPoliciesRow, error) { - rows, err := q.db.QueryContext(ctx, listPendingPIIPolicies) - if err != nil { - return nil, err - } - defer rows.Close() - var items []ListPendingPIIPoliciesRow - for rows.Next() { - var i ListPendingPIIPoliciesRow - if err := rows.Scan( - &i.ServiceName, - &i.LogEventName, - &i.Analysis, - &i.VolumePerHour, - &i.AnyObserved, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listTopPendingPoliciesByCategory = `-- name: ListTopPendingPoliciesByCategory :many -SELECT - COALESCE(service_name, '') AS service_name, - COALESCE(log_event_name, '') AS log_event_name, - volume_per_hour, - bytes_per_hour, - estimated_cost_reduction_per_hour_usd AS estimated_cost_per_hour, - estimated_cost_reduction_per_hour_bytes_usd AS estimated_cost_per_hour_bytes, - estimated_cost_reduction_per_hour_volume_usd AS estimated_cost_per_hour_volume, - estimated_bytes_reduction_per_hour AS estimated_bytes_per_hour, - estimated_volume_reduction_per_hour AS estimated_volume_per_hour -FROM log_event_policy_statuses_cache -WHERE category = ?1 AND status = 'PENDING' -ORDER BY estimated_cost_reduction_per_hour_usd DESC, volume_per_hour DESC -LIMIT ?2 -` - -type ListTopPendingPoliciesByCategoryParams struct { - Category *string - Limit int64 -} - -type ListTopPendingPoliciesByCategoryRow struct { - ServiceName string - LogEventName string - VolumePerHour *float64 - BytesPerHour *float64 - EstimatedCostPerHour *float64 - EstimatedCostPerHourBytes *float64 - EstimatedCostPerHourVolume *float64 - EstimatedBytesPerHour *float64 - EstimatedVolumePerHour *float64 -} - -func (q *Queries) ListTopPendingPoliciesByCategory(ctx context.Context, arg ListTopPendingPoliciesByCategoryParams) ([]ListTopPendingPoliciesByCategoryRow, error) { - rows, err := q.db.QueryContext(ctx, listTopPendingPoliciesByCategory, arg.Category, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - var items []ListTopPendingPoliciesByCategoryRow - for rows.Next() { - var i ListTopPendingPoliciesByCategoryRow - if err := rows.Scan( - &i.ServiceName, - &i.LogEventName, - &i.VolumePerHour, - &i.BytesPerHour, - &i.EstimatedCostPerHour, - &i.EstimatedCostPerHourBytes, - &i.EstimatedCostPerHourVolume, - &i.EstimatedBytesPerHour, - &i.EstimatedVolumePerHour, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/internal/sqlite/gen/log_event_statuses.sql.go b/internal/sqlite/gen/log_event_statuses.sql.go deleted file mode 100644 index a8454883..00000000 --- a/internal/sqlite/gen/log_event_statuses.sql.go +++ /dev/null @@ -1,82 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: log_event_statuses.sql - -package gen - -import ( - "context" -) - -const listLogEventStatusesByService = `-- name: ListLogEventStatusesByService :many -SELECT - COALESCE(le.name, '') AS log_event_name, - les.volume_per_hour, - les.bytes_per_hour, - les.cost_per_hour_usd, - CAST(COALESCE(les.pending_policy_count, 0) AS INTEGER) AS pending_policy_count, - CAST(COALESCE(les.approved_policy_count, 0) AS INTEGER) AS approved_policy_count, - CAST(COALESCE(les.policy_pending_critical_count, 0) AS INTEGER) AS policy_pending_critical_count, - CAST(COALESCE(les.policy_pending_high_count, 0) AS INTEGER) AS policy_pending_high_count, - CAST(COALESCE(les.policy_pending_medium_count, 0) AS INTEGER) AS policy_pending_medium_count, - CAST(COALESCE(les.policy_pending_low_count, 0) AS INTEGER) AS policy_pending_low_count -FROM log_events le -JOIN services s ON s.id = le.service_id -LEFT JOIN log_event_statuses_cache les ON les.log_event_id = le.id -WHERE s.name = ?1 -ORDER BY les.cost_per_hour_usd DESC, les.volume_per_hour DESC -LIMIT ?2 -` - -type ListLogEventStatusesByServiceParams struct { - Name *string - Limit int64 -} - -type ListLogEventStatusesByServiceRow struct { - LogEventName string - VolumePerHour *float64 - BytesPerHour *float64 - CostPerHourUsd *float64 - PendingPolicyCount int64 - ApprovedPolicyCount int64 - PolicyPendingCriticalCount int64 - PolicyPendingHighCount int64 - PolicyPendingMediumCount int64 - PolicyPendingLowCount int64 -} - -func (q *Queries) ListLogEventStatusesByService(ctx context.Context, arg ListLogEventStatusesByServiceParams) ([]ListLogEventStatusesByServiceRow, error) { - rows, err := q.db.QueryContext(ctx, listLogEventStatusesByService, arg.Name, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - var items []ListLogEventStatusesByServiceRow - for rows.Next() { - var i ListLogEventStatusesByServiceRow - if err := rows.Scan( - &i.LogEventName, - &i.VolumePerHour, - &i.BytesPerHour, - &i.CostPerHourUsd, - &i.PendingPolicyCount, - &i.ApprovedPolicyCount, - &i.PolicyPendingCriticalCount, - &i.PolicyPendingHighCount, - &i.PolicyPendingMediumCount, - &i.PolicyPendingLowCount, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/internal/sqlite/gen/log_events.sql.go b/internal/sqlite/gen/log_events.sql.go deleted file mode 100644 index 83e29f25..00000000 --- a/internal/sqlite/gen/log_events.sql.go +++ /dev/null @@ -1,21 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: log_events.sql - -package gen - -import ( - "context" -) - -const countLogEvents = `-- name: CountLogEvents :one -SELECT COUNT(*) FROM log_events -` - -func (q *Queries) CountLogEvents(ctx context.Context) (int64, error) { - row := q.db.QueryRowContext(ctx, countLogEvents) - var count int64 - err := row.Scan(&count) - return count, err -} diff --git a/internal/sqlite/gen/messages.sql.go b/internal/sqlite/gen/messages.sql.go deleted file mode 100644 index 723700e4..00000000 --- a/internal/sqlite/gen/messages.sql.go +++ /dev/null @@ -1,219 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: messages.sql - -package gen - -import ( - "context" -) - -const countMessages = `-- name: CountMessages :one -SELECT COUNT(*) FROM messages -` - -func (q *Queries) CountMessages(ctx context.Context) (int64, error) { - row := q.db.QueryRowContext(ctx, countMessages) - var count int64 - err := row.Scan(&count) - return count, err -} - -const countMessagesByConversation = `-- name: CountMessagesByConversation :one -SELECT COUNT(*) FROM messages WHERE conversation_id = ? -` - -func (q *Queries) CountMessagesByConversation(ctx context.Context, conversationID *string) (int64, error) { - row := q.db.QueryRowContext(ctx, countMessagesByConversation, conversationID) - var count int64 - err := row.Scan(&count) - return count, err -} - -const deleteMessage = `-- name: DeleteMessage :exec -DELETE FROM messages WHERE id = ? -` - -func (q *Queries) DeleteMessage(ctx context.Context, id *string) error { - _, err := q.db.ExecContext(ctx, deleteMessage, id) - return err -} - -const getLatestMessageByConversation = `-- name: GetLatestMessageByConversation :one -SELECT id, account_id, content, conversation_id, created_at, model, role, stop_reason FROM messages -WHERE conversation_id = ? -ORDER BY created_at DESC -LIMIT 1 -` - -func (q *Queries) GetLatestMessageByConversation(ctx context.Context, conversationID *string) (Message, error) { - row := q.db.QueryRowContext(ctx, getLatestMessageByConversation, conversationID) - var i Message - err := row.Scan( - &i.ID, - &i.AccountID, - &i.Content, - &i.ConversationID, - &i.CreatedAt, - &i.Model, - &i.Role, - &i.StopReason, - ) - return i, err -} - -const getMessage = `-- name: GetMessage :one -SELECT id, account_id, content, conversation_id, created_at, model, role, stop_reason FROM messages WHERE id = ? -` - -func (q *Queries) GetMessage(ctx context.Context, id *string) (Message, error) { - row := q.db.QueryRowContext(ctx, getMessage, id) - var i Message - err := row.Scan( - &i.ID, - &i.AccountID, - &i.Content, - &i.ConversationID, - &i.CreatedAt, - &i.Model, - &i.Role, - &i.StopReason, - ) - return i, err -} - -const insertMessage = `-- name: InsertMessage :exec -INSERT INTO messages (id, account_id, content, conversation_id, created_at, model, role, stop_reason) -VALUES (?, ?, ?, ?, ?, ?, ?, ?) -` - -type InsertMessageParams struct { - ID *string - AccountID *string - Content *string - ConversationID *string - CreatedAt *string - Model *string - Role *string - StopReason *string -} - -func (q *Queries) InsertMessage(ctx context.Context, arg InsertMessageParams) error { - _, err := q.db.ExecContext(ctx, insertMessage, - arg.ID, - arg.AccountID, - arg.Content, - arg.ConversationID, - arg.CreatedAt, - arg.Model, - arg.Role, - arg.StopReason, - ) - return err -} - -const listMessagesByConversation = `-- name: ListMessagesByConversation :many -SELECT id, account_id, content, conversation_id, created_at, model, role, stop_reason FROM messages -WHERE conversation_id = ? -ORDER BY created_at ASC -` - -func (q *Queries) ListMessagesByConversation(ctx context.Context, conversationID *string) ([]Message, error) { - rows, err := q.db.QueryContext(ctx, listMessagesByConversation, conversationID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Message - for rows.Next() { - var i Message - if err := rows.Scan( - &i.ID, - &i.AccountID, - &i.Content, - &i.ConversationID, - &i.CreatedAt, - &i.Model, - &i.Role, - &i.StopReason, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listMessagesByConversationDesc = `-- name: ListMessagesByConversationDesc :many -SELECT id, account_id, content, conversation_id, created_at, model, role, stop_reason FROM messages -WHERE conversation_id = ? -ORDER BY created_at DESC -` - -func (q *Queries) ListMessagesByConversationDesc(ctx context.Context, conversationID *string) ([]Message, error) { - rows, err := q.db.QueryContext(ctx, listMessagesByConversationDesc, conversationID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Message - for rows.Next() { - var i Message - if err := rows.Scan( - &i.ID, - &i.AccountID, - &i.Content, - &i.ConversationID, - &i.CreatedAt, - &i.Model, - &i.Role, - &i.StopReason, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const updateMessageContent = `-- name: UpdateMessageContent :exec -UPDATE messages SET content = ? WHERE id = ? -` - -type UpdateMessageContentParams struct { - Content *string - ID *string -} - -func (q *Queries) UpdateMessageContent(ctx context.Context, arg UpdateMessageContentParams) error { - _, err := q.db.ExecContext(ctx, updateMessageContent, arg.Content, arg.ID) - return err -} - -const updateMessageMeta = `-- name: UpdateMessageMeta :exec -UPDATE messages SET model = ?, stop_reason = ? WHERE id = ? -` - -type UpdateMessageMetaParams struct { - Model *string - StopReason *string - ID *string -} - -func (q *Queries) UpdateMessageMeta(ctx context.Context, arg UpdateMessageMetaParams) error { - _, err := q.db.ExecContext(ctx, updateMessageMeta, arg.Model, arg.StopReason, arg.ID) - return err -} diff --git a/internal/sqlite/gen/models.go b/internal/sqlite/gen/models.go deleted file mode 100644 index 56484f63..00000000 --- a/internal/sqlite/gen/models.go +++ /dev/null @@ -1,325 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 - -package gen - -type Conversation struct { - ID *string - AccountID *string - CreatedAt *string - Title *string - UserID *string - ViewID *string - WorkspaceID *string -} - -type ConversationContext struct { - ID *string - AccountID *string - AddedBy *string - ConversationID *string - CreatedAt *string - EntityID *string - EntityType *string -} - -type DatadogAccount struct { - ID *string - AccountID *string - CostPerGbIngested *float64 - CreatedAt *string - Name *string - Site *string -} - -type DatadogAccountStatusesCache struct { - ID *string - AccountID *string - DatadogAccountID *string - DisabledServices *int64 - EstimatedBytesReductionPerHour *float64 - EstimatedCostReductionPerHourBytesUsd *float64 - EstimatedCostReductionPerHourUsd *float64 - EstimatedCostReductionPerHourVolumeUsd *float64 - EstimatedVolumeReductionPerHour *float64 - Health *string - InactiveServices *int64 - LogActiveServices *int64 - LogEventAnalyzedCount *int64 - LogEventBytesPerHour *float64 - LogEventCostPerHourBytesUsd *float64 - LogEventCostPerHourUsd *float64 - LogEventCostPerHourVolumeUsd *float64 - LogEventCount *int64 - LogEventVolumePerHour *float64 - LogServiceCount *int64 - ObservedBytesPerHourAfter *float64 - ObservedBytesPerHourBefore *float64 - ObservedCostPerHourAfterBytesUsd *float64 - ObservedCostPerHourAfterUsd *float64 - ObservedCostPerHourAfterVolumeUsd *float64 - ObservedCostPerHourBeforeBytesUsd *float64 - ObservedCostPerHourBeforeUsd *float64 - ObservedCostPerHourBeforeVolumeUsd *float64 - ObservedVolumePerHourAfter *float64 - ObservedVolumePerHourBefore *float64 - OkServices *int64 - PolicyApprovedCount *int64 - PolicyDismissedCount *int64 - PolicyPendingCount *int64 - PolicyPendingCriticalCount *int64 - PolicyPendingHighCount *int64 - PolicyPendingLowCount *int64 - PolicyPendingMediumCount *int64 - ReadyForUse *int64 - ServiceCostPerHourVolumeUsd *float64 - ServiceVolumePerHour *float64 -} - -type DatadogLogIndex struct { - ID *string - AccountID *string - CostPerMillionEventsIndexed *float64 - CreatedAt *string - DatadogAccountID *string - Name *string -} - -type LogEvent struct { - ID *string - AccountID *string - BaselineAvgBytes *float64 - BaselineVolumePerHour *float64 - CreatedAt *string - Description *string - EventNature *string - Examples *string - Matchers *string - Name *string - ServiceID *string - Severity *string - SignalPurpose *string -} - -type LogEventField struct { - ID *string - AccountID *string - BaselineAvgBytes *float64 - CreatedAt *string - FieldPath *string - LogEventID *string - ValueDistribution *string -} - -type LogEventPolicy struct { - ID *string - AccountID *string - Action *string - Analysis *string - ApprovedAt *string - ApprovedBaselineAvgBytes *float64 - ApprovedBaselineVolumePerHour *float64 - ApprovedBy *string - Category *string - CategoryType *string - CreatedAt *string - DismissedAt *string - DismissedBy *string - LogEventID *string - Severity *string - Subjective *int64 - WorkspaceID *string -} - -type LogEventPolicyCategoryStatusesCache struct { - ID *string - AccountID *string - Action *string - ApprovedCount *int64 - Boundary *string - Category *string - CategoryType *string - DismissedCount *int64 - DisplayName *string - EstimatedBytesReductionPerHour *float64 - EstimatedCostReductionPerHourBytesUsd *float64 - EstimatedCostReductionPerHourUsd *float64 - EstimatedCostReductionPerHourVolumeUsd *float64 - EstimatedVolumeReductionPerHour *float64 - EventsWithVolumes *int64 - PendingCount *int64 - PolicyPendingCriticalCount *int64 - PolicyPendingHighCount *int64 - PolicyPendingLowCount *int64 - PolicyPendingMediumCount *int64 - Principle *string - Subjective *int64 - TotalEventCount *int64 -} - -type LogEventPolicyStatusesCache struct { - ID *string - AccountID *string - Action *string - ApprovedAt *string - BytesPerHour *float64 - Category *string - CategoryType *string - CreatedAt *string - DismissedAt *string - EstimatedBytesReductionPerHour *float64 - EstimatedCostReductionPerHourBytesUsd *float64 - EstimatedCostReductionPerHourUsd *float64 - EstimatedCostReductionPerHourVolumeUsd *float64 - EstimatedVolumeReductionPerHour *float64 - LogEventID *string - LogEventName *string - PolicyID *string - ServiceID *string - ServiceName *string - Severity *string - Status *string - Subjective *int64 - SurvivalRate *float64 - VolumePerHour *float64 - WorkspaceID *string -} - -type LogEventStatusesCache struct { - ID *string - AccountID *string - ApprovedPolicyCount *int64 - BytesPerHour *float64 - CostPerHourBytesUsd *float64 - CostPerHourUsd *float64 - CostPerHourVolumeUsd *float64 - DismissedPolicyCount *int64 - EstimatedBytesReductionPerHour *float64 - EstimatedCostReductionPerHourBytesUsd *float64 - EstimatedCostReductionPerHourUsd *float64 - EstimatedCostReductionPerHourVolumeUsd *float64 - EstimatedVolumeReductionPerHour *float64 - HasBeenAnalyzed *int64 - HasVolumes *int64 - LogEventID *string - ObservedBytesPerHourAfter *float64 - ObservedBytesPerHourBefore *float64 - ObservedCostPerHourAfterBytesUsd *float64 - ObservedCostPerHourAfterUsd *float64 - ObservedCostPerHourAfterVolumeUsd *float64 - ObservedCostPerHourBeforeBytesUsd *float64 - ObservedCostPerHourBeforeUsd *float64 - ObservedCostPerHourBeforeVolumeUsd *float64 - ObservedVolumePerHourAfter *float64 - ObservedVolumePerHourBefore *float64 - PendingPolicyCount *int64 - PolicyCount *int64 - PolicyPendingCriticalCount *int64 - PolicyPendingHighCount *int64 - PolicyPendingLowCount *int64 - PolicyPendingMediumCount *int64 - ServiceID *string - VolumePerHour *float64 -} - -type Message struct { - ID *string - AccountID *string - Content *string - ConversationID *string - CreatedAt *string - Model *string - Role *string - StopReason *string -} - -type Service struct { - ID *string - AccountID *string - CreatedAt *string - Description *string - Enabled *int64 - InitialWeeklyLogCount *int64 - Name *string -} - -type ServiceStatusesCache struct { - ID *string - AccountID *string - DatadogAccountID *string - EstimatedBytesReductionPerHour *float64 - EstimatedCostReductionPerHourBytesUsd *float64 - EstimatedCostReductionPerHourUsd *float64 - EstimatedCostReductionPerHourVolumeUsd *float64 - EstimatedVolumeReductionPerHour *float64 - Health *string - LogEventAnalyzedCount *int64 - LogEventBytesPerHour *float64 - LogEventCostPerHourBytesUsd *float64 - LogEventCostPerHourUsd *float64 - LogEventCostPerHourVolumeUsd *float64 - LogEventCount *int64 - LogEventVolumePerHour *float64 - ObservedBytesPerHourAfter *float64 - ObservedBytesPerHourBefore *float64 - ObservedCostPerHourAfterBytesUsd *float64 - ObservedCostPerHourAfterUsd *float64 - ObservedCostPerHourAfterVolumeUsd *float64 - ObservedCostPerHourBeforeBytesUsd *float64 - ObservedCostPerHourBeforeUsd *float64 - ObservedCostPerHourBeforeVolumeUsd *float64 - ObservedVolumePerHourAfter *float64 - ObservedVolumePerHourBefore *float64 - PolicyApprovedCount *int64 - PolicyDismissedCount *int64 - PolicyPendingCount *int64 - PolicyPendingCriticalCount *int64 - PolicyPendingHighCount *int64 - PolicyPendingLowCount *int64 - PolicyPendingMediumCount *int64 - ServiceCostPerHourVolumeUsd *float64 - ServiceDebugVolumePerHour *float64 - ServiceErrorVolumePerHour *float64 - ServiceID *string - ServiceInfoVolumePerHour *float64 - ServiceOtherVolumePerHour *float64 - ServiceVolumePerHour *float64 - ServiceWarnVolumePerHour *float64 -} - -type Team struct { - ID *string - AccountID *string - CreatedAt *string - Name *string - WorkspaceID *string -} - -type View struct { - ID *string - AccountID *string - ConversationID *string - CreatedAt *string - CreatedBy *string - EntityType *string - ForkedFromID *string - MessageID *string - Query *string -} - -type ViewFavorite struct { - ID *string - AccountID *string - CreatedAt *string - UserID *string - ViewID *string -} - -type Workspace struct { - ID *string - AccountID *string - CreatedAt *string - Name *string - Purpose *string -} diff --git a/internal/sqlite/gen/policy_cards.sql.go b/internal/sqlite/gen/policy_cards.sql.go deleted file mode 100644 index d80d7bfe..00000000 --- a/internal/sqlite/gen/policy_cards.sql.go +++ /dev/null @@ -1,132 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: policy_cards.sql - -package gen - -import ( - "context" -) - -const getPolicyCard = `-- name: GetPolicyCard :one -SELECT - COALESCE(ps.policy_id, '') AS policy_id, - COALESCE(ps.service_name, '') AS service_name, - COALESCE(ps.log_event_name, '') AS log_event_name, - COALESCE(ps.category, '') AS category, - COALESCE(ps.category_type, '') AS category_type, - COALESCE(ps.action, '') AS "action", - COALESCE(ps.status, '') AS status, - ps.volume_per_hour, - ps.bytes_per_hour, - ps.estimated_cost_reduction_per_hour_usd AS estimated_cost_per_hour, - ps.estimated_volume_reduction_per_hour AS estimated_volume_per_hour, - ps.estimated_bytes_reduction_per_hour AS estimated_bytes_per_hour, - COALESCE(ps.severity, '') AS severity, - ps.survival_rate, - COALESCE(cat.display_name, '') AS category_display_name, - COALESCE(lep.analysis, '') AS analysis, - COALESCE(le.examples, '') AS examples, - le.baseline_avg_bytes AS event_baseline_avg_bytes, - le.baseline_volume_per_hour AS event_baseline_volume_per_hour, - COALESCE(ps.log_event_id, '') AS log_event_id -FROM log_event_policy_statuses_cache ps -LEFT JOIN log_event_policy_category_statuses_cache cat ON cat.category = ps.category -LEFT JOIN log_event_policies lep ON lep.id = ps.policy_id -LEFT JOIN log_events le ON le.id = ps.log_event_id -WHERE ps.policy_id = ?1 -` - -type GetPolicyCardRow struct { - PolicyID string - ServiceName string - LogEventName string - Category string - CategoryType string - Action string - Status string - VolumePerHour *float64 - BytesPerHour *float64 - EstimatedCostPerHour *float64 - EstimatedVolumePerHour *float64 - EstimatedBytesPerHour *float64 - Severity string - SurvivalRate *float64 - CategoryDisplayName string - Analysis string - Examples string - EventBaselineAvgBytes *float64 - EventBaselineVolumePerHour *float64 - LogEventID string -} - -// Fetches a single policy with all context needed for rich card rendering. -// Main table: log_event_policy_statuses_cache (denormalized policy + metrics). -// JOINs enrich with: category display name, AI analysis, log examples, baselines. -func (q *Queries) GetPolicyCard(ctx context.Context, policyID *string) (GetPolicyCardRow, error) { - row := q.db.QueryRowContext(ctx, getPolicyCard, policyID) - var i GetPolicyCardRow - err := row.Scan( - &i.PolicyID, - &i.ServiceName, - &i.LogEventName, - &i.Category, - &i.CategoryType, - &i.Action, - &i.Status, - &i.VolumePerHour, - &i.BytesPerHour, - &i.EstimatedCostPerHour, - &i.EstimatedVolumePerHour, - &i.EstimatedBytesPerHour, - &i.Severity, - &i.SurvivalRate, - &i.CategoryDisplayName, - &i.Analysis, - &i.Examples, - &i.EventBaselineAvgBytes, - &i.EventBaselineVolumePerHour, - &i.LogEventID, - ) - return i, err -} - -const listFieldsByLogEvent = `-- name: ListFieldsByLogEvent :many -SELECT - COALESCE(field_path, '') AS field_path, - baseline_avg_bytes -FROM log_event_fields -WHERE log_event_id = ?1 -ORDER BY baseline_avg_bytes DESC -` - -type ListFieldsByLogEventRow struct { - FieldPath string - BaselineAvgBytes *float64 -} - -// Returns per-field metadata for a log event, used to show per-field byte impact -// in quality policies (instrumentation_bloat, oversized_fields, duplicate_fields). -func (q *Queries) ListFieldsByLogEvent(ctx context.Context, logEventID *string) ([]ListFieldsByLogEventRow, error) { - rows, err := q.db.QueryContext(ctx, listFieldsByLogEvent, logEventID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []ListFieldsByLogEventRow - for rows.Next() { - var i ListFieldsByLogEventRow - if err := rows.Scan(&i.FieldPath, &i.BaselineAvgBytes); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/internal/sqlite/gen/querier.go b/internal/sqlite/gen/querier.go deleted file mode 100644 index 02a3dcf1..00000000 --- a/internal/sqlite/gen/querier.go +++ /dev/null @@ -1,60 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 - -package gen - -import ( - "context" -) - -type Querier interface { - ApproveLogEventPolicy(ctx context.Context, arg ApproveLogEventPolicyParams) error - CountConversations(ctx context.Context) (int64, error) - CountFixedPIIPolicies(ctx context.Context) (int64, error) - CountLogEventPolicies(ctx context.Context) (int64, error) - CountLogEvents(ctx context.Context) (int64, error) - CountMessages(ctx context.Context) (int64, error) - CountMessagesByConversation(ctx context.Context, conversationID *string) (int64, error) - // Returns, per compliance category, how many pending policies have observed (leaking) data. - CountObservedPoliciesByComplianceCategory(ctx context.Context) ([]CountObservedPoliciesByComplianceCategoryRow, error) - CountServices(ctx context.Context) (int64, error) - DeleteMessage(ctx context.Context, id *string) error - DismissLogEventPolicy(ctx context.Context, arg DismissLogEventPolicyParams) error - GetAccountSummary(ctx context.Context) (GetAccountSummaryRow, error) - GetConversation(ctx context.Context, id *string) (Conversation, error) - GetLatestConversationByAccount(ctx context.Context, accountID *string) (Conversation, error) - GetLatestMessageByConversation(ctx context.Context, conversationID *string) (Message, error) - GetMessage(ctx context.Context, id *string) (Message, error) - // Fetches a single policy with all context needed for rich card rendering. - // Main table: log_event_policy_statuses_cache (denormalized policy + metrics). - // JOINs enrich with: category display name, AI analysis, log examples, baselines. - GetPolicyCard(ctx context.Context, policyID *string) (GetPolicyCardRow, error) - GetService(ctx context.Context, id *string) (Service, error) - InsertConversation(ctx context.Context, arg InsertConversationParams) error - InsertMessage(ctx context.Context, arg InsertMessageParams) error - ListAllServiceStatuses(ctx context.Context) ([]ListAllServiceStatusesRow, error) - // Pre-computed per-category rollup filtered by category_type (waste or compliance). - ListCategoryStatusesByCostAndType(ctx context.Context, categoryType *string) ([]ListCategoryStatusesByCostAndTypeRow, error) - ListConversationsByAccount(ctx context.Context, accountID *string) ([]Conversation, error) - ListEnabledServiceStatuses(ctx context.Context, rowLimit int64) ([]ListEnabledServiceStatusesRow, error) - // Returns per-field metadata for a log event, used to show per-field byte impact - // in quality policies (instrumentation_bloat, oversized_fields, duplicate_fields). - ListFieldsByLogEvent(ctx context.Context, logEventID *string) ([]ListFieldsByLogEventRow, error) - ListLogEventStatusesByService(ctx context.Context, arg ListLogEventStatusesByServiceParams) ([]ListLogEventStatusesByServiceRow, error) - ListMessagesByConversation(ctx context.Context, conversationID *string) ([]Message, error) - ListMessagesByConversationDesc(ctx context.Context, conversationID *string) ([]Message, error) - // Compliance policy queries for PII, Secrets, PHI, and Payment Data leakage. - // Returns pending compliance policies for a specific category, sorted by observed then volume. - ListPendingCompliancePoliciesByCategory(ctx context.Context, arg ListPendingCompliancePoliciesByCategoryParams) ([]ListPendingCompliancePoliciesByCategoryRow, error) - ListPendingPIIPolicies(ctx context.Context) ([]ListPendingPIIPoliciesRow, error) - ListServices(ctx context.Context) ([]Service, error) - ListServicesByAccount(ctx context.Context, accountID *string) ([]Service, error) - ListTopPendingPoliciesByCategory(ctx context.Context, arg ListTopPendingPoliciesByCategoryParams) ([]ListTopPendingPoliciesByCategoryRow, error) - SetServiceEnabled(ctx context.Context, arg SetServiceEnabledParams) error - UpdateConversationTitle(ctx context.Context, arg UpdateConversationTitleParams) error - UpdateMessageContent(ctx context.Context, arg UpdateMessageContentParams) error - UpdateMessageMeta(ctx context.Context, arg UpdateMessageMetaParams) error -} - -var _ Querier = (*Queries)(nil) diff --git a/internal/sqlite/gen/service_statuses.sql.go b/internal/sqlite/gen/service_statuses.sql.go deleted file mode 100644 index b748e219..00000000 --- a/internal/sqlite/gen/service_statuses.sql.go +++ /dev/null @@ -1,322 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: service_statuses.sql - -package gen - -import ( - "context" -) - -const listAllServiceStatuses = `-- name: ListAllServiceStatuses :many -SELECT - s.name AS service_name, - COALESCE(ssc.health, '') AS health, - CAST(COALESCE(ssc.log_event_count, 0) AS INTEGER) AS log_event_count, - CAST(COALESCE(ssc.log_event_analyzed_count, 0) AS INTEGER) AS log_event_analyzed_count, - CAST(COALESCE(ssc.policy_pending_count, 0) AS INTEGER) AS policy_pending_count, - CAST(COALESCE(ssc.policy_approved_count, 0) AS INTEGER) AS policy_approved_count, - CAST(COALESCE(ssc.policy_dismissed_count, 0) AS INTEGER) AS policy_dismissed_count, - CAST(COALESCE(ssc.policy_pending_critical_count, 0) AS INTEGER) AS policy_pending_critical_count, - CAST(COALESCE(ssc.policy_pending_high_count, 0) AS INTEGER) AS policy_pending_high_count, - CAST(COALESCE(ssc.policy_pending_medium_count, 0) AS INTEGER) AS policy_pending_medium_count, - CAST(COALESCE(ssc.policy_pending_low_count, 0) AS INTEGER) AS policy_pending_low_count, - ssc.service_volume_per_hour, - ssc.service_debug_volume_per_hour, - ssc.service_info_volume_per_hour, - ssc.service_warn_volume_per_hour, - ssc.service_error_volume_per_hour, - ssc.service_other_volume_per_hour, - ssc.service_cost_per_hour_volume_usd, - ssc.log_event_volume_per_hour, - ssc.log_event_bytes_per_hour, - ssc.log_event_cost_per_hour_usd, - ssc.log_event_cost_per_hour_bytes_usd, - ssc.log_event_cost_per_hour_volume_usd, - ssc.estimated_volume_reduction_per_hour, - ssc.estimated_bytes_reduction_per_hour, - ssc.estimated_cost_reduction_per_hour_usd, - ssc.estimated_cost_reduction_per_hour_bytes_usd, - ssc.estimated_cost_reduction_per_hour_volume_usd, - ssc.observed_volume_per_hour_before, - ssc.observed_volume_per_hour_after, - ssc.observed_bytes_per_hour_before, - ssc.observed_bytes_per_hour_after, - ssc.observed_cost_per_hour_before_usd, - ssc.observed_cost_per_hour_before_bytes_usd, - ssc.observed_cost_per_hour_before_volume_usd, - ssc.observed_cost_per_hour_after_usd, - ssc.observed_cost_per_hour_after_bytes_usd, - ssc.observed_cost_per_hour_after_volume_usd -FROM service_statuses_cache ssc -JOIN services s ON ssc.service_id = s.id -ORDER BY - CASE ssc.health - WHEN 'OK' THEN 1 - WHEN 'DISABLED' THEN 2 - WHEN 'INACTIVE' THEN 3 - ELSE 4 - END, - ssc.log_event_cost_per_hour_usd DESC, - s.name -` - -type ListAllServiceStatusesRow struct { - ServiceName *string - Health string - LogEventCount int64 - LogEventAnalyzedCount int64 - PolicyPendingCount int64 - PolicyApprovedCount int64 - PolicyDismissedCount int64 - PolicyPendingCriticalCount int64 - PolicyPendingHighCount int64 - PolicyPendingMediumCount int64 - PolicyPendingLowCount int64 - ServiceVolumePerHour *float64 - ServiceDebugVolumePerHour *float64 - ServiceInfoVolumePerHour *float64 - ServiceWarnVolumePerHour *float64 - ServiceErrorVolumePerHour *float64 - ServiceOtherVolumePerHour *float64 - ServiceCostPerHourVolumeUsd *float64 - LogEventVolumePerHour *float64 - LogEventBytesPerHour *float64 - LogEventCostPerHourUsd *float64 - LogEventCostPerHourBytesUsd *float64 - LogEventCostPerHourVolumeUsd *float64 - EstimatedVolumeReductionPerHour *float64 - EstimatedBytesReductionPerHour *float64 - EstimatedCostReductionPerHourUsd *float64 - EstimatedCostReductionPerHourBytesUsd *float64 - EstimatedCostReductionPerHourVolumeUsd *float64 - ObservedVolumePerHourBefore *float64 - ObservedVolumePerHourAfter *float64 - ObservedBytesPerHourBefore *float64 - ObservedBytesPerHourAfter *float64 - ObservedCostPerHourBeforeUsd *float64 - ObservedCostPerHourBeforeBytesUsd *float64 - ObservedCostPerHourBeforeVolumeUsd *float64 - ObservedCostPerHourAfterUsd *float64 - ObservedCostPerHourAfterBytesUsd *float64 - ObservedCostPerHourAfterVolumeUsd *float64 -} - -func (q *Queries) ListAllServiceStatuses(ctx context.Context) ([]ListAllServiceStatusesRow, error) { - rows, err := q.db.QueryContext(ctx, listAllServiceStatuses) - if err != nil { - return nil, err - } - defer rows.Close() - var items []ListAllServiceStatusesRow - for rows.Next() { - var i ListAllServiceStatusesRow - if err := rows.Scan( - &i.ServiceName, - &i.Health, - &i.LogEventCount, - &i.LogEventAnalyzedCount, - &i.PolicyPendingCount, - &i.PolicyApprovedCount, - &i.PolicyDismissedCount, - &i.PolicyPendingCriticalCount, - &i.PolicyPendingHighCount, - &i.PolicyPendingMediumCount, - &i.PolicyPendingLowCount, - &i.ServiceVolumePerHour, - &i.ServiceDebugVolumePerHour, - &i.ServiceInfoVolumePerHour, - &i.ServiceWarnVolumePerHour, - &i.ServiceErrorVolumePerHour, - &i.ServiceOtherVolumePerHour, - &i.ServiceCostPerHourVolumeUsd, - &i.LogEventVolumePerHour, - &i.LogEventBytesPerHour, - &i.LogEventCostPerHourUsd, - &i.LogEventCostPerHourBytesUsd, - &i.LogEventCostPerHourVolumeUsd, - &i.EstimatedVolumeReductionPerHour, - &i.EstimatedBytesReductionPerHour, - &i.EstimatedCostReductionPerHourUsd, - &i.EstimatedCostReductionPerHourBytesUsd, - &i.EstimatedCostReductionPerHourVolumeUsd, - &i.ObservedVolumePerHourBefore, - &i.ObservedVolumePerHourAfter, - &i.ObservedBytesPerHourBefore, - &i.ObservedBytesPerHourAfter, - &i.ObservedCostPerHourBeforeUsd, - &i.ObservedCostPerHourBeforeBytesUsd, - &i.ObservedCostPerHourBeforeVolumeUsd, - &i.ObservedCostPerHourAfterUsd, - &i.ObservedCostPerHourAfterBytesUsd, - &i.ObservedCostPerHourAfterVolumeUsd, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listEnabledServiceStatuses = `-- name: ListEnabledServiceStatuses :many -SELECT - s.name AS service_name, - COALESCE(ssc.health, '') AS health, - CAST(COALESCE(ssc.log_event_count, 0) AS INTEGER) AS log_event_count, - CAST(COALESCE(ssc.log_event_analyzed_count, 0) AS INTEGER) AS log_event_analyzed_count, - CAST(COALESCE(ssc.policy_pending_count, 0) AS INTEGER) AS policy_pending_count, - CAST(COALESCE(ssc.policy_approved_count, 0) AS INTEGER) AS policy_approved_count, - CAST(COALESCE(ssc.policy_dismissed_count, 0) AS INTEGER) AS policy_dismissed_count, - CAST(COALESCE(ssc.policy_pending_critical_count, 0) AS INTEGER) AS policy_pending_critical_count, - CAST(COALESCE(ssc.policy_pending_high_count, 0) AS INTEGER) AS policy_pending_high_count, - CAST(COALESCE(ssc.policy_pending_medium_count, 0) AS INTEGER) AS policy_pending_medium_count, - CAST(COALESCE(ssc.policy_pending_low_count, 0) AS INTEGER) AS policy_pending_low_count, - ssc.service_volume_per_hour, - ssc.service_debug_volume_per_hour, - ssc.service_info_volume_per_hour, - ssc.service_warn_volume_per_hour, - ssc.service_error_volume_per_hour, - ssc.service_other_volume_per_hour, - ssc.service_cost_per_hour_volume_usd, - ssc.log_event_volume_per_hour, - ssc.log_event_bytes_per_hour, - ssc.log_event_cost_per_hour_usd, - ssc.log_event_cost_per_hour_bytes_usd, - ssc.log_event_cost_per_hour_volume_usd, - ssc.estimated_volume_reduction_per_hour, - ssc.estimated_bytes_reduction_per_hour, - ssc.estimated_cost_reduction_per_hour_usd, - ssc.estimated_cost_reduction_per_hour_bytes_usd, - ssc.estimated_cost_reduction_per_hour_volume_usd, - ssc.observed_volume_per_hour_before, - ssc.observed_volume_per_hour_after, - ssc.observed_bytes_per_hour_before, - ssc.observed_bytes_per_hour_after, - ssc.observed_cost_per_hour_before_usd, - ssc.observed_cost_per_hour_before_bytes_usd, - ssc.observed_cost_per_hour_before_volume_usd, - ssc.observed_cost_per_hour_after_usd, - ssc.observed_cost_per_hour_after_bytes_usd, - ssc.observed_cost_per_hour_after_volume_usd -FROM service_statuses_cache ssc -JOIN services s ON ssc.service_id = s.id -WHERE ssc.health NOT IN ('DISABLED', 'INACTIVE') -ORDER BY - CASE ssc.health - WHEN 'OK' THEN 1 - ELSE 2 - END, - ssc.log_event_cost_per_hour_usd DESC, - s.name -LIMIT ?1 -` - -type ListEnabledServiceStatusesRow struct { - ServiceName *string - Health string - LogEventCount int64 - LogEventAnalyzedCount int64 - PolicyPendingCount int64 - PolicyApprovedCount int64 - PolicyDismissedCount int64 - PolicyPendingCriticalCount int64 - PolicyPendingHighCount int64 - PolicyPendingMediumCount int64 - PolicyPendingLowCount int64 - ServiceVolumePerHour *float64 - ServiceDebugVolumePerHour *float64 - ServiceInfoVolumePerHour *float64 - ServiceWarnVolumePerHour *float64 - ServiceErrorVolumePerHour *float64 - ServiceOtherVolumePerHour *float64 - ServiceCostPerHourVolumeUsd *float64 - LogEventVolumePerHour *float64 - LogEventBytesPerHour *float64 - LogEventCostPerHourUsd *float64 - LogEventCostPerHourBytesUsd *float64 - LogEventCostPerHourVolumeUsd *float64 - EstimatedVolumeReductionPerHour *float64 - EstimatedBytesReductionPerHour *float64 - EstimatedCostReductionPerHourUsd *float64 - EstimatedCostReductionPerHourBytesUsd *float64 - EstimatedCostReductionPerHourVolumeUsd *float64 - ObservedVolumePerHourBefore *float64 - ObservedVolumePerHourAfter *float64 - ObservedBytesPerHourBefore *float64 - ObservedBytesPerHourAfter *float64 - ObservedCostPerHourBeforeUsd *float64 - ObservedCostPerHourBeforeBytesUsd *float64 - ObservedCostPerHourBeforeVolumeUsd *float64 - ObservedCostPerHourAfterUsd *float64 - ObservedCostPerHourAfterBytesUsd *float64 - ObservedCostPerHourAfterVolumeUsd *float64 -} - -func (q *Queries) ListEnabledServiceStatuses(ctx context.Context, rowLimit int64) ([]ListEnabledServiceStatusesRow, error) { - rows, err := q.db.QueryContext(ctx, listEnabledServiceStatuses, rowLimit) - if err != nil { - return nil, err - } - defer rows.Close() - var items []ListEnabledServiceStatusesRow - for rows.Next() { - var i ListEnabledServiceStatusesRow - if err := rows.Scan( - &i.ServiceName, - &i.Health, - &i.LogEventCount, - &i.LogEventAnalyzedCount, - &i.PolicyPendingCount, - &i.PolicyApprovedCount, - &i.PolicyDismissedCount, - &i.PolicyPendingCriticalCount, - &i.PolicyPendingHighCount, - &i.PolicyPendingMediumCount, - &i.PolicyPendingLowCount, - &i.ServiceVolumePerHour, - &i.ServiceDebugVolumePerHour, - &i.ServiceInfoVolumePerHour, - &i.ServiceWarnVolumePerHour, - &i.ServiceErrorVolumePerHour, - &i.ServiceOtherVolumePerHour, - &i.ServiceCostPerHourVolumeUsd, - &i.LogEventVolumePerHour, - &i.LogEventBytesPerHour, - &i.LogEventCostPerHourUsd, - &i.LogEventCostPerHourBytesUsd, - &i.LogEventCostPerHourVolumeUsd, - &i.EstimatedVolumeReductionPerHour, - &i.EstimatedBytesReductionPerHour, - &i.EstimatedCostReductionPerHourUsd, - &i.EstimatedCostReductionPerHourBytesUsd, - &i.EstimatedCostReductionPerHourVolumeUsd, - &i.ObservedVolumePerHourBefore, - &i.ObservedVolumePerHourAfter, - &i.ObservedBytesPerHourBefore, - &i.ObservedBytesPerHourAfter, - &i.ObservedCostPerHourBeforeUsd, - &i.ObservedCostPerHourBeforeBytesUsd, - &i.ObservedCostPerHourBeforeVolumeUsd, - &i.ObservedCostPerHourAfterUsd, - &i.ObservedCostPerHourAfterBytesUsd, - &i.ObservedCostPerHourAfterVolumeUsd, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/internal/sqlite/gen/services.sql.go b/internal/sqlite/gen/services.sql.go deleted file mode 100644 index 6bc51e37..00000000 --- a/internal/sqlite/gen/services.sql.go +++ /dev/null @@ -1,124 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: services.sql - -package gen - -import ( - "context" -) - -const countServices = `-- name: CountServices :one -SELECT COUNT(*) FROM services -` - -func (q *Queries) CountServices(ctx context.Context) (int64, error) { - row := q.db.QueryRowContext(ctx, countServices) - var count int64 - err := row.Scan(&count) - return count, err -} - -const getService = `-- name: GetService :one -SELECT id, account_id, created_at, description, enabled, initial_weekly_log_count, name FROM services WHERE id = ? -` - -func (q *Queries) GetService(ctx context.Context, id *string) (Service, error) { - row := q.db.QueryRowContext(ctx, getService, id) - var i Service - err := row.Scan( - &i.ID, - &i.AccountID, - &i.CreatedAt, - &i.Description, - &i.Enabled, - &i.InitialWeeklyLogCount, - &i.Name, - ) - return i, err -} - -const listServices = `-- name: ListServices :many -SELECT id, account_id, created_at, description, enabled, initial_weekly_log_count, name FROM services ORDER BY name -` - -func (q *Queries) ListServices(ctx context.Context) ([]Service, error) { - rows, err := q.db.QueryContext(ctx, listServices) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Service - for rows.Next() { - var i Service - if err := rows.Scan( - &i.ID, - &i.AccountID, - &i.CreatedAt, - &i.Description, - &i.Enabled, - &i.InitialWeeklyLogCount, - &i.Name, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listServicesByAccount = `-- name: ListServicesByAccount :many -SELECT id, account_id, created_at, description, enabled, initial_weekly_log_count, name FROM services WHERE account_id = ? ORDER BY name -` - -func (q *Queries) ListServicesByAccount(ctx context.Context, accountID *string) ([]Service, error) { - rows, err := q.db.QueryContext(ctx, listServicesByAccount, accountID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Service - for rows.Next() { - var i Service - if err := rows.Scan( - &i.ID, - &i.AccountID, - &i.CreatedAt, - &i.Description, - &i.Enabled, - &i.InitialWeeklyLogCount, - &i.Name, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const setServiceEnabled = `-- name: SetServiceEnabled :exec -UPDATE services SET enabled = ? WHERE id = ? -` - -type SetServiceEnabledParams struct { - Enabled *int64 - ID *string -} - -func (q *Queries) SetServiceEnabled(ctx context.Context, arg SetServiceEnabledParams) error { - _, err := q.db.ExecContext(ctx, setServiceEnabled, arg.Enabled, arg.ID) - return err -} diff --git a/internal/sqlite/generate/main.go b/internal/sqlite/generate/main.go deleted file mode 100644 index bb0e30af..00000000 --- a/internal/sqlite/generate/main.go +++ /dev/null @@ -1,249 +0,0 @@ -// Package main generates SQLite schema types from PowerSync. -// -// It boots a temp database with the PowerSync extension and embedded schema, -// then reflects the schema back to a SQL file for sqlc. -// -// Prerequisites: Run `go generate ./internal/powersync` first to generate schema.json. -// -// Usage: -// -// doppler run -- go generate ./internal/sqlite -package main - -import ( - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "sort" - "strings" - - "github.com/usetero/cli/internal/powersync/extension" - "github.com/usetero/cli/internal/sqlite" -) - -func main() { - if err := run(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -func run() error { - ctx := context.Background() - - // Create temp database - tmpDir, err := os.MkdirTemp("", "tero-generate-*") - if err != nil { - return fmt.Errorf("create temp dir: %w", err) - } - defer os.RemoveAll(tmpDir) - - dbPath := filepath.Join(tmpDir, "generate.db") - db, err := sqlite.Open(ctx, dbPath) - if err != nil { - return fmt.Errorf("open database: %w", err) - } - defer db.Close() - - fmt.Println("Applying embedded PowerSync schema...") - - // Apply embedded schema to create views (just like runtime) - if _, err := db.Exec(ctx, "SELECT powersync_replace_schema(?)", extension.SchemaJSON()); err != nil { - return fmt.Errorf("replace schema: %w", err) - } - - fmt.Println("Reflecting schema from SQLite...") - - // Reflect schema to SQL - schemaSQL, err := reflectSchema(ctx, db) - if err != nil { - return fmt.Errorf("reflect schema: %w", err) - } - - // Write schema.sql - outputDir := mustGetwd() - schemaPath := filepath.Join(outputDir, "schema.sql") - if err := os.WriteFile(schemaPath, []byte(schemaSQL), 0o644); err != nil { - return fmt.Errorf("write schema.sql: %w", err) - } - - fmt.Printf("Wrote %s\n", schemaPath) - - // Run sqlc generate - fmt.Println("Running sqlc generate...") - if err := runSqlc(ctx); err != nil { - return fmt.Errorf("sqlc generate: %w", err) - } - - fmt.Println("Done!") - return nil -} - -// reflectSchema reflects the schema from sqlite_master to SQL. -// It extracts CREATE VIEW statements for PowerSync views (excluding internal tables). -func reflectSchema(ctx context.Context, db sqlite.DB) (string, error) { - // Query all views created by PowerSync (they have the auto-generated comment) - rows, err := db.Query(ctx, ` - SELECT name, sql - FROM sqlite_master - WHERE type = 'view' - AND sql LIKE '%-- powersync-auto-generated%' - ORDER BY name - `) - if err != nil { - return "", fmt.Errorf("query sqlite_master: %w", err) - } - defer rows.Close() - - var views []struct { - name string - sql string - } - - for rows.Next() { - var name, sql string - if err := rows.Scan(&name, &sql); err != nil { - return "", fmt.Errorf("scan row: %w", err) - } - views = append(views, struct { - name string - sql string - }{name, sql}) - } - - if err := rows.Err(); err != nil { - return "", fmt.Errorf("iterate rows: %w", err) - } - - // Sort by name for deterministic output - sort.Slice(views, func(i, j int) bool { - return views[i].name < views[j].name - }) - - // Convert views to CREATE TABLE statements for sqlc - // (sqlc needs tables, not views, to generate types) - var statements []string - statements = append(statements, "-- Code generated by go generate; DO NOT EDIT.") - statements = append(statements, "-- Source: PowerSync schema reflected from sqlite_master") - statements = append(statements, "") - - for _, view := range views { - // Parse the view to extract column info and generate CREATE TABLE - tableSQL, err := viewToTable(ctx, db, view.name) - if err != nil { - return "", fmt.Errorf("convert view %s: %w", view.name, err) - } - statements = append(statements, tableSQL) - statements = append(statements, "") - } - - return strings.Join(statements, "\n"), nil -} - -// viewToTable converts a view definition to a CREATE TABLE statement. -// It queries the view's column info using PRAGMA table_info. -func viewToTable(ctx context.Context, db sqlite.DB, viewName string) (string, error) { - // Get column info from the view - rows, err := db.Query(ctx, fmt.Sprintf("PRAGMA table_info(%s)", viewName)) - if err != nil { - return "", fmt.Errorf("pragma table_info: %w", err) - } - defer rows.Close() - - var columns []string - seen := make(map[string]bool) - for rows.Next() { - var cid int - var name, colType string - var notNull, pk int - var dfltValue *string - - if err := rows.Scan(&cid, &name, &colType, ¬Null, &dfltValue, &pk); err != nil { - return "", fmt.Errorf("scan column: %w", err) - } - - // Skip columns with invalid names (like "id:1" aliases from SQLite) - if strings.Contains(name, ":") { - continue - } - - // Skip duplicates - if seen[name] { - continue - } - seen[name] = true - - // Normalize type for sqlc - sqlType := normalizeSQLiteType(colType) - - col := fmt.Sprintf(" %s %s", name, sqlType) - if pk == 1 { - col += " PRIMARY KEY" - } - if notNull == 1 && pk != 1 { - col += " NOT NULL" - } - columns = append(columns, col) - } - - if err := rows.Err(); err != nil { - return "", fmt.Errorf("iterate columns: %w", err) - } - - return fmt.Sprintf("CREATE TABLE %s (\n%s\n);", viewName, strings.Join(columns, ",\n")), nil -} - -// normalizeSQLiteType normalizes SQLite type names for sqlc. -func normalizeSQLiteType(t string) string { - t = strings.ToUpper(strings.TrimSpace(t)) - switch t { - case "INT", "INTEGER", "BIGINT", "SMALLINT", "TINYINT": - return "INTEGER" - case "REAL", "DOUBLE", "FLOAT": - return "REAL" - case "BLOB": - return "BLOB" - default: - return "TEXT" - } -} - -// runSqlc runs sqlc generate from the repository root. -func runSqlc(ctx context.Context) error { - // Find repo root (where sqlc.yaml lives) - repoRoot, err := findRepoRoot() - if err != nil { - return err - } - - cmd := exec.CommandContext(ctx, "sqlc", "generate") - cmd.Dir = repoRoot - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} - -// findRepoRoot finds the repository root by looking for go.mod. -func findRepoRoot() (string, error) { - dir := mustGetwd() - for { - if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { - return dir, nil - } - parent := filepath.Dir(dir) - if parent == dir { - return "", fmt.Errorf("could not find repository root (go.mod)") - } - dir = parent - } -} - -func mustGetwd() string { - dir, err := os.Getwd() - if err != nil { - panic(err) - } - return dir -} diff --git a/internal/sqlite/log_event_policies.go b/internal/sqlite/log_event_policies.go deleted file mode 100644 index 5c1f8f98..00000000 --- a/internal/sqlite/log_event_policies.go +++ /dev/null @@ -1,56 +0,0 @@ -package sqlite - -import ( - "context" - "time" - - "github.com/usetero/cli/internal/sqlite/gen" -) - -// LogEventPolicies provides type-safe access to log event policies. -type LogEventPolicies interface { - Count(ctx context.Context) (int64, error) - Approve(ctx context.Context, id, userID string) error - Dismiss(ctx context.Context, id, userID string) error -} - -// logEventPoliciesImpl implements LogEventPolicies. -type logEventPoliciesImpl struct { - read *gen.Queries - write *gen.Queries -} - -// Count returns the total number of log event policies. -func (l *logEventPoliciesImpl) Count(ctx context.Context) (int64, error) { - count, err := l.read.CountLogEventPolicies(ctx) - if err != nil { - return 0, WrapSQLiteError(err, "count log event policies") - } - return count, nil -} - -func (l *logEventPoliciesImpl) Approve(ctx context.Context, id, userID string) error { - now := time.Now().UTC().Format(time.RFC3339) - err := l.write.ApproveLogEventPolicy(ctx, gen.ApproveLogEventPolicyParams{ - ID: &id, - ApprovedAt: &now, - ApprovedBy: &userID, - }) - if err != nil { - return WrapSQLiteError(err, "approve log event policy") - } - return nil -} - -func (l *logEventPoliciesImpl) Dismiss(ctx context.Context, id, userID string) error { - now := time.Now().UTC().Format(time.RFC3339) - err := l.write.DismissLogEventPolicy(ctx, gen.DismissLogEventPolicyParams{ - ID: &id, - DismissedAt: &now, - DismissedBy: &userID, - }) - if err != nil { - return WrapSQLiteError(err, "dismiss log event policy") - } - return nil -} diff --git a/internal/sqlite/log_event_policy_category_statuses.go b/internal/sqlite/log_event_policy_category_statuses.go deleted file mode 100644 index 2c21dd1b..00000000 --- a/internal/sqlite/log_event_policy_category_statuses.go +++ /dev/null @@ -1,83 +0,0 @@ -package sqlite - -import ( - "context" - - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/sqlite/gen" -) - -// LogEventPolicyCategoryStatuses provides access to pre-computed per-category policy rollups. -type LogEventPolicyCategoryStatuses interface { - ListWasteCategoryStatuses(ctx context.Context) ([]domain.PolicyCategoryStatus, error) - ListQualityCategoryStatuses(ctx context.Context) ([]domain.PolicyCategoryStatus, error) - ListComplianceCategoryStatuses(ctx context.Context) ([]domain.PolicyCategoryStatus, error) - CountObservedByComplianceCategory(ctx context.Context) (map[domain.PolicyCategory]int64, error) -} - -// logEventPolicyCategoryStatusesImpl implements LogEventPolicyCategoryStatuses. -type logEventPolicyCategoryStatusesImpl struct { - queries *gen.Queries -} - -// ListWasteCategoryStatuses returns pre-computed category rollups for the waste tab. -func (l *logEventPolicyCategoryStatusesImpl) ListWasteCategoryStatuses(ctx context.Context) ([]domain.PolicyCategoryStatus, error) { - return l.listByType(ctx, domain.CategoryTypeWaste) -} - -// ListQualityCategoryStatuses returns pre-computed category rollups for the quality tab. -func (l *logEventPolicyCategoryStatusesImpl) ListQualityCategoryStatuses(ctx context.Context) ([]domain.PolicyCategoryStatus, error) { - return l.listByType(ctx, domain.CategoryTypeQuality) -} - -// ListComplianceCategoryStatuses returns pre-computed category rollups for the compliance tab. -func (l *logEventPolicyCategoryStatusesImpl) ListComplianceCategoryStatuses(ctx context.Context) ([]domain.PolicyCategoryStatus, error) { - return l.listByType(ctx, domain.CategoryTypeCompliance) -} - -// CountObservedByComplianceCategory returns the number of leaking (observed) pending policies per compliance category. -func (l *logEventPolicyCategoryStatusesImpl) CountObservedByComplianceCategory(ctx context.Context) (map[domain.PolicyCategory]int64, error) { - rows, err := l.queries.CountObservedPoliciesByComplianceCategory(ctx) - if err != nil { - return nil, WrapSQLiteError(err, "count observed policies by compliance category") - } - - result := make(map[domain.PolicyCategory]int64, len(rows)) - for _, row := range rows { - if row.Category != nil { - result[domain.PolicyCategory(*row.Category)] = row.ObservedCount - } - } - return result, nil -} - -func (l *logEventPolicyCategoryStatusesImpl) listByType(ctx context.Context, categoryType domain.CategoryType) ([]domain.PolicyCategoryStatus, error) { - ct := string(categoryType) - rows, err := l.queries.ListCategoryStatusesByCostAndType(ctx, &ct) - if err != nil { - return nil, WrapSQLiteError(err, "list policy category statuses by type") - } - - result := make([]domain.PolicyCategoryStatus, len(rows)) - for i, row := range rows { - result[i] = domain.PolicyCategoryStatus{ - Category: domain.PolicyCategory(row.Category), - DisplayName: row.DisplayName, - Principle: row.Principle, - PendingCount: row.PendingCount, - ApprovedCount: row.ApprovedCount, - DismissedCount: row.DismissedCount, - PolicyPendingCriticalCount: row.PolicyPendingCriticalCount, - PolicyPendingHighCount: row.PolicyPendingHighCount, - PolicyPendingMediumCount: row.PolicyPendingMediumCount, - PolicyPendingLowCount: row.PolicyPendingLowCount, - EstimatedVolumePerHour: row.EstimatedVolumeReductionPerHour, - EstimatedBytesPerHour: row.EstimatedBytesReductionPerHour, - EstimatedCostPerHour: row.EstimatedCostReductionPerHourUsd, - EventsWithVolumes: row.EventsWithVolumes, - TotalEvents: row.TotalEventCount, - Action: domain.PolicyAction(row.PolicyAction), - } - } - return result, nil -} diff --git a/internal/sqlite/log_event_policy_statuses.go b/internal/sqlite/log_event_policy_statuses.go deleted file mode 100644 index fe26736e..00000000 --- a/internal/sqlite/log_event_policy_statuses.go +++ /dev/null @@ -1,105 +0,0 @@ -package sqlite - -import ( - "context" - - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/sqlite/gen" -) - -// LogEventPolicyStatuses provides type-safe access to pre-computed policy status data. -type LogEventPolicyStatuses interface { - GetPolicyCard(ctx context.Context, policyID string) (*domain.PolicyCard, error) - ListTopPendingPoliciesByCategory(ctx context.Context, category domain.PolicyCategory, limit int64) ([]domain.WastePolicy, error) -} - -type logEventPolicyStatusesImpl struct { - queries *gen.Queries -} - -func (l *logEventPolicyStatusesImpl) GetPolicyCard(ctx context.Context, policyID string) (*domain.PolicyCard, error) { - row, err := l.queries.GetPolicyCard(ctx, &policyID) - if err != nil { - return nil, WrapSQLiteError(err, "get policy card") - } - - card := &domain.PolicyCard{ - PolicyID: row.PolicyID, - ServiceName: row.ServiceName, - LogEventName: row.LogEventName, - Category: row.Category, - CategoryType: row.CategoryType, - Action: row.Action, - Status: row.Status, - Severity: row.Severity, - CategoryDisplayName: row.CategoryDisplayName, - VolumePerHour: row.VolumePerHour, - BytesPerHour: row.BytesPerHour, - EstimatedCostPerHour: row.EstimatedCostPerHour, - EstimatedVolumePerHour: row.EstimatedVolumePerHour, - EstimatedBytesPerHour: row.EstimatedBytesPerHour, - SurvivalRate: row.SurvivalRate, - Analysis: row.Analysis, - Examples: row.Examples, - EventBaselineAvgBytes: row.EventBaselineAvgBytes, - EventBaselineVolumePerHour: row.EventBaselineVolumePerHour, - } - - // Quality policies operate on fields — enrich with per-field byte sizes - // so BuildEvidence can produce FieldListEvidence. - if row.LogEventID != "" && row.CategoryType == string(domain.CategoryTypeQuality) { - card.FieldSizes = l.fieldSizes(ctx, row.LogEventID) - } - - return card, nil -} - -// fieldSizes fetches per-field byte sizes for a log event and returns them -// keyed by dot-path. Returns nil if no data is available. -func (l *logEventPolicyStatusesImpl) fieldSizes(ctx context.Context, logEventID string) map[string]float64 { - rows, err := l.queries.ListFieldsByLogEvent(ctx, &logEventID) - if err != nil || len(rows) == 0 { - return nil - } - - sizes := make(map[string]float64, len(rows)) - for _, row := range rows { - if row.BaselineAvgBytes != nil { - fp := domain.ParseFieldPathPg(row.FieldPath) - if !fp.IsEmpty() { - sizes[fp.Key()] = *row.BaselineAvgBytes - } - } - } - if len(sizes) == 0 { - return nil - } - return sizes -} - -func (l *logEventPolicyStatusesImpl) ListTopPendingPoliciesByCategory(ctx context.Context, category domain.PolicyCategory, limit int64) ([]domain.WastePolicy, error) { - catStr := string(category) - rows, err := l.queries.ListTopPendingPoliciesByCategory(ctx, gen.ListTopPendingPoliciesByCategoryParams{ - Category: &catStr, - Limit: limit, - }) - if err != nil { - return nil, WrapSQLiteError(err, "list top pending policies by category") - } - - result := make([]domain.WastePolicy, len(rows)) - for i, row := range rows { - result[i] = domain.WastePolicy{ - LogEventName: row.LogEventName, - ServiceName: row.ServiceName, - VolumePerHour: row.VolumePerHour, - BytesPerHour: row.BytesPerHour, - EstimatedCostPerHour: row.EstimatedCostPerHour, - EstimatedCostPerHourBytes: row.EstimatedCostPerHourBytes, - EstimatedCostPerHourVolume: row.EstimatedCostPerHourVolume, - EstimatedBytesPerHour: row.EstimatedBytesPerHour, - EstimatedVolumePerHour: row.EstimatedVolumePerHour, - } - } - return result, nil -} diff --git a/internal/sqlite/log_event_statuses.go b/internal/sqlite/log_event_statuses.go deleted file mode 100644 index 5de01bf0..00000000 --- a/internal/sqlite/log_event_statuses.go +++ /dev/null @@ -1,45 +0,0 @@ -package sqlite - -import ( - "context" - - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/sqlite/gen" -) - -// LogEventStatuses provides access to per-log-event status data. -type LogEventStatuses interface { - ListByService(ctx context.Context, serviceName string, limit int64) ([]domain.LogEventStatus, error) -} - -type logEventStatusesImpl struct { - queries *gen.Queries -} - -// ListByService returns log event statuses for a service, ordered by cost. -func (l *logEventStatusesImpl) ListByService(ctx context.Context, serviceName string, limit int64) ([]domain.LogEventStatus, error) { - rows, err := l.queries.ListLogEventStatusesByService(ctx, gen.ListLogEventStatusesByServiceParams{ - Name: &serviceName, - Limit: limit, - }) - if err != nil { - return nil, WrapSQLiteError(err, "list log event statuses by service") - } - - result := make([]domain.LogEventStatus, len(rows)) - for i, row := range rows { - result[i] = domain.LogEventStatus{ - Name: row.LogEventName, - VolumePerHour: row.VolumePerHour, - BytesPerHour: row.BytesPerHour, - CostPerHourUSD: row.CostPerHourUsd, - PendingPolicyCount: row.PendingPolicyCount, - ApprovedPolicyCount: row.ApprovedPolicyCount, - PolicyPendingCriticalCount: row.PolicyPendingCriticalCount, - PolicyPendingHighCount: row.PolicyPendingHighCount, - PolicyPendingMediumCount: row.PolicyPendingMediumCount, - PolicyPendingLowCount: row.PolicyPendingLowCount, - } - } - return result, nil -} diff --git a/internal/sqlite/log_events.go b/internal/sqlite/log_events.go deleted file mode 100644 index 62bbab63..00000000 --- a/internal/sqlite/log_events.go +++ /dev/null @@ -1,26 +0,0 @@ -package sqlite - -import ( - "context" - - "github.com/usetero/cli/internal/sqlite/gen" -) - -// LogEvents provides type-safe access to log events. -type LogEvents interface { - Count(ctx context.Context) (int64, error) -} - -// logEventsImpl implements LogEvents. -type logEventsImpl struct { - queries *gen.Queries -} - -// Count returns the total number of log events. -func (l *logEventsImpl) Count(ctx context.Context) (int64, error) { - count, err := l.queries.CountLogEvents(ctx) - if err != nil { - return 0, WrapSQLiteError(err, "count log events") - } - return count, nil -} diff --git a/internal/sqlite/messages.go b/internal/sqlite/messages.go deleted file mode 100644 index a54c1437..00000000 --- a/internal/sqlite/messages.go +++ /dev/null @@ -1,213 +0,0 @@ -package sqlite - -import ( - "context" - "time" - - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/sqlite/gen" -) - -// Messages provides type-safe access to messages. -type Messages interface { - Count(ctx context.Context) (int64, error) - Get(ctx context.Context, id domain.MessageID) (*domain.Message, error) - CreateUserMessage(ctx context.Context, accountID domain.AccountID, conversationID domain.ConversationID, text string) (domain.MessageID, error) - CreateToolResultMessage(ctx context.Context, accountID domain.AccountID, conversationID domain.ConversationID, results []domain.ToolResult) (domain.MessageID, error) - CreateAssistantMessage(ctx context.Context, accountID domain.AccountID, conversationID domain.ConversationID, model string) (domain.MessageID, error) - UpdateContent(ctx context.Context, id domain.MessageID, content string) error - UpdateMeta(ctx context.Context, id domain.MessageID, model, stopReason string) error - Delete(ctx context.Context, id domain.MessageID) error - List(ctx context.Context, conversationID domain.ConversationID) ([]domain.Message, error) -} - -// messagesImpl implements Messages. -type messagesImpl struct { - read *gen.Queries // read pool — List, Count, Get - write *gen.Queries // write pool — Create*, Update* -} - -// Count returns the total number of messages. -func (m *messagesImpl) Count(ctx context.Context) (int64, error) { - count, err := m.read.CountMessages(ctx) - if err != nil { - return 0, WrapSQLiteError(err, "count messages") - } - return count, nil -} - -// Get retrieves a message by ID. -func (m *messagesImpl) Get(ctx context.Context, id domain.MessageID) (*domain.Message, error) { - idStr := id.String() - row, err := m.read.GetMessage(ctx, &idStr) - if err != nil { - return nil, WrapSQLiteError(err, "get message") - } - return toMessage(row), nil -} - -// CreateUserMessage creates a user message with properly encoded content. -// Returns the new message ID. -func (m *messagesImpl) CreateUserMessage(ctx context.Context, accountID domain.AccountID, conversationID domain.ConversationID, text string) (domain.MessageID, error) { - msgID := domain.NewMessageID() - msgIDStr := msgID.String() - accountIDStr := accountID.String() - convIDStr := conversationID.String() - now := time.Now().UTC().Format(time.RFC3339) - role := string(domain.RoleUser) - - content, err := domain.EncodeText(text) - if err != nil { - return "", err - } - - err = m.write.InsertMessage(ctx, gen.InsertMessageParams{ - ID: &msgIDStr, - AccountID: &accountIDStr, - ConversationID: &convIDStr, - Content: &content, - CreatedAt: &now, - Role: &role, - }) - if err != nil { - return "", WrapSQLiteError(err, "insert user message") - } - - return msgID, nil -} - -// CreateToolResultMessage creates a user message containing tool results. -func (m *messagesImpl) CreateToolResultMessage(ctx context.Context, accountID domain.AccountID, conversationID domain.ConversationID, results []domain.ToolResult) (domain.MessageID, error) { - msgID := domain.NewMessageID() - msgIDStr := msgID.String() - accountIDStr := accountID.String() - convIDStr := conversationID.String() - now := time.Now().UTC().Format(time.RFC3339) - role := string(domain.RoleUser) - - content, err := domain.EncodeToolResults(results) - if err != nil { - return "", err - } - - err = m.write.InsertMessage(ctx, gen.InsertMessageParams{ - ID: &msgIDStr, - AccountID: &accountIDStr, - ConversationID: &convIDStr, - Content: &content, - CreatedAt: &now, - Role: &role, - }) - if err != nil { - return "", WrapSQLiteError(err, "insert tool result message") - } - - return msgID, nil -} - -// CreateAssistantMessage creates an empty assistant message placeholder. -// Returns the new message ID. Content is added via UpdateContent as it streams in. -func (m *messagesImpl) CreateAssistantMessage(ctx context.Context, accountID domain.AccountID, conversationID domain.ConversationID, model string) (domain.MessageID, error) { - msgID := domain.NewMessageID() - msgIDStr := msgID.String() - accountIDStr := accountID.String() - convIDStr := conversationID.String() - now := time.Now().UTC().Format(time.RFC3339) - role := string(domain.RoleAssistant) - content := "[]" // Empty JSON array - - err := m.write.InsertMessage(ctx, gen.InsertMessageParams{ - ID: &msgIDStr, - AccountID: &accountIDStr, - ConversationID: &convIDStr, - Content: &content, - CreatedAt: &now, - Model: &model, - Role: &role, - }) - if err != nil { - return "", WrapSQLiteError(err, "insert assistant message") - } - - return msgID, nil -} - -// UpdateContent updates the content of a message. -func (m *messagesImpl) UpdateContent(ctx context.Context, id domain.MessageID, content string) error { - idStr := id.String() - err := m.write.UpdateMessageContent(ctx, gen.UpdateMessageContentParams{ - ID: &idStr, - Content: &content, - }) - return WrapSQLiteError(err, "update message content") -} - -// UpdateMeta updates the model and stop_reason of a message. -func (m *messagesImpl) UpdateMeta(ctx context.Context, id domain.MessageID, model, stopReason string) error { - idStr := id.String() - err := m.write.UpdateMessageMeta(ctx, gen.UpdateMessageMetaParams{ - ID: &idStr, - Model: &model, - StopReason: &stopReason, - }) - return WrapSQLiteError(err, "update message meta") -} - -// Delete removes a message by ID. -func (m *messagesImpl) Delete(ctx context.Context, id domain.MessageID) error { - idStr := id.String() - err := m.write.DeleteMessage(ctx, &idStr) - return WrapSQLiteError(err, "delete message") -} - -// List returns all messages for a conversation, ordered by creation time. -func (m *messagesImpl) List(ctx context.Context, conversationID domain.ConversationID) ([]domain.Message, error) { - convIDStr := conversationID.String() - rows, err := m.read.ListMessagesByConversation(ctx, &convIDStr) - if err != nil { - return nil, WrapSQLiteError(err, "list messages") - } - - messages := make([]domain.Message, 0, len(rows)) - for _, row := range rows { - if msg := toMessage(row); msg != nil { - messages = append(messages, *msg) - } - } - return messages, nil -} - -// toMessage converts a gen.Message to a domain.Message. -// Returns nil if essential fields are missing. -func toMessage(row gen.Message) *domain.Message { - if row.ID == nil || row.Role == nil { - return nil - } - - msg := &domain.Message{ - ID: domain.MessageID(*row.ID), - Role: domain.Role(*row.Role), - } - - if row.ConversationID != nil { - msg.ConversationID = domain.ConversationID(*row.ConversationID) - } - if row.Model != nil { - msg.Model = *row.Model - } - if row.StopReason != nil { - msg.StopReason = *row.StopReason - } - if row.CreatedAt != nil { - if t, err := time.Parse(time.RFC3339, *row.CreatedAt); err == nil { - msg.CreatedAt = t - } - } - if row.Content != nil { - if blocks, err := domain.ParseBlocks(*row.Content); err == nil { - msg.Content = blocks - } - } - - return msg -} diff --git a/internal/sqlite/queries/AGENTS.md b/internal/sqlite/queries/AGENTS.md deleted file mode 100644 index a665d7cb..00000000 --- a/internal/sqlite/queries/AGENTS.md +++ /dev/null @@ -1,29 +0,0 @@ -# SQLite Queries - -This directory contains sqlc source queries. Generated Go lives in `internal/sqlite/gen`. - -## Query Naming - -Use explicit intent and scope in names: - -1. Prefix by operation: `Get`, `List`, `Count`, `Insert`, `Update`, `Delete`, `Set`. -2. Include filter scope: `ByAccount`, `ByConversation`, etc. -3. Encode sort in the name when SQL has `ORDER BY`. - -Examples: - -1. `ListMessagesByConversationByCreatedAsc` -2. `ListWasteCategoryStatusesByCost` - -## Ordering Rules - -1. If SQL orders data, query name must indicate that order. -2. If a caller needs a different order and query uses `LIMIT`, add a new query. -3. For small result sets, re-sort in wrapper code (not in generated code). - -## Null Handling Conventions - -1. Use `COALESCE` for nullable text where empty string is acceptable. -2. Keep nullable floats nullable when semantics require distinguishing missing vs zero. -3. Prefer consistent parameter style across files. - diff --git a/internal/sqlite/queries/CLAUDE.md b/internal/sqlite/queries/CLAUDE.md deleted file mode 100644 index 0480db90..00000000 --- a/internal/sqlite/queries/CLAUDE.md +++ /dev/null @@ -1,31 +0,0 @@ -# SQLite Queries - -This file mirrors `AGENTS.md` for tools that still load `CLAUDE.md`. -Authoritative source: `AGENTS.md`. - -This directory contains sqlc source queries. Generated Go lives in `internal/sqlite/gen`. - -## Query Naming - -Use explicit intent and scope in names: - -1. Prefix by operation: `Get`, `List`, `Count`, `Insert`, `Update`, `Delete`, `Set`. -2. Include filter scope: `ByAccount`, `ByConversation`, etc. -3. Encode sort in the name when SQL has `ORDER BY`. - -Examples: - -1. `ListMessagesByConversationByCreatedAsc` -2. `ListWasteCategoryStatusesByCost` - -## Ordering Rules - -1. If SQL orders data, query name must indicate that order. -2. If a caller needs a different order and query uses `LIMIT`, add a new query. -3. For small result sets, re-sort in wrapper code (not in generated code). - -## Null Handling Conventions - -1. Use `COALESCE` for nullable text where empty string is acceptable. -2. Keep nullable floats nullable when semantics require distinguishing missing vs zero. -3. Prefer consistent parameter style across files. diff --git a/internal/sqlite/queries/compliance_policies.sql b/internal/sqlite/queries/compliance_policies.sql deleted file mode 100644 index 48697fa0..00000000 --- a/internal/sqlite/queries/compliance_policies.sql +++ /dev/null @@ -1,34 +0,0 @@ --- Compliance policy queries for PII, Secrets, PHI, and Payment Data leakage. - --- name: ListPendingCompliancePoliciesByCategory :many --- Returns pending compliance policies for a specific category, sorted by observed then volume. -SELECT - COALESCE(s.name, '') AS service_name, - COALESCE(le.name, '') AS log_event_name, - COALESCE(lep.analysis, '') AS analysis, - les.volume_per_hour, - CAST(COALESCE(( - SELECT MAX(CASE json_extract(f.value, '$.observed') WHEN 1 THEN 1 ELSE 0 END) - FROM json_each(json_extract(lep.analysis, '$.' || ?1 || '.fields')) f - ), 0) AS INTEGER) AS any_observed -FROM log_event_policy_statuses_cache leps -JOIN log_events le ON le.id = leps.log_event_id -JOIN services s ON s.id = le.service_id -LEFT JOIN log_event_policies lep ON lep.id = leps.policy_id -LEFT JOIN log_event_statuses_cache les ON les.log_event_id = leps.log_event_id -WHERE leps.category = ?1 AND leps.status = 'PENDING' -ORDER BY any_observed DESC, les.volume_per_hour DESC -LIMIT ?2; - --- name: CountObservedPoliciesByComplianceCategory :many --- Returns, per compliance category, how many pending policies have observed (leaking) data. -SELECT - leps.category, - CAST(SUM(CASE WHEN COALESCE(( - SELECT MAX(CASE json_extract(f.value, '$.observed') WHEN 1 THEN 1 ELSE 0 END) - FROM json_each(json_extract(lep.analysis, '$.' || leps.category || '.fields')) f - ), 0) = 1 THEN 1 ELSE 0 END) AS INTEGER) AS observed_count -FROM log_event_policy_statuses_cache leps -LEFT JOIN log_event_policies lep ON lep.id = leps.policy_id -WHERE leps.category_type = 'compliance' AND leps.status = 'PENDING' -GROUP BY leps.category; diff --git a/internal/sqlite/queries/conversations.sql b/internal/sqlite/queries/conversations.sql deleted file mode 100644 index 099fdbd0..00000000 --- a/internal/sqlite/queries/conversations.sql +++ /dev/null @@ -1,23 +0,0 @@ --- name: GetConversation :one -SELECT * FROM conversations WHERE id = ?; - --- name: ListConversationsByAccount :many -SELECT * FROM conversations -WHERE account_id = ? -ORDER BY created_at DESC; - --- name: GetLatestConversationByAccount :one -SELECT * FROM conversations -WHERE account_id = ? -ORDER BY created_at DESC -LIMIT 1; - --- name: CountConversations :one -SELECT COUNT(*) FROM conversations; - --- name: InsertConversation :exec -INSERT INTO conversations (id, account_id, workspace_id, created_at) -VALUES (?, ?, ?, ?); - --- name: UpdateConversationTitle :exec -UPDATE conversations SET title = ? WHERE id = ?; diff --git a/internal/sqlite/queries/datadog_account_statuses.sql b/internal/sqlite/queries/datadog_account_statuses.sql deleted file mode 100644 index 5f44d0c6..00000000 --- a/internal/sqlite/queries/datadog_account_statuses.sql +++ /dev/null @@ -1,58 +0,0 @@ --- name: GetAccountSummary :one -SELECT - -- ready - CAST(COALESCE(MAX(ready_for_use), 0) AS INTEGER) AS ready_for_use, - - -- health - COALESCE(MAX(health), '') AS health, - - -- services - CAST(COALESCE(SUM(log_service_count), 0) AS INTEGER) AS service_count, - CAST(COALESCE(SUM(log_active_services), 0) AS INTEGER) AS active_services, - CAST(COALESCE(SUM(ok_services), 0) AS INTEGER) AS ok_services, - CAST(COALESCE(SUM(disabled_services), 0) AS INTEGER) AS disabled_services, - CAST(COALESCE(SUM(inactive_services), 0) AS INTEGER) AS inactive_services, - - -- events - CAST(COALESCE(SUM(log_event_count), 0) AS INTEGER) AS event_count, - CAST(COALESCE(SUM(log_event_analyzed_count), 0) AS INTEGER) AS analyzed_count, - - -- policies - CAST(COALESCE(SUM(policy_pending_count), 0) AS INTEGER) AS pending_policy_count, - CAST(COALESCE(SUM(policy_approved_count), 0) AS INTEGER) AS approved_policy_count, - CAST(COALESCE(SUM(policy_dismissed_count), 0) AS INTEGER) AS dismissed_policy_count, - CAST(COALESCE(SUM(policy_pending_critical_count), 0) AS INTEGER) AS policy_pending_critical_count, - CAST(COALESCE(SUM(policy_pending_high_count), 0) AS INTEGER) AS policy_pending_high_count, - CAST(COALESCE(SUM(policy_pending_medium_count), 0) AS INTEGER) AS policy_pending_medium_count, - CAST(COALESCE(SUM(policy_pending_low_count), 0) AS INTEGER) AS policy_pending_low_count, - - -- estimated savings - SUM(estimated_cost_reduction_per_hour_usd) AS estimated_cost_per_hour, - SUM(estimated_cost_reduction_per_hour_bytes_usd) AS estimated_cost_per_hour_bytes, - SUM(estimated_cost_reduction_per_hour_volume_usd) AS estimated_cost_per_hour_volume, - SUM(estimated_volume_reduction_per_hour) AS estimated_volume_per_hour, - SUM(estimated_bytes_reduction_per_hour) AS estimated_bytes_per_hour, - - -- observed impact - SUM(observed_cost_per_hour_before_usd) AS observed_cost_before, - SUM(observed_cost_per_hour_before_bytes_usd) AS observed_cost_before_bytes, - SUM(observed_cost_per_hour_before_volume_usd) AS observed_cost_before_volume, - SUM(observed_cost_per_hour_after_usd) AS observed_cost_after, - SUM(observed_cost_per_hour_after_bytes_usd) AS observed_cost_after_bytes, - SUM(observed_cost_per_hour_after_volume_usd) AS observed_cost_after_volume, - SUM(observed_volume_per_hour_before) AS observed_volume_before, - SUM(observed_volume_per_hour_after) AS observed_volume_after, - SUM(observed_bytes_per_hour_before) AS observed_bytes_before, - SUM(observed_bytes_per_hour_after) AS observed_bytes_after, - - -- totals - SUM(log_event_cost_per_hour_usd) AS total_cost_per_hour, - SUM(log_event_cost_per_hour_bytes_usd) AS total_cost_per_hour_bytes, - SUM(log_event_cost_per_hour_volume_usd) AS total_cost_per_hour_volume, - SUM(log_event_volume_per_hour) AS total_volume_per_hour, - SUM(log_event_bytes_per_hour) AS total_bytes_per_hour, - - -- service-level throughput - SUM(service_volume_per_hour) AS total_service_volume_per_hour, - SUM(service_cost_per_hour_volume_usd) AS total_service_cost_per_hour -FROM datadog_account_statuses_cache; diff --git a/internal/sqlite/queries/log_event_policies.sql b/internal/sqlite/queries/log_event_policies.sql deleted file mode 100644 index c9bffb35..00000000 --- a/internal/sqlite/queries/log_event_policies.sql +++ /dev/null @@ -1,12 +0,0 @@ --- name: CountLogEventPolicies :one -SELECT COUNT(*) FROM log_event_policies; - --- name: ApproveLogEventPolicy :exec -UPDATE log_event_policies -SET approved_at = ?, approved_by = ? -WHERE id = ?; - --- name: DismissLogEventPolicy :exec -UPDATE log_event_policies -SET dismissed_at = ?, dismissed_by = ? -WHERE id = ?; diff --git a/internal/sqlite/queries/log_event_policy_category_statuses.sql b/internal/sqlite/queries/log_event_policy_category_statuses.sql deleted file mode 100644 index b5285bf6..00000000 --- a/internal/sqlite/queries/log_event_policy_category_statuses.sql +++ /dev/null @@ -1,26 +0,0 @@ --- name: ListCategoryStatusesByCostAndType :many --- Pre-computed per-category rollup filtered by category_type (waste or compliance). -SELECT - COALESCE(category, '') AS category, - COALESCE(category_type, '') AS category_type, - COALESCE("action", '') AS policy_action, - COALESCE(display_name, '') AS display_name, - COALESCE(principle, '') AS principle, - CAST(COALESCE(pending_count, 0) AS INTEGER) AS pending_count, - CAST(COALESCE(approved_count, 0) AS INTEGER) AS approved_count, - CAST(COALESCE(dismissed_count, 0) AS INTEGER) AS dismissed_count, - estimated_volume_reduction_per_hour, - estimated_bytes_reduction_per_hour, - estimated_cost_reduction_per_hour_usd, - estimated_cost_reduction_per_hour_bytes_usd, - estimated_cost_reduction_per_hour_volume_usd, - CAST(COALESCE(events_with_volumes, 0) AS INTEGER) AS events_with_volumes, - CAST(COALESCE(total_event_count, 0) AS INTEGER) AS total_event_count, - CAST(COALESCE(policy_pending_critical_count, 0) AS INTEGER) AS policy_pending_critical_count, - CAST(COALESCE(policy_pending_high_count, 0) AS INTEGER) AS policy_pending_high_count, - CAST(COALESCE(policy_pending_medium_count, 0) AS INTEGER) AS policy_pending_medium_count, - CAST(COALESCE(policy_pending_low_count, 0) AS INTEGER) AS policy_pending_low_count -FROM log_event_policy_category_statuses_cache -WHERE category IS NOT NULL AND category != '' - AND category_type = ?1 -ORDER BY estimated_cost_reduction_per_hour_usd DESC NULLS LAST, pending_count DESC; diff --git a/internal/sqlite/queries/log_event_policy_statuses.sql b/internal/sqlite/queries/log_event_policy_statuses.sql deleted file mode 100644 index 1c6980c2..00000000 --- a/internal/sqlite/queries/log_event_policy_statuses.sql +++ /dev/null @@ -1,34 +0,0 @@ --- name: ListTopPendingPoliciesByCategory :many -SELECT - COALESCE(service_name, '') AS service_name, - COALESCE(log_event_name, '') AS log_event_name, - volume_per_hour, - bytes_per_hour, - estimated_cost_reduction_per_hour_usd AS estimated_cost_per_hour, - estimated_cost_reduction_per_hour_bytes_usd AS estimated_cost_per_hour_bytes, - estimated_cost_reduction_per_hour_volume_usd AS estimated_cost_per_hour_volume, - estimated_bytes_reduction_per_hour AS estimated_bytes_per_hour, - estimated_volume_reduction_per_hour AS estimated_volume_per_hour -FROM log_event_policy_statuses_cache -WHERE category = ?1 AND status = 'PENDING' -ORDER BY estimated_cost_reduction_per_hour_usd DESC, volume_per_hour DESC -LIMIT ?2; - --- name: ListPendingPIIPolicies :many -SELECT - COALESCE(service_name, '') AS service_name, - COALESCE(log_event_name, '') AS log_event_name, - COALESCE(lep.analysis, '') AS analysis, - volume_per_hour, - CAST(COALESCE(( - SELECT MAX(CASE json_extract(f.value, '$.observed') WHEN 1 THEN 1 ELSE 0 END) - FROM json_each(json_extract(lep.analysis, '$.pii_leakage.fields')) f - ), 0) AS INTEGER) AS any_observed -FROM log_event_policy_statuses_cache leps -LEFT JOIN log_event_policies lep ON lep.id = leps.policy_id -WHERE leps.category = 'pii_leakage' AND leps.status = 'PENDING' -ORDER BY any_observed DESC, leps.volume_per_hour DESC; - --- name: CountFixedPIIPolicies :one -SELECT CAST(COUNT(*) AS INTEGER) FROM log_event_policy_statuses_cache -WHERE category = 'pii_leakage' AND status = 'APPROVED'; diff --git a/internal/sqlite/queries/log_event_statuses.sql b/internal/sqlite/queries/log_event_statuses.sql deleted file mode 100644 index 5bc309b1..00000000 --- a/internal/sqlite/queries/log_event_statuses.sql +++ /dev/null @@ -1,18 +0,0 @@ --- name: ListLogEventStatusesByService :many -SELECT - COALESCE(le.name, '') AS log_event_name, - les.volume_per_hour, - les.bytes_per_hour, - les.cost_per_hour_usd, - CAST(COALESCE(les.pending_policy_count, 0) AS INTEGER) AS pending_policy_count, - CAST(COALESCE(les.approved_policy_count, 0) AS INTEGER) AS approved_policy_count, - CAST(COALESCE(les.policy_pending_critical_count, 0) AS INTEGER) AS policy_pending_critical_count, - CAST(COALESCE(les.policy_pending_high_count, 0) AS INTEGER) AS policy_pending_high_count, - CAST(COALESCE(les.policy_pending_medium_count, 0) AS INTEGER) AS policy_pending_medium_count, - CAST(COALESCE(les.policy_pending_low_count, 0) AS INTEGER) AS policy_pending_low_count -FROM log_events le -JOIN services s ON s.id = le.service_id -LEFT JOIN log_event_statuses_cache les ON les.log_event_id = le.id -WHERE s.name = ?1 -ORDER BY les.cost_per_hour_usd DESC, les.volume_per_hour DESC -LIMIT ?2; diff --git a/internal/sqlite/queries/log_events.sql b/internal/sqlite/queries/log_events.sql deleted file mode 100644 index b2f04d41..00000000 --- a/internal/sqlite/queries/log_events.sql +++ /dev/null @@ -1,2 +0,0 @@ --- name: CountLogEvents :one -SELECT COUNT(*) FROM log_events; diff --git a/internal/sqlite/queries/messages.sql b/internal/sqlite/queries/messages.sql deleted file mode 100644 index 959a3537..00000000 --- a/internal/sqlite/queries/messages.sql +++ /dev/null @@ -1,37 +0,0 @@ --- name: GetMessage :one -SELECT * FROM messages WHERE id = ?; - --- name: ListMessagesByConversation :many -SELECT * FROM messages -WHERE conversation_id = ? -ORDER BY created_at ASC; - --- name: ListMessagesByConversationDesc :many -SELECT * FROM messages -WHERE conversation_id = ? -ORDER BY created_at DESC; - --- name: GetLatestMessageByConversation :one -SELECT * FROM messages -WHERE conversation_id = ? -ORDER BY created_at DESC -LIMIT 1; - --- name: CountMessages :one -SELECT COUNT(*) FROM messages; - --- name: CountMessagesByConversation :one -SELECT COUNT(*) FROM messages WHERE conversation_id = ?; - --- name: InsertMessage :exec -INSERT INTO messages (id, account_id, content, conversation_id, created_at, model, role, stop_reason) -VALUES (?, ?, ?, ?, ?, ?, ?, ?); - --- name: UpdateMessageContent :exec -UPDATE messages SET content = ? WHERE id = ?; - --- name: UpdateMessageMeta :exec -UPDATE messages SET model = ?, stop_reason = ? WHERE id = ?; - --- name: DeleteMessage :exec -DELETE FROM messages WHERE id = ?; diff --git a/internal/sqlite/queries/policy_cards.sql b/internal/sqlite/queries/policy_cards.sql deleted file mode 100644 index 151c63fd..00000000 --- a/internal/sqlite/queries/policy_cards.sql +++ /dev/null @@ -1,40 +0,0 @@ --- name: GetPolicyCard :one --- Fetches a single policy with all context needed for rich card rendering. --- Main table: log_event_policy_statuses_cache (denormalized policy + metrics). --- JOINs enrich with: category display name, AI analysis, log examples, baselines. -SELECT - COALESCE(ps.policy_id, '') AS policy_id, - COALESCE(ps.service_name, '') AS service_name, - COALESCE(ps.log_event_name, '') AS log_event_name, - COALESCE(ps.category, '') AS category, - COALESCE(ps.category_type, '') AS category_type, - COALESCE(ps.action, '') AS "action", - COALESCE(ps.status, '') AS status, - ps.volume_per_hour, - ps.bytes_per_hour, - ps.estimated_cost_reduction_per_hour_usd AS estimated_cost_per_hour, - ps.estimated_volume_reduction_per_hour AS estimated_volume_per_hour, - ps.estimated_bytes_reduction_per_hour AS estimated_bytes_per_hour, - COALESCE(ps.severity, '') AS severity, - ps.survival_rate, - COALESCE(cat.display_name, '') AS category_display_name, - COALESCE(lep.analysis, '') AS analysis, - COALESCE(le.examples, '') AS examples, - le.baseline_avg_bytes AS event_baseline_avg_bytes, - le.baseline_volume_per_hour AS event_baseline_volume_per_hour, - COALESCE(ps.log_event_id, '') AS log_event_id -FROM log_event_policy_statuses_cache ps -LEFT JOIN log_event_policy_category_statuses_cache cat ON cat.category = ps.category -LEFT JOIN log_event_policies lep ON lep.id = ps.policy_id -LEFT JOIN log_events le ON le.id = ps.log_event_id -WHERE ps.policy_id = ?1; - --- name: ListFieldsByLogEvent :many --- Returns per-field metadata for a log event, used to show per-field byte impact --- in quality policies (instrumentation_bloat, oversized_fields, duplicate_fields). -SELECT - COALESCE(field_path, '') AS field_path, - baseline_avg_bytes -FROM log_event_fields -WHERE log_event_id = ?1 -ORDER BY baseline_avg_bytes DESC; diff --git a/internal/sqlite/queries/service_statuses.sql b/internal/sqlite/queries/service_statuses.sql deleted file mode 100644 index 0b82e7a7..00000000 --- a/internal/sqlite/queries/service_statuses.sql +++ /dev/null @@ -1,103 +0,0 @@ --- name: ListAllServiceStatuses :many -SELECT - s.name AS service_name, - COALESCE(ssc.health, '') AS health, - CAST(COALESCE(ssc.log_event_count, 0) AS INTEGER) AS log_event_count, - CAST(COALESCE(ssc.log_event_analyzed_count, 0) AS INTEGER) AS log_event_analyzed_count, - CAST(COALESCE(ssc.policy_pending_count, 0) AS INTEGER) AS policy_pending_count, - CAST(COALESCE(ssc.policy_approved_count, 0) AS INTEGER) AS policy_approved_count, - CAST(COALESCE(ssc.policy_dismissed_count, 0) AS INTEGER) AS policy_dismissed_count, - CAST(COALESCE(ssc.policy_pending_critical_count, 0) AS INTEGER) AS policy_pending_critical_count, - CAST(COALESCE(ssc.policy_pending_high_count, 0) AS INTEGER) AS policy_pending_high_count, - CAST(COALESCE(ssc.policy_pending_medium_count, 0) AS INTEGER) AS policy_pending_medium_count, - CAST(COALESCE(ssc.policy_pending_low_count, 0) AS INTEGER) AS policy_pending_low_count, - ssc.service_volume_per_hour, - ssc.service_debug_volume_per_hour, - ssc.service_info_volume_per_hour, - ssc.service_warn_volume_per_hour, - ssc.service_error_volume_per_hour, - ssc.service_other_volume_per_hour, - ssc.service_cost_per_hour_volume_usd, - ssc.log_event_volume_per_hour, - ssc.log_event_bytes_per_hour, - ssc.log_event_cost_per_hour_usd, - ssc.log_event_cost_per_hour_bytes_usd, - ssc.log_event_cost_per_hour_volume_usd, - ssc.estimated_volume_reduction_per_hour, - ssc.estimated_bytes_reduction_per_hour, - ssc.estimated_cost_reduction_per_hour_usd, - ssc.estimated_cost_reduction_per_hour_bytes_usd, - ssc.estimated_cost_reduction_per_hour_volume_usd, - ssc.observed_volume_per_hour_before, - ssc.observed_volume_per_hour_after, - ssc.observed_bytes_per_hour_before, - ssc.observed_bytes_per_hour_after, - ssc.observed_cost_per_hour_before_usd, - ssc.observed_cost_per_hour_before_bytes_usd, - ssc.observed_cost_per_hour_before_volume_usd, - ssc.observed_cost_per_hour_after_usd, - ssc.observed_cost_per_hour_after_bytes_usd, - ssc.observed_cost_per_hour_after_volume_usd -FROM service_statuses_cache ssc -JOIN services s ON ssc.service_id = s.id -ORDER BY - CASE ssc.health - WHEN 'OK' THEN 1 - WHEN 'DISABLED' THEN 2 - WHEN 'INACTIVE' THEN 3 - ELSE 4 - END, - ssc.log_event_cost_per_hour_usd DESC, - s.name; - --- name: ListEnabledServiceStatuses :many -SELECT - s.name AS service_name, - COALESCE(ssc.health, '') AS health, - CAST(COALESCE(ssc.log_event_count, 0) AS INTEGER) AS log_event_count, - CAST(COALESCE(ssc.log_event_analyzed_count, 0) AS INTEGER) AS log_event_analyzed_count, - CAST(COALESCE(ssc.policy_pending_count, 0) AS INTEGER) AS policy_pending_count, - CAST(COALESCE(ssc.policy_approved_count, 0) AS INTEGER) AS policy_approved_count, - CAST(COALESCE(ssc.policy_dismissed_count, 0) AS INTEGER) AS policy_dismissed_count, - CAST(COALESCE(ssc.policy_pending_critical_count, 0) AS INTEGER) AS policy_pending_critical_count, - CAST(COALESCE(ssc.policy_pending_high_count, 0) AS INTEGER) AS policy_pending_high_count, - CAST(COALESCE(ssc.policy_pending_medium_count, 0) AS INTEGER) AS policy_pending_medium_count, - CAST(COALESCE(ssc.policy_pending_low_count, 0) AS INTEGER) AS policy_pending_low_count, - ssc.service_volume_per_hour, - ssc.service_debug_volume_per_hour, - ssc.service_info_volume_per_hour, - ssc.service_warn_volume_per_hour, - ssc.service_error_volume_per_hour, - ssc.service_other_volume_per_hour, - ssc.service_cost_per_hour_volume_usd, - ssc.log_event_volume_per_hour, - ssc.log_event_bytes_per_hour, - ssc.log_event_cost_per_hour_usd, - ssc.log_event_cost_per_hour_bytes_usd, - ssc.log_event_cost_per_hour_volume_usd, - ssc.estimated_volume_reduction_per_hour, - ssc.estimated_bytes_reduction_per_hour, - ssc.estimated_cost_reduction_per_hour_usd, - ssc.estimated_cost_reduction_per_hour_bytes_usd, - ssc.estimated_cost_reduction_per_hour_volume_usd, - ssc.observed_volume_per_hour_before, - ssc.observed_volume_per_hour_after, - ssc.observed_bytes_per_hour_before, - ssc.observed_bytes_per_hour_after, - ssc.observed_cost_per_hour_before_usd, - ssc.observed_cost_per_hour_before_bytes_usd, - ssc.observed_cost_per_hour_before_volume_usd, - ssc.observed_cost_per_hour_after_usd, - ssc.observed_cost_per_hour_after_bytes_usd, - ssc.observed_cost_per_hour_after_volume_usd -FROM service_statuses_cache ssc -JOIN services s ON ssc.service_id = s.id -WHERE ssc.health NOT IN ('DISABLED', 'INACTIVE') -ORDER BY - CASE ssc.health - WHEN 'OK' THEN 1 - ELSE 2 - END, - ssc.log_event_cost_per_hour_usd DESC, - s.name -LIMIT sqlc.arg('row_limit'); diff --git a/internal/sqlite/queries/services.sql b/internal/sqlite/queries/services.sql deleted file mode 100644 index 55189fd7..00000000 --- a/internal/sqlite/queries/services.sql +++ /dev/null @@ -1,14 +0,0 @@ --- name: GetService :one -SELECT * FROM services WHERE id = ?; - --- name: ListServices :many -SELECT * FROM services ORDER BY name; - --- name: ListServicesByAccount :many -SELECT * FROM services WHERE account_id = ? ORDER BY name; - --- name: CountServices :one -SELECT COUNT(*) FROM services; - --- name: SetServiceEnabled :exec -UPDATE services SET enabled = ? WHERE id = ?; diff --git a/internal/sqlite/schema.sql b/internal/sqlite/schema.sql deleted file mode 100644 index e287370a..00000000 --- a/internal/sqlite/schema.sql +++ /dev/null @@ -1,322 +0,0 @@ --- Code generated by go generate; DO NOT EDIT. --- Source: PowerSync schema reflected from sqlite_master - -CREATE TABLE conversation_contexts ( - id TEXT, - account_id TEXT, - added_by TEXT, - conversation_id TEXT, - created_at TEXT, - entity_id TEXT, - entity_type TEXT -); - -CREATE TABLE conversations ( - id TEXT, - account_id TEXT, - created_at TEXT, - title TEXT, - user_id TEXT, - view_id TEXT, - workspace_id TEXT -); - -CREATE TABLE datadog_account_statuses_cache ( - id TEXT, - account_id TEXT, - datadog_account_id TEXT, - disabled_services INTEGER, - estimated_bytes_reduction_per_hour REAL, - estimated_cost_reduction_per_hour_bytes_usd REAL, - estimated_cost_reduction_per_hour_usd REAL, - estimated_cost_reduction_per_hour_volume_usd REAL, - estimated_volume_reduction_per_hour REAL, - health TEXT, - inactive_services INTEGER, - log_active_services INTEGER, - log_event_analyzed_count INTEGER, - log_event_bytes_per_hour REAL, - log_event_cost_per_hour_bytes_usd REAL, - log_event_cost_per_hour_usd REAL, - log_event_cost_per_hour_volume_usd REAL, - log_event_count INTEGER, - log_event_volume_per_hour REAL, - log_service_count INTEGER, - observed_bytes_per_hour_after REAL, - observed_bytes_per_hour_before REAL, - observed_cost_per_hour_after_bytes_usd REAL, - observed_cost_per_hour_after_usd REAL, - observed_cost_per_hour_after_volume_usd REAL, - observed_cost_per_hour_before_bytes_usd REAL, - observed_cost_per_hour_before_usd REAL, - observed_cost_per_hour_before_volume_usd REAL, - observed_volume_per_hour_after REAL, - observed_volume_per_hour_before REAL, - ok_services INTEGER, - policy_approved_count INTEGER, - policy_dismissed_count INTEGER, - policy_pending_count INTEGER, - policy_pending_critical_count INTEGER, - policy_pending_high_count INTEGER, - policy_pending_low_count INTEGER, - policy_pending_medium_count INTEGER, - ready_for_use INTEGER, - service_cost_per_hour_volume_usd REAL, - service_volume_per_hour REAL -); - -CREATE TABLE datadog_accounts ( - id TEXT, - account_id TEXT, - cost_per_gb_ingested REAL, - created_at TEXT, - name TEXT, - site TEXT -); - -CREATE TABLE datadog_log_indexes ( - id TEXT, - account_id TEXT, - cost_per_million_events_indexed REAL, - created_at TEXT, - datadog_account_id TEXT, - name TEXT -); - -CREATE TABLE log_event_fields ( - id TEXT, - account_id TEXT, - baseline_avg_bytes REAL, - created_at TEXT, - field_path TEXT, - log_event_id TEXT, - value_distribution TEXT -); - -CREATE TABLE log_event_policies ( - id TEXT, - account_id TEXT, - action TEXT, - analysis TEXT, - approved_at TEXT, - approved_baseline_avg_bytes REAL, - approved_baseline_volume_per_hour REAL, - approved_by TEXT, - category TEXT, - category_type TEXT, - created_at TEXT, - dismissed_at TEXT, - dismissed_by TEXT, - log_event_id TEXT, - severity TEXT, - subjective INTEGER, - workspace_id TEXT -); - -CREATE TABLE log_event_policy_category_statuses_cache ( - id TEXT, - account_id TEXT, - action TEXT, - approved_count INTEGER, - boundary TEXT, - category TEXT, - category_type TEXT, - dismissed_count INTEGER, - display_name TEXT, - estimated_bytes_reduction_per_hour REAL, - estimated_cost_reduction_per_hour_bytes_usd REAL, - estimated_cost_reduction_per_hour_usd REAL, - estimated_cost_reduction_per_hour_volume_usd REAL, - estimated_volume_reduction_per_hour REAL, - events_with_volumes INTEGER, - pending_count INTEGER, - policy_pending_critical_count INTEGER, - policy_pending_high_count INTEGER, - policy_pending_low_count INTEGER, - policy_pending_medium_count INTEGER, - principle TEXT, - subjective INTEGER, - total_event_count INTEGER -); - -CREATE TABLE log_event_policy_statuses_cache ( - id TEXT, - account_id TEXT, - action TEXT, - approved_at TEXT, - bytes_per_hour REAL, - category TEXT, - category_type TEXT, - created_at TEXT, - dismissed_at TEXT, - estimated_bytes_reduction_per_hour REAL, - estimated_cost_reduction_per_hour_bytes_usd REAL, - estimated_cost_reduction_per_hour_usd REAL, - estimated_cost_reduction_per_hour_volume_usd REAL, - estimated_volume_reduction_per_hour REAL, - log_event_id TEXT, - log_event_name TEXT, - policy_id TEXT, - service_id TEXT, - service_name TEXT, - severity TEXT, - status TEXT, - subjective INTEGER, - survival_rate REAL, - volume_per_hour REAL, - workspace_id TEXT -); - -CREATE TABLE log_event_statuses_cache ( - id TEXT, - account_id TEXT, - approved_policy_count INTEGER, - bytes_per_hour REAL, - cost_per_hour_bytes_usd REAL, - cost_per_hour_usd REAL, - cost_per_hour_volume_usd REAL, - dismissed_policy_count INTEGER, - estimated_bytes_reduction_per_hour REAL, - estimated_cost_reduction_per_hour_bytes_usd REAL, - estimated_cost_reduction_per_hour_usd REAL, - estimated_cost_reduction_per_hour_volume_usd REAL, - estimated_volume_reduction_per_hour REAL, - has_been_analyzed INTEGER, - has_volumes INTEGER, - log_event_id TEXT, - observed_bytes_per_hour_after REAL, - observed_bytes_per_hour_before REAL, - observed_cost_per_hour_after_bytes_usd REAL, - observed_cost_per_hour_after_usd REAL, - observed_cost_per_hour_after_volume_usd REAL, - observed_cost_per_hour_before_bytes_usd REAL, - observed_cost_per_hour_before_usd REAL, - observed_cost_per_hour_before_volume_usd REAL, - observed_volume_per_hour_after REAL, - observed_volume_per_hour_before REAL, - pending_policy_count INTEGER, - policy_count INTEGER, - policy_pending_critical_count INTEGER, - policy_pending_high_count INTEGER, - policy_pending_low_count INTEGER, - policy_pending_medium_count INTEGER, - service_id TEXT, - volume_per_hour REAL -); - -CREATE TABLE log_events ( - id TEXT, - account_id TEXT, - baseline_avg_bytes REAL, - baseline_volume_per_hour REAL, - created_at TEXT, - description TEXT, - event_nature TEXT, - examples TEXT, - matchers TEXT, - name TEXT, - service_id TEXT, - severity TEXT, - signal_purpose TEXT -); - -CREATE TABLE messages ( - id TEXT, - account_id TEXT, - content TEXT, - conversation_id TEXT, - created_at TEXT, - model TEXT, - role TEXT, - stop_reason TEXT -); - -CREATE TABLE service_statuses_cache ( - id TEXT, - account_id TEXT, - datadog_account_id TEXT, - estimated_bytes_reduction_per_hour REAL, - estimated_cost_reduction_per_hour_bytes_usd REAL, - estimated_cost_reduction_per_hour_usd REAL, - estimated_cost_reduction_per_hour_volume_usd REAL, - estimated_volume_reduction_per_hour REAL, - health TEXT, - log_event_analyzed_count INTEGER, - log_event_bytes_per_hour REAL, - log_event_cost_per_hour_bytes_usd REAL, - log_event_cost_per_hour_usd REAL, - log_event_cost_per_hour_volume_usd REAL, - log_event_count INTEGER, - log_event_volume_per_hour REAL, - observed_bytes_per_hour_after REAL, - observed_bytes_per_hour_before REAL, - observed_cost_per_hour_after_bytes_usd REAL, - observed_cost_per_hour_after_usd REAL, - observed_cost_per_hour_after_volume_usd REAL, - observed_cost_per_hour_before_bytes_usd REAL, - observed_cost_per_hour_before_usd REAL, - observed_cost_per_hour_before_volume_usd REAL, - observed_volume_per_hour_after REAL, - observed_volume_per_hour_before REAL, - policy_approved_count INTEGER, - policy_dismissed_count INTEGER, - policy_pending_count INTEGER, - policy_pending_critical_count INTEGER, - policy_pending_high_count INTEGER, - policy_pending_low_count INTEGER, - policy_pending_medium_count INTEGER, - service_cost_per_hour_volume_usd REAL, - service_debug_volume_per_hour REAL, - service_error_volume_per_hour REAL, - service_id TEXT, - service_info_volume_per_hour REAL, - service_other_volume_per_hour REAL, - service_volume_per_hour REAL, - service_warn_volume_per_hour REAL -); - -CREATE TABLE services ( - id TEXT, - account_id TEXT, - created_at TEXT, - description TEXT, - enabled INTEGER, - initial_weekly_log_count INTEGER, - name TEXT -); - -CREATE TABLE teams ( - id TEXT, - account_id TEXT, - created_at TEXT, - name TEXT, - workspace_id TEXT -); - -CREATE TABLE view_favorites ( - id TEXT, - account_id TEXT, - created_at TEXT, - user_id TEXT, - view_id TEXT -); - -CREATE TABLE views ( - id TEXT, - account_id TEXT, - conversation_id TEXT, - created_at TEXT, - created_by TEXT, - entity_type TEXT, - forked_from_id TEXT, - message_id TEXT, - query TEXT -); - -CREATE TABLE workspaces ( - id TEXT, - account_id TEXT, - created_at TEXT, - name TEXT, - purpose TEXT -); diff --git a/internal/sqlite/service_statuses.go b/internal/sqlite/service_statuses.go deleted file mode 100644 index a76e2ab9..00000000 --- a/internal/sqlite/service_statuses.go +++ /dev/null @@ -1,149 +0,0 @@ -package sqlite - -import ( - "context" - - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/sqlite/gen" -) - -// ServiceStatuses provides access to per-service status data. -type ServiceStatuses interface { - ListAllServiceStatuses(ctx context.Context) ([]domain.ServiceStatus, error) - ListEnabledServiceStatuses(ctx context.Context, limit int64) ([]domain.ServiceStatus, error) -} - -type serviceStatusesImpl struct { - queries *gen.Queries -} - -// ListAllServiceStatuses returns all services sorted by severity. -func (s *serviceStatusesImpl) ListAllServiceStatuses(ctx context.Context) ([]domain.ServiceStatus, error) { - rows, err := s.queries.ListAllServiceStatuses(ctx) - if err != nil { - return nil, WrapSQLiteError(err, "list all service statuses") - } - - result := make([]domain.ServiceStatus, len(rows)) - for i, row := range rows { - result[i] = mapServiceStatus( - row.ServiceName, row.Health, - row.LogEventCount, row.LogEventAnalyzedCount, - row.PolicyPendingCount, row.PolicyApprovedCount, row.PolicyDismissedCount, - row.PolicyPendingCriticalCount, row.PolicyPendingHighCount, row.PolicyPendingMediumCount, row.PolicyPendingLowCount, - row.ServiceVolumePerHour, - row.ServiceDebugVolumePerHour, row.ServiceInfoVolumePerHour, row.ServiceWarnVolumePerHour, - row.ServiceErrorVolumePerHour, row.ServiceOtherVolumePerHour, - row.ServiceCostPerHourVolumeUsd, - row.LogEventVolumePerHour, row.LogEventBytesPerHour, - row.LogEventCostPerHourUsd, row.LogEventCostPerHourBytesUsd, row.LogEventCostPerHourVolumeUsd, - row.EstimatedVolumeReductionPerHour, row.EstimatedBytesReductionPerHour, - row.EstimatedCostReductionPerHourUsd, row.EstimatedCostReductionPerHourBytesUsd, row.EstimatedCostReductionPerHourVolumeUsd, - row.ObservedVolumePerHourBefore, row.ObservedVolumePerHourAfter, - row.ObservedBytesPerHourBefore, row.ObservedBytesPerHourAfter, - row.ObservedCostPerHourBeforeUsd, row.ObservedCostPerHourBeforeBytesUsd, row.ObservedCostPerHourBeforeVolumeUsd, - row.ObservedCostPerHourAfterUsd, row.ObservedCostPerHourAfterBytesUsd, row.ObservedCostPerHourAfterVolumeUsd, - ) - } - return result, nil -} - -// ListEnabledServiceStatuses returns enabled services (not DISABLED/INACTIVE), -// sorted by severity, limited to the given count. -func (s *serviceStatusesImpl) ListEnabledServiceStatuses(ctx context.Context, limit int64) ([]domain.ServiceStatus, error) { - rows, err := s.queries.ListEnabledServiceStatuses(ctx, limit) - if err != nil { - return nil, WrapSQLiteError(err, "list enabled service statuses") - } - - result := make([]domain.ServiceStatus, len(rows)) - for i, row := range rows { - result[i] = mapServiceStatus( - row.ServiceName, row.Health, - row.LogEventCount, row.LogEventAnalyzedCount, - row.PolicyPendingCount, row.PolicyApprovedCount, row.PolicyDismissedCount, - row.PolicyPendingCriticalCount, row.PolicyPendingHighCount, row.PolicyPendingMediumCount, row.PolicyPendingLowCount, - row.ServiceVolumePerHour, - row.ServiceDebugVolumePerHour, row.ServiceInfoVolumePerHour, row.ServiceWarnVolumePerHour, - row.ServiceErrorVolumePerHour, row.ServiceOtherVolumePerHour, - row.ServiceCostPerHourVolumeUsd, - row.LogEventVolumePerHour, row.LogEventBytesPerHour, - row.LogEventCostPerHourUsd, row.LogEventCostPerHourBytesUsd, row.LogEventCostPerHourVolumeUsd, - row.EstimatedVolumeReductionPerHour, row.EstimatedBytesReductionPerHour, - row.EstimatedCostReductionPerHourUsd, row.EstimatedCostReductionPerHourBytesUsd, row.EstimatedCostReductionPerHourVolumeUsd, - row.ObservedVolumePerHourBefore, row.ObservedVolumePerHourAfter, - row.ObservedBytesPerHourBefore, row.ObservedBytesPerHourAfter, - row.ObservedCostPerHourBeforeUsd, row.ObservedCostPerHourBeforeBytesUsd, row.ObservedCostPerHourBeforeVolumeUsd, - row.ObservedCostPerHourAfterUsd, row.ObservedCostPerHourAfterBytesUsd, row.ObservedCostPerHourAfterVolumeUsd, - ) - } - return result, nil -} - -//nolint:unparam // positional args mirror generated row fields -func mapServiceStatus( - serviceName *string, - health string, - eventCount, analyzedCount int64, - policyPending, policyApproved, policyDismissed int64, - pendingCritical, pendingHigh, pendingMedium, pendingLow int64, - svcVolume *float64, - svcDebugVolume, svcInfoVolume, svcWarnVolume, svcErrorVolume, svcOtherVolume *float64, - svcCostVolume *float64, - volume, bytes, costUSD, costBytes, costVolume *float64, - estVolume, estBytes, estCostUSD, estCostBytes, estCostVolume *float64, - obsVolBefore, obsVolAfter, obsBytesBefore, obsBytesAfter *float64, - obsCostBefore, obsCostBeforeBytes, obsCostBeforeVolume *float64, - obsCostAfter, obsCostAfterBytes, obsCostAfterVolume *float64, -) domain.ServiceStatus { - name := "" - if serviceName != nil { - name = *serviceName - } - return domain.ServiceStatus{ - Name: name, - Health: domain.ServiceHealth(health), - - LogEventCount: eventCount, - LogEventAnalyzedCount: analyzedCount, - - PolicyPendingCount: policyPending, - PolicyApprovedCount: policyApproved, - PolicyDismissedCount: policyDismissed, - PolicyPendingCriticalCount: pendingCritical, - PolicyPendingHighCount: pendingHigh, - PolicyPendingMediumCount: pendingMedium, - PolicyPendingLowCount: pendingLow, - - ServiceVolumePerHour: svcVolume, - ServiceDebugVolumePerHour: svcDebugVolume, - ServiceInfoVolumePerHour: svcInfoVolume, - ServiceWarnVolumePerHour: svcWarnVolume, - ServiceErrorVolumePerHour: svcErrorVolume, - ServiceOtherVolumePerHour: svcOtherVolume, - ServiceCostPerHourVolumeUSD: svcCostVolume, - - LogEventVolumePerHour: volume, - LogEventBytesPerHour: bytes, - LogEventCostPerHourUSD: costUSD, - LogEventCostPerHourBytesUSD: costBytes, - LogEventCostPerHourVolumeUSD: costVolume, - - EstimatedVolumeReductionPerHour: estVolume, - EstimatedBytesReductionPerHour: estBytes, - EstimatedCostReductionPerHourUSD: estCostUSD, - EstimatedCostReductionPerHourBytes: estCostBytes, - EstimatedCostReductionPerHourVolume: estCostVolume, - - ObservedVolumePerHourBefore: obsVolBefore, - ObservedVolumePerHourAfter: obsVolAfter, - ObservedBytesPerHourBefore: obsBytesBefore, - ObservedBytesPerHourAfter: obsBytesAfter, - ObservedCostPerHourBeforeUSD: obsCostBefore, - ObservedCostPerHourBeforeBytesUSD: obsCostBeforeBytes, - ObservedCostPerHourBeforeVolumeUSD: obsCostBeforeVolume, - ObservedCostPerHourAfterUSD: obsCostAfter, - ObservedCostPerHourAfterBytesUSD: obsCostAfterBytes, - ObservedCostPerHourAfterVolumeUSD: obsCostAfterVolume, - } -} diff --git a/internal/sqlite/services.go b/internal/sqlite/services.go deleted file mode 100644 index 0bf2fb27..00000000 --- a/internal/sqlite/services.go +++ /dev/null @@ -1,57 +0,0 @@ -package sqlite - -import ( - "context" - - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/sqlite/gen" -) - -// Services provides type-safe access to services. -type Services interface { - Count(ctx context.Context) (int64, error) - Get(ctx context.Context, id domain.ServiceID) (gen.Service, error) - SetEnabled(ctx context.Context, id domain.ServiceID, enabled bool) error -} - -// servicesImpl implements Services. -type servicesImpl struct { - read *gen.Queries - write *gen.Queries -} - -// Count returns the total number of services. -func (s *servicesImpl) Count(ctx context.Context) (int64, error) { - count, err := s.read.CountServices(ctx) - if err != nil { - return 0, WrapSQLiteError(err, "count services") - } - return count, nil -} - -// Get returns a service by ID. -func (s *servicesImpl) Get(ctx context.Context, id domain.ServiceID) (gen.Service, error) { - idStr := id.String() - svc, err := s.read.GetService(ctx, &idStr) - if err != nil { - return gen.Service{}, WrapSQLiteError(err, "get service") - } - return svc, nil -} - -// SetEnabled enables or disables a service. -func (s *servicesImpl) SetEnabled(ctx context.Context, id domain.ServiceID, enabled bool) error { - var val int64 - if enabled { - val = 1 - } - idStr := id.String() - err := s.write.SetServiceEnabled(ctx, gen.SetServiceEnabledParams{ - Enabled: &val, - ID: &idStr, - }) - if err != nil { - return WrapSQLiteError(err, "set service enabled") - } - return nil -} diff --git a/internal/sqlite/sqlitetest/db.go b/internal/sqlite/sqlitetest/db.go deleted file mode 100644 index 93d40c6c..00000000 --- a/internal/sqlite/sqlitetest/db.go +++ /dev/null @@ -1,25 +0,0 @@ -package sqlitetest - -import ( - "context" - "path/filepath" - "testing" - - "github.com/usetero/cli/internal/sqlite" -) - -// OpenBareDB creates a temporary SQLite database WITHOUT the PowerSync schema. -// Use this only for low-level tests (extension loading, watch hooks with custom tables). -// For most tests, use dbtest.OpenTestDB() which includes the full schema. -func OpenBareDB(t *testing.T) sqlite.DB { - t.Helper() - - dbPath := filepath.Join(t.TempDir(), "test.sqlite") - db, err := sqlite.Open(context.Background(), dbPath) - if err != nil { - t.Fatalf("sqlite.Open() error = %v", err) - } - - t.Cleanup(func() { db.Close() }) - return db -} diff --git a/internal/sqlite/sqlitetest/mock_db.go b/internal/sqlite/sqlitetest/mock_db.go deleted file mode 100644 index 25ce6886..00000000 --- a/internal/sqlite/sqlitetest/mock_db.go +++ /dev/null @@ -1,170 +0,0 @@ -// Package sqlitetest provides test doubles for the sqlite package. -package sqlitetest - -import ( - "context" - "database/sql" - - "github.com/usetero/cli/internal/sqlite" -) - -// MockDB is a test double for sqlite.DB. -type MockDB struct { - // MessagesImpl is the mock messages implementation. - MessagesImpl sqlite.Messages - - // ConversationsImpl is the mock conversations implementation. - ConversationsImpl sqlite.Conversations - - // DatadogAccountStatusesImpl is the mock Datadog account statuses implementation. - DatadogAccountStatusesImpl sqlite.DatadogAccountStatuses - - // ServiceStatusesImpl is the mock service statuses implementation. - ServiceStatusesImpl sqlite.ServiceStatuses - - // ServicesImpl is the mock services implementation. - ServicesImpl sqlite.Services - - // LogEventsImpl is the mock log events implementation. - LogEventsImpl sqlite.LogEvents - - // LogEventStatusesImpl is the mock log event statuses implementation. - LogEventStatusesImpl sqlite.LogEventStatuses - - // LogEventPoliciesImpl is the mock log event policies implementation. - LogEventPoliciesImpl sqlite.LogEventPolicies - - // LogEventPolicyStatusesImpl is the mock policy statuses implementation. - LogEventPolicyStatusesImpl sqlite.LogEventPolicyStatuses - - // LogEventPolicyCategoryStatusesImpl is the mock policy category statuses implementation. - LogEventPolicyCategoryStatusesImpl sqlite.LogEventPolicyCategoryStatuses - - // CompliancePoliciesImpl is the mock compliance policies implementation. - CompliancePoliciesImpl sqlite.CompliancePolicies - - // QueryFunc is called by Query. - QueryFunc func(ctx context.Context, sql string, args ...any) (*sql.Rows, error) - - // QueryRowFunc is called by QueryRow. - QueryRowFunc func(ctx context.Context, sql string, args ...any) *sql.Row - - // ExecFunc is called by Exec. - ExecFunc func(ctx context.Context, sql string, args ...any) (sql.Result, error) - - // Closed is set to true when Close is called. - Closed bool -} - -// Ensure MockDB implements sqlite.DB. -var _ sqlite.DB = (*MockDB)(nil) - -// NewMockDB creates a new MockDB with sensible defaults. -func NewMockDB() *MockDB { - return &MockDB{} -} - -// Messages implements sqlite.DB. -func (m *MockDB) Messages() sqlite.Messages { - return m.MessagesImpl -} - -// Conversations implements sqlite.DB. -func (m *MockDB) Conversations() sqlite.Conversations { - return m.ConversationsImpl -} - -// DatadogAccountStatuses implements sqlite.DB. -func (m *MockDB) DatadogAccountStatuses() sqlite.DatadogAccountStatuses { - return m.DatadogAccountStatusesImpl -} - -// ServiceStatuses implements sqlite.DB. -func (m *MockDB) ServiceStatuses() sqlite.ServiceStatuses { - return m.ServiceStatusesImpl -} - -// Query implements sqlite.DB. -func (m *MockDB) Query(ctx context.Context, sql string, args ...any) (*sql.Rows, error) { - if m.QueryFunc != nil { - return m.QueryFunc(ctx, sql, args...) - } - return nil, nil -} - -// QueryRow implements sqlite.DB. -func (m *MockDB) QueryRow(ctx context.Context, sql string, args ...any) *sql.Row { - if m.QueryRowFunc != nil { - return m.QueryRowFunc(ctx, sql, args...) - } - return nil -} - -// Exec implements sqlite.DB. -func (m *MockDB) Exec(ctx context.Context, sql string, args ...any) (sql.Result, error) { - if m.ExecFunc != nil { - return m.ExecFunc(ctx, sql, args...) - } - return nil, nil -} - -// WithTx implements sqlite.DB. -func (m *MockDB) WithTx(ctx context.Context, fn func(tx *sqlite.Tx) error) error { - return nil -} - -// Raw implements sqlite.DB. -func (m *MockDB) Raw() *sql.DB { - return nil -} - -// ReadRaw implements sqlite.DB. -func (m *MockDB) ReadRaw() *sql.DB { - return nil -} - -// Services implements sqlite.DB. -func (m *MockDB) Services() sqlite.Services { - return m.ServicesImpl -} - -// LogEvents implements sqlite.DB. -func (m *MockDB) LogEvents() sqlite.LogEvents { - return m.LogEventsImpl -} - -// LogEventStatuses implements sqlite.DB. -func (m *MockDB) LogEventStatuses() sqlite.LogEventStatuses { - return m.LogEventStatusesImpl -} - -// LogEventPolicies implements sqlite.DB. -func (m *MockDB) LogEventPolicies() sqlite.LogEventPolicies { - return m.LogEventPoliciesImpl -} - -// LogEventPolicyStatuses implements sqlite.DB. -func (m *MockDB) LogEventPolicyStatuses() sqlite.LogEventPolicyStatuses { - return m.LogEventPolicyStatusesImpl -} - -// LogEventPolicyCategoryStatuses implements sqlite.DB. -func (m *MockDB) LogEventPolicyCategoryStatuses() sqlite.LogEventPolicyCategoryStatuses { - return m.LogEventPolicyCategoryStatusesImpl -} - -// CompliancePolicies implements sqlite.DB. -func (m *MockDB) CompliancePolicies() sqlite.CompliancePolicies { - return m.CompliancePoliciesImpl -} - -// PendingUploadCounts implements sqlite.DB. -func (m *MockDB) PendingUploadCounts(_ context.Context) (map[sqlite.Table]int64, error) { - return nil, nil -} - -// Close implements sqlite.DB. -func (m *MockDB) Close() error { - m.Closed = true - return nil -} diff --git a/internal/sqlite/storage.go b/internal/sqlite/storage.go deleted file mode 100644 index 97183ae6..00000000 --- a/internal/sqlite/storage.go +++ /dev/null @@ -1,74 +0,0 @@ -package sqlite - -import ( - "os" - "path/filepath" - - "github.com/usetero/cli/internal/config" -) - -// Storage provides database file operations. -type Storage interface { - DatabasePath(accountID string) (string, error) - ClearDatabase(accountID string) error - Clear() error -} - -// StorageService implements Storage. -type StorageService struct { - config *config.Config -} - -// Ensure StorageService implements Storage. -var _ Storage = (*StorageService)(nil) - -// NewStorageService creates a new storage service. -func NewStorageService(cfg *config.Config) *StorageService { - return &StorageService{config: cfg} -} - -// dataDir returns the directory for storing database files. -func (s *StorageService) dataDir() (string, error) { - baseDir, err := s.config.BaseDir() - if err != nil { - return "", err - } - return filepath.Join(baseDir, "databases"), nil -} - -// DatabasePath returns the path to the SQLite database for a specific account. -func (s *StorageService) DatabasePath(accountID string) (string, error) { - dataDir, err := s.dataDir() - if err != nil { - return "", err - } - return filepath.Join(dataDir, accountID+".sqlite"), nil -} - -// ClearDatabase removes the SQLite database file for a specific account. -func (s *StorageService) ClearDatabase(accountID string) error { - dbPath, err := s.DatabasePath(accountID) - if err != nil { - return err - } - if err := os.Remove(dbPath); err != nil && !os.IsNotExist(err) { - return err - } - return nil -} - -// Clear removes all SQLite database files. -func (s *StorageService) Clear() error { - dataDir, err := s.dataDir() - if err != nil { - return err - } - - files, _ := filepath.Glob(filepath.Join(dataDir, "*.sqlite")) - for _, f := range files { - if err := os.Remove(f); err != nil && !os.IsNotExist(err) { - return err - } - } - return nil -} diff --git a/internal/sqlite/storage_test.go b/internal/sqlite/storage_test.go deleted file mode 100644 index 798c2e75..00000000 --- a/internal/sqlite/storage_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package sqlite_test - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/usetero/cli/internal/config" - "github.com/usetero/cli/internal/sqlite" -) - -func TestStorageService_DatabasePath(t *testing.T) { - t.Parallel() - - t.Run("returns path in databases directory", func(t *testing.T) { - t.Parallel() - - cfg, _ := config.Load("test-storage-path", "") - storage := sqlite.NewStorageService(cfg) - - path, err := storage.DatabasePath("acc_123") - if err != nil { - t.Fatalf("DatabasePath() error = %v", err) - } - - if !strings.Contains(path, "databases") { - t.Errorf("path should contain 'databases' directory: %s", path) - } - if !strings.HasSuffix(path, "acc_123.sqlite") { - t.Errorf("path should end with account ID and .sqlite extension: %s", path) - } - }) - - t.Run("different accounts have different paths", func(t *testing.T) { - t.Parallel() - - cfg, _ := config.Load("test-storage-diff", "") - storage := sqlite.NewStorageService(cfg) - - path1, _ := storage.DatabasePath("acc_1") - path2, _ := storage.DatabasePath("acc_2") - - if path1 == path2 { - t.Error("different accounts should have different paths") - } - }) -} - -func TestStorageService_ClearDatabase(t *testing.T) { - t.Parallel() - - t.Run("removes existing database file", func(t *testing.T) { - t.Parallel() - - cfg, _ := config.Load("test-clear-db", "") - storage := sqlite.NewStorageService(cfg) - - // Create the file first - path, _ := storage.DatabasePath("acc_to_delete") - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - t.Fatalf("mkdir: %v", err) - } - if err := os.WriteFile(path, []byte("test"), 0644); err != nil { - t.Fatalf("write file: %v", err) - } - - // Verify it exists - if _, err := os.Stat(path); os.IsNotExist(err) { - t.Fatal("file should exist before clear") - } - - // Clear it - err := storage.ClearDatabase("acc_to_delete") - if err != nil { - t.Fatalf("ClearDatabase() error = %v", err) - } - - // Verify it's gone - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Error("file should not exist after clear") - } - - // Cleanup - t.Cleanup(func() { - baseDir, _ := cfg.BaseDir() - os.RemoveAll(baseDir) - }) - }) - - t.Run("succeeds when file does not exist", func(t *testing.T) { - t.Parallel() - - cfg, _ := config.Load("test-clear-nonexistent", "") - storage := sqlite.NewStorageService(cfg) - - err := storage.ClearDatabase("nonexistent_account") - - if err != nil { - t.Errorf("ClearDatabase() should not error for nonexistent file: %v", err) - } - }) -} - -func TestStorageService_Clear(t *testing.T) { - t.Parallel() - - t.Run("removes all sqlite files", func(t *testing.T) { - t.Parallel() - - cfg, _ := config.Load("test-clear-all", "") - storage := sqlite.NewStorageService(cfg) - - // Create multiple files - path1, _ := storage.DatabasePath("acc_1") - path2, _ := storage.DatabasePath("acc_2") - if err := os.MkdirAll(filepath.Dir(path1), 0755); err != nil { - t.Fatalf("mkdir: %v", err) - } - if err := os.WriteFile(path1, []byte("test1"), 0644); err != nil { - t.Fatalf("write file 1: %v", err) - } - if err := os.WriteFile(path2, []byte("test2"), 0644); err != nil { - t.Fatalf("write file 2: %v", err) - } - - // Clear all - err := storage.Clear() - if err != nil { - t.Fatalf("Clear() error = %v", err) - } - - // Verify both are gone - if _, err := os.Stat(path1); !os.IsNotExist(err) { - t.Error("file 1 should not exist after clear") - } - if _, err := os.Stat(path2); !os.IsNotExist(err) { - t.Error("file 2 should not exist after clear") - } - - // Cleanup - t.Cleanup(func() { - baseDir, _ := cfg.BaseDir() - os.RemoveAll(baseDir) - }) - }) - - t.Run("succeeds when directory is empty", func(t *testing.T) { - t.Parallel() - - cfg, _ := config.Load("test-clear-empty", "") - storage := sqlite.NewStorageService(cfg) - - err := storage.Clear() - - if err != nil { - t.Errorf("Clear() should not error for empty directory: %v", err) - } - }) -} diff --git a/internal/sqlite/tables.go b/internal/sqlite/tables.go deleted file mode 100644 index 689f2f07..00000000 --- a/internal/sqlite/tables.go +++ /dev/null @@ -1,13 +0,0 @@ -package sqlite - -// Table represents a watched table. -type Table string - -// Table names matching the schema. -const ( - TableConversations Table = "conversations" - TableLogEventPolicies Table = "log_event_policies" - TableLogEvents Table = "log_events" - TableMessages Table = "messages" - TableServices Table = "services" -) diff --git a/internal/sqlite/timeout.go b/internal/sqlite/timeout.go deleted file mode 100644 index b5488531..00000000 --- a/internal/sqlite/timeout.go +++ /dev/null @@ -1,68 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "time" - - "github.com/usetero/cli/internal/sqlite/gen" -) - -// defaultQueryTimeout is applied to any query whose context has no deadline. -// Callers can override by setting their own deadline on the context. -const defaultQueryTimeout = 3 * time.Second - -// Ensure timeoutDB implements gen.DBTX. -var _ gen.DBTX = (*timeoutDB)(nil) - -// timeoutDB wraps a *sql.DB and applies defaultQueryTimeout to any context -// that doesn't already have a deadline set by the caller. -type timeoutDB struct { - db *sql.DB -} - -func withTimeout(ctx context.Context) (context.Context, context.CancelFunc) { - return WithTimeout(ctx, defaultQueryTimeout) -} - -// WithTimeout applies timeout unless ctx already has a deadline. -// If ctx is nil, context.Background() is used. -func WithTimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { - if ctx == nil { - ctx = context.Background() - } - if _, ok := ctx.Deadline(); ok { - return ctx, func() {} - } - if timeout <= 0 { - timeout = defaultQueryTimeout - } - return context.WithTimeout(ctx, timeout) -} - -func (t *timeoutDB) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { - ctx, cancel := withTimeout(ctx) - defer cancel() - return t.db.ExecContext(ctx, query, args...) -} - -func (t *timeoutDB) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) { - ctx, cancel := withTimeout(ctx) - defer cancel() - return t.db.PrepareContext(ctx, query) -} - -func (t *timeoutDB) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) { - // Not canceled here — the caller iterates the returned *sql.Rows after - // this method returns. The timeout still fires after defaultQueryTimeout - // if the query is stuck; it just isn't eagerly cleaned up. - ctx, _ = withTimeout(ctx) //nolint:lostcancel // caller iterates rows after return; timeout still fires - return t.db.QueryContext(ctx, query, args...) -} - -func (t *timeoutDB) QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row { - // Not canceled here — the caller calls Scan() on the returned *sql.Row - // after this method returns, and Scan checks ctx.Err(). Same as QueryContext. - ctx, _ = withTimeout(ctx) //nolint:lostcancel // caller calls Scan after return; timeout still fires - return t.db.QueryRowContext(ctx, query, args...) -} diff --git a/internal/tea/components/status/status.go b/internal/tea/components/status/status.go index 5b1fbaff..afcb096a 100644 --- a/internal/tea/components/status/status.go +++ b/internal/tea/components/status/status.go @@ -40,6 +40,8 @@ func serviceColor(theme styles.Theme, h domain.ServiceHealth) color.Color { switch h { case domain.ServiceHealthOK: return theme.Success + case domain.ServiceHealthError: + return theme.Error case domain.ServiceHealthDisabled, domain.ServiceHealthInactive: return theme.TextMuted default: diff --git a/internal/upload/conversation_handler.go b/internal/upload/conversation_handler.go deleted file mode 100644 index 8c5cb695..00000000 --- a/internal/upload/conversation_handler.go +++ /dev/null @@ -1,106 +0,0 @@ -package upload - -import ( - "context" - "errors" - "fmt" - - "github.com/google/uuid" - graphql "github.com/usetero/cli/internal/boundary/graphql" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/powersync/db" -) - -// Idempotent operation handling: -// - DELETE returning "not found" is success (resource already gone) -// - CREATE returning "already exists" is success (resource already there) - -// conversationHandler handles uploading conversations to the GraphQL API. -type conversationHandler struct { - conversations graphql.Conversations - scope log.Scope -} - -func newConversationHandler(conversations graphql.Conversations, scope log.Scope) *conversationHandler { - return &conversationHandler{ - conversations: conversations, - scope: scope.Child("conversations"), - } -} - -func (h *conversationHandler) Handle(ctx context.Context, entry *db.CrudEntry, emit Emitter) error { - // Conversation handler doesn't emit events currently - _ = emit - - switch entry.Op { - case db.OpPut: - return h.handlePut(ctx, entry) - case db.OpPatch: - return h.handlePatch(ctx, entry) - case db.OpDelete: - return h.handleDelete(ctx, entry) - default: - h.scope.Warn("unknown conversation op", "op", entry.Op) - return nil - } -} - -func (h *conversationHandler) handlePut(ctx context.Context, entry *db.CrudEntry) error { - workspaceID, _ := entry.Data["workspace_id"].(string) - title, _ := entry.Data["title"].(string) - - id, err := uuid.Parse(entry.RowID) - if err != nil { - return fmt.Errorf("invalid conversation ID %q: %w", entry.RowID, err) - } - - _, err = h.conversations.Create(ctx, graphql.CreateConversationInput{ - ID: id, - WorkspaceID: domain.WorkspaceID(workspaceID), - Title: title, - }) - if err != nil { - // Already exists is fine - the conversation is there, which is what we wanted - if errors.Is(err, graphql.ErrAlreadyExists) { - h.scope.Debug("conversation already exists, skipping", "id", entry.RowID) - return nil - } - return fmt.Errorf("create conversation: %w", err) - } - - h.scope.Debug("uploaded conversation", "id", entry.RowID) - return nil -} - -func (h *conversationHandler) handlePatch(ctx context.Context, entry *db.CrudEntry) error { - var input graphql.UpdateConversationInput - - if titleVal, ok := entry.Data["title"]; ok { - title, _ := titleVal.(string) - input.Title = &title - } - - _, err := h.conversations.Update(ctx, domain.ConversationID(entry.RowID), input) - if err != nil { - return fmt.Errorf("update conversation: %w", err) - } - - h.scope.Debug("updated conversation", "id", entry.RowID) - return nil -} - -func (h *conversationHandler) handleDelete(ctx context.Context, entry *db.CrudEntry) error { - err := h.conversations.Delete(ctx, domain.ConversationID(entry.RowID)) - if err != nil { - // Not found is fine - the conversation is gone, which is what we wanted - if errors.Is(err, graphql.ErrNotFound) { - h.scope.Debug("conversation already deleted, skipping", "id", entry.RowID) - return nil - } - return fmt.Errorf("delete conversation: %w", err) - } - - h.scope.Debug("deleted conversation", "id", entry.RowID) - return nil -} diff --git a/internal/upload/conversation_handler_test.go b/internal/upload/conversation_handler_test.go deleted file mode 100644 index f426621d..00000000 --- a/internal/upload/conversation_handler_test.go +++ /dev/null @@ -1,277 +0,0 @@ -package upload - -import ( - "context" - "errors" - "fmt" - "testing" - - "github.com/google/uuid" - graphql "github.com/usetero/cli/internal/boundary/graphql" - "github.com/usetero/cli/internal/boundary/graphql/apitest" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/db" -) - -func noopEmitter() Emitter { - return func(Event) {} -} - -func TestConversationHandler_Handle(t *testing.T) { - t.Parallel() - - t.Run("PUT creates conversation", func(t *testing.T) { - t.Parallel() - - testID := uuid.New() - var calledWith struct { - id uuid.UUID - workspaceID domain.WorkspaceID - title string - } - - mock := &apitest.MockConversations{ - CreateFunc: func(ctx context.Context, input graphql.CreateConversationInput) (*domain.Conversation, error) { - calledWith.id = input.ID - calledWith.workspaceID = input.WorkspaceID - calledWith.title = input.Title - return &domain.Conversation{ID: domain.ConversationID(input.ID.String())}, nil - }, - } - - h := newConversationHandler(mock, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: db.OpPut, - RowID: testID.String(), - Data: map[string]any{ - "workspace_id": "ws-1", - "title": "Test Conversation", - }, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Fatalf("Handle() error = %v", err) - } - - if calledWith.id != testID { - t.Errorf("Create called with id = %v, want %v", calledWith.id, testID) - } - if calledWith.workspaceID != "ws-1" { - t.Errorf("Create called with workspaceID = %q, want %q", calledWith.workspaceID, "ws-1") - } - if calledWith.title != "Test Conversation" { - t.Errorf("Create called with title = %q, want %q", calledWith.title, "Test Conversation") - } - }) - - t.Run("PUT returns error on failure", func(t *testing.T) { - t.Parallel() - - mock := &apitest.MockConversations{ - CreateFunc: func(ctx context.Context, input graphql.CreateConversationInput) (*domain.Conversation, error) { - return nil, errors.New("network error") - }, - } - - h := newConversationHandler(mock, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: db.OpPut, - RowID: uuid.New().String(), - Data: map[string]any{}, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err == nil { - t.Error("Handle() expected error, got nil") - } - }) - - t.Run("PATCH updates conversation", func(t *testing.T) { - t.Parallel() - - var calledWith struct { - id domain.ConversationID - title string - } - - mock := &apitest.MockConversations{ - UpdateFunc: func(ctx context.Context, id domain.ConversationID, input graphql.UpdateConversationInput) (*domain.Conversation, error) { - calledWith.id = id - if input.Title != nil { - calledWith.title = *input.Title - } - return &domain.Conversation{ID: id, Title: calledWith.title}, nil - }, - } - - h := newConversationHandler(mock, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: db.OpPatch, - RowID: "conv-1", - Data: map[string]any{ - "title": "Updated Title", - }, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Fatalf("Handle() error = %v", err) - } - - if calledWith.id != "conv-1" { - t.Errorf("Update called with id = %q, want %q", calledWith.id, "conv-1") - } - if calledWith.title != "Updated Title" { - t.Errorf("Update called with title = %q, want %q", calledWith.title, "Updated Title") - } - }) - - t.Run("PATCH returns error on failure", func(t *testing.T) { - t.Parallel() - - mock := &apitest.MockConversations{ - UpdateFunc: func(ctx context.Context, id domain.ConversationID, input graphql.UpdateConversationInput) (*domain.Conversation, error) { - return nil, errors.New("network error") - }, - } - - h := newConversationHandler(mock, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: db.OpPatch, - RowID: "conv-1", - Data: map[string]any{}, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err == nil { - t.Error("Handle() expected error, got nil") - } - }) - - t.Run("DELETE deletes conversation", func(t *testing.T) { - t.Parallel() - - var deletedID domain.ConversationID - - mock := &apitest.MockConversations{ - DeleteFunc: func(ctx context.Context, id domain.ConversationID) error { - deletedID = id - return nil - }, - } - - h := newConversationHandler(mock, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: db.OpDelete, - RowID: "conv-1", - Data: map[string]any{}, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Fatalf("Handle() error = %v", err) - } - - if deletedID != "conv-1" { - t.Errorf("Delete called with id = %q, want %q", deletedID, "conv-1") - } - }) - - t.Run("DELETE returns error on failure", func(t *testing.T) { - t.Parallel() - - mock := &apitest.MockConversations{ - DeleteFunc: func(ctx context.Context, id domain.ConversationID) error { - return errors.New("network error") - }, - } - - h := newConversationHandler(mock, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: db.OpDelete, - RowID: "conv-1", - Data: map[string]any{}, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err == nil { - t.Error("Handle() expected error, got nil") - } - }) - - t.Run("DELETE succeeds when resource not found", func(t *testing.T) { - t.Parallel() - - mock := &apitest.MockConversations{ - DeleteFunc: func(ctx context.Context, id domain.ConversationID) error { - // Service layer returns wrapped ErrNotFound - return fmt.Errorf("delete conversation %s: %w", id, graphql.ErrNotFound) - }, - } - - h := newConversationHandler(mock, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: db.OpDelete, - RowID: "conv-1", - Data: map[string]any{}, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Errorf("Handle() error = %v, want nil for not found (idempotent delete)", err) - } - }) - - t.Run("PUT succeeds when resource already exists", func(t *testing.T) { - t.Parallel() - - mock := &apitest.MockConversations{ - CreateFunc: func(ctx context.Context, input graphql.CreateConversationInput) (*domain.Conversation, error) { - // Service layer returns wrapped ErrAlreadyExists - return nil, fmt.Errorf("create conversation %s: %w", input.ID, graphql.ErrAlreadyExists) - }, - } - - h := newConversationHandler(mock, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: db.OpPut, - RowID: uuid.New().String(), - Data: map[string]any{ - "workspace_id": "ws-1", - "title": "Test", - }, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Errorf("Handle() error = %v, want nil for already exists (idempotent create)", err) - } - }) - - t.Run("unknown op returns nil", func(t *testing.T) { - t.Parallel() - - h := newConversationHandler(apitest.NewMockConversations(), logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: "UNKNOWN", - RowID: "conv-1", - Data: map[string]any{}, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Errorf("Handle() error = %v, want nil for unknown op", err) - } - }) -} diff --git a/internal/upload/event.go b/internal/upload/event.go deleted file mode 100644 index 0e4e13e3..00000000 --- a/internal/upload/event.go +++ /dev/null @@ -1,39 +0,0 @@ -package upload - -import ( - "time" - - "github.com/usetero/cli/internal/sqlite" -) - -// Event is the interface for all upload events. -// Handlers can define their own event types that implement this interface. -type Event interface { - uploadEvent() -} - -// Core upload events - emitted by the uploader itself - -// SyncingEvent is emitted when entries are being processed. -type SyncingEvent struct { - ProcessedCount int -} - -func (SyncingEvent) uploadEvent() {} - -// StalledEvent is emitted when upload is blocked on a failing entry. -type StalledEvent struct { - Error error - Table sqlite.Table - RowID string - StalledFor time.Duration -} - -func (StalledEvent) uploadEvent() {} - -// RecoveredEvent is emitted when upload recovers from a stalled state. -type RecoveredEvent struct { - StalledFor time.Duration -} - -func (RecoveredEvent) uploadEvent() {} diff --git a/internal/upload/handler.go b/internal/upload/handler.go deleted file mode 100644 index 9560abbd..00000000 --- a/internal/upload/handler.go +++ /dev/null @@ -1,15 +0,0 @@ -package upload - -import ( - "context" - - "github.com/usetero/cli/internal/powersync/db" -) - -// Handler processes CRUD entries for a specific table. -type Handler interface { - Handle(ctx context.Context, entry *db.CrudEntry, emit Emitter) error -} - -// Emitter is a function that handlers use to emit events. -type Emitter func(Event) diff --git a/internal/upload/message_handler.go b/internal/upload/message_handler.go deleted file mode 100644 index 62992b9c..00000000 --- a/internal/upload/message_handler.go +++ /dev/null @@ -1,111 +0,0 @@ -package upload - -// Message Upload Flow -// -// The upload queue handles DURABILITY ONLY. It persists messages to the -// control plane via GraphQL so they survive device loss and sync across devices. -// -// The upload queue does NOT call the Chat API. The TUI calls the Chat API -// directly with the full message history and streams the response. -// -// Flow: -// 1. User submits message in TUI -// 2. TUI writes user message to SQLite (creates ps_crud PUT entry) -// 3. TUI calls Chat API directly, streams response -// 4. TUI writes assistant message to SQLite as deltas arrive -// 5. Upload queue (background) persists messages to GraphQL -// -// For user messages: upload on PUT (initial create) -// For assistant messages: upload when complete (entry contains stop_reason) - -import ( - "context" - "fmt" - - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/powersync/db" - "github.com/usetero/cli/internal/sqlite" -) - -// messageHandler persists messages to the control plane via GraphQL. -type messageHandler struct { - messages messageMutations - db sqlite.DB - scope log.Scope -} - -func newMessageHandler(messages messageMutations, db sqlite.DB, scope log.Scope) *messageHandler { - return &messageHandler{ - messages: messages, - db: db, - scope: scope.Child("messages"), - } -} - -// Handle persists a message to the control plane. -func (h *messageHandler) Handle(ctx context.Context, entry *db.CrudEntry, emit Emitter) error { - switch entry.Op { - case db.OpPut, db.OpPatch: - return h.handlePutOrPatch(ctx, entry) - case db.OpDelete: - // Messages are deleted via conversation deletion, not individually. - h.scope.Debug("skipping message DELETE", "id", entry.RowID) - return nil - default: - h.scope.Warn("unknown message op", "op", entry.Op) - return nil - } -} - -func (h *messageHandler) handlePutOrPatch(ctx context.Context, entry *db.CrudEntry) error { - msg, err := h.db.Messages().Get(ctx, domain.MessageID(entry.RowID)) - if err != nil { - // Message may have been deleted, skip - h.scope.Debug("message not found, skipping", "id", entry.RowID, "error", err) - return nil - } - - switch msg.Role { - case domain.RoleUser: - // User messages: persist on PUT (initial create) - if entry.Op == db.OpPut { - return h.persistUserMessage(ctx, entry, msg) - } - h.scope.Debug("skipping user message PATCH", "id", entry.RowID) - return nil - - case domain.RoleAssistant: - // Assistant messages: persist when complete (entry sets stop_reason) - _, hasStopReason := entry.Data["stop_reason"] - if !hasStopReason { - h.scope.Debug("skipping assistant message (incomplete)", "id", entry.RowID, "op", entry.Op) - return nil - } - return h.persistAssistantMessage(ctx, msg) - - default: - h.scope.Warn("unknown message role", "role", msg.Role) - return nil - } -} - -func (h *messageHandler) persistUserMessage(ctx context.Context, entry *db.CrudEntry, msg *domain.Message) error { - err := h.messages.CreateMessage(ctx, msg) - if err != nil { - return fmt.Errorf("persist user message: %w", err) - } - - h.scope.Debug("persisted user message", "id", entry.RowID) - return nil -} - -func (h *messageHandler) persistAssistantMessage(ctx context.Context, msg *domain.Message) error { - err := h.messages.CreateMessage(ctx, msg) - if err != nil { - return fmt.Errorf("persist assistant message: %w", err) - } - - h.scope.Debug("persisted assistant message", "id", msg.ID) - return nil -} diff --git a/internal/upload/message_handler_test.go b/internal/upload/message_handler_test.go deleted file mode 100644 index 3c62c154..00000000 --- a/internal/upload/message_handler_test.go +++ /dev/null @@ -1,280 +0,0 @@ -package upload - -import ( - "context" - "errors" - "testing" - - "github.com/usetero/cli/internal/boundary/graphql/apitest" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/db" - "github.com/usetero/cli/internal/powersync/db/dbtest" -) - -func TestMessageHandler_Handle(t *testing.T) { - t.Parallel() - - t.Run("persists user message on PUT", func(t *testing.T) { - t.Parallel() - - testDB := dbtest.OpenTestDB(t) - - _, err := testDB.Exec(context.Background(), - `INSERT INTO messages (id, account_id, conversation_id, content, role, created_at) - VALUES ('msg-1', 'acc-1', 'conv-1', '[{"type":"text","text":{"content":"Hello"}}]', 'user', '2024-01-01T00:00:00Z')`) - if err != nil { - t.Fatalf("insert message: %v", err) - } - - var captured *domain.Message - - mock := &apitest.MockMessages{ - CreateMessageFunc: func(ctx context.Context, msg *domain.Message) error { - captured = msg - return nil - }, - } - - h := newMessageHandler(mock, testDB, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: db.OpPut, - RowID: "msg-1", - Data: map[string]any{ - "role": "user", - "conversation_id": "conv-1", - "account_id": "acc-1", - "content": `[{"type":"text","text":{"content":"Hello"}}]`, - }, - } - - err = h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Fatalf("Handle() error = %v", err) - } - - if captured == nil { - t.Fatal("expected CreateMessage to be called") - } - if captured.ID != "msg-1" { - t.Errorf("id = %q, want %q", captured.ID, "msg-1") - } - if captured.ConversationID != "conv-1" { - t.Errorf("conversationID = %q, want %q", captured.ConversationID, "conv-1") - } - if captured.Role != domain.RoleUser { - t.Errorf("role = %q, want %q", captured.Role, domain.RoleUser) - } - }) - - t.Run("skips user message on PATCH", func(t *testing.T) { - t.Parallel() - - testDB := dbtest.OpenTestDB(t) - - _, err := testDB.Exec(context.Background(), - `INSERT INTO messages (id, account_id, conversation_id, content, role, created_at) - VALUES ('msg-1', 'acc-1', 'conv-1', '[{"type":"text","text":{"content":"Hello"}}]', 'user', '2024-01-01T00:00:00Z')`) - if err != nil { - t.Fatalf("insert message: %v", err) - } - - called := false - mock := &apitest.MockMessages{ - CreateMessageFunc: func(ctx context.Context, msg *domain.Message) error { - called = true - return nil - }, - } - - h := newMessageHandler(mock, testDB, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: db.OpPatch, - RowID: "msg-1", - Data: map[string]any{ - "content": `[{"type":"text","text":{"content":"Hello updated"}}]`, - }, - } - - err = h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Fatalf("Handle() error = %v", err) - } - if called { - t.Error("expected CreateMessage to not be called on PATCH for user message") - } - }) - - t.Run("persists assistant message when stop_reason is set", func(t *testing.T) { - t.Parallel() - - testDB := dbtest.OpenTestDB(t) - - _, err := testDB.Exec(context.Background(), - `INSERT INTO messages (id, account_id, conversation_id, content, role, model, stop_reason, created_at) - VALUES ('msg-1', 'acc-1', 'conv-1', '[{"type":"text","text":{"content":"Hi"}}]', 'assistant', 'claude-3', 'end_turn', '2024-01-01T00:00:00Z')`) - if err != nil { - t.Fatalf("insert message: %v", err) - } - - var captured *domain.Message - - mock := &apitest.MockMessages{ - CreateMessageFunc: func(ctx context.Context, msg *domain.Message) error { - captured = msg - return nil - }, - } - - h := newMessageHandler(mock, testDB, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: db.OpPatch, - RowID: "msg-1", - Data: map[string]any{ - "stop_reason": "end_turn", - }, - } - - err = h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Fatalf("Handle() error = %v", err) - } - - if captured == nil { - t.Fatal("expected CreateMessage to be called") - } - if captured.ID != "msg-1" { - t.Errorf("id = %q, want %q", captured.ID, "msg-1") - } - if captured.Model != "claude-3" { - t.Errorf("model = %q, want %q", captured.Model, "claude-3") - } - if captured.StopReason != "end_turn" { - t.Errorf("stopReason = %q, want %q", captured.StopReason, "end_turn") - } - }) - - t.Run("skips assistant message without stop_reason", func(t *testing.T) { - t.Parallel() - - testDB := dbtest.OpenTestDB(t) - - _, err := testDB.Exec(context.Background(), - `INSERT INTO messages (id, account_id, conversation_id, content, role, model, created_at) - VALUES ('msg-1', 'acc-1', 'conv-1', '[]', 'assistant', 'claude-3', '2024-01-01T00:00:00Z')`) - if err != nil { - t.Fatalf("insert message: %v", err) - } - - called := false - mock := &apitest.MockMessages{ - CreateMessageFunc: func(ctx context.Context, msg *domain.Message) error { - called = true - return nil - }, - } - - h := newMessageHandler(mock, testDB, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: db.OpPatch, - RowID: "msg-1", - Data: map[string]any{ - "content": `[{"type":"text","text":{"content":"partial"}}]`, - }, - } - - err = h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Fatalf("Handle() error = %v", err) - } - if called { - t.Error("expected CreateMessage to not be called without stop_reason") - } - }) - - t.Run("returns error when persist fails", func(t *testing.T) { - t.Parallel() - - testDB := dbtest.OpenTestDB(t) - - _, err := testDB.Exec(context.Background(), - `INSERT INTO messages (id, account_id, conversation_id, content, role, created_at) - VALUES ('msg-1', 'acc-1', 'conv-1', '[{"type":"text","text":{"content":"Hello"}}]', 'user', '2024-01-01T00:00:00Z')`) - if err != nil { - t.Fatalf("insert message: %v", err) - } - - mock := &apitest.MockMessages{ - CreateMessageFunc: func(ctx context.Context, msg *domain.Message) error { - return errors.New("network error") - }, - } - - h := newMessageHandler(mock, testDB, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: db.OpPut, - RowID: "msg-1", - Data: map[string]any{ - "role": "user", - "conversation_id": "conv-1", - "content": `[{"type":"text","text":{"content":"Hello"}}]`, - }, - } - - err = h.Handle(context.Background(), entry, noopEmitter()) - if err == nil { - t.Error("Handle() expected error, got nil") - } - }) - - t.Run("skips DELETE", func(t *testing.T) { - t.Parallel() - - testDB := dbtest.OpenTestDB(t) - h := newMessageHandler(apitest.NewMockMessages(), testDB, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: db.OpDelete, - RowID: "msg-1", - Data: map[string]any{}, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Errorf("Handle() error = %v, want nil", err) - } - }) - - t.Run("skips unknown role", func(t *testing.T) { - t.Parallel() - - testDB := dbtest.OpenTestDB(t) - - _, err := testDB.Exec(context.Background(), - `INSERT INTO messages (id, account_id, conversation_id, content, role, created_at) - VALUES ('msg-1', 'acc-1', 'conv-1', '[]', 'system', '2024-01-01T00:00:00Z')`) - if err != nil { - t.Fatalf("insert message: %v", err) - } - - h := newMessageHandler(apitest.NewMockMessages(), testDB, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: db.OpPut, - RowID: "msg-1", - Data: map[string]any{ - "role": "system", - }, - } - - err = h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Errorf("Handle() error = %v, want nil", err) - } - }) -} diff --git a/internal/upload/policy_handler.go b/internal/upload/policy_handler.go deleted file mode 100644 index 1e6aee4e..00000000 --- a/internal/upload/policy_handler.go +++ /dev/null @@ -1,46 +0,0 @@ -package upload - -import ( - "context" - - graphql "github.com/usetero/cli/internal/boundary/graphql" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/powersync/db" -) - -// policyHandler handles uploading log event policy mutations to the GraphQL API. -type policyHandler struct { - policies graphql.Policies - scope log.Scope -} - -func newPolicyHandler(policies graphql.Policies, scope log.Scope) *policyHandler { - return &policyHandler{ - policies: policies, - scope: scope.Child("policies"), - } -} - -func (h *policyHandler) Handle(ctx context.Context, entry *db.CrudEntry, emit Emitter) error { - _ = emit - - switch entry.Op { - case db.OpPatch: - return h.handlePatch(ctx, entry) - default: - h.scope.Warn("unsupported policy op, dropping", "op", entry.Op, "rowId", entry.RowID) - return nil - } -} - -func (h *policyHandler) handlePatch(ctx context.Context, entry *db.CrudEntry) error { - if _, ok := entry.Data["approved_at"]; ok { - return h.policies.ApprovePolicy(ctx, entry.RowID) - } - if _, ok := entry.Data["dismissed_at"]; ok { - return h.policies.DismissPolicy(ctx, entry.RowID) - } - - h.scope.Warn("no mutation for patched fields, dropping", "rowId", entry.RowID, "fields", fieldNames(entry.Data)) - return nil -} diff --git a/internal/upload/ports.go b/internal/upload/ports.go deleted file mode 100644 index 74285281..00000000 --- a/internal/upload/ports.go +++ /dev/null @@ -1,21 +0,0 @@ -package upload - -import ( - "context" - - graphql "github.com/usetero/cli/internal/boundary/graphql" - "github.com/usetero/cli/internal/domain" -) - -// MutationDeps groups GraphQL mutation dependencies needed by the uploader. -type MutationDeps struct { - Conversations graphql.Conversations - Messages graphql.Messages - Services graphql.Services - Policies graphql.Policies -} - -// Local ports used by upload handlers. -type messageMutations interface { - CreateMessage(ctx context.Context, message *domain.Message) error -} diff --git a/internal/upload/service_handler.go b/internal/upload/service_handler.go deleted file mode 100644 index f4886d06..00000000 --- a/internal/upload/service_handler.go +++ /dev/null @@ -1,77 +0,0 @@ -package upload - -import ( - "context" - - graphql "github.com/usetero/cli/internal/boundary/graphql" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/powersync/db" -) - -// serviceHandler handles uploading service mutations to the GraphQL API. -// Services are server-owned — the client only patches them (e.g. enable/disable). -type serviceHandler struct { - services graphql.Services - scope log.Scope -} - -func newServiceHandler(services graphql.Services, scope log.Scope) *serviceHandler { - return &serviceHandler{ - services: services, - scope: scope.Child("services"), - } -} - -func (h *serviceHandler) Handle(ctx context.Context, entry *db.CrudEntry, emit Emitter) error { - _ = emit - - switch entry.Op { - case db.OpPatch: - return h.handlePatch(ctx, entry) - default: - h.scope.Warn("unsupported service op, dropping", "op", entry.Op, "rowId", entry.RowID) - return nil - } -} - -func (h *serviceHandler) handlePatch(ctx context.Context, entry *db.CrudEntry) error { - if enabledVal, ok := entry.Data["enabled"]; ok { - id := domain.ServiceID(entry.RowID) - if toBool(enabledVal) { - return h.services.EnableService(ctx, id) - } - return h.services.DisableService(ctx, id) - } - - // No mutation available for the patched fields - h.scope.Warn("no mutation for patched fields, dropping", "rowId", entry.RowID, "fields", fieldNames(entry.Data)) - return nil -} - -// toBool converts a value from the CRUD entry data to a boolean. -// SQLite stores booleans as integers (0/1), and JSON decoding may -// produce float64 or bool depending on the source. -func toBool(v any) bool { - switch val := v.(type) { - case bool: - return val - case float64: - return val != 0 - case int64: - return val != 0 - case int: - return val != 0 - default: - return false - } -} - -// fieldNames returns the keys of a map for logging. -func fieldNames(data map[string]any) []string { - names := make([]string, 0, len(data)) - for k := range data { - names = append(names, k) - } - return names -} diff --git a/internal/upload/service_handler_test.go b/internal/upload/service_handler_test.go deleted file mode 100644 index 48101354..00000000 --- a/internal/upload/service_handler_test.go +++ /dev/null @@ -1,170 +0,0 @@ -package upload - -import ( - "context" - "errors" - "testing" - - "github.com/usetero/cli/internal/boundary/graphql/apitest" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/db" -) - -func TestServiceHandler_Handle(t *testing.T) { - t.Parallel() - - t.Run("PATCH enabled=true calls EnableService", func(t *testing.T) { - t.Parallel() - - var enabledID domain.ServiceID - mock := &apitest.MockAPIServiceServices{ - EnableServiceFunc: func(ctx context.Context, serviceID domain.ServiceID) error { - enabledID = serviceID - return nil - }, - } - - h := newServiceHandler(mock, logtest.NewScope(t)) - entry := &db.CrudEntry{ - Op: db.OpPatch, - RowID: "svc-1", - Data: map[string]any{"enabled": true}, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Fatalf("Handle() error = %v", err) - } - if enabledID != "svc-1" { - t.Errorf("EnableService called with %q, want %q", enabledID, "svc-1") - } - }) - - t.Run("PATCH enabled=false calls DisableService", func(t *testing.T) { - t.Parallel() - - var disabledID domain.ServiceID - mock := &apitest.MockAPIServiceServices{ - DisableServiceFunc: func(ctx context.Context, serviceID domain.ServiceID) error { - disabledID = serviceID - return nil - }, - } - - h := newServiceHandler(mock, logtest.NewScope(t)) - entry := &db.CrudEntry{ - Op: db.OpPatch, - RowID: "svc-2", - Data: map[string]any{"enabled": false}, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Fatalf("Handle() error = %v", err) - } - if disabledID != "svc-2" { - t.Errorf("DisableService called with %q, want %q", disabledID, "svc-2") - } - }) - - t.Run("PATCH enabled as int 1 calls EnableService", func(t *testing.T) { - t.Parallel() - - var called bool - mock := &apitest.MockAPIServiceServices{ - EnableServiceFunc: func(ctx context.Context, serviceID domain.ServiceID) error { - called = true - return nil - }, - } - - h := newServiceHandler(mock, logtest.NewScope(t)) - entry := &db.CrudEntry{ - Op: db.OpPatch, - RowID: "svc-3", - Data: map[string]any{"enabled": float64(1)}, // JSON decodes numbers as float64 - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Fatalf("Handle() error = %v", err) - } - if !called { - t.Error("expected EnableService to be called") - } - }) - - t.Run("PATCH returns error on API failure", func(t *testing.T) { - t.Parallel() - - mock := &apitest.MockAPIServiceServices{ - EnableServiceFunc: func(ctx context.Context, serviceID domain.ServiceID) error { - return errors.New("network error") - }, - } - - h := newServiceHandler(mock, logtest.NewScope(t)) - entry := &db.CrudEntry{ - Op: db.OpPatch, - RowID: "svc-1", - Data: map[string]any{"enabled": true}, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err == nil { - t.Error("Handle() expected error, got nil") - } - }) - - t.Run("PATCH without enabled field drops silently", func(t *testing.T) { - t.Parallel() - - mock := apitest.NewMockAPIServiceServices() - h := newServiceHandler(mock, logtest.NewScope(t)) - entry := &db.CrudEntry{ - Op: db.OpPatch, - RowID: "svc-1", - Data: map[string]any{"name": "updated-name"}, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Errorf("Handle() error = %v, want nil for unhandled fields", err) - } - }) - - t.Run("PUT drops silently", func(t *testing.T) { - t.Parallel() - - mock := apitest.NewMockAPIServiceServices() - h := newServiceHandler(mock, logtest.NewScope(t)) - entry := &db.CrudEntry{ - Op: db.OpPut, - RowID: "svc-1", - Data: map[string]any{}, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Errorf("Handle() error = %v, want nil for unsupported op", err) - } - }) - - t.Run("DELETE drops silently", func(t *testing.T) { - t.Parallel() - - mock := apitest.NewMockAPIServiceServices() - h := newServiceHandler(mock, logtest.NewScope(t)) - entry := &db.CrudEntry{ - Op: db.OpDelete, - RowID: "svc-1", - Data: map[string]any{}, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Errorf("Handle() error = %v, want nil for unsupported op", err) - } - }) -} diff --git a/internal/upload/uploader.go b/internal/upload/uploader.go deleted file mode 100644 index e5ccf689..00000000 --- a/internal/upload/uploader.go +++ /dev/null @@ -1,278 +0,0 @@ -// Package upload handles uploading local changes to the backend. -package upload - -import ( - "context" - "fmt" - "time" - - psapi "github.com/usetero/cli/internal/boundary/powersync" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/powersync/db" - "github.com/usetero/cli/internal/sqlite" -) - -const ( - defaultPollInterval = 100 * time.Millisecond - defaultRetryDelay = 1 * time.Second - defaultMaxRetries = 3 -) - -// TokenRefresher provides access tokens for authentication. -type TokenRefresher interface { - GetAccessToken(ctx context.Context) (string, error) -} - -// Uploader watches the CRUD queue and uploads changes to the backend. -type Uploader interface { - Run(ctx context.Context) error - Events() <-chan Event -} - -// BatchCompletedHook is called after a batch is atomically completed in SQLite. -// Errors are logged and do not fail the upload cycle. -type BatchCompletedHook func(ctx context.Context) error - -// Option configures uploader behavior. -type Option func(*uploader) - -// WithBatchCompletedHook installs a callback invoked after each successful batch completion. -func WithBatchCompletedHook(hook BatchCompletedHook) Option { - return func(u *uploader) { - u.batchCompletedHook = hook - } -} - -// uploader is the concrete implementation of Uploader. -type uploader struct { - db sqlite.DB - queue *db.CrudQueue - client psapi.Client - tokenRefresher TokenRefresher - handlers map[sqlite.Table]Handler - scope log.Scope - - // Configuration - pollInterval time.Duration - retryDelay time.Duration - maxRetries int - - // Event channel for status updates - events chan Event - - // State tracking - stalledSince *time.Time - stalledEntry *db.CrudEntry - - batchCompletedHook BatchCompletedHook -} - -// New creates a new uploader. -func New( - database sqlite.DB, - client psapi.Client, - tokenRefresher TokenRefresher, - mutations MutationDeps, - scope log.Scope, - opts ...Option, -) Uploader { - scope = scope.Child("upload") - u := &uploader{ - db: database, - queue: db.NewCrudQueue(database), - client: client, - tokenRefresher: tokenRefresher, - handlers: map[sqlite.Table]Handler{ - sqlite.TableConversations: newConversationHandler(mutations.Conversations, scope), - sqlite.TableMessages: newMessageHandler(mutations.Messages, database, scope), - sqlite.TableServices: newServiceHandler(mutations.Services, scope), - sqlite.TableLogEventPolicies: newPolicyHandler(mutations.Policies, scope), - }, - scope: scope, - pollInterval: defaultPollInterval, - retryDelay: defaultRetryDelay, - maxRetries: defaultMaxRetries, - events: make(chan Event, 10), - } - for _, opt := range opts { - opt(u) - } - return u -} - -// Events returns the channel for receiving upload status events. -func (u *uploader) Events() <-chan Event { - return u.events -} - -// Run starts the upload loop. It blocks until the context is cancelled. -func (u *uploader) Run(ctx context.Context) error { - u.scope.Info("upload loop started") - defer func() { - u.scope.Info("upload loop stopped") - close(u.events) - }() - - for { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - // Process all pending entries - processed, err := u.uploadAll(ctx) - if err != nil { - u.handleError(ctx, err) - u.wait(ctx, u.retryDelay) - continue - } - - // Clear stalled state on success - if u.stalledSince != nil { - stalledFor := time.Since(*u.stalledSince) - u.scope.Info("upload queue recovered", "stalledFor", stalledFor.Round(time.Second)) - u.emit(ctx, RecoveredEvent{StalledFor: stalledFor}) - u.stalledSince = nil - u.stalledEntry = nil - } - - if processed > 0 { - u.emit(ctx, SyncingEvent{ProcessedCount: processed}) - continue - } - - u.wait(ctx, u.pollInterval) - } -} - -// uploadAll uploads the next pending CRUD transaction following the PowerSync protocol: -// 1. Read entries from queue -// 2. Upload each to backend -// 3. Fetch write checkpoint -// 4. Complete batch atomically -func (u *uploader) uploadAll(ctx context.Context) (int, error) { - entries, err := u.queue.GetNextTransaction(ctx) - if err != nil { - return 0, fmt.Errorf("get next transaction: %w", err) - } - if len(entries) == 0 { - return 0, nil - } - - token, err := u.tokenRefresher.GetAccessToken(ctx) - if err != nil { - return 0, fmt.Errorf("get access token: %w", err) - } - u.client.SetToken(token) - - // Upload each entry to backend - emit := u.emitter(ctx) - for i := range entries { - if err := u.uploadEntry(ctx, &entries[i], emit); err != nil { - return 0, err - } - } - - // Get write checkpoint from PowerSync server - clientID, err := db.GetClientID(ctx, u.db) - if err != nil { - return 0, fmt.Errorf("get client id: %w", err) - } - - checkpoint, err := u.client.GetWriteCheckpoint(ctx, clientID) - if err != nil { - return 0, fmt.Errorf("get write checkpoint: %w", err) - } - - // Complete batch atomically - lastID := entries[len(entries)-1].ID - if err := db.CompleteBatch(ctx, u.db, lastID, checkpoint); err != nil { - return 0, fmt.Errorf("complete batch: %w", err) - } - if u.batchCompletedHook != nil { - if err := u.batchCompletedHook(ctx); err != nil { - u.scope.Warn("batch completion hook failed", "error", err) - } - } - - u.scope.Debug("completed batch", "count", len(entries), "checkpoint", checkpoint) - return len(entries), nil -} - -// uploadEntry uploads a single entry with retries. -func (u *uploader) uploadEntry(ctx context.Context, entry *db.CrudEntry, emit Emitter) error { - handler, ok := u.handlers[entry.Table] - if !ok { - u.scope.Warn("no handler for table, skipping", "table", entry.Table, "rowId", entry.RowID) - return nil - } - - var lastErr error - for attempt := 0; attempt <= u.maxRetries; attempt++ { - if attempt > 0 { - u.scope.Debug("retrying upload", "table", entry.Table, "rowId", entry.RowID, "attempt", attempt) - u.wait(ctx, u.retryDelay*time.Duration(attempt)) - } - - err := handler.Handle(ctx, entry, emit) - if err == nil { - u.scope.Debug("uploaded entry", "table", entry.Table, "rowId", entry.RowID, "op", entry.Op) - return nil - } - - lastErr = err - u.scope.Warn("upload failed", "table", entry.Table, "rowId", entry.RowID, "attempt", attempt, "error", err) - } - - u.stalledEntry = entry - return lastErr -} - -func (u *uploader) handleError(ctx context.Context, err error) { - if u.stalledSince == nil { - now := time.Now() - u.stalledSince = &now - u.scope.Warn("upload queue stalled", "error", err) - } - - u.emit(ctx, StalledEvent{ - Error: err, - Table: u.stalledTable(), - RowID: u.stalledRowID(), - StalledFor: time.Since(*u.stalledSince), - }) -} - -func (u *uploader) stalledTable() sqlite.Table { - if u.stalledEntry != nil { - return u.stalledEntry.Table - } - return "" -} - -func (u *uploader) stalledRowID() string { - if u.stalledEntry != nil { - return u.stalledEntry.RowID - } - return "" -} - -func (u *uploader) emit(ctx context.Context, event Event) { - select { - case u.events <- event: - case <-ctx.Done(): - default: - } -} - -func (u *uploader) emitter(ctx context.Context) Emitter { - return func(event Event) { u.emit(ctx, event) } -} - -func (u *uploader) wait(ctx context.Context, d time.Duration) { - select { - case <-ctx.Done(): - case <-time.After(d): - } -} diff --git a/internal/upload/uploader_test.go b/internal/upload/uploader_test.go deleted file mode 100644 index 6e459349..00000000 --- a/internal/upload/uploader_test.go +++ /dev/null @@ -1,471 +0,0 @@ -package upload_test - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/google/uuid" - graphql "github.com/usetero/cli/internal/boundary/graphql" - "github.com/usetero/cli/internal/boundary/graphql/apitest" - psapitest "github.com/usetero/cli/internal/boundary/powersync/apitest" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/db" - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/powersync/powersynctest" - "github.com/usetero/cli/internal/upload" -) - -func TestUploader_Run(t *testing.T) { - t.Parallel() - - t.Run("returns on context cancellation", func(t *testing.T) { - t.Parallel() - - testDB := dbtest.OpenTestDB(t) - - uploader := upload.New( - testDB, - psapitest.NewMockClient(), - powersynctest.NewMockTokenRefresher("token"), - upload.MutationDeps{ - Conversations: apitest.NewMockConversations(), - Messages: apitest.NewMockMessages(), - Services: apitest.NewMockAPIServiceServices(), - Policies: apitest.NewMockPolicies(), - }, - logtest.NewScope(t), - ) - - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - err := uploader.Run(ctx) - if !errors.Is(err, context.Canceled) { - t.Errorf("Run() error = %v, want context.Canceled", err) - } - }) - - t.Run("closes event channel on exit", func(t *testing.T) { - t.Parallel() - - testDB := dbtest.OpenTestDB(t) - - uploader := upload.New( - testDB, - psapitest.NewMockClient(), - powersynctest.NewMockTokenRefresher("token"), - upload.MutationDeps{ - Conversations: apitest.NewMockConversations(), - Messages: apitest.NewMockMessages(), - Services: apitest.NewMockAPIServiceServices(), - Policies: apitest.NewMockPolicies(), - }, - logtest.NewScope(t), - ) - - ctx, cancel := context.WithCancel(context.Background()) - - done := make(chan error) - go func() { - done <- uploader.Run(ctx) - }() - - cancel() - <-done - - // Drain any pending events, then verify channel is closed - for { - _, ok := <-uploader.Events() - if !ok { - break - } - } - }) - - t.Run("processes entry and completes batch", func(t *testing.T) { - t.Parallel() - - testDB := dbtest.OpenTestDB(t) - ctx := context.Background() - - _, err := testDB.Exec(ctx, "INSERT INTO ps_buckets (name, last_op, target_op) VALUES ('$local', 0, 0)") - if err != nil { - t.Fatalf("setup bucket: %v", err) - } - - convID := uuid.New().String() - dbtest.InsertCrudEntry(t, testDB, 1, nil, `{"op":"PUT","type":"conversations","id":"`+convID+`","data":{"workspace_id":"ws-1","title":"Test"}}`) - - conversations := &apitest.MockConversations{ - CreateFunc: func(ctx context.Context, input graphql.CreateConversationInput) (*domain.Conversation, error) { - return &domain.Conversation{ID: domain.ConversationID(input.ID.String())}, nil - }, - } - - uploader := upload.New( - testDB, - psapitest.NewMockClient(), - powersynctest.NewMockTokenRefresher("token"), - upload.MutationDeps{ - Conversations: conversations, - Messages: apitest.NewMockMessages(), - Services: apitest.NewMockAPIServiceServices(), - Policies: apitest.NewMockPolicies(), - }, - logtest.NewScope(t), - ) - - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - done := make(chan error) - go func() { - done <- uploader.Run(ctx) - }() - - select { - case event := <-uploader.Events(): - syncEvent, ok := event.(upload.SyncingEvent) - if !ok { - t.Errorf("expected SyncingEvent, got %T", event) - } else if syncEvent.ProcessedCount != 1 { - t.Errorf("expected ProcessedCount=1, got %d", syncEvent.ProcessedCount) - } - case <-time.After(time.Second): - t.Fatal("timeout waiting for syncing event") - } - - cancel() - <-done - - queue := db.NewCrudQueue(testDB) - entry, err := queue.GetNextEntry(context.Background()) - if err != nil { - t.Fatalf("GetNextEntry() error = %v", err) - } - if entry != nil { - t.Error("entry should be deleted after successful upload") - } - }) - - t.Run("processes one transaction per upload cycle", func(t *testing.T) { - t.Parallel() - - testDB := dbtest.OpenTestDB(t) - ctx := context.Background() - - _, err := testDB.Exec(ctx, "INSERT INTO ps_buckets (name, last_op, target_op) VALUES ('$local', 0, 0)") - if err != nil { - t.Fatalf("setup bucket: %v", err) - } - - convID1 := uuid.New().String() - convID2 := uuid.New().String() - dbtest.InsertCrudEntry(t, testDB, 1, nil, `{"op":"PUT","type":"conversations","id":"`+convID1+`","data":{"workspace_id":"ws-1","title":"First"}}`) - dbtest.InsertCrudEntry(t, testDB, 2, nil, `{"op":"PUT","type":"conversations","id":"`+convID2+`","data":{"workspace_id":"ws-1","title":"Second"}}`) - - conversations := &apitest.MockConversations{ - CreateFunc: func(ctx context.Context, input graphql.CreateConversationInput) (*domain.Conversation, error) { - return &domain.Conversation{ID: domain.ConversationID(input.ID.String())}, nil - }, - } - - uploader := upload.New( - testDB, - psapitest.NewMockClient(), - powersynctest.NewMockTokenRefresher("token"), - upload.MutationDeps{ - Conversations: conversations, - Messages: apitest.NewMockMessages(), - Services: apitest.NewMockAPIServiceServices(), - Policies: apitest.NewMockPolicies(), - }, - logtest.NewScope(t), - ) - - runCtx, cancel := context.WithCancel(ctx) - done := make(chan error) - go func() { - done <- uploader.Run(runCtx) - }() - - select { - case event := <-uploader.Events(): - syncEvent, ok := event.(upload.SyncingEvent) - if !ok { - t.Fatalf("expected SyncingEvent, got %T", event) - } - if syncEvent.ProcessedCount != 1 { - t.Fatalf("expected ProcessedCount=1 for single transaction cycle, got %d", syncEvent.ProcessedCount) - } - case <-time.After(time.Second): - t.Fatal("timeout waiting for syncing event") - } - - cancel() - <-done - }) - - t.Run("invokes batch completed hook after successful batch", func(t *testing.T) { - t.Parallel() - - testDB := dbtest.OpenTestDB(t) - ctx := context.Background() - - _, err := testDB.Exec(ctx, "INSERT INTO ps_buckets (name, last_op, target_op) VALUES ('$local', 0, 0)") - if err != nil { - t.Fatalf("setup bucket: %v", err) - } - - convID := uuid.New().String() - dbtest.InsertCrudEntry(t, testDB, 1, nil, `{"op":"PUT","type":"conversations","id":"`+convID+`","data":{"workspace_id":"ws-1","title":"Test"}}`) - - conversations := &apitest.MockConversations{ - CreateFunc: func(ctx context.Context, input graphql.CreateConversationInput) (*domain.Conversation, error) { - return &domain.Conversation{ID: domain.ConversationID(input.ID.String())}, nil - }, - } - - hookCalled := make(chan struct{}, 1) - uploader := upload.New( - testDB, - psapitest.NewMockClient(), - powersynctest.NewMockTokenRefresher("token"), - upload.MutationDeps{ - Conversations: conversations, - Messages: apitest.NewMockMessages(), - Services: apitest.NewMockAPIServiceServices(), - Policies: apitest.NewMockPolicies(), - }, - logtest.NewScope(t), - upload.WithBatchCompletedHook(func(context.Context) error { - select { - case hookCalled <- struct{}{}: - default: - } - return nil - }), - ) - - runCtx, cancel := context.WithCancel(ctx) - defer cancel() - - done := make(chan error) - go func() { - done <- uploader.Run(runCtx) - }() - - select { - case <-hookCalled: - case <-time.After(2 * time.Second): - t.Fatal("timeout waiting for batch completed hook") - } - - cancel() - <-done - }) - - t.Run("batch completed hook error is non-fatal", func(t *testing.T) { - t.Parallel() - - testDB := dbtest.OpenTestDB(t) - ctx := context.Background() - - _, err := testDB.Exec(ctx, "INSERT INTO ps_buckets (name, last_op, target_op) VALUES ('$local', 0, 0)") - if err != nil { - t.Fatalf("setup bucket: %v", err) - } - - convID := uuid.New().String() - dbtest.InsertCrudEntry(t, testDB, 1, nil, `{"op":"PUT","type":"conversations","id":"`+convID+`","data":{"workspace_id":"ws-1","title":"Test"}}`) - - conversations := &apitest.MockConversations{ - CreateFunc: func(ctx context.Context, input graphql.CreateConversationInput) (*domain.Conversation, error) { - return &domain.Conversation{ID: domain.ConversationID(input.ID.String())}, nil - }, - } - - uploader := upload.New( - testDB, - psapitest.NewMockClient(), - powersynctest.NewMockTokenRefresher("token"), - upload.MutationDeps{ - Conversations: conversations, - Messages: apitest.NewMockMessages(), - Services: apitest.NewMockAPIServiceServices(), - Policies: apitest.NewMockPolicies(), - }, - logtest.NewScope(t), - upload.WithBatchCompletedHook(func(context.Context) error { - return errors.New("hook failed") - }), - ) - - runCtx, cancel := context.WithCancel(ctx) - defer cancel() - - done := make(chan error) - go func() { - done <- uploader.Run(runCtx) - }() - - select { - case event := <-uploader.Events(): - if _, ok := event.(upload.SyncingEvent); !ok { - t.Fatalf("expected SyncingEvent, got %T", event) - } - case <-time.After(2 * time.Second): - t.Fatal("timeout waiting for syncing event") - } - - cancel() - <-done - }) - - t.Run("skips unknown tables and completes batch", func(t *testing.T) { - t.Parallel() - - testDB := dbtest.OpenTestDB(t) - ctx := context.Background() - - _, err := testDB.Exec(ctx, "INSERT INTO ps_buckets (name, last_op, target_op) VALUES ('$local', 0, 0)") - if err != nil { - t.Fatalf("setup bucket: %v", err) - } - - dbtest.InsertCrudEntry(t, testDB, 1, nil, `{"op":"PUT","type":"unknown_table","id":"row-1","data":{}}`) - - uploader := upload.New( - testDB, - psapitest.NewMockClient(), - powersynctest.NewMockTokenRefresher("token"), - upload.MutationDeps{ - Conversations: apitest.NewMockConversations(), - Messages: apitest.NewMockMessages(), - Services: apitest.NewMockAPIServiceServices(), - Policies: apitest.NewMockPolicies(), - }, - logtest.NewScope(t), - ) - - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - done := make(chan error) - go func() { - done <- uploader.Run(ctx) - }() - - select { - case event := <-uploader.Events(): - if _, ok := event.(upload.SyncingEvent); !ok { - t.Errorf("expected SyncingEvent, got %T", event) - } - case <-time.After(time.Second): - t.Fatal("timeout waiting for syncing event") - } - - cancel() - <-done - - queue := db.NewCrudQueue(testDB) - entry, err := queue.GetNextEntry(context.Background()) - if err != nil { - t.Fatalf("GetNextEntry() error = %v", err) - } - if entry != nil { - t.Error("unknown table entry should be deleted after batch completes") - } - }) - - t.Run("emits stalled event on failure and recovered on success", func(t *testing.T) { - t.Parallel() - - testDB := dbtest.OpenTestDB(t) - ctx := context.Background() - - _, err := testDB.Exec(ctx, "INSERT INTO ps_buckets (name, last_op, target_op) VALUES ('$local', 0, 0)") - if err != nil { - t.Fatalf("setup bucket: %v", err) - } - - convID := uuid.New().String() - dbtest.InsertCrudEntry(t, testDB, 1, nil, `{"op":"PUT","type":"conversations","id":"`+convID+`","data":{"workspace_id":"ws-1","title":"Test"}}`) - - callCount := 0 - conversations := &apitest.MockConversations{ - CreateFunc: func(ctx context.Context, input graphql.CreateConversationInput) (*domain.Conversation, error) { - callCount++ - if callCount <= 4 { - return nil, errors.New("temporary error") - } - return &domain.Conversation{ID: domain.ConversationID(input.ID.String())}, nil - }, - } - - uploader := upload.New( - testDB, - psapitest.NewMockClient(), - powersynctest.NewMockTokenRefresher("token"), - upload.MutationDeps{ - Conversations: conversations, - Messages: apitest.NewMockMessages(), - Services: apitest.NewMockAPIServiceServices(), - Policies: apitest.NewMockPolicies(), - }, - logtest.NewScope(t), - ) - - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - done := make(chan error) - go func() { - done <- uploader.Run(ctx) - }() - - // Stalled event after retries exhausted - select { - case event := <-uploader.Events(): - stalledEvent, ok := event.(upload.StalledEvent) - if !ok { - t.Errorf("expected StalledEvent, got %T", event) - } else { - if stalledEvent.Error == nil { - t.Error("expected error in stalled event") - } - if stalledEvent.Table != "conversations" { - t.Errorf("expected table=conversations, got %s", stalledEvent.Table) - } - } - case <-time.After(10 * time.Second): - t.Fatal("timeout waiting for stalled event") - } - - // Recovered event after success - select { - case event := <-uploader.Events(): - if _, ok := event.(upload.RecoveredEvent); !ok { - t.Errorf("expected RecoveredEvent, got %T", event) - } - case <-time.After(10 * time.Second): - t.Fatal("timeout waiting for recovered event") - } - - // Syncing event - select { - case event := <-uploader.Events(): - if _, ok := event.(upload.SyncingEvent); !ok { - t.Errorf("expected SyncingEvent, got %T", event) - } - case <-time.After(time.Second): - t.Fatal("timeout waiting for syncing event") - } - - cancel() - <-done - }) -}