diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c56eae8..e81732b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.26.1] - 2026-06-16 + +### Fixed + +- **Canonical-rename stragglers** — fixed user-facing strings that still referenced the pre-rename flag/arg vocabulary after the 1.26.0 canonical rename: `file-tail add` usage and required-flag error (`--hostname`→`--host`), the `ai incident-context` required-flag errors (`--from`/`--to`→`--since`/`--until`), the search/abuse/ask-history "scan capped" hints (`--from`/`--to`→`--since`/`--until`), the `file_tails op=add` admin help parameter (`hostname`→`host`), and the `similar_incidents` MCP input example (`app_name`→`app`). Also corrected the `similar_incidents` doc "Response fields" line back to the unchanged output names (`hostname`/`app_name`), matching the actual response contract. + +## [1.26.0] - 2026-06-16 + +### Added + +- **Shell completion** — `cortex completions zsh` installs a tab-completion function that completes command names (with descriptions), per-command flags, and **live values** (hostnames, apps, source IDs pulled from the DB, cached ~60s with a 150 ms timeout that degrades silently). Driven by a hidden `cortex __complete` command and the single `ACTION_SPECS` registry. +- **Per-command examples in help** — `cortex --help` now shows copy-paste examples sourced from `ACTION_SPECS`, kept in lockstep with the canonical flags. + +### Changed + +- **Canonical flag/argument vocabulary (breaking)** — unified the CLI flags and MCP tool-argument names so they match everywhere: `--hostname`→`--host`, `--source-ip`→`--source`, `--app-name`→`--app`, `--from`→`--since`, `--to`→`--until`, `--received-from`→`--received-since`, `--received-to`→`--received-until` (with `-s`/`-n` short forms for severity/limit). The rename spans every CLI command, the MCP tool arguments (the request-arg domain fields were renamed so the wire key = field name, no serde aliases), help text, the bundled skills, and docs. Response/output field names (e.g. a log entry's `hostname`/`source_ip`) are unchanged — the output contract is unaffected. +- `ACTION_SPECS` now carries per-action flag and example metadata; the CLI parser, completion, and help all derive from it. + ## [1.25.1] - 2026-06-16 ### Added @@ -2376,7 +2394,10 @@ start and verify with `cortex --http db status`. --- -[Unreleased]: https://github.com/jmagar/cortex/compare/v1.20.0...HEAD +[Unreleased]: https://github.com/jmagar/cortex/compare/v1.26.1...HEAD +[1.26.1]: https://github.com/jmagar/cortex/compare/v1.26.0...v1.26.1 +[1.26.0]: https://github.com/jmagar/cortex/compare/v1.25.1...v1.26.0 +[1.25.1]: https://github.com/jmagar/cortex/compare/v1.20.0...v1.25.1 [1.20.0]: https://github.com/jmagar/cortex/compare/v1.19.0...v1.20.0 [1.14.0]: https://github.com/jmagar/cortex/compare/v1.13.3...v1.14.0 [1.13.3]: https://github.com/jmagar/cortex/compare/v1.13.2...v1.13.3 diff --git a/Cargo.lock b/Cargo.lock index 311586c7..6b449624 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -411,7 +411,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cortex" -version = "1.25.1" +version = "1.26.1" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 5d6eb79f..9a75a6a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cortex" -version = "1.25.1" +version = "1.26.1" edition = "2024" rust-version = "1.86" license = "MIT" @@ -42,7 +42,7 @@ rmcp = { version = "1.7", default-features = false, features = [ tower-http = { version = "0.6", features = ["cors", "limit", "trace"] } # SQLite -rusqlite = { version = "0.39", features = ["bundled", "vtab", "backup"] } +rusqlite = { version = "0.39", features = ["bundled", "vtab", "backup", "hooks"] } parking_lot = "0.12" r2d2 = "0.8" r2d2_sqlite = "0.33" diff --git a/README.md b/README.md index 00f302ea..f304f696 100644 --- a/README.md +++ b/README.md @@ -189,12 +189,12 @@ Full-text search across all syslog messages with optional filters. Uses SQLite F | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `query` | string | no | — | FTS5 search query (see [FTS5 query syntax](#fts5-query-syntax)) | -| `hostname` | string | no | — | Exact hostname match. Use `cortex` with `action: "hosts"` to enumerate. | -| `source_ip` | string | no | — | Exact source identifier. Syslog entries use the verified network sender address (`IP:port`); OTLP rows use the verified peer IP; Docker ingest stream rows use `docker://host/container/stream`; Docker lifecycle event rows use `docker-event://host/container/action`. | +| `host` | string | no | — | Exact hostname match. Use `cortex` with `action: "hosts"` to enumerate. | +| `source` | string | no | — | Exact source identifier. Syslog entries use the verified network sender address (`IP:port`); OTLP rows use the verified peer IP; Docker ingest stream rows use `docker://host/container/stream`; Docker lifecycle event rows use `docker-event://host/container/action`. | | `severity` | string | no | — | One of: `emerg alert crit err warning notice info debug` | -| `app_name` | string | no | — | Application name, e.g. `sshd`, `dockerd`, `kernel` | -| `from` | string | no | — | Start of time range (ISO 8601 / RFC 3339, e.g. `2025-01-15T00:00:00Z`) | -| `to` | string | no | — | End of time range (ISO 8601) | +| `app` | string | no | — | Application name, e.g. `sshd`, `dockerd`, `kernel` | +| `since` | string | no | — | Start of time range (relative like `1h`/`yesterday`, or ISO 8601 / RFC 3339, e.g. `2025-01-15T00:00:00Z`) | +| `until` | string | no | — | End of time range (relative or ISO 8601) | | `limit` | integer | no | 100 | Max results (hard cap: 1000) | **Response** @@ -235,7 +235,7 @@ query: "restart*" # matches restart, restarted, restarting Structured filter-only retrieval for correlation workflows. This action rejects `query`; use `search` for FTS5 message-body search. -Common filters match `search`: `hostname`, `source_ip`, `severity`, `app_name`, `facility`, `exclude_facility`, `process_id`, `from`, `to`, `received_from`, `received_to`, and `limit`. +Common filters match `search`: `host`, `source`, `severity`, `app`, `facility`, `exclude_facility`, `process_id`, `since`, `until`, `received_since`, `received_until`, and `limit`. Correlation aliases include `source_kind` (`docker-stream`, `docker-event`, `agent-command`, `shell-history`, `transcript`, `claude`, `codex`, `gemini`), plus `tool`, `project`, `session_id`, `container`, `docker_host`, `stream`, and `event_action`. `source_kind=file-tail` filters managed file-tail rows (`source_ip` prefix `file-tail://`). @@ -250,9 +250,9 @@ Return the N most recent log entries. Equivalent to `tail -f` across all hosts. | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| -| `hostname` | string | no | — | Filter to a specific host | -| `source_ip` | string | no | — | Filter to an exact source identifier. Syslog entries use the verified network sender address (`IP:port`); OTLP rows use the verified peer IP; Docker ingest stream rows use `docker://host/container/stream`; Docker lifecycle event rows use `docker-event://host/container/action`. | -| `app_name` | string | no | — | Filter to a specific application | +| `host` | string | no | — | Filter to a specific host | +| `source` | string | no | — | Filter to an exact source identifier. Syslog entries use the verified network sender address (`IP:port`); OTLP rows use the verified peer IP; Docker ingest stream rows use `docker://host/container/stream`; Docker lifecycle event rows use `docker-event://host/container/action`. | +| `app` | string | no | — | Filter to a specific application | | `n` | integer | no | 50 | Number of recent entries (hard cap: 500) | **Response** @@ -269,8 +269,8 @@ Summarize warnings and errors across all hosts in a time window. Groups by hostn | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| -| `from` | string | no | all time | Start of time range (ISO 8601) | -| `to` | string | no | now | End of time range (ISO 8601) | +| `since` | string | no | all time | Start of time range (ISO 8601) | +| `until` | string | no | now | End of time range (ISO 8601) | Severities included: `emerg`, `alert`, `crit`, `err`, `warning`. @@ -321,9 +321,9 @@ List AI transcript sessions grouped by project, tool, session, and host. |-----------|------|----------|---------|-------------| | `project` | string | no | — | Exact project path, e.g. `/home/jmagar/workspace/cortex` | | `tool` | string | no | — | AI tool filter: `claude`, `codex`, or `gemini` | -| `hostname` | string | no | — | Restrict to one host | -| `from` | string | no | — | Start of time range (ISO 8601) | -| `to` | string | no | — | End of time range (ISO 8601) | +| `host` | string | no | — | Restrict to one host | +| `since` | string | no | — | Start of time range (ISO 8601) | +| `until` | string | no | — | End of time range (ISO 8601) | | `limit` | integer | no | 100 | Max sessions (hard cap: 1000) | **Response** @@ -358,8 +358,8 @@ Search for related events across multiple hosts within a ±N minute window aroun | `reference_time` | string | **yes** | — | Center timestamp (ISO 8601, e.g. `2025-01-15T14:30:00Z`) | | `window_minutes` | integer | no | 5 | Minutes before and after `reference_time` (max 60) | | `severity_min` | string | no | `warning` | Minimum severity to include. `warning` returns `warning/err/crit/alert/emerg`. `debug` returns everything. | -| `hostname` | string | no | — | Limit correlation to one host | -| `source_ip` | string | no | — | Limit correlation to an exact source identifier. Syslog entries use the verified network sender address (`IP:port`); OTLP rows use the verified peer IP; Docker ingest stream rows use `docker://host/container/stream`; Docker lifecycle event rows use `docker-event://host/container/action`. | +| `host` | string | no | — | Limit correlation to one host | +| `source` | string | no | — | Limit correlation to an exact source identifier. Syslog entries use the verified network sender address (`IP:port`); OTLP rows use the verified peer IP; Docker ingest stream rows use `docker://host/container/stream`; Docker lifecycle event rows use `docker-event://host/container/action`. | | `query` | string | no | — | FTS5 query to narrow results | | `limit` | integer | no | 500 | Max total events (hard cap: 999) | @@ -832,19 +832,19 @@ mounted and reviewed broader read-only roots such as `/var/log` or `/logs`. ```bash cortex file-tail add --id swag-access \ --path /file-tail-root/swag/log/nginx/access.log \ - --tag swag-access --hostname squirts --facility local4 + --tag swag-access --host squirts --facility local4 cortex file-tail add --id swag-error \ --path /file-tail-root/swag/log/nginx/error.log \ - --tag swag-error --hostname squirts --facility local4 --severity warning + --tag swag-error --host squirts --facility local4 --severity warning cortex file-tail add --id fail2ban \ --path /file-tail-root/swag/log/fail2ban/fail2ban.log \ - --tag fail2ban --hostname squirts --facility local5 + --tag fail2ban --host squirts --facility local5 cortex file-tail add --id authelia \ --path /file-tail-root/authelia/logs/authelia.log \ - --tag authelia --hostname squirts --facility local5 + --tag authelia --host squirts --facility local5 cortex file-tail add --id adguard-query \ --path /file-tail-root/adguard/var/data/querylog.json \ - --tag adguard-query --hostname squirts --facility local6 + --tag adguard-query --host squirts --facility local6 ``` The default starts at EOF. Add `--from-start` only when you intentionally want @@ -978,9 +978,9 @@ Both modes use the same config and environment variable loader. `cortex mcp` is The direct CLI uses the same shared service layer as the MCP tool, so results and validation match the MCP actions without needing an MCP client: ```bash -cortex search 'error AND nginx' --hostname proxy --limit 10 -cortex tail -n 20 --app-name kernel -cortex errors --from 2026-01-01T00:00:00Z +cortex search 'error AND nginx' --host proxy --limit 10 +cortex tail -n 20 --app kernel +cortex errors --since 2026-01-01T00:00:00Z cortex hosts cortex correlate --reference-time 2026-01-01T12:00:00Z --window-minutes 10 --severity-min warning cortex entity host tootie @@ -1001,7 +1001,7 @@ cortex clock-skew --since 2026-05-20T00:00:00Z cortex anomalies --recent-minutes 30 --baseline-minutes 720 cortex compare --a-from 2026-05-20T00:00:00Z --a-to 2026-05-20T23:59:59Z \ --b-from 2026-05-21T00:00:00Z --b-to 2026-05-21T23:59:59Z -cortex apps --hostname dookie --limit 50 +cortex apps --host dookie --limit 50 ``` ### REST endpoints (2026-05-22 surface parity) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 56565ac6..041bd2a0 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -23,7 +23,7 @@ services: # Default tag is kept in sync by scripts/bump-version.sh (version canon); # previously this was frozen at 1.0.0 while migrations moved forward — # a stale binary against a newer schema (full-review OH1). - image: ghcr.io/jmagar/cortex:${CORTEX_VERSION:-1.25.1} + image: ghcr.io/jmagar/cortex:${CORTEX_VERSION:-1.26.1} container_name: cortex user: "${CORTEX_UID:-1000}:${CORTEX_GID:-1000}" env_file: diff --git a/docs/CLI.md b/docs/CLI.md index 63803f9d..d7d9cd35 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -35,7 +35,7 @@ configured database in `file-tails.json` and reconciled by the running ```bash cortex file-tail list [--json] cortex file-tail status [--json] -cortex file-tail add --id ID --path PATH --tag TAG --hostname HOST [--facility FACILITY] [--severity SEVERITY] [--from-start] [--json] +cortex file-tail add --id ID --path PATH --tag TAG --host HOST [--facility FACILITY] [--severity SEVERITY] [--from-start] [--json] cortex file-tail remove --id ID [--json] cortex file-tail enable --id ID [--json] cortex file-tail disable --id ID [--json] @@ -67,8 +67,8 @@ cortex search 'error AND nginx' --limit 5 --json Search logs with optional FTS5 query and filters. ```bash -cortex search 'error AND nginx' --hostname proxy --limit 10 -cortex search '"disk full"' --source-ip 10.0.0.5:514 --from 2026-01-01T00:00:00Z +cortex search 'error AND nginx' --host proxy --limit 10 +cortex search '"disk full"' --source 10.0.0.5:514 --since 2026-01-01T00:00:00Z ``` Flags: @@ -76,12 +76,12 @@ Flags: | Flag | Description | | --- | --- | | positional query | Optional SQLite FTS5 query. Multiple words are joined with spaces. | -| `--hostname HOST` | Exact claimed hostname filter | -| `--source-ip SOURCE` | Exact source identifier filter | +| `--host HOST` | Exact claimed hostname filter | +| `--source SOURCE` | Exact source identifier filter | | `--severity LEVEL` | Syslog severity filter: `emerg`, `alert`, `crit`, `err`, `warning`, `notice`, `info`, `debug` | -| `--app-name APP` | Application/process name filter | -| `--from TIME` | RFC3339 start timestamp | -| `--to TIME` | RFC3339 end timestamp | +| `--app APP` | Application/process name filter | +| `--since TIME` | RFC3339 start timestamp | +| `--until TIME` | RFC3339 end timestamp | | `--limit N` | Maximum returned rows | | `--json` | Print JSON response | @@ -91,7 +91,7 @@ Return recent log entries, optionally filtered by host, source, or app. ```bash cortex tail -n 20 -cortex tail 50 --hostname nas --app-name kernel +cortex tail 50 --host nas --app kernel ``` Flags: @@ -100,9 +100,9 @@ Flags: | --- | --- | | positional `N` | Number of rows to return | | `-n N`, `--n N` | Number of rows to return | -| `--hostname HOST` | Exact claimed hostname filter | -| `--source-ip SOURCE` | Exact source identifier filter | -| `--app-name APP` | Application/process name filter | +| `--host HOST` | Exact claimed hostname filter | +| `--source SOURCE` | Exact source identifier filter | +| `--app APP` | Application/process name filter | | `--json` | Print JSON response | ### `cortex errors` @@ -111,15 +111,15 @@ Summarize error and warning counts by host and severity. ```bash cortex errors -cortex errors --from 2026-01-01T00:00:00Z --to 2026-01-02T00:00:00Z --json +cortex errors --since 2026-01-01T00:00:00Z --until 2026-01-02T00:00:00Z --json ``` Flags: | Flag | Description | | --- | --- | -| `--from TIME` | RFC3339 start timestamp | -| `--to TIME` | RFC3339 end timestamp | +| `--since TIME` | RFC3339 start timestamp | +| `--until TIME` | RFC3339 end timestamp | | `--json` | Print JSON response | ### `cortex hosts` @@ -166,9 +166,9 @@ Flags: | --- | --- | | `--project PATH` | Exact project path filter | | `--tool TOOL` | AI tool filter: `claude`, `codex`, or `gemini` | -| `--hostname HOST` | Filter by host | -| `--from TIME` | RFC3339 start timestamp | -| `--to TIME` | RFC3339 end timestamp | +| `--host HOST` | Filter by host | +| `--since TIME` | RFC3339 start timestamp | +| `--until TIME` | RFC3339 end timestamp | | `--limit N` | Maximum returned rows | | `--json` | Print JSON response | @@ -183,8 +183,8 @@ cortex ai search authentication --tool claude --limit 10 Human output now states that grouping is computed over the newest matching candidate window. JSON includes `total_candidates`, `candidate_rows`, `candidate_cap`, `candidate_window_truncated`, and `truncated`; when the -candidate window is truncated, narrow with `--project`, `--tool`, `--from`, or -`--to` for exact grouping within that filter. +candidate window is truncated, narrow with `--project`, `--tool`, `--since`, or +`--until` for exact grouping within that filter. ### `cortex ai abuse` @@ -251,7 +251,7 @@ Bucket AI activity into 5-hour UTC windows. cortex ai blocks --project /home/jmagar/workspace/cortex ``` -When `--from` is omitted, usage blocks default to the last 30 days. Returned +When `--since` is omitted, usage blocks default to the last 30 days. Returned JSON includes `total_blocks` and `truncated`; at most 1000 buckets are returned. ### `cortex ai context` @@ -675,8 +675,8 @@ Flags: | `--reference-time TIME` | RFC3339 center timestamp | | `--window-minutes N` | Minutes before and after the reference time | | `--severity-min LEVEL` | Minimum severity to include | -| `--hostname HOST` | Exact claimed hostname filter | -| `--source-ip SOURCE` | Exact source identifier filter | +| `--host HOST` | Exact claimed hostname filter | +| `--source SOURCE` | Exact source identifier filter | | `--query FTS` | Optional FTS5 query | | `--limit N` | Maximum total events | | `--json` | Print JSON response | @@ -686,7 +686,7 @@ Flags: Return the latest bounded heartbeat state for one host. ```bash -cortex host-state --hostname tootie +cortex host-state --host tootie cortex host-state --host-id host-a --limit 5 --json ``` @@ -695,7 +695,7 @@ Flags: | Flag | Description | | --- | --- | | `--host-id ID` | Authoritative heartbeat host identity | -| `--hostname HOST` | Self-reported hostname fallback (must resolve to one host) | +| `--host HOST` | Self-reported hostname fallback (must resolve to one host) | | `--since TIME` | Minimum `sampled_at` timestamp (ISO 8601) | | `--limit N` | Number of samples (default 1, max 100) | | `--json` | Print JSON response | diff --git a/docs/CONFIG.md b/docs/CONFIG.md index d657dff7..1f696633 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -150,14 +150,14 @@ cortex file-tail add \ --id swag-access \ --path /file-tail-root/swag/log/nginx/access.log \ --tag swag-access \ - --hostname squirts \ + --host squirts \ --facility local4 cortex file-tail add \ --id swag-error \ --path /file-tail-root/swag/log/nginx/error.log \ --tag swag-error \ - --hostname squirts \ + --host squirts \ --facility local4 \ --severity warning @@ -165,21 +165,21 @@ cortex file-tail add \ --id fail2ban \ --path /file-tail-root/swag/log/fail2ban/fail2ban.log \ --tag fail2ban \ - --hostname squirts \ + --host squirts \ --facility local5 cortex file-tail add \ --id authelia \ --path /file-tail-root/authelia/logs/authelia.log \ --tag authelia \ - --hostname squirts \ + --host squirts \ --facility local5 cortex file-tail add \ --id adguard-query \ --path /file-tail-root/adguard/var/data/querylog.json \ --tag adguard-query \ - --hostname squirts \ + --host squirts \ --facility local6 ``` diff --git a/docs/api.md b/docs/api.md index 2b4ef5ad..07d935cc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -148,7 +148,7 @@ operators can opt out of HTTP transport for the duration of the script: ```bash ( unset CORTEX_USE_HTTP; \ for h in $(cortex hosts --json | jq -r '.hosts[].hostname'); do \ - cortex tail --hostname "$h" --n 50; \ + cortex tail --host "$h" --n 50; \ done ) ``` diff --git a/docs/contracts/cli-surface.md b/docs/contracts/cli-surface.md index 1819f4d9..e798b52e 100644 --- a/docs/contracts/cli-surface.md +++ b/docs/contracts/cli-surface.md @@ -40,7 +40,7 @@ This is the most important table for operators — it disambiguates where each c | `cortex agent issue` | server | local SQLite (insert pending row) | Prints one-time token to stdout. | | `cortex agent revoke` | server | local SQLite + active WS connections | Kicks live conn if attached. | | `cortex agent rotate` | server | local SQLite | Returns new token; old hash in grace for 300s. | -| `cortex agent tail` | server | local SQLite (`logs` view filtered to host) | Convenience wrapper over `cortex search hostname=...`. | +| `cortex agent tail` | server | local SQLite (`logs` view filtered to host) | Convenience wrapper over `cortex search host=...`. | | `cortex agent status` (server form) | server | local SQLite + live WS state | Same data as MCP `agent_status` action; omit `--host` for fleet. | | `cortex agent run` | client (per-host) | `wss://syslog.tootie.tv/ws/agent` | Long-lived daemon. | | `cortex agent enroll` | client | server WS endpoint | Performs one-time-token handshake. | @@ -73,12 +73,12 @@ Exit codes: `0` success. ### syslog agent issue ``` -cortex agent issue --hostname [--ttl ] [--json] +cortex agent issue --host [--ttl ] [--json] ``` Generate a one-time enrollment token. Inserts a pending row in `agents` with `connection_state=NeverConnected` and the token's BLAKE3 hash. Prints the raw token **once** on stdout — operator must copy it now; the server does not retain it in plaintext. -- `--hostname ` (required): the canonical hostname this token will be bound to. Server checks that the hostname is not already `Active` with a different `host_id` (error code 3 if conflict). +- `--host ` (required): the canonical hostname this token will be bound to. Server checks that the hostname is not already `Active` with a different `host_id` (error code 3 if conflict). - `--ttl ` (optional, default `15m`): how long the one-time token is valid before its hash is purged from `agents` (a never-claimed token shouldn't sit on disk forever). Parsed by `humantime`. - `--json`: emit `{"token": "...", "host_id": "...", "expires_at": "..."}`. @@ -113,7 +113,7 @@ Exit codes: `0`, `3` (unknown host_id). cortex agent tail [--follow] [--severity-min ] [--lines ] ``` -Server-side `tail -f` of recent log rows from that host. Convenience wrapper over `cortex search hostname=` with `--follow` mapping to a 2-second polling loop. Honors the same flags as the existing `cortex tail` command. +Server-side `tail -f` of recent log rows from that host. Convenience wrapper over `cortex search host=` with `--follow` mapping to a 2-second polling loop. Honors the same flags as the existing `cortex tail` command. - `--lines ` default 50. diff --git a/docs/contracts/credentials.md b/docs/contracts/credentials.md index cd27bfc8..d06ef732 100644 --- a/docs/contracts/credentials.md +++ b/docs/contracts/credentials.md @@ -61,7 +61,7 @@ One row per distinct secret. **Sensitivity tiers:** | 4 | **JWT signing private key** | RSA/Ed25519 PEM used to sign JWT access + refresh tokens. Verifying party is also us; this is symmetric trust on a per-process basis. | none — file-only | `/auth-jwt.pem` (relative path resolved against `[storage].db_path` dir) | `0600` | Existing (`src/config.rs::AuthConfig::key_path`) | `high` | `rm auth-jwt.pem`, restart (regenerates on first boot). **Side effect:** every issued access + refresh token is invalidated; all OAuth users must re-login. This is the documented "kill all sessions" effect — see §5. | Key material never logged. Path is logged at INFO. | | 5 | **Non-MCP API token** | Bearer for the optional `[api]` JSON API (separate from `/mcp`). Required when `CORTEX_API_ENABLED=true`. | `CORTEX_API_TOKEN` | none | n/a | Existing (`src/config.rs::ApiConfig::api_token`) | `high` | Same as MCP token — env edit + restart. Validation rejects empty tokens at startup. | Never logged; same prefix-only redaction discipline as the MCP token. | | 6 | **OTLP token (logical alias of MCP token)** | Authenticates `/v1/logs` OTLP HTTP ingestion. **In V1, this is the same token as `CORTEX_TOKEN`.** The OTLP path only honors the static bearer; the OAuth path does not gate OTLP. The non-loopback safety gate in `validate_auth_config` enforces this explicitly. | `CORTEX_TOKEN` (same var) | none | n/a | Existing | `high` | Same as MCP token. If split into a dedicated `CORTEX_OTLP_TOKEN` later, add a new row here and bump this contract. | Never logged. | -| 7 | **Agent enrollment token (one-time)** | One-shot bearer printed by `cortex agent issue --hostname `. Operator pastes it into the agent host's `/etc/syslog-agent/token`. Server stores only `BLAKE3(token)` in `agents.token_hash` and never the raw value. | none — printed once to stdout by admin CLI | (ephemeral; never persisted server-side in plaintext) | n/a | Epic A — agent mode (`docs/superpowers/specs/2026-05-16-agent-mode-design.md` §6.2) | `medium` | Single use. If the operator loses it before the agent enrolls, revoke (`cortex agent revoke --host-id `) and re-issue. | Server: token hash only; raw token never written to disk or logs. Printed once on the admin CLI stdout — operator owns transport (typically scp/paste). | +| 7 | **Agent enrollment token (one-time)** | One-shot bearer printed by `cortex agent issue --host `. Operator pastes it into the agent host's `/etc/syslog-agent/token`. Server stores only `BLAKE3(token)` in `agents.token_hash` and never the raw value. | none — printed once to stdout by admin CLI | (ephemeral; never persisted server-side in plaintext) | n/a | Epic A — agent mode (`docs/superpowers/specs/2026-05-16-agent-mode-design.md` §6.2) | `medium` | Single use. If the operator loses it before the agent enrolls, revoke (`cortex agent revoke --host-id `) and re-issue. | Server: token hash only; raw token never written to disk or logs. Printed once on the admin CLI stdout — operator owns transport (typically scp/paste). | | 8 | **Agent long-lived token** | After successful enrollment, the agent's persistent bearer used on every reconnect. Sent in the first JSON-RPC `agent.hello.params.token` message; never in URL params or headers. | none — file-only | `/etc/syslog-agent/token` on the agent host (or `~/.config/cortex/agent-token` for user installs) | `0600` | Epic A — agent mode (§6.2) | `medium` | `cortex agent rotate --host-id ` issues a new token. Server keeps both `token_hash` (new) and `token_hash_prev` (old) for `rotation_grace_secs = 300` seconds (5 min, from spec §6.2). The agent receives the new token on its next reconnect (delivered via `agent.shutdown` payload); after the grace window the old hash is dropped. | Never logged. `HelloParams` overrides `Display`/`Debug` to redact `token`; test-verified per spec §10. | | 9 | **UniFi controller API key** | `X-API-KEY` header for read-only access to `/proxy/network/api/s//{stat/event,stat/alarm}`. Issued in the UniFi OS console UI. | `CORTEX_POLLERS_UNIFI_API_KEY` | `~/.cortex/.env` line `CORTEX_POLLERS_UNIFI_API_KEY=…` (loaded by `load_setup_env_file` if not already in process env; symlinks rejected) | `0600` on the env file | Epic C — API pollers (`docs/superpowers/specs/2026-05-16-api-pollers-design.md` §4) | `medium` | Revoke in UniFi OS admin → System → Application UI → Admins → API keys; issue replacement; update env; restart (or send `SIGHUP` once dynamic reload lands — currently restart-only). | Never logged. UniFi poller `Debug` impls redact the key. | | 10 | **AdGuard Home credentials** | HTTP Basic auth for `/control/querylog`. Token-based auth is not exposed by AdGuard. | `CORTEX_POLLERS_ADGUARD_USERNAME` + `CORTEX_POLLERS_ADGUARD_PASSWORD` (separate vars, not the `user:pass` form the original draft contemplated — verified against `api-pollers-design.md` §5 lines 243–244) | `~/.cortex/.env` | `0600` on the env file | Epic C — API pollers (§5) | `medium` | Change in AdGuard Home admin UI; update env; restart. AdGuard does not support overlapping credentials, so rotation is a hard cutover — operator should expect one missed poll cycle. | Username may be logged at debug; password never logged. | @@ -217,7 +217,7 @@ two-token blue/green primitive in V1. Single use. There is no rotation — only revocation (`cortex agent revoke `) and re-issuance (`cortex agent issue ---hostname `). If a token is exposed before the agent enrolls, revoke +--host `). If a token is exposed before the agent enrolls, revoke the row and issue a new one. ### Pollers (UniFi, AdGuard, Apprise) diff --git a/docs/contracts/forwarder-dropins.md b/docs/contracts/forwarder-dropins.md index 02bcf295..7766c7f0 100644 --- a/docs/contracts/forwarder-dropins.md +++ b/docs/contracts/forwarder-dropins.md @@ -225,7 +225,7 @@ long-lived token (BLAKE3-hashed server-side). See ```bash # Single host -cortex agent issue --hostname=dookie +cortex agent issue --host=dookie # Output (token is shown ONCE; re-run if lost): # host_id: 2b9a0b3a-7e3c-4d2a-9c0e-9bbf5d3a1f01 @@ -335,7 +335,7 @@ logger -t deploy-test "hello from $(hostname)" ```bash # replace with the actual hostname -cortex tail --hostname= --limit=5 +cortex tail --host= --limit=5 # OR via MCP: mcporter call --config config/mcporter.json cortex.search query=deploy-test limit=5 ``` @@ -344,7 +344,7 @@ For agent-mode hosts, also check: ```bash cortex agent list # Active state, recent last_seen -cortex tail --hostname= --limit=5 # logs.push entries landing +cortex tail --host= --limit=5 # logs.push entries landing ``` For OTLP hosts: diff --git a/docs/contracts/http-endpoints.md b/docs/contracts/http-endpoints.md index 16c5bcdf..d36ecafb 100644 --- a/docs/contracts/http-endpoints.md +++ b/docs/contracts/http-endpoints.md @@ -293,7 +293,7 @@ plumbing, no new behaviour. All require the standard bearer token. | Method | Path | Body / Query | Service method | |---|---|---|---| | GET | `/api/source-ips` | `?limit=N&offset=N` | `list_source_ips` | -| GET | `/api/timeline` | `?bucket=...&group_by=...&from=...&to=...&hostname=...&app_name=...&severity_min=...` | `timeline` | +| GET | `/api/timeline` | `?bucket=...&group_by=...&since=...&until=...&host=...&app=...&severity_min=...` | `timeline` | | GET | `/api/patterns` | filter + `&scan_limit=N&top_n=N` | `patterns` | | GET | `/api/ingest-rate` | `?by_host=true` | `ingest_rate` | | GET | `/api/get` | `?id=N` | `get_log` | diff --git a/docs/mcp/PATTERNS.md b/docs/mcp/PATTERNS.md index ca634048..56ddc716 100644 --- a/docs/mcp/PATTERNS.md +++ b/docs/mcp/PATTERNS.md @@ -43,8 +43,8 @@ async fn tool_search_logs(state: &AppState, args: Value) -> anyhow::Result`, `syslog ai ask-history `, `syslog ai incident-context --from X --to Y` +- CLI: `syslog ai similar `, `syslog ai ask-history `, `syslog ai incident-context --since X --until Y` ## Verification Evidence diff --git a/docs/sessions/2026-05-29-cli-performance-benchmark-and-fixes.md b/docs/sessions/2026-05-29-cli-performance-benchmark-and-fixes.md index fd027537..c2a3ca2d 100644 --- a/docs/sessions/2026-05-29-cli-performance-benchmark-and-fixes.md +++ b/docs/sessions/2026-05-29-cli-performance-benchmark-and-fixes.md @@ -146,7 +146,7 @@ Three sequential goals: (1) "execute each of our CLI commands and record how lon | area | before | after | |---|---|---| | `syslog timeline` (no date args) | full scan, 72–99s | per-bucket default window (e.g. 30d for day), seconds | -| `syslog timeline --to ` only | (new code) would return 0 rows | returns historical data; default skipped when `to` set | +| `syslog timeline --until ` only | (new code) would return 0 rows | returns historical data; default skipped when `to` set | | `syslog timeline --bucket week/month` | HTTP 400 | returns bucketed counts | | `syslog --http db backup` | failed: "database is locked" | server-side backup via rusqlite, cooperates with WAL | | `db integrity --quick` over HTTP | silent 600s timeout | 120s timeout with actionable container instructions | diff --git a/docs/sessions/2026-06-02-pr65-cli-ux-help-and-merge.md b/docs/sessions/2026-06-02-pr65-cli-ux-help-and-merge.md index 827a3812..c15c16f6 100644 --- a/docs/sessions/2026-06-02-pr65-cli-ux-help-and-merge.md +++ b/docs/sessions/2026-06-02-pr65-cli-ux-help-and-merge.md @@ -47,7 +47,7 @@ comments via `/gh-pr`, get it green, **merge it**, and clean up. `src/cli/help.rs` (CommandDoc/NestedCommandDoc CATALOG, SECTIONS) with cyan headers and white command names; wired per-command/nested `--help`. 3. Systematically tested and timed every CLI command; identified the slow tier. -4. Fixed perf bugs (e.g. search `--hostname` 200s → index-led intersect plan; +4. Fixed perf bugs (e.g. search `--host` 200s → index-led intersect plan; established that ANALYZE/`sqlite_stat1` stats are load-bearing for plan choice); added `PRAGMA analysis_limit=400` and a 6h `PRAGMA optimize` task. 5. Added `timeline_hourly` incremental rollup (watermark `source_max_id`, diff --git a/docs/sessions/2026-06-12-file-tail-ingest-closeout.md b/docs/sessions/2026-06-12-file-tail-ingest-closeout.md index e45bf2d8..2153a8e0 100644 --- a/docs/sessions/2026-06-12-file-tail-ingest-closeout.md +++ b/docs/sessions/2026-06-12-file-tail-ingest-closeout.md @@ -105,7 +105,7 @@ The final repository state before this note was clean on `main` at merge commit | modified | `src/cli/http_client.rs` | - | Add file-tail API client and non-retry admin POST | `git diff --name-status 5632f78..8f092ac` | | modified | `src/cli/http_client_tests.rs` | - | Test 503 non-retry and redaction behavior | `git diff --name-status 5632f78..8f092ac` | | modified | `src/cli/parse.rs` | - | Route file-tail parse branch | `git diff --name-status 5632f78..8f092ac` | -| modified | `src/cli/parse_tests.rs` | - | Test required `--hostname` and command parsing | `git diff --name-status 5632f78..8f092ac` | +| modified | `src/cli/parse_tests.rs` | - | Test required `--host` and command parsing | `git diff --name-status 5632f78..8f092ac` | | modified | `src/cli/run.rs` | - | Run file-tail command arm | `git diff --name-status 5632f78..8f092ac` | | modified | `src/config.rs` | - | Add file-tail and agent parity config fields | `git diff --name-status 5632f78..8f092ac` | | modified | `src/config_tests.rs` | - | Cover config additions | `git diff --name-status 5632f78..8f092ac` | @@ -275,6 +275,6 @@ No new bead was created by this `save-to-md` pass. `bd list --all --sort updated - Deploy the new `main` image/config if production has not already picked up PR #73. - Configure `CORTEX_FILE_TAIL_LOG_VOLUME` per host for the app log root that should appear as `/file-tail-root` inside Cortex. -- Add file-tail sources for SWAG, Authelia, AdGuard, fail2ban, or other explicit log files using `cortex file-tail add --id ... --path /file-tail-root/... --tag ... --hostname ...`. +- Add file-tail sources for SWAG, Authelia, AdGuard, fail2ban, or other explicit log files using `cortex file-tail add --id ... --path /file-tail-root/... --tag ... --host ...`. - Use `cortex file-tail status --json` and `cortex action=status`/MCP `status` to watch tail health and blocked writer state. - Review the three ambiguous historical `docs/plans/` files in a separate cleanup pass before moving them to `docs/plans/complete/`. diff --git a/docs/superpowers/plans/2026-05-21-ai-abuse-incidents.md b/docs/superpowers/plans/2026-05-21-ai-abuse-incidents.md index 4a168030..a932e377 100644 --- a/docs/superpowers/plans/2026-05-21-ai-abuse-incidents.md +++ b/docs/superpowers/plans/2026-05-21-ai-abuse-incidents.md @@ -132,8 +132,8 @@ fn parse_ai_incidents(args: &[String]) -> Result { "--json" => parsed.json = true, "--project" => parsed.project = Some(flags.value("--project")?), "--tool" => parsed.tool = Some(flags.value("--tool")?), - "--from" => parsed.from = Some(flags.value("--from")?), - "--to" => parsed.to = Some(flags.value("--to")?), + "--since" => parsed.from = Some(flags.value("--since")?), + "--until" => parsed.to = Some(flags.value("--until")?), "--limit" => parsed.limit = Some(parse_u32_flag("--limit", flags.value("--limit")?)?), "--window-minutes" => { parsed.window_minutes = Some(parse_u32_flag( @@ -148,10 +148,10 @@ fn parse_ai_incidents(args: &[String]) -> Result { _ if arg.starts_with("--tool=") => { parsed.tool = Some(value_after_equals(arg, "--tool")?) } - _ if arg.starts_with("--from=") => { - parsed.from = Some(value_after_equals(arg, "--from")?) + _ if arg.starts_with("--since=") => { + parsed.from = Some(value_after_equals(arg, "--since")?) } - _ if arg.starts_with("--to=") => parsed.to = Some(value_after_equals(arg, "--to")?), + _ if arg.starts_with("--until=") => parsed.to = Some(value_after_equals(arg, "--until")?), _ if arg.starts_with("--limit=") => { parsed.limit = Some(parse_u32_flag( "--limit", @@ -301,8 +301,8 @@ fn parse_ai_investigate(args: &[String]) -> Result { "--json" => parsed.json = true, "--project" => parsed.project = Some(flags.value("--project")?), "--tool" => parsed.tool = Some(flags.value("--tool")?), - "--from" => parsed.from = Some(flags.value("--from")?), - "--to" => parsed.to = Some(flags.value("--to")?), + "--since" => parsed.from = Some(flags.value("--since")?), + "--until" => parsed.to = Some(flags.value("--until")?), "--limit" => parsed.limit = Some(parse_u32_flag("--limit", flags.value("--limit")?)?), "--window-minutes" => { parsed.window_minutes = Some(parse_u32_flag( @@ -323,10 +323,10 @@ fn parse_ai_investigate(args: &[String]) -> Result { _ if arg.starts_with("--tool=") => { parsed.tool = Some(value_after_equals(arg, "--tool")?) } - _ if arg.starts_with("--from=") => { - parsed.from = Some(value_after_equals(arg, "--from")?) + _ if arg.starts_with("--since=") => { + parsed.from = Some(value_after_equals(arg, "--since")?) } - _ if arg.starts_with("--to=") => parsed.to = Some(value_after_equals(arg, "--to")?), + _ if arg.starts_with("--until=") => parsed.to = Some(value_after_equals(arg, "--until")?), _ if arg.starts_with("--limit=") => { parsed.limit = Some(parse_u32_flag( "--limit", @@ -431,7 +431,7 @@ pub(super) fn print_ai_incidents_response( if response.truncated { " (truncated)" } else { "" }, if response.candidate_window_truncated { format!( - "\nwarning: candidate scan capped at {} rows; narrow with --project/--tool/--from/--to", + "\nwarning: candidate scan capped at {} rows; narrow with --project/--tool/--from/--until", response.candidate_cap ) } else { diff --git a/docs/superpowers/plans/2026-05-21-rag-historical-incidents.md b/docs/superpowers/plans/2026-05-21-rag-historical-incidents.md index 5d1d10e0..b9ffafd4 100644 --- a/docs/superpowers/plans/2026-05-21-rag-historical-incidents.md +++ b/docs/superpowers/plans/2026-05-21-rag-historical-incidents.md @@ -1470,29 +1470,29 @@ Add: ```rust "similar" => AiCommand::SimilarIncidents(AiSimilarArgs { query: require_arg(&args, "", "syslog ai similar ")?, - hostname: flag_val(&args, "--hostname"), - app_name: flag_val(&args, "--app-name"), + hostname: flag_val(&args, "--host"), + app_name: flag_val(&args, "--app"), severity_min: flag_val(&args, "--severity-min"), - from: flag_val(&args, "--from"), - to: flag_val(&args, "--to"), + from: flag_val(&args, "--since"), + to: flag_val(&args, "--until"), window_minutes: flag_u32(&args, "--window-minutes")?, limit: flag_u32(&args, "--limit")?, output: output_format(&args), }), "ask-history" => AiCommand::AskHistory(AiAskHistoryArgs { query: require_arg(&args, "", "syslog ai ask-history ")?, - hostname: flag_val(&args, "--hostname"), - app_name: flag_val(&args, "--app-name"), - from: flag_val(&args, "--from"), - to: flag_val(&args, "--to"), + hostname: flag_val(&args, "--host"), + app_name: flag_val(&args, "--app"), + from: flag_val(&args, "--since"), + to: flag_val(&args, "--until"), limit: flag_u32(&args, "--limit")?, output: output_format(&args), }), "incident-context" => AiCommand::IncidentContext(AiIncidentContextArgs { - from: flag_val(&args, "--from").ok_or_else(|| anyhow::anyhow!("--from is required"))?, - to: flag_val(&args, "--to").ok_or_else(|| anyhow::anyhow!("--to is required"))?, - hostname: flag_val(&args, "--hostname"), - app_name: flag_val(&args, "--app-name"), + from: flag_val(&args, "--since").ok_or_else(|| anyhow::anyhow!("--since is required"))?, + to: flag_val(&args, "--until").ok_or_else(|| anyhow::anyhow!("--until is required"))?, + hostname: flag_val(&args, "--host"), + app_name: flag_val(&args, "--app"), query: flag_val(&args, "--query"), severity_min: flag_val(&args, "--severity-min"), limit: flag_u32(&args, "--limit")?, @@ -1558,16 +1558,16 @@ Note: Check how existing commands import service types — it may be via `use sy Find the `help` or `usage` string for `syslog ai` subcommands (search for `"syslog ai search"` or similar). Add: ``` - syslog ai similar [--hostname H] [--app-name A] [--from T] [--to T] + syslog ai similar [--host H] [--app A] [--since T] [--until T] [--severity-min S] [--window-minutes N] [--limit N] Find historical incidents similar to a query. Returns FTS5-matched log clusters grouped by host+app within a time window, with correlated AI sessions. - syslog ai ask-history [--hostname H] [--app-name A] [--from T] [--to T] [--limit N] + syslog ai ask-history [--host H] [--app A] [--since T] [--until T] [--limit N] Search AI session transcripts for past work related to a topic. Returns sessions ranked by match count with system log context from the top session's window. - syslog ai incident-context --from T --to T [--hostname H] [--app-name A] + syslog ai incident-context --since T --until T [--host H] [--app A] [--query Q] [--severity-min S] [--limit N] Full context for a time window: log summary by severity/app, error logs, correlated AI sessions. Useful for known incident windows. diff --git a/docs/superpowers/plans/2026-05-21-surface-parity.md b/docs/superpowers/plans/2026-05-21-surface-parity.md index fd3084e4..0e229a01 100644 --- a/docs/superpowers/plans/2026-05-21-surface-parity.md +++ b/docs/superpowers/plans/2026-05-21-surface-parity.md @@ -653,8 +653,8 @@ pub(crate) struct IngestRateArgs { Add CLI parsing in the `parse_args` function (follow the pattern for `SearchArgs`). Add help strings to `print_usage()`: ``` syslog source-ips [--limit N] [--offset N] [--json] - syslog timeline [--bucket 1m|5m|1h|1d] [--group-by hostname|severity|app] [--hostname HOST] [--app-name APP] [--severity-min LEVEL] [--from TIME] [--to TIME] [--json] - syslog patterns [--hostname HOST] [--app-name APP] [--severity-min LEVEL] [--from TIME] [--to TIME] [--scan-limit N] [--top-n N] [--json] + syslog timeline [--bucket 1m|5m|1h|1d] [--group-by hostname|severity|app] [--host HOST] [--app APP] [--severity-min LEVEL] [--since TIME] [--until TIME] [--json] + syslog patterns [--host HOST] [--app APP] [--severity-min LEVEL] [--since TIME] [--until TIME] [--scan-limit N] [--top-n N] [--json] syslog ingest-rate [--by-host] [--json] ``` diff --git a/docs/superpowers/plans/2026-05-22-surface-parity-gap-closure.md b/docs/superpowers/plans/2026-05-22-surface-parity-gap-closure.md index 032aa2e7..773a399d 100644 --- a/docs/superpowers/plans/2026-05-22-surface-parity-gap-closure.md +++ b/docs/superpowers/plans/2026-05-22-surface-parity-gap-closure.md @@ -1627,7 +1627,7 @@ impl AppsArgs { #[test] fn parse_apps_with_hostname_limit() { let cmd = CliCommand::parse(strings(&[ - "apps", "--hostname", "dookie", "--limit", "50", + "apps", "--host", "dookie", "--limit", "50", ])) .expect("parse apps"); match cmd { @@ -1660,11 +1660,11 @@ pub(crate) fn parse_apps(args: &[String]) -> Result { while let Some(arg) = flags.next() { if arg == "--json" { parsed.json = true; - } else if let Some(v) = flags.match_value(&arg, "--hostname")? { + } else if let Some(v) = flags.match_value(&arg, "--host")? { parsed.hostname = Some(v.to_owned()); - } else if let Some(v) = flags.match_value(&arg, "--from")? { + } else if let Some(v) = flags.match_value(&arg, "--since")? { parsed.from = Some(v.to_owned()); - } else if let Some(v) = flags.match_value(&arg, "--to")? { + } else if let Some(v) = flags.match_value(&arg, "--until")? { parsed.to = Some(v.to_owned()); } else if let Some(v) = flags.match_value(&arg, "--limit")? { parsed.limit = Some(parse_u32_flag("--limit", v)?); @@ -1824,7 +1824,7 @@ syslog silent-hosts [--silent-minutes N] [--json] syslog clock-skew [--since RFC3339] [--json] syslog anomalies [--recent-minutes N] [--baseline-minutes N] [--json] syslog compare --a-from RFC3339 --a-to RFC3339 --b-from RFC3339 --b-to RFC3339 [--json] -syslog apps [--hostname H] [--from RFC3339] [--to RFC3339] [--limit N] [--offset N] [--json] +syslog apps [--host H] [--since RFC3339] [--until RFC3339] [--limit N] [--offset N] [--json] ``` - [ ] **Step 5: Add smoke-test entries to tests/test_live.sh** diff --git a/docs/superpowers/plans/2026-05-25-first-class-log-filter-surface.md b/docs/superpowers/plans/2026-05-25-first-class-log-filter-surface.md index e3766583..fe52d896 100644 --- a/docs/superpowers/plans/2026-05-25-first-class-log-filter-surface.md +++ b/docs/superpowers/plans/2026-05-25-first-class-log-filter-surface.md @@ -190,13 +190,13 @@ Expected: pass. Add examples for: ```bash -syslog filter --from 2026-05-24T20:00:00Z --to 2026-05-24T21:00:00Z -syslog filter --source-kind docker-stream --container swag --stream stdout --from ... --to ... -syslog filter --source-kind docker-event --event-action die --from ... --to ... -syslog filter --tool claude --project /home/jmagar/workspace/syslog-mcp --from ... --to ... -syslog filter --source-kind agent-command --from ... --to ... -syslog filter --source-kind shell-history --from ... --to ... -syslog filter --app-name systemd --from ... --to ... +syslog filter --since 2026-05-24T20:00:00Z --until 2026-05-24T21:00:00Z +syslog filter --source-kind docker-stream --container swag --stream stdout --since ... --until ... +syslog filter --source-kind docker-event --event-action die --since ... --until ... +syslog filter --tool claude --project /home/jmagar/workspace/syslog-mcp --since ... --until ... +syslog filter --source-kind agent-command --since ... --until ... +syslog filter --source-kind shell-history --since ... --until ... +syslog filter --app systemd --since ... --until ... ``` - [ ] **Step 2: Add smoke coverage** diff --git a/docs/superpowers/plans/2026-06-11-file-tail-ingest.md b/docs/superpowers/plans/2026-06-11-file-tail-ingest.md index 753c1ea0..a3e1eb93 100644 --- a/docs/superpowers/plans/2026-06-11-file-tail-ingest.md +++ b/docs/superpowers/plans/2026-06-11-file-tail-ingest.md @@ -1366,7 +1366,7 @@ fn parses_file_tail_add() { "/mnt/appdata/swag/log/nginx/access.log".into(), "--tag".into(), "swag-access".into(), - "--hostname".into(), + "--host".into(), "squirts".into(), "--facility".into(), "local4".into(), @@ -1538,9 +1538,9 @@ fn parse_add(args: &[String]) -> Result { i += 1; out.tag = required(args, i, "--tag")?; } - "--hostname" => { + "--host" => { i += 1; - out.hostname = Some(required(args, i, "--hostname")?); + out.hostname = Some(required(args, i, "--host")?); } "--facility" => { i += 1; @@ -1553,7 +1553,7 @@ fn parse_add(args: &[String]) -> Result { "--from-start" => out.start_at_end = false, "--json" => out.json = true, other => bail!("{}", suggest::unknown_option("file-tail add", other, &[ - "--id", "--path", "--tag", "--hostname", "--facility", "--severity", "--from-start", "--json", + "--id", "--path", "--tag", "--host", "--facility", "--severity", "--from-start", "--json", ])), } i += 1; @@ -1575,7 +1575,7 @@ fn required(args: &[String], index: usize, flag: &str) -> Result { } fn usage() -> &'static str { - "Usage: cortex file-tail list [--json]\n cortex file-tail add --id ID --path PATH --tag TAG [--hostname HOST] [--facility FACILITY] [--severity SEVERITY] [--from-start] [--json]\n cortex file-tail remove --id ID [--json]\n cortex file-tail enable --id ID [--json]\n cortex file-tail disable --id ID [--json]" + "Usage: cortex file-tail list [--json]\n cortex file-tail add --id ID --path PATH --tag TAG [--host HOST] [--facility FACILITY] [--severity SEVERITY] [--from-start] [--json]\n cortex file-tail remove --id ID [--json]\n cortex file-tail enable --id ID [--json]\n cortex file-tail disable --id ID [--json]" } ``` @@ -1794,14 +1794,14 @@ cortex file-tail add \ --id swag-access \ --path /mnt/appdata/swag/log/nginx/access.log \ --tag swag-access \ - --hostname squirts \ + --host squirts \ --facility local4 cortex file-tail add \ --id swag-error \ --path /mnt/appdata/swag/log/nginx/error.log \ --tag swag-error \ - --hostname squirts \ + --host squirts \ --facility local4 \ --severity warning @@ -1809,21 +1809,21 @@ cortex file-tail add \ --id fail2ban \ --path /mnt/appdata/swag/log/fail2ban/fail2ban.log \ --tag fail2ban \ - --hostname squirts \ + --host squirts \ --facility local5 cortex file-tail add \ --id authelia \ --path /mnt/appdata/authelia/logs/authelia.log \ --tag authelia \ - --hostname squirts \ + --host squirts \ --facility local5 cortex file-tail add \ --id adguard-query \ --path /mnt/appdata/adguard/var/data/querylog.json \ --tag adguard-query \ - --hostname squirts \ + --host squirts \ --facility local6 ``` @@ -1841,7 +1841,7 @@ Manage Cortex-owned file-tail ingest sources. ```bash cortex file-tail list [--json] cortex file-tail status [--json] -cortex file-tail add --id ID --path PATH --tag TAG [--hostname HOST] [--facility FACILITY] [--severity SEVERITY] [--from-start] [--json] +cortex file-tail add --id ID --path PATH --tag TAG [--host HOST] [--facility FACILITY] [--severity SEVERITY] [--from-start] [--json] cortex file-tail remove --id ID [--json] cortex file-tail enable --id ID [--json] cortex file-tail disable --id ID [--json] @@ -1943,7 +1943,7 @@ export CORTEX_API_TOKEN="test-token" target/debug/cortex serve mcp --no-auth >"$tmpdir/cortex.log" 2>&1 & pid=$! sleep 2 -target/debug/cortex file-tail add --id smoke-file --path "$tmpdir/app.log" --tag smoke-app --hostname smoke-host --from-start --json +target/debug/cortex file-tail add --id smoke-file --path "$tmpdir/app.log" --tag smoke-app --host smoke-host --from-start --json printf 'hello from managed file tail\n' >> "$tmpdir/app.log" sleep 2 target/debug/cortex search '"hello from managed file tail"' --json diff --git a/docs/superpowers/plans/2026-06-15-cortex-cli-query-safety-and-time-parsing.md b/docs/superpowers/plans/2026-06-15-cortex-cli-query-safety-and-time-parsing.md index 2c4dc5a3..df4d9973 100644 --- a/docs/superpowers/plans/2026-06-15-cortex-cli-query-safety-and-time-parsing.md +++ b/docs/superpowers/plans/2026-06-15-cortex-cli-query-safety-and-time-parsing.md @@ -17,7 +17,7 @@ - **Create** `src/cli/timearg.rs` — pure time-argument normalizer (`parse_time_arg`). One responsibility: turn a user string into an RFC3339 string, given an injected `now`. - **Create** `src/cli/timearg_tests.rs` — sidecar unit tests for the parser. - **Modify** `src/cli.rs` — register `pub(crate) mod timearg;`. -- **Modify** `src/cli/parse_logs.rs` — route the existing `--from/--to/--received-from/--received-to` values through `parse_time_arg` so relative input is accepted. (Flag *names* are unchanged here; the rename to `--since/--until` is Plan 2.) +- **Modify** `src/cli/parse_logs.rs` — route the existing `--from/--to/--received-since/--received-until` values through `parse_time_arg` so relative input is accepted. (Flag *names* are unchanged here; the rename to `--since/--until` is Plan 2.) - **Modify** `src/db/queries.rs` — add `lint_fts_query` (fix-it detection) and call it from `validate_fts_query`; add `fts_phrase_literal` to wrap `--grep` input as a safe FTS5 phrase. - **Modify** `src/db/queries_tests.rs` — sidecar tests for the lint + phrase helpers. - **Modify** the search args struct + `parse_search` (in `src/cli/args*.rs` and `src/cli/parse_logs.rs`) — add a `grep: Option` field, parse `--grep`, and make it mutually exclusive with `--query`. @@ -275,7 +275,7 @@ git commit -m "feat(cli): time parser accepts RFC3339, date, and date-time forms - Modify: `src/cli/parse_logs.rs` (the `parse_search` and `parse_filter` time-flag arms) - Modify: `src/cli/parse_logs_tests.rs` -> The flags keep their current names (`--from`, `--to`, `--received-from`, `--received-to`) in this plan; renaming to `--since/--until` is Plan 2. Here we only normalize their *values* through `parse_time_arg`. +> The flags keep their current names (`--from`, `--to`, `--received-since`, `--received-until`) in this plan; renaming to `--since/--until` is Plan 2. Here we only normalize their *values* through `parse_time_arg`. - [ ] **Step 1: Write the failing test** @@ -284,7 +284,7 @@ Add to `src/cli/parse_logs_tests.rs`: ```rust #[test] fn search_normalizes_relative_from() { - let cmd = parse_search(&["error".into(), "--from".into(), "1h".into()]).unwrap(); + let cmd = parse_search(&["error".into(), "--since".into(), "1h".into()]).unwrap(); let CliCommand::Search(args) = cmd else { panic!("expected Search") }; let from = args.from.expect("from set"); // Relative input is normalized to an absolute RFC3339 timestamp. @@ -313,16 +313,16 @@ fn norm_time(raw: String) -> anyhow::Result { Then change each time-flag assignment in `parse_search` (and the equals-form arms) from: ```rust -"--from" => parsed.from = Some(flags.value("--from")?), +"--since" => parsed.from = Some(flags.value("--since")?), ``` to: ```rust -"--from" => parsed.from = Some(norm_time(flags.value("--from")?)?), +"--since" => parsed.from = Some(norm_time(flags.value("--since")?)?), ``` -Apply the same wrap to `--to`, `--received-from`, `--received-to` in both the +Apply the same wrap to `--to`, `--received-since`, `--received-until` in both the space-separated and `--flag=value` arms. - [ ] **Step 4: Run the test** diff --git a/docs/superpowers/plans/2026-06-15-cortex-cli-registry-completion-and-rename.md b/docs/superpowers/plans/2026-06-15-cortex-cli-registry-completion-and-rename.md index 5ccb0566..3f882609 100644 --- a/docs/superpowers/plans/2026-06-15-cortex-cli-registry-completion-and-rename.md +++ b/docs/superpowers/plans/2026-06-15-cortex-cli-registry-completion-and-rename.md @@ -24,16 +24,16 @@ | Concept | Canonical | Old CLI flag | Old MCP property | |---|---|---|---| -| host | `--host` | `--hostname` | `hostname` | +| host | `--host` | `--host` | `hostname` | | literal text | `--grep` | (Plan 1) | (n/a) | | limit | `-n`, `--limit` | `--limit` | `limit` | | min severity | `-s`, `--severity` | `--severity` | `severity` | -| app | `--app` | `--app-name` | `app_name` | -| source id | `--source` | `--source-ip` | `source_ip` | +| app | `--app` | `--app` | `app_name` | +| source id | `--source` | `--source` | `source_ip` | | event-time start | `--since` | `--from` | `from` | | event-time end | `--until` | `--to` | `to` | -| received start | `--received-since` | `--received-from` | `received_from` | -| received end | `--received-until` | `--received-to` | `received_to` | +| received start | `--received-since` | `--received-since` | `received_from` | +| received end | `--received-until` | `--received-until` | `received_to` | `--container`, `--stream`, `--source-kind`, `--json`, `--facility`, `--exclude-facility` keep their names. @@ -281,11 +281,11 @@ to every `parse_*` fn. One worked example + a completeness gate keeps it honest. In `src/cli/parse_logs.rs::parse_search`, rename per the table. Before: ```rust -"--hostname" => parsed.hostname = Some(flags.value("--hostname")?), -"--source-ip" => parsed.source_ip = Some(flags.value("--source-ip")?), -"--app-name" => parsed.app_name = Some(flags.value("--app-name")?), -"--from" => parsed.from = Some(norm_time(flags.value("--from")?)?), -"--to" => parsed.to = Some(norm_time(flags.value("--to")?)?), +"--host" => parsed.hostname = Some(flags.value("--host")?), +"--source" => parsed.source_ip = Some(flags.value("--source")?), +"--app" => parsed.app_name = Some(flags.value("--app")?), +"--since" => parsed.from = Some(norm_time(flags.value("--since")?)?), +"--until" => parsed.to = Some(norm_time(flags.value("--until")?)?), ``` After (note `-s`/`-n` short forms and `--since/--until`): @@ -311,7 +311,7 @@ one asserting the old name now errors: ```rust #[test] fn search_rejects_legacy_hostname_flag() { - let err = parse_search(&["x".into(), "--hostname".into(), "dookie".into()]) + let err = parse_search(&["x".into(), "--host".into(), "dookie".into()]) .unwrap_err().to_string(); assert!(err.contains("--host"), "should suggest canonical flag: {err}"); } @@ -329,7 +329,7 @@ renamed flags. Work file-by-file; after each file run its sidecar tests. Run: ```bash -rg -n -- '--hostname|--source-ip|--app-name|"--from"|"--to"|--received-from|--received-to' src/cli +rg -n -- '--host|--source|--app|"--since"|"--until"|--received-since|--received-until' src/cli ``` Expected: **no matches in `parse_*`/command code** (matches only allowed in help text @@ -813,7 +813,7 @@ git commit -m "feat(cli): registry-driven overview + missing-arg examples" - [ ] **Step 1: Find every legacy name reference** ```bash -rg -n -- '--hostname|--source-ip|--app-name|action=.*hostname=|"hostname"|"source_ip"|"app_name"| from=| to=' \ +rg -n -- '--host|--source|--app|action=.*hostname=|"hostname"|"source_ip"|"app_name"| from=| to=' \ plugins CLAUDE.md README* docs scripts config ``` diff --git a/docs/superpowers/specs/2026-05-16-agent-mode-design.md b/docs/superpowers/specs/2026-05-16-agent-mode-design.md index f6c7865c..443a13e2 100644 --- a/docs/superpowers/specs/2026-05-16-agent-mode-design.md +++ b/docs/superpowers/specs/2026-05-16-agent-mode-design.md @@ -316,7 +316,7 @@ The unauthenticated socket window is bounded by `handshake_timeout = 5s` and an ### 6.2 Token lifecycle -- **Issuance:** `cortex admin agent issue --hostname dookie` on the server prints a one-time token (32 bytes, base64url). Server stores only its hash (BLAKE3 of the raw token) in `agents.token_hash`. A pending row is inserted with `connection_state = NeverConnected`. +- **Issuance:** `cortex admin agent issue --host dookie` on the server prints a one-time token (32 bytes, base64url). Server stores only its hash (BLAKE3 of the raw token) in `agents.token_hash`. A pending row is inserted with `connection_state = NeverConnected`. - **Bootstrap on agent:** an operator pastes the token into `/etc/syslog-agent/token` (mode 0600), or feeds it via `syslog-agent register --token <…>` which writes the same file. - **Storage on agent:** plain file on disk, perms 0600, owned by the dedicated `syslog-agent` user. Not encrypted at rest — tailnet trust + filesystem perms are the boundary, matching every other agent in this class (Promtail, Filebeat). - **Rotation:** `cortex admin agent rotate --host-id ` issues a new token; server keeps both old and new `token_hash` for `rotation_grace_secs` (default 300). After grace, old hash is dropped. @@ -691,7 +691,7 @@ If duplicates do appear, downstream search queries can filter on `source_ip LIKE 6. **Bootstrap UX.** One-time tokens via copy-paste vs. printing a `wireguard-style` invite URL the agent can read. Lean toward QR/URL for the next epic. 7. ~~**`cortex agent` CLI subcommand surface.**~~ **RESOLVED — IN SCOPE.** Server-side CLI subcommands (run on `tootie`, operate on the central DB): - `cortex agent list` — table of agents: host_id, hostname, connection_state, last_handshake, agent_version - - `cortex agent issue --hostname=` — generate and print a one-time enrollment token; record `token_hash` row in `agents` with `connection_state=NeverConnected` + - `cortex agent issue --host=` — generate and print a one-time enrollment token; record `token_hash` row in `agents` with `connection_state=NeverConnected` - `cortex agent revoke ` — set `connection_state=Revoked`, server-side kicks any active connection - `cortex agent rotate ` — issue a new token, mark old `token_hash_prev` for grace window, agent picks up on next reconnect - `cortex agent tail ` — server-side `tail -f` of recent log rows from that host (convenience wrapper over `search` with `hostname=...`) diff --git a/docs/superpowers/specs/2026-06-15-cortex-cli-ergonomics-design.md b/docs/superpowers/specs/2026-06-15-cortex-cli-ergonomics-design.md index 5020ed18..eb090763 100644 --- a/docs/superpowers/specs/2026-06-15-cortex-cli-ergonomics-design.md +++ b/docs/superpowers/specs/2026-06-15-cortex-cli-ergonomics-design.md @@ -12,7 +12,7 @@ each with its own flag set, reachable only by repeatedly consulting `help`. Four distinct pains, all confirmed as real: 1. **Discoverability** — you know what you want to know, not which action provides it. -2. **Flag recall** — you know the action, not its knobs (`--hostname` vs `--source-ip`, +2. **Flag recall** — you know the action, not its knobs (`--host` vs `--source`, time formats, severity levels). 3. **Query syntax** — FTS5 gotchas (hyphen = NOT operator, phrase quoting for hyphenated terms) produce cryptic DB errors. @@ -85,18 +85,18 @@ One name per concept, identical across every action and across CLI + MCP: | Concept | Canonical | Replaces | Notes | |---|---|---|---| -| host | `--host` (+ positional where obvious) | `--hostname` | dynamic completion | +| host | `--host` (+ positional where obvious) | `--host` | dynamic completion | | FTS5 query | `--query` (+ positional) | `query` | raw FTS5 | | literal text | `--grep` | (new) | substring, FTS5-safe (Component 3) | | result limit | `-n`, `--limit` | `--limit` | | | min severity | `-s`, `--severity` | `--severity` | enum completion | -| app / program | `--app` | `--app-name` | | -| source id | `--source` | `--source-ip` | `docker://…` or `IP:port` | +| app / program | `--app` | `--app` | | +| source id | `--source` | `--source` | `docker://…` or `IP:port` | | container | `--container` | `--container` | unchanged | | stream | `--stream` | `--stream` | `stdout`/`stderr` enum | | source kind | `--source-kind` | `--source-kind` | enum | | event-time window | `--since`, `--until` | `--from`, `--to` | unified parser (Component 6) | -| received-time window | `--received-since`, `--received-until` | `--received-from`, `--received-to` | | +| received-time window | `--received-since`, `--received-until` | `--received-since`, `--received-until` | | | JSON output | `--json` | `--json` | global | The canonical name and aliases (`-n`, `-s`) live in `ACTION_SPECS` as flag metadata so diff --git a/mcpb/manifest.json b/mcpb/manifest.json index 68a1883f..94cfa111 100644 --- a/mcpb/manifest.json +++ b/mcpb/manifest.json @@ -3,7 +3,7 @@ "manifest_version": "0.4", "name": "cortex", "display_name": "Cortex", - "version": "1.25.1", + "version": "1.26.1", "description": "Query local cortex SQLite logs through a bundled stdio MCP server.", "long_description": "cortex packages the existing cortex stdio entrypoint as a local MCP Bundle. It is query-only: it reads the configured SQLite database and does not start syslog listeners, HTTP servers, Docker Compose, REST, or deploy flows.", "author": { diff --git a/plugins/cortex/skills/cortex-report/SKILL.md b/plugins/cortex/skills/cortex-report/SKILL.md index b1ef6874..713af4c0 100644 --- a/plugins/cortex/skills/cortex-report/SKILL.md +++ b/plugins/cortex/skills/cortex-report/SKILL.md @@ -22,16 +22,16 @@ Use the `cortex` MCP tool as the source of truth for recent device logs. Query b - If the MCP tool is unavailable, report that no live syslog evidence could be collected and include the failure details. 3. Collect incident candidates. - - Call `cortex action=errors from= to=` for warning/error summaries grouped by host and severity. - - Call `cortex action=search query=error from= to= limit=1000` for error detail inside the report window. - - Call `cortex action=search query="warning OR warn" from= to= limit=1000` when warning coverage is not already clear from `errors`. + - Call `cortex action=errors since= until=` for warning/error summaries grouped by host and severity. + - Call `cortex action=search query=error since= until= limit=1000` for error detail inside the report window. + - Call `cortex action=search query="warning OR warn" since= until= limit=1000` when warning coverage is not already clear from `errors`. - Call `cortex action=tail n=100` for recent fleet-wide context. - Use host/app/time filters when available to narrow noisy hosts or services. 4. Correlate likely related events. - Call `cortex action=correlate` around high-severity timestamps or spikes. - Prefer small focused windows around incidents over one huge correlation query. - - Use `cortex action=timeline from= to= bucket=hour group_by=severity` or a narrower bucket to find spikes before correlation when the incident time is not obvious. + - Use `cortex action=timeline since= until= bucket=hour group_by=severity` or a narrower bucket to find spikes before correlation when the incident time is not obvious. - Group events by likely shared cause only when timestamps, hosts, apps, or message content support that relationship. 5. Write an actionable markdown report. diff --git a/plugins/cortex/skills/cortex/SKILL.md b/plugins/cortex/skills/cortex/SKILL.md index e263e289..5ca47713 100644 --- a/plugins/cortex/skills/cortex/SKILL.md +++ b/plugins/cortex/skills/cortex/SKILL.md @@ -151,8 +151,8 @@ curl -s -X POST "$CLAUDE_PLUGIN_OPTION_SERVER_URL/mcp" \ ``` mcp__cortex__cortex(action="errors") -mcp__cortex__cortex(action="tail", hostname="unraid", n=50) -mcp__cortex__cortex(action="search", query='OOM OR "out of memory"', hostname="unraid") +mcp__cortex__cortex(action="tail", host="unraid", n=50) +mcp__cortex__cortex(action="search", query='OOM OR "out of memory"', host="unraid") ``` ### Incident investigation @@ -175,7 +175,7 @@ mcp__cortex__cortex(action="hosts") ``` # Docker ingest sets source_ip to docker://host/container/stream -mcp__cortex__cortex(action="search", source_ip="docker://squirts/postgres/stdout", limit=50) +mcp__cortex__cortex(action="search", source="docker://squirts/postgres/stdout", limit=50) ``` ### Storage health diff --git a/scripts/smoke-ai.sh b/scripts/smoke-ai.sh index 4801539c..04942416 100755 --- a/scripts/smoke-ai.sh +++ b/scripts/smoke-ai.sh @@ -102,7 +102,7 @@ checkpoints="$(run_cortex ai checkpoints --limit 20 --json)" require_json_count "ai checkpoints did not include fixture source" "$checkpoints" "any(item.get('canonical_path', '').endswith('ai-session-smoke.jsonl') for item in data)" pass "ai checkpoints" -tail_output="$(run_cortex tail -n 5 --app-name claude-transcript)" +tail_output="$(run_cortex tail -n 5 --app claude-transcript)" grep -q 'ai-smoke-session' <<<"$tail_output" || fail "tail output did not include fixture session" if grep -qE '\blocalhost\b' <<<"$tail_output"; then fail "tail output still shows synthetic localhost transcript row" diff --git a/scripts/smoke-test-http.sh b/scripts/smoke-test-http.sh index ca5bead0..265d0398 100755 --- a/scripts/smoke-test-http.sh +++ b/scripts/smoke-test-http.sh @@ -123,7 +123,7 @@ assert_json "http: search (limit 1)" http search --json --limit 1 assert_json "http: tail (limit 1)" http tail --json --limit 1 assert_json "http: errors" http errors --json assert_json "http: hosts" http hosts --json -assert_json "http: correlate (1m, h=_)" http correlate --json --reference-time "$CORTEX_SMOKE_REFTIME" --hostname _smoke_ --window-minutes 1 +assert_json "http: correlate (1m, h=_)" http correlate --json --reference-time "$CORTEX_SMOKE_REFTIME" --host _smoke_ --window-minutes 1 assert_json "http: stats" http stats --json assert_json "http: sessions (limit 1)" http sessions --json --limit 1 diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 6bdb816d..7cb53bf7 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -468,7 +468,7 @@ if mcp_admin_scope_available; then "id=${FILE_TAIL_SMOKE_ID}" \ "path=${FILE_TAIL_SMOKE_SERVER_PATH}" \ "tag=${FILE_TAIL_SMOKE_TAG}" \ - "hostname=${SEED_HOST}" \ + "host=${SEED_HOST}" \ "facility=local7" \ "severity=info" \ "start_at_end=true" 2>&1) @@ -479,7 +479,7 @@ if mcp_admin_scope_available; then FILE_TAIL_SEARCH=$(mcp_call search \ "query=\"${FILE_TAIL_SMOKE_MARKER}\"" \ "source_kind=file-tail" \ - "app_name=${FILE_TAIL_SMOKE_TAG}" \ + "app=${FILE_TAIL_SMOKE_TAG}" \ "limit=5" 2>&1 || true) FILE_TAIL_COUNT=$(json_get "$FILE_TAIL_SEARCH" "['count']" || true) if [[ "${FILE_TAIL_COUNT:-0}" -ge 1 ]]; then @@ -596,7 +596,7 @@ echo "" echo "Action: sessions" # Use a time-windowed query so smoke data seeded directly into SQLite is read # live instead of through a periodically refreshed session rollup. -SESSIONS=$(mcp_call sessions "limit=10" "from=1970-01-01T00:00:00Z" 2>&1) +SESSIONS=$(mcp_call sessions "limit=10" "since=1970-01-01T00:00:00Z" 2>&1) assert_no_error "sessions: no error" "$SESSIONS" SESSIONS_VALID=$(printf '%s\n' "$SESSIONS" | python3 -c " @@ -782,8 +782,8 @@ print('ok') assert_eq "tail: results in non-increasing timestamp order" "$TAIL_ORDER" "ok" if [[ "$SKIP_SEED" -eq 0 ]]; then - # hostname= filter must only return logs for that host - TAIL_FILTERED=$(mcp_call tail "hostname=${SEED_HOST}" "n=50" 2>&1) + # host= filter must only return logs for that host + TAIL_FILTERED=$(mcp_call tail "host=${SEED_HOST}" "n=50" 2>&1) assert_no_error "tail(hostname filter): no error" "$TAIL_FILTERED" TAIL_FILTER_VALID=$(printf '%s\n' "$TAIL_FILTERED" | python3 -c " import sys, json @@ -838,8 +838,8 @@ print('ok') assert_eq "search(phrase): results contain exact phrase" "$PHRASE_MATCH" "ok" if [[ "$SKIP_SEED" -eq 0 ]]; then - # hostname= filter: should return only that host's logs - SEARCH_HOST=$(mcp_call search "hostname=${SEED_HOST}" "limit=50" 2>&1) + # host= filter: should return only that host's logs + SEARCH_HOST=$(mcp_call search "host=${SEED_HOST}" "limit=50" 2>&1) assert_no_error "search(hostname filter): no error" "$SEARCH_HOST" SEARCH_HOST_VALID=$(printf '%s\n' "$SEARCH_HOST" | python3 -c " import sys, json @@ -852,7 +852,7 @@ print('ok') assert_eq "search(hostname filter): only returns logs for '$SEED_HOST'" "$SEARCH_HOST_VALID" "ok" # severity= filter: warning should only return warning-level logs - SEARCH_SEV=$(mcp_call search "hostname=${SEED_HOST}" "severity=warning" "limit=50" 2>&1) + SEARCH_SEV=$(mcp_call search "host=${SEED_HOST}" "severity=warning" "limit=50" 2>&1) assert_no_error "search(severity filter): no error" "$SEARCH_SEV" SEARCH_SEV_VALID=$(printf '%s\n' "$SEARCH_SEV" | python3 -c " import sys, json @@ -864,7 +864,7 @@ print('ok') " 2>/dev/null || echo "error") assert_eq "search(severity filter): only returns warning-level logs" "$SEARCH_SEV_VALID" "ok" - SEARCH_TCP=$(mcp_call search "query=${TCP_MARKER}" "hostname=${SEED_HOST}" "limit=10" 2>&1) + SEARCH_TCP=$(mcp_call search "query=${TCP_MARKER}" "host=${SEED_HOST}" "limit=10" 2>&1) assert_no_error "search(TCP seed marker): no error" "$SEARCH_TCP" SEARCH_TCP_VALID=$(printf '%s\n' "$SEARCH_TCP" | python3 -c " import sys, json @@ -879,7 +879,7 @@ print('ok') fi # Nonexistent hostname must return 0 results (filter is not ignored) -SEARCH_GHOST=$(mcp_call search "hostname=${GHOST_HOST}" "limit=10" 2>&1) +SEARCH_GHOST=$(mcp_call search "host=${GHOST_HOST}" "limit=10" 2>&1) assert_no_error "search(nonexistent hostname): no error" "$SEARCH_GHOST" GHOST_COUNT=$(printf '%s\n' "$SEARCH_GHOST" | python3 -c "import sys,json; print(json.load(sys.stdin)['count'])" 2>/dev/null || echo "-1") assert_eq "search(nonexistent hostname): returns 0 results" "$GHOST_COUNT" "0" @@ -893,7 +893,7 @@ assert_eq "search(limit=0): returns 0 results" "$ZERO_COUNT" "0" # ── filter ─────────────────────────────────────────────────────────────────── echo "" echo "Action: filter" -FILTER_HOST=$(mcp_call filter "hostname=${SEED_HOST}" "limit=50" 2>&1) +FILTER_HOST=$(mcp_call filter "host=${SEED_HOST}" "limit=50" 2>&1) assert_no_error "filter(hostname): no error" "$FILTER_HOST" FILTER_HOST_VALID=$(printf '%s\n' "$FILTER_HOST" | python3 -c " import sys, json diff --git a/server.json b/server.json index fcc4cf6c..6a1d25f5 100644 --- a/server.json +++ b/server.json @@ -7,11 +7,11 @@ "url": "https://github.com/jmagar/cortex", "source": "github" }, - "version": "1.25.1", + "version": "1.26.1", "packages": [ { "registryType": "oci", - "identifier": "ghcr.io/jmagar/cortex:v1.25.1", + "identifier": "ghcr.io/jmagar/cortex:v1.26.1", "transport": { "type": "stdio" }, diff --git a/src/api.rs b/src/api.rs index 583d79f8..e339f82b 100644 --- a/src/api.rs +++ b/src/api.rs @@ -374,17 +374,17 @@ fn require_api_admin_token( #[serde(deny_unknown_fields)] struct SearchQuery { query: Option, - hostname: Option, - source_ip: Option, + host: Option, + source: Option, severity: Option, - app_name: Option, + app: Option, facility: Option, exclude_facility: Option, process_id: Option, - from: Option, - to: Option, - received_from: Option, - received_to: Option, + since: Option, + until: Option, + received_since: Option, + received_until: Option, limit: Option, source_kind: Option, tool: Option, @@ -405,17 +405,17 @@ async fn search( .service .search_logs(SearchLogsRequest { query: query.query, - hostname: query.hostname, - source_ip: query.source_ip, + host: query.host, + source: query.source, severity: query.severity, - app_name: query.app_name, + app: query.app, facility: query.facility, exclude_facility: query.exclude_facility, process_id: query.process_id, - from: query.from, - to: query.to, - received_from: query.received_from, - received_to: query.received_to, + since: query.since, + until: query.until, + received_since: query.received_since, + received_until: query.received_until, limit: query.limit, source_kind: query.source_kind, tool: query.tool, @@ -440,9 +440,9 @@ async fn filter( #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct TailQuery { - hostname: Option, - source_ip: Option, - app_name: Option, + host: Option, + source: Option, + app: Option, severity_min: Option, n: Option, } @@ -452,9 +452,9 @@ async fn tail(State(state): State, Query(query): Query) -> state .service .tail_logs(TailLogsRequest { - hostname: query.hostname, - source_ip: query.source_ip, - app_name: query.app_name, + host: query.host, + source: query.source, + app: query.app, severity_min: query.severity_min, n: query.n, }) @@ -465,8 +465,8 @@ async fn tail(State(state): State, Query(query): Query) -> #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct ErrorQuery { - from: Option, - to: Option, + since: Option, + until: Option, group_by: Option, limit: Option, } @@ -479,8 +479,8 @@ async fn errors( state .service .get_errors(GetErrorsRequest { - from: query.from, - to: query.to, + since: query.since, + until: query.until, group_by: query.group_by, limit: query.limit, }) @@ -498,8 +498,8 @@ struct CorrelateQuery { reference_time: String, window_minutes: Option, severity_min: Option, - hostname: Option, - source_ip: Option, + host: Option, + source: Option, query: Option, limit: Option, } @@ -515,8 +515,8 @@ async fn correlate( reference_time: query.reference_time, window_minutes: query.window_minutes, severity_min: query.severity_min, - hostname: query.hostname, - source_ip: query.source_ip, + host: query.host, + source: query.source, query: query.query, limit: query.limit, }) @@ -558,13 +558,14 @@ async fn source_ips( } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct TimelineQuery { bucket: Option, group_by: Option, - from: Option, - to: Option, - hostname: Option, - app_name: Option, + since: Option, + until: Option, + host: Option, + app: Option, severity_min: Option, } @@ -573,7 +574,7 @@ async fn timeline( Query(query): Query, ) -> impl IntoResponse { // Default lookback is centralized in `CortexService::timeline` (bead dyqw): - // it applies a bucket-sized window only when neither `from` nor `to` is set, + // it applies a bucket-sized window only when neither `since` nor `until` is set, // preventing full table scans without recreating the logic per transport. respond( state @@ -581,10 +582,10 @@ async fn timeline( .timeline(TimelineRequest { bucket: query.bucket, group_by: query.group_by, - from: query.from, - to: query.to, - hostname: query.hostname, - app_name: query.app_name, + since: query.since, + until: query.until, + host: query.host, + app: query.app, severity_min: query.severity_min, }) .await, @@ -592,11 +593,12 @@ async fn timeline( } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct PatternsQuery { - from: Option, - to: Option, - hostname: Option, - app_name: Option, + since: Option, + until: Option, + host: Option, + app: Option, severity_min: Option, scan_limit: Option, top_n: Option, @@ -610,10 +612,10 @@ async fn patterns( state .service .patterns(PatternsRequest { - from: query.from, - to: query.to, - hostname: query.hostname, - app_name: query.app_name, + since: query.since, + until: query.until, + host: query.host, + app: query.app, severity_min: query.severity_min, scan_limit: query.scan_limit, top_n: query.top_n, @@ -860,9 +862,9 @@ async fn compare( #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct AppsQuery { - hostname: Option, - from: Option, - to: Option, + host: Option, + since: Option, + until: Option, limit: Option, offset: Option, } @@ -872,9 +874,9 @@ async fn apps(State(state): State, Query(query): Query) -> state .service .list_apps(ListAppsRequest { - hostname: query.hostname, - from: query.from, - to: query.to, + host: query.host, + since: query.since, + until: query.until, limit: query.limit, offset: query.offset, }) @@ -886,11 +888,11 @@ async fn apps(State(state): State, Query(query): Query) -> #[serde(deny_unknown_fields)] struct SimilarIncidentsQuery { query: String, - hostname: Option, - app_name: Option, + host: Option, + app: Option, severity_min: Option, - from: Option, - to: Option, + since: Option, + until: Option, window_minutes: Option, limit: Option, } @@ -904,11 +906,11 @@ async fn similar_incidents( .service .similar_incidents(SimilarIncidentsRequest { query: q.query, - hostname: q.hostname, - app_name: q.app_name, + host: q.host, + app: q.app, severity_min: q.severity_min, - from: q.from, - to: q.to, + since: q.since, + until: q.until, window_minutes: q.window_minutes, limit: q.limit, }) @@ -919,10 +921,10 @@ async fn similar_incidents( #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct IncidentContextQuery { - from: String, - to: String, - hostname: Option, - app_name: Option, + since: String, + until: String, + host: Option, + app: Option, query: Option, severity_min: Option, limit: Option, @@ -936,10 +938,10 @@ async fn incident_context( state .service .incident_context(IncidentContextRequest { - from: q.from, - to: q.to, - hostname: q.hostname, - app_name: q.app_name, + since: q.since, + until: q.until, + host: q.host, + app: q.app, query: q.query, severity_min: q.severity_min, limit: q.limit, @@ -980,10 +982,10 @@ async fn graph_evidence( #[serde(deny_unknown_fields)] struct AskHistoryQuery { query: String, - hostname: Option, - app_name: Option, - from: Option, - to: Option, + host: Option, + app: Option, + since: Option, + until: Option, limit: Option, } @@ -996,10 +998,10 @@ async fn ai_ask_history( .service .ask_history(AskHistoryRequest { query: q.query, - hostname: q.hostname, - app_name: q.app_name, - from: q.from, - to: q.to, + host: q.host, + app: q.app, + since: q.since, + until: q.until, limit: q.limit, }) .await, @@ -1014,8 +1016,8 @@ async fn ai_ask_history( struct AiIncidentsQuery { project: Option, tool: Option, - from: Option, - to: Option, + since: Option, + until: Option, limit: Option, window_minutes: Option, #[serde(default)] @@ -1032,8 +1034,8 @@ async fn ai_incidents( .list_ai_incidents(AiIncidentRequest { project: q.project, tool: q.tool, - from: q.from, - to: q.to, + since: q.since, + until: q.until, limit: q.limit, window_minutes: q.window_minutes, terms: q.terms, @@ -1047,8 +1049,8 @@ async fn ai_incidents( struct AiInvestigateQuery { project: Option, tool: Option, - from: Option, - to: Option, + since: Option, + until: Option, limit: Option, window_minutes: Option, correlation_window_minutes: Option, @@ -1067,8 +1069,8 @@ async fn ai_investigate( incident_id: None, project: q.project, tool: q.tool, - from: q.from, - to: q.to, + since: q.since, + until: q.until, limit: q.limit, window_minutes: q.window_minutes, correlation_window_minutes: q.correlation_window_minutes, diff --git a/src/api_tests.rs b/src/api_tests.rs index 7e944448..69c5d08a 100644 --- a/src/api_tests.rs +++ b/src/api_tests.rs @@ -165,7 +165,7 @@ async fn file_tails_route_adds_and_lists_sources() { "id": "swag-access", "path": log_path, "tag": "swag-access", - "hostname": "squirts", + "host": "squirts", "facility": "local4", "severity": "info", "start_at_end": true @@ -459,8 +459,7 @@ async fn tail_route_returns_plain_api_json() { .unwrap(); let app = router(state).unwrap(); - let (status, value) = - get_json(app, "/api/tail?source_ip=10.0.0.2:514&n=5", Some("secret")).await; + let (status, value) = get_json(app, "/api/tail?source=10.0.0.2:514&n=5", Some("secret")).await; assert_eq!(status, axum::http::StatusCode::OK); assert!( value.get("content").is_none(), @@ -2181,7 +2180,7 @@ async fn incident_context_returns_200_with_token() { let app = test_router(state); let (status, _value) = get_json( app, - "/api/incident-context?from=2026-05-21T11:00:00Z&to=2026-05-21T13:00:00Z", + "/api/incident-context?since=2026-05-21T11:00:00Z&until=2026-05-21T13:00:00Z", Some("secret"), ) .await; @@ -2456,7 +2455,7 @@ async fn host_state_returns_400_for_invalid_since_timestamp() { let app = test_router(state); let (status, value) = get_json( app, - "/api/host-state?hostname=foo&since=not-a-timestamp", + "/api/host-state?host=foo&since=not-a-timestamp", Some("secret"), ) .await; @@ -2468,8 +2467,7 @@ async fn host_state_returns_400_for_invalid_since_timestamp() { async fn host_state_returns_404_for_unknown_host() { let (state, _pool, _dir) = test_state(Some("secret".into())); let app = test_router(state); - let (status, _value) = - get_json(app, "/api/host-state?hostname=nonexistent", Some("secret")).await; + let (status, _value) = get_json(app, "/api/host-state?host=nonexistent", Some("secret")).await; assert_eq!(status, axum::http::StatusCode::NOT_FOUND); } @@ -2601,7 +2599,7 @@ async fn graph_routes_return_shared_service_payloads() { async fn host_state_route_requires_bearer() { let (state, _pool, _dir) = test_state(Some("secret".into())); let app = router(state).unwrap(); - let (status, _) = get_json(app, "/api/host-state?hostname=foo", None).await; + let (status, _) = get_json(app, "/api/host-state?host=foo", None).await; assert_eq!(status, axum::http::StatusCode::UNAUTHORIZED); } @@ -2642,7 +2640,7 @@ async fn graph_routes_require_bearer() { async fn unknown_query_param_returns_400_on_host_state() { let (state, _pool, _dir) = test_state(Some("secret".into())); let app = router(state).unwrap(); - let (status, _) = get_json(app, "/api/host-state?hostname=foo&bogus=1", Some("secret")).await; + let (status, _) = get_json(app, "/api/host-state?host=foo&bogus=1", Some("secret")).await; assert_eq!(status, axum::http::StatusCode::BAD_REQUEST); } diff --git a/src/app/models/ai_incidents.rs b/src/app/models/ai_incidents.rs index 0e9448fb..316fa552 100644 --- a/src/app/models/ai_incidents.rs +++ b/src/app/models/ai_incidents.rs @@ -5,8 +5,8 @@ use super::*; pub struct AiIncidentRequest { pub project: Option, pub tool: Option, - pub from: Option, - pub to: Option, + pub since: Option, + pub until: Option, pub limit: Option, pub window_minutes: Option, #[serde(default)] @@ -75,8 +75,8 @@ pub struct AiInvestigateRequest { pub incident_id: Option, pub project: Option, pub tool: Option, - pub from: Option, - pub to: Option, + pub since: Option, + pub until: Option, pub limit: Option, pub window_minutes: Option, pub correlation_window_minutes: Option, @@ -149,8 +149,8 @@ pub struct AiAssessRequest { pub model: Option, pub project: Option, pub tool: Option, - pub from: Option, - pub to: Option, + pub since: Option, + pub until: Option, pub window_minutes: Option, pub correlation_window_minutes: Option, #[serde(default)] @@ -181,11 +181,11 @@ pub struct AiCorrelateRequest { pub session_id: Option, pub ai_query: Option, pub log_query: Option, - pub hostname: Option, - pub source_ip: Option, - pub app_name: Option, - pub from: Option, - pub to: Option, + pub host: Option, + pub source: Option, + pub app: Option, + pub since: Option, + pub until: Option, pub window_minutes: Option, pub severity_min: Option, pub limit: Option, diff --git a/src/app/models/ai_inventory.rs b/src/app/models/ai_inventory.rs index efa93171..03b27da4 100644 --- a/src/app/models/ai_inventory.rs +++ b/src/app/models/ai_inventory.rs @@ -5,8 +5,8 @@ use super::*; pub struct UsageBlocksRequest { pub project: Option, pub tool: Option, - pub from: Option, - pub to: Option, + pub since: Option, + pub until: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -90,8 +90,8 @@ impl From for ProjectContextResponse { #[serde(deny_unknown_fields)] pub struct ListAiToolsRequest { pub project: Option, - pub from: Option, - pub to: Option, + pub since: Option, + pub until: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -136,8 +136,8 @@ impl From for ListAiToolsResponse { #[serde(deny_unknown_fields)] pub struct ListAiProjectsRequest { pub tool: Option, - pub from: Option, - pub to: Option, + pub since: Option, + pub until: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/app/models/ai_sessions.rs b/src/app/models/ai_sessions.rs index f8f0ddc3..791cec74 100644 --- a/src/app/models/ai_sessions.rs +++ b/src/app/models/ai_sessions.rs @@ -5,9 +5,9 @@ use super::*; pub struct ListSessionsRequest { pub project: Option, pub tool: Option, - pub hostname: Option, - pub from: Option, - pub to: Option, + pub host: Option, + pub since: Option, + pub until: Option, pub limit: Option, } @@ -68,8 +68,8 @@ pub struct SearchSessionsRequest { pub query: String, pub project: Option, pub tool: Option, - pub from: Option, - pub to: Option, + pub since: Option, + pub until: Option, pub limit: Option, } @@ -154,8 +154,8 @@ impl From for SearchSessionsResponse { pub struct AbuseSearchRequest { pub project: Option, pub tool: Option, - pub from: Option, - pub to: Option, + pub since: Option, + pub until: Option, pub limit: Option, pub before: Option, pub after: Option, diff --git a/src/app/models/context.rs b/src/app/models/context.rs index 3b0ab106..45bf6226 100644 --- a/src/app/models/context.rs +++ b/src/app/models/context.rs @@ -4,7 +4,7 @@ use super::*; #[serde(deny_unknown_fields)] pub struct ContextRequest { pub log_id: Option, - pub hostname: Option, + pub host: Option, pub timestamp: Option, pub before: Option, pub after: Option, diff --git a/src/app/models/core.rs b/src/app/models/core.rs index 483f0d0b..e671f082 100644 --- a/src/app/models/core.rs +++ b/src/app/models/core.rs @@ -191,8 +191,8 @@ pub struct DbBackupResult { #[serde(deny_unknown_fields)] pub struct ServiceLogsRequest { pub service: String, - pub from: Option, - pub to: Option, + pub since: Option, + pub until: Option, pub tail: Option, } @@ -258,7 +258,7 @@ pub struct IncidentRequest { pub around: String, pub minutes: Option, pub service: Option, - pub hostname: Option, + pub host: Option, pub limit: Option, } @@ -330,7 +330,7 @@ impl From for LogEntry { #[serde(deny_unknown_fields)] pub struct HostStateRequest { pub host_id: Option, - pub hostname: Option, + pub host: Option, pub since: Option, pub limit: Option, } diff --git a/src/app/models/log_query.rs b/src/app/models/log_query.rs index ceb72dbd..e1368323 100644 --- a/src/app/models/log_query.rs +++ b/src/app/models/log_query.rs @@ -43,17 +43,17 @@ pub mod topology_findings { #[serde(deny_unknown_fields)] pub struct SearchLogsRequest { pub query: Option, - pub hostname: Option, - pub source_ip: Option, + pub host: Option, + pub source: Option, pub severity: Option, - pub app_name: Option, + pub app: Option, pub facility: Option, pub exclude_facility: Option, pub process_id: Option, - pub from: Option, - pub to: Option, - pub received_from: Option, - pub received_to: Option, + pub since: Option, + pub until: Option, + pub received_since: Option, + pub received_until: Option, pub limit: Option, pub source_kind: Option, pub tool: Option, @@ -68,17 +68,17 @@ pub struct SearchLogsRequest { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct FilterLogsRequest { - pub hostname: Option, - pub source_ip: Option, + pub host: Option, + pub source: Option, pub severity: Option, - pub app_name: Option, + pub app: Option, pub facility: Option, pub exclude_facility: Option, pub process_id: Option, - pub from: Option, - pub to: Option, - pub received_from: Option, - pub received_to: Option, + pub since: Option, + pub until: Option, + pub received_since: Option, + pub received_until: Option, pub limit: Option, pub source_kind: Option, pub tool: Option, @@ -99,9 +99,9 @@ pub struct SearchLogsResponse { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct TailLogsRequest { - pub hostname: Option, - pub source_ip: Option, - pub app_name: Option, + pub host: Option, + pub source: Option, + pub app: Option, /// Minimum severity to return (e.g. `warning` returns warning + worse). pub severity_min: Option, pub n: Option, @@ -131,8 +131,8 @@ impl From for ErrorSummaryEntry { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct GetErrorsRequest { - pub from: Option, - pub to: Option, + pub since: Option, + pub until: Option, /// Secondary grouping key. Currently supports `app_name`. pub group_by: Option, /// Max summary rows to return. Defaults to all rows; clamped by service. @@ -396,8 +396,8 @@ pub struct CorrelateEventsRequest { pub reference_time: String, pub window_minutes: Option, pub severity_min: Option, - pub hostname: Option, - pub source_ip: Option, + pub host: Option, + pub source: Option, pub query: Option, pub limit: Option, } diff --git a/src/app/models/rag.rs b/src/app/models/rag.rs index 426e2ae8..eadc99e5 100644 --- a/src/app/models/rag.rs +++ b/src/app/models/rag.rs @@ -8,11 +8,11 @@ use super::*; #[serde(deny_unknown_fields)] pub struct SimilarIncidentsRequest { pub query: String, - pub hostname: Option, - pub app_name: Option, + pub host: Option, + pub app: Option, pub severity_min: Option, - pub from: Option, - pub to: Option, + pub since: Option, + pub until: Option, /// Cluster window in minutes. Default 30, clamp 5..=120. pub window_minutes: Option, /// Max clusters to return. Default 10, clamp 1..=50. @@ -92,10 +92,10 @@ impl From for SimilarIncidentsResponse { #[serde(deny_unknown_fields)] pub struct AskHistoryRequest { pub query: String, - pub hostname: Option, - pub app_name: Option, - pub from: Option, - pub to: Option, + pub host: Option, + pub app: Option, + pub since: Option, + pub until: Option, /// Max sessions to return. Default 10, clamp 1..=50. pub limit: Option, } @@ -124,10 +124,10 @@ impl From for AskHistoryResponse { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct IncidentContextRequest { - pub from: String, - pub to: String, - pub hostname: Option, - pub app_name: Option, + pub since: String, + pub until: String, + pub host: Option, + pub app: Option, pub query: Option, pub severity_min: Option, /// Max error log rows. Default 50, clamp 1..=200. diff --git a/src/app/models/stats.rs b/src/app/models/stats.rs index 2a9510f8..43dc0b31 100644 --- a/src/app/models/stats.rs +++ b/src/app/models/stats.rs @@ -43,9 +43,9 @@ impl From for DbStats { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ListAppsRequest { - pub hostname: Option, - pub from: Option, - pub to: Option, + pub host: Option, + pub since: Option, + pub until: Option, /// Page size. Default 500, max 5000. pub limit: Option, /// Page offset. Default 0. @@ -139,10 +139,10 @@ impl From for SourceIpEntry { pub struct TimelineRequest { pub bucket: Option, pub group_by: Option, - pub from: Option, - pub to: Option, - pub hostname: Option, - pub app_name: Option, + pub since: Option, + pub until: Option, + pub host: Option, + pub app: Option, pub severity_min: Option, } @@ -179,10 +179,10 @@ impl From for TimelinePoint { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct PatternsRequest { - pub from: Option, - pub to: Option, - pub hostname: Option, - pub app_name: Option, + pub since: Option, + pub until: Option, + pub host: Option, + pub app: Option, pub severity_min: Option, pub scan_limit: Option, #[serde(alias = "limit")] diff --git a/src/app/service_tests.rs b/src/app/service_tests.rs index 49493fc5..feb3e7a3 100644 --- a/src/app/service_tests.rs +++ b/src/app/service_tests.rs @@ -792,7 +792,7 @@ async fn incident_returns_ordered_db_events_for_window() { around: "2026-01-01T00:05:00Z".into(), minutes: Some(5), service: None, - hostname: Some("host-a".into()), + host: Some("host-a".into()), limit: Some(10), }) .await @@ -841,8 +841,8 @@ async fn correlate_events_normalizes_window_groups_and_truncates() { reference_time: "2026-01-01T01:00:00+01:00".into(), window_minutes: Some(2), severity_min: Some("warning".into()), - hostname: None, - source_ip: None, + host: None, + source: None, query: None, limit: Some(1), }) @@ -1117,7 +1117,7 @@ async fn source_ip_filter_uses_network_sender_identity() { let response = service .search_logs(SearchLogsRequest { - source_ip: Some("10.0.0.2:514".into()), + source: Some("10.0.0.2:514".into()), ..Default::default() }) .await @@ -1344,8 +1344,8 @@ async fn run_gemini_assess_rejects_missing_incident_before_gemini() { model: Some("gemini-test-model".into()), project: None, tool: None, - from: None, - to: None, + since: None, + until: None, window_minutes: Some(30), correlation_window_minutes: Some(10), terms: vec!["panic".into()], @@ -1468,10 +1468,10 @@ async fn timeline_applies_default_lookback_only_when_from_and_to_both_absent() { .timeline(TimelineRequest { bucket: Some("day".into()), group_by: None, - from: None, - to: None, - hostname: None, - app_name: None, + since: None, + until: None, + host: None, + app: None, severity_min: None, }) .await @@ -1492,10 +1492,10 @@ async fn timeline_applies_default_lookback_only_when_from_and_to_both_absent() { .timeline(TimelineRequest { bucket: Some("day".into()), group_by: None, - from: None, - to: Some(to), - hostname: None, - app_name: None, + since: None, + until: Some(to), + host: None, + app: None, severity_min: None, }) .await @@ -1644,7 +1644,7 @@ async fn file_tails_add_list_disable_enable_remove_round_trip() { id: "swag-access".into(), path: log_path.to_string_lossy().into_owned(), tag: "swag-access".into(), - hostname: Some("squirts".into()), + host: Some("squirts".into()), facility: Some("local4".into()), severity: Some("info".into()), start_at_end: Some(true), @@ -1720,7 +1720,7 @@ async fn file_tails_list_and_status_do_not_reconcile() { id: "swag-access".into(), path: log_path.to_string_lossy().into_owned(), tag: "swag-access".into(), - hostname: Some("squirts".into()), + host: Some("squirts".into()), facility: Some("local4".into()), severity: Some("info".into()), start_at_end: Some(true), @@ -1761,7 +1761,7 @@ async fn file_tails_duplicate_add_is_rejected_without_resetting_checkpoint() { id: "swag-access".into(), path: log_path.to_string_lossy().into_owned(), tag: "swag-access".into(), - hostname: Some("squirts".into()), + host: Some("squirts".into()), facility: Some("local4".into()), severity: Some("info".into()), start_at_end: Some(true), @@ -1804,7 +1804,7 @@ async fn file_tails_reconcile_failure_reports_committed_mutation() { id: "swag-access".into(), path: log_path.to_string_lossy().into_owned(), tag: "swag-access".into(), - hostname: Some("squirts".into()), + host: Some("squirts".into()), facility: Some("local4".into()), severity: Some("info".into()), start_at_end: Some(true), @@ -1838,7 +1838,7 @@ async fn file_tails_mutations_reject_registry_only_query_mode() { id: "swag-access".into(), path: log_path.to_string_lossy().into_owned(), tag: "swag-access".into(), - hostname: Some("squirts".into()), + host: Some("squirts".into()), facility: Some("local4".into()), severity: Some("info".into()), start_at_end: Some(true), @@ -1891,7 +1891,7 @@ fn add_file_tail_request( id: id.into(), path: path.into(), tag: id.into(), - hostname: Some("squirts".into()), + host: Some("squirts".into()), facility: facility.map(str::to_string), severity: severity.map(str::to_string), start_at_end: Some(true), diff --git a/src/app/services/ai.rs b/src/app/services/ai.rs index 7408945b..0f68373f 100644 --- a/src/app/services/ai.rs +++ b/src/app/services/ai.rs @@ -5,8 +5,8 @@ impl CortexService { &self, req: ListSessionsRequest, ) -> ServiceResult { - let from = parse_optional_timestamp(req.from.as_deref(), "from")?; - let to = parse_optional_timestamp(req.to.as_deref(), "to")?; + let from = parse_optional_timestamp(req.since.as_deref(), "since")?; + let to = parse_optional_timestamp(req.until.as_deref(), "until")?; // The unbounded (no time-window) path reads from the periodically // refreshed rollup; expose its staleness so callers know the `as_of`. // Time-windowed queries run live, so no staleness applies. @@ -14,9 +14,9 @@ impl CortexService { let params = db::ListAiSessionsParams { ai_project: req.project, ai_tool: req.tool, - hostname: req.hostname, - from, - to, + host: req.host, + since: from, + until: to, limit: req.limit, }; let (rows, rollup_as_of) = self @@ -62,16 +62,16 @@ impl CortexService { if let Some(policy) = limit_clamped_to { req.limit = Some(policy.limit_cap); } - let from = parse_optional_timestamp(req.from.as_deref(), "from")?; - let to = parse_optional_timestamp(req.to.as_deref(), "to")?; + let from = parse_optional_timestamp(req.since.as_deref(), "since")?; + let to = parse_optional_timestamp(req.until.as_deref(), "until")?; let params = db::SearchAiSessionsParams { query: req.query, ai_project: req.project, ai_tool: req.tool, - hostname: None, - app_name: None, - from, - to, + host: None, + app: None, + since: from, + until: to, limit: req.limit, }; let result = self @@ -107,13 +107,13 @@ impl CortexService { if let Some(policy) = limit_clamped_to { req.limit = Some(policy.limit_cap); } - let from = parse_optional_timestamp(req.from.as_deref(), "from")?; - let to = parse_optional_timestamp(req.to.as_deref(), "to")?; + let from = parse_optional_timestamp(req.since.as_deref(), "since")?; + let to = parse_optional_timestamp(req.until.as_deref(), "until")?; let params = db::AiAbuseParams { ai_project: req.project, ai_tool: req.tool, - from, - to, + since: from, + until: to, limit: req.limit, before: req.before, after: req.after, @@ -136,8 +136,8 @@ impl CortexService { &self, req: AiIncidentRequest, ) -> ServiceResult { - let from = parse_optional_timestamp(req.from.as_deref(), "from")?; - let to = parse_optional_timestamp(req.to.as_deref(), "to")?; + let from = parse_optional_timestamp(req.since.as_deref(), "since")?; + let to = parse_optional_timestamp(req.until.as_deref(), "until")?; let result = self .run_db("list_ai_incidents", move |pool| { db::search_ai_incidents( @@ -145,8 +145,8 @@ impl CortexService { &db::AiIncidentParams { ai_project: req.project, ai_tool: req.tool, - from, - to, + since: from, + until: to, limit: req.limit, window_minutes: req.window_minutes, terms: req.terms, @@ -168,8 +168,8 @@ impl CortexService { &self, req: AiInvestigateRequest, ) -> ServiceResult { - let from = parse_optional_timestamp(req.from.as_deref(), "from")?; - let to = parse_optional_timestamp(req.to.as_deref(), "to")?; + let from = parse_optional_timestamp(req.since.as_deref(), "since")?; + let to = parse_optional_timestamp(req.until.as_deref(), "until")?; let result = self .run_db("investigate_ai_incidents", move |pool| { db::investigate_ai_incidents( @@ -178,8 +178,8 @@ impl CortexService { incident_id: req.incident_id, ai_project: req.project, ai_tool: req.tool, - from, - to, + since: from, + until: to, limit: req.limit, window_minutes: req.window_minutes, correlation_window_minutes: req.correlation_window_minutes, @@ -209,8 +209,8 @@ impl CortexService { policy: AiCorrelateLimitPolicy, ) -> ServiceResult { let (req, events_per_anchor_clamped_to) = req.normalize_limits(policy); - let from = parse_optional_timestamp(req.from.as_deref(), "from")?; - let to = parse_optional_timestamp(req.to.as_deref(), "to")?; + let from = parse_optional_timestamp(req.since.as_deref(), "since")?; + let to = parse_optional_timestamp(req.until.as_deref(), "until")?; let window = req.window_minutes.unwrap_or(5).clamp(1, 120); let related_limit = req .events_per_anchor @@ -229,14 +229,14 @@ impl CortexService { ai_tool: req.tool, ai_session_id: req.session_id, ai_query: req.ai_query, - from, - to, + since: from, + until: to, limit: Some(anchor_limit), }; let log_query = req.log_query; - let hostname = req.hostname; - let source_ip = req.source_ip; - let app_name = req.app_name; + let hostname = req.host; + let source_ip = req.source; + let app_name = req.app; type CorrelateDbResult = ( bool, @@ -273,10 +273,10 @@ impl CortexService { let related_params = db::AiRelatedLogsParams { windows, query: log_query, - hostname, - source_ip, + host: hostname, + source: source_ip, severity_in: severity_levels, - app_name, + app: app_name, limit_per_anchor: related_limit, }; let related_by_anchor = db::search_ai_related_logs(pool, &related_params)?; @@ -328,13 +328,13 @@ impl CortexService { &self, req: UsageBlocksRequest, ) -> ServiceResult { - let from = parse_optional_timestamp(req.from.as_deref(), "from")?; - let to = parse_optional_timestamp(req.to.as_deref(), "to")?; + let from = parse_optional_timestamp(req.since.as_deref(), "since")?; + let to = parse_optional_timestamp(req.until.as_deref(), "until")?; let params = db::AiUsageBlocksParams { ai_project: req.project, ai_tool: req.tool, - from, - to, + since: from, + until: to, }; let result = self .run_db("usage_blocks", move |pool| { @@ -365,12 +365,12 @@ impl CortexService { &self, req: ListAiToolsRequest, ) -> ServiceResult { - let from = parse_optional_timestamp(req.from.as_deref(), "from")?; - let to = parse_optional_timestamp(req.to.as_deref(), "to")?; + let from = parse_optional_timestamp(req.since.as_deref(), "since")?; + let to = parse_optional_timestamp(req.until.as_deref(), "until")?; let params = db::ListAiToolsParams { ai_project: req.project, - from, - to, + since: from, + until: to, }; let result = self .run_db("list_ai_tools", move |pool| { @@ -384,12 +384,12 @@ impl CortexService { &self, req: ListAiProjectsRequest, ) -> ServiceResult { - let from = parse_optional_timestamp(req.from.as_deref(), "from")?; - let to = parse_optional_timestamp(req.to.as_deref(), "to")?; + let from = parse_optional_timestamp(req.since.as_deref(), "since")?; + let to = parse_optional_timestamp(req.until.as_deref(), "until")?; let params = db::ListAiProjectsParams { ai_tool: req.tool, - from, - to, + since: from, + until: to, }; let result = self .run_db("list_ai_projects", move |pool| { @@ -414,19 +414,19 @@ impl CortexService { let limit = req.limit.unwrap_or(500).min(999); let params = SearchParams { query: req.query, - hostname: req.hostname, - source_ip: req.source_ip, + host: req.host, + source: req.source, source_ip_prefix: None, severity: None, severity_in: Some(severity_levels), - app_name: None, + app: None, facility: None, exclude_facility: None, process_id: None, - from: Some(from.clone()), - to: Some(to.clone()), - received_from: None, - received_to: None, + since: Some(from.clone()), + until: Some(to.clone()), + received_since: None, + received_until: None, limit: Some(limit + 1), ai_tool: None, ai_project: None, diff --git a/src/app/services/analytics.rs b/src/app/services/analytics.rs index e4d5ebd9..55afd79c 100644 --- a/src/app/services/analytics.rs +++ b/src/app/services/analytics.rs @@ -2,14 +2,14 @@ use super::*; impl CortexService { pub async fn list_apps(&self, req: ListAppsRequest) -> ServiceResult { - let from = parse_optional_timestamp(req.from.as_deref(), "from")?; - let to = parse_optional_timestamp(req.to.as_deref(), "to")?; + let from = parse_optional_timestamp(req.since.as_deref(), "since")?; + let to = parse_optional_timestamp(req.until.as_deref(), "until")?; let result = self .run_db("list_apps", move |pool| { db::list_apps( pool, &db::ListAppsParams { - hostname: req.hostname.as_deref(), + hostname: req.host.as_deref(), from: from.as_deref(), to: to.as_deref(), limit: req.limit.unwrap_or(500) as usize, @@ -67,14 +67,14 @@ impl CortexService { // or we'd create an impossible range. All transport call sites (api.rs, // mcp/tools.rs, cli/dispatch_surface.rs) now pass `from`/`to` through // verbatim, so this is the single source of truth. - let from_raw = match (req.from, req.to.is_some()) { + let from_raw = match (req.since, req.until.is_some()) { (None, false) => chrono::Utc::now() .checked_sub_signed(chrono::Duration::days(bucket.default_lookback_days())) .map(|dt| dt.to_rfc3339()), (other, _) => other, }; - let from = parse_optional_timestamp(from_raw.as_deref(), "from")?; - let to = parse_optional_timestamp(req.to.as_deref(), "to")?; + let from = parse_optional_timestamp(from_raw.as_deref(), "since")?; + let to = parse_optional_timestamp(req.until.as_deref(), "until")?; let severity_in = match req.severity_min.as_deref() { Some(min) => Some(severity_at_or_above(min)?), None => None, @@ -91,8 +91,8 @@ impl CortexService { group_by, from.as_deref(), to.as_deref(), - req.hostname.as_deref(), - req.app_name.as_deref(), + req.host.as_deref(), + req.app.as_deref(), severity_in.as_deref(), )?; let as_of = if served_by_rollup { @@ -112,8 +112,8 @@ impl CortexService { } pub async fn patterns(&self, req: PatternsRequest) -> ServiceResult { - let from = parse_optional_timestamp(req.from.as_deref(), "from")?; - let to = parse_optional_timestamp(req.to.as_deref(), "to")?; + let from = parse_optional_timestamp(req.since.as_deref(), "since")?; + let to = parse_optional_timestamp(req.until.as_deref(), "until")?; let severity_in = match req.severity_min.as_deref() { Some(min) => Some(severity_at_or_above(min)?), None => None, @@ -126,8 +126,8 @@ impl CortexService { pool, from.as_deref(), to.as_deref(), - req.hostname.as_deref(), - req.app_name.as_deref(), + req.host.as_deref(), + req.app.as_deref(), severity_in.as_deref(), scan_limit, )?; @@ -186,7 +186,7 @@ impl CortexService { (entry, row.hostname, row.timestamp, Some(row.id)) } else { let hostname = req - .hostname + .host .clone() .ok_or_else(|| anyhow::anyhow!("context_missing_pivot"))?; let timestamp = synthetic_timestamp @@ -229,7 +229,7 @@ impl CortexService { let msg = inner.to_string(); if msg == "context_missing_pivot" { ServiceError::InvalidInput( - "Either `log_id` or both `hostname` + `timestamp` are required".into(), + "Either `log_id` or both `host` + `timestamp` are required".into(), ) } else if let Some(id) = msg.strip_prefix("context_log_not_found:") { ServiceError::NotFound(format!("No log found for id {id}")) diff --git a/src/app/services/assessment.rs b/src/app/services/assessment.rs index d9d96d44..a2c79165 100644 --- a/src/app/services/assessment.rs +++ b/src/app/services/assessment.rs @@ -19,8 +19,8 @@ impl CortexService { incident_id: Some(incident_id.clone()), project: req.project, tool: req.tool, - from: req.from, - to: req.to, + since: req.since, + until: req.until, limit: Some(req.limit.unwrap_or(200).max(200)), window_minutes: req.window_minutes, correlation_window_minutes: req.correlation_window_minutes, diff --git a/src/app/services/filters.rs b/src/app/services/filters.rs index fcc7769f..4018c447 100644 --- a/src/app/services/filters.rs +++ b/src/app/services/filters.rs @@ -2,17 +2,17 @@ use super::*; pub(super) fn search_request_to_params(req: SearchLogsRequest) -> ServiceResult { request_parts_to_params(FilterRequestParts { - hostname: req.hostname.clone(), - source_ip: req.source_ip.clone(), + hostname: req.host.clone(), + source_ip: req.source.clone(), severity: req.severity, - app_name: req.app_name.clone(), + app_name: req.app.clone(), facility: req.facility.clone(), exclude_facility: req.exclude_facility.clone(), process_id: req.process_id.clone(), - from: req.from, - to: req.to, - received_from: req.received_from, - received_to: req.received_to, + from: req.since, + to: req.until, + received_since: req.received_since, + received_until: req.received_until, limit: req.limit, source_kind: req.source_kind, tool: req.tool, @@ -27,17 +27,17 @@ pub(super) fn search_request_to_params(req: SearchLogsRequest) -> ServiceResult< pub(super) fn filter_request_to_params(req: FilterLogsRequest) -> ServiceResult { request_parts_to_params(FilterRequestParts { - hostname: req.hostname, - source_ip: req.source_ip, + hostname: req.host, + source_ip: req.source, severity: req.severity, - app_name: req.app_name, + app_name: req.app, facility: req.facility, exclude_facility: req.exclude_facility, process_id: req.process_id, - from: req.from, - to: req.to, - received_from: req.received_from, - received_to: req.received_to, + from: req.since, + to: req.until, + received_since: req.received_since, + received_until: req.received_until, limit: req.limit, source_kind: req.source_kind, tool: req.tool, @@ -60,8 +60,8 @@ struct FilterRequestParts { process_id: Option, from: Option, to: Option, - received_from: Option, - received_to: Option, + received_since: Option, + received_until: Option, limit: Option, source_kind: Option, tool: Option, @@ -77,19 +77,19 @@ fn request_parts_to_params(req: FilterRequestParts) -> ServiceResult { if let Some(container) = &req.container { - params.app_name.get_or_insert_with(|| container.clone()); + params.app.get_or_insert_with(|| container.clone()); } } Some("docker-stream") => { params.source_ip_prefix = Some(docker_source_prefix("docker://", req)); if let Some(container) = &req.container { - params.app_name.get_or_insert_with(|| container.clone()); + params.app.get_or_insert_with(|| container.clone()); } } Some("docker-event") => { params.source_ip_prefix = Some(docker_source_prefix("docker-event://", req)); if let Some(container) = &req.container { - params.app_name.get_or_insert_with(|| container.clone()); + params.app.get_or_insert_with(|| container.clone()); } } Some("agent-command") => { @@ -158,7 +158,7 @@ fn apply_log_filter_aliases( } Some("syslog-udp") | Some("syslog-tcp") | Some("otlp") => { return Err(ServiceError::InvalidInput(format!( - "source_kind={} is not indexed separately in v1; filter by hostname, source_ip, app_name, facility, and time range instead", + "source_kind={} is not indexed separately in v1; filter by host, source, app, facility, and time range instead", source_kind.unwrap() ))); } diff --git a/src/app/services/incidents.rs b/src/app/services/incidents.rs index ea8e9c2c..c7fc8a12 100644 --- a/src/app/services/incidents.rs +++ b/src/app/services/incidents.rs @@ -50,10 +50,10 @@ fn incident_sort_key(timestamp: &str) -> i64 { impl CortexService { pub async fn incident(&self, req: IncidentRequest) -> ServiceResult { - if req.hostname.is_some() && req.service.is_some() { + if req.host.is_some() && req.service.is_some() { return Err(ServiceError::InvalidInput( - "hostname and service cannot be combined: journal entries are always local \ - and cannot be filtered by remote hostname" + "host and service cannot be combined: journal entries are always local \ + and cannot be filtered by remote host" .into(), )); } @@ -67,19 +67,19 @@ impl CortexService { let app_name = req.service.as_deref().map(service_app_filter); let params = SearchParams { query: None, - hostname: req.hostname.clone(), - source_ip: None, + host: req.host.clone(), + source: None, source_ip_prefix: None, severity: None, severity_in: None, - app_name, + app: app_name, facility: None, exclude_facility: None, process_id: None, - from: Some(from.clone()), - to: Some(to.clone()), - received_from: None, - received_to: None, + since: Some(from.clone()), + until: Some(to.clone()), + received_since: None, + received_until: None, limit: Some(limit + 1), ai_tool: None, ai_project: None, @@ -103,8 +103,8 @@ impl CortexService { match self .service_logs(ServiceLogsRequest { service, - from: Some(from.clone()), - to: Some(to.clone()), + since: Some(from.clone()), + until: Some(to.clone()), tail: Some(limit.saturating_add(1)), }) .await diff --git a/src/app/services/journal.rs b/src/app/services/journal.rs index e48915a1..a74e4528 100644 --- a/src/app/services/journal.rs +++ b/src/app/services/journal.rs @@ -94,17 +94,18 @@ pub async fn run_service_logs( "--output".to_string(), "json".to_string(), ]; - if let Some(from) = &req.from { + if let Some(from) = &req.since { // Validate as RFC 3339 before passing to journalctl to prevent // argument injection (e.g. "--rotate", "--vacuum-size=1"). - chrono::DateTime::parse_from_rfc3339(from) - .map_err(|_| ServiceError::InvalidInput(format!("invalid `from` timestamp: {from}")))?; + chrono::DateTime::parse_from_rfc3339(from).map_err(|_| { + ServiceError::InvalidInput(format!("invalid `since` timestamp: {from}")) + })?; args.push("--since".to_string()); args.push(from.clone()); } - if let Some(to) = &req.to { + if let Some(to) = &req.until { chrono::DateTime::parse_from_rfc3339(to) - .map_err(|_| ServiceError::InvalidInput(format!("invalid `to` timestamp: {to}")))?; + .map_err(|_| ServiceError::InvalidInput(format!("invalid `until` timestamp: {to}")))?; args.push("--until".to_string()); args.push(to.clone()); } @@ -125,8 +126,8 @@ pub async fn run_service_logs( } Ok(ServiceLogsResponse { service, - from: req.from, - to: req.to, + from: req.since, + to: req.until, tail, entries, dropped_lines, diff --git a/src/app/services/logs.rs b/src/app/services/logs.rs index 54f86e96..094d56ec 100644 --- a/src/app/services/logs.rs +++ b/src/app/services/logs.rs @@ -29,7 +29,7 @@ impl CortexService { &self, req: models::HostStateRequest, ) -> ServiceResult { - let lookup = match (req.host_id, req.hostname) { + let lookup = match (req.host_id, req.host) { (Some(host_id), _) if !host_id.trim().is_empty() => { db::HeartbeatHostLookup::HostId(host_id) } @@ -38,7 +38,7 @@ impl CortexService { } _ => { return Err(ServiceError::InvalidInput( - "host_state requires host_id or hostname".into(), + "host_state requires host_id or host".into(), )); } }; @@ -235,9 +235,9 @@ impl CortexService { db::search_logs( pool, &db::SearchParams { - hostname: Some(hostname_filter.clone()), - from: Some(from3), - to: Some(to3), + host: Some(hostname_filter.clone()), + since: Some(from3), + until: Some(to3), severity_in: Some(sev_levels), limit: Some(fetch_limit as u32), // correlate_state correlates non-AI logs with heartbeat @@ -298,9 +298,9 @@ impl CortexService { .run_db("tail_logs", move |pool| { db::tail_logs( pool, - req.hostname.as_deref(), - req.source_ip.as_deref(), - req.app_name.as_deref(), + req.host.as_deref(), + req.source.as_deref(), + req.app.as_deref(), severity_in.as_deref(), req.n.unwrap_or(50), ) @@ -314,8 +314,8 @@ impl CortexService { } pub async fn get_errors(&self, req: GetErrorsRequest) -> ServiceResult { - let from = parse_optional_timestamp(req.from.as_deref(), "from")?; - let to = parse_optional_timestamp(req.to.as_deref(), "to")?; + let from = parse_optional_timestamp(req.since.as_deref(), "since")?; + let to = parse_optional_timestamp(req.until.as_deref(), "until")?; let group_by_app = match req.group_by.as_deref() { None => false, Some("app_name") | Some("app") => true, diff --git a/src/app/services/rag.rs b/src/app/services/rag.rs index d196976c..88197bc9 100644 --- a/src/app/services/rag.rs +++ b/src/app/services/rag.rs @@ -121,8 +121,8 @@ impl CortexService { &self, req: SimilarIncidentsRequest, ) -> ServiceResult { - let from = parse_optional_timestamp(req.from.as_deref(), "from")?; - let to = parse_optional_timestamp(req.to.as_deref(), "to")?; + let from = parse_optional_timestamp(req.since.as_deref(), "since")?; + let to = parse_optional_timestamp(req.until.as_deref(), "until")?; let severity_min = validate_optional_severity(req.severity_min)?; let result = self .run_db("similar_incidents", move |pool| { @@ -130,11 +130,11 @@ impl CortexService { pool, &db::SimilarIncidentsParams { query: req.query, - hostname: req.hostname, - app_name: req.app_name, + host: req.host, + app: req.app, severity_min, - from, - to, + since: from, + until: to, window_minutes: req.window_minutes, limit: req.limit, }, @@ -145,18 +145,18 @@ impl CortexService { } pub async fn ask_history(&self, req: AskHistoryRequest) -> ServiceResult { - let from = parse_optional_timestamp(req.from.as_deref(), "from")?; - let to = parse_optional_timestamp(req.to.as_deref(), "to")?; + let from = parse_optional_timestamp(req.since.as_deref(), "since")?; + let to = parse_optional_timestamp(req.until.as_deref(), "until")?; let result = self .run_db("ask_history", move |pool| { db::ask_history_sessions( pool, &db::AskHistoryParams { query: req.query, - hostname: req.hostname, - app_name: req.app_name, - from, - to, + host: req.host, + app: req.app, + since: from, + until: to, limit: req.limit, }, ) @@ -170,17 +170,17 @@ impl CortexService { req: IncidentContextRequest, ) -> ServiceResult { // Both from and to are required — validate and normalize to rfc3339_z format. - let from = rfc3339_z(parse_required_timestamp(&req.from, "from")?); - let to = rfc3339_z(parse_required_timestamp(&req.to, "to")?); + let from = rfc3339_z(parse_required_timestamp(&req.since, "since")?); + let to = rfc3339_z(parse_required_timestamp(&req.until, "until")?); let result = self .run_db("incident_context", move |pool| { db::incident_context_summary( pool, &db::IncidentContextParams { - from, - to, - hostname: req.hostname, - app_name: req.app_name, + since: from, + until: to, + host: req.host, + app: req.app, // req.query accepted but deferred to v2 FTS integration severity_min: req.severity_min, limit: req.limit, diff --git a/src/cli.rs b/src/cli.rs index 17ae842f..3306aa27 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -36,6 +36,8 @@ pub(crate) use run::{CliMode, GlobalFlags, run}; mod ai_watch; pub(crate) mod color; +mod complete; +mod completions; mod config_cmd; mod config_toml; mod coordination; @@ -77,6 +79,45 @@ impl CliCommand { } } +// ── Registry facade: CLI command names (hyphenated) ↔ ACTION_SPECS metadata +// (MCP action names are underscored). Used by completion + discoverability help. + +/// All CLI command names paired with their one-line description (empty when the +/// command has no `ACTION_SPECS` entry, e.g. grouping commands like `ai`). +pub(crate) fn registry_actions() -> Vec<(&'static str, &'static str)> { + parse::TOP_LEVEL_COMMANDS + .iter() + .map(|&cmd| { + let desc = cortex::mcp::description_for(&cmd.replace('-', "_")).unwrap_or(""); + (cmd, desc) + }) + .collect() +} + +/// Canonical flag metadata for a CLI command (empty slice when none). +pub(crate) fn registry_flags(cli_command: &str) -> &'static [cortex::mcp::FlagSpec] { + cortex::mcp::flags_for(&cli_command.replace('-', "_")).unwrap_or(&[]) +} + +/// Copy-paste examples for a CLI command (empty slice when none). +pub(crate) fn registry_examples(cli_command: &str) -> &'static [&'static str] { + cortex::mcp::examples_for(&cli_command.replace('-', "_")).unwrap_or(&[]) +} + +/// `cortex __complete ...` — print shell-completion candidates to stdout. +pub(crate) fn run_complete(args: &[String]) -> Result<()> { + for line in complete::complete(args)? { + println!("{line}"); + } + Ok(()) +} + +/// `cortex completions ` — print a completion script to stdout. +pub(crate) fn run_completions(args: &[String]) -> Result<()> { + let shell = args.first().map(|s| s.as_str()).unwrap_or("zsh"); + completions::print_completions(shell) +} + pub(crate) fn run_compose(command: CliCommand) -> Result<()> { let CliCommand::Compose(command) = command else { bail!("run_compose called with non-compose command"); @@ -178,8 +219,8 @@ pub(crate) async fn run_service_no_db(command: CliCommand) -> Result<()> { let report = cortex::app::run_service_logs( ServiceLogsRequest { service: args.service, - from: args.from, - to: args.to, + since: args.since, + until: args.until, tail: args.tail, }, &cortex::app::SystemOsAdapter, diff --git a/src/cli/ai_watch.rs b/src/cli/ai_watch.rs index 3dde45c7..36494971 100644 --- a/src/cli/ai_watch.rs +++ b/src/cli/ai_watch.rs @@ -40,8 +40,8 @@ pub(crate) async fn ai_smoke_watch(service: &CortexService) -> Result ...`). + Complete(Vec), + /// Emit a shell completion script (`cortex completions `). + Completions(Vec), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -81,7 +85,7 @@ pub(crate) struct FileTailAddArgs { pub id: String, pub path: String, pub tag: String, - pub hostname: Option, + pub host: Option, pub facility: Option, pub severity: Option, pub start_at_end: bool, @@ -316,8 +320,8 @@ pub(crate) struct ComposeLogsArgs { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ServiceLogsArgs { pub service: String, - pub from: Option, - pub to: Option, + pub since: Option, + pub until: Option, pub tail: Option, pub json: bool, } @@ -326,8 +330,8 @@ impl Default for ServiceLogsArgs { fn default() -> Self { Self { service: String::new(), - from: None, - to: None, + since: None, + until: None, tail: Some(200), json: false, } @@ -339,7 +343,7 @@ pub(crate) struct IncidentArgs { pub around: String, pub minutes: Option, pub service: Option, - pub hostname: Option, + pub host: Option, pub limit: Option, pub json: bool, } @@ -348,9 +352,9 @@ pub(crate) struct IncidentArgs { pub(crate) struct SessionsArgs { pub project: Option, pub tool: Option, - pub hostname: Option, - pub from: Option, - pub to: Option, + pub host: Option, + pub since: Option, + pub until: Option, pub limit: Option, pub json: bool, } @@ -360,33 +364,33 @@ pub(crate) struct SearchArgs { pub query: Option, /// Literal substring text (FTS5-safe); mutually exclusive with `query`. pub grep: Option, - pub hostname: Option, - pub source_ip: Option, + pub host: Option, + pub source: Option, pub severity: Option, - pub app_name: Option, + pub app: Option, pub facility: Option, pub exclude_facility: Option, - pub from: Option, - pub to: Option, - pub received_from: Option, - pub received_to: Option, + pub since: Option, + pub until: Option, + pub received_since: Option, + pub received_until: Option, pub limit: Option, pub json: bool, } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct FilterArgs { - pub hostname: Option, - pub source_ip: Option, + pub host: Option, + pub source: Option, pub severity: Option, - pub app_name: Option, + pub app: Option, pub facility: Option, pub exclude_facility: Option, pub process_id: Option, - pub from: Option, - pub to: Option, - pub received_from: Option, - pub received_to: Option, + pub since: Option, + pub until: Option, + pub received_since: Option, + pub received_until: Option, pub limit: Option, pub source_kind: Option, pub tool: Option, @@ -401,17 +405,17 @@ pub(crate) struct FilterArgs { #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct TailArgs { - pub hostname: Option, - pub source_ip: Option, - pub app_name: Option, + pub host: Option, + pub source: Option, + pub app: Option, pub n: Option, pub json: bool, } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct TimeRangeArgs { - pub from: Option, - pub to: Option, + pub since: Option, + pub until: Option, pub limit: Option, pub json: bool, } @@ -421,8 +425,8 @@ pub(crate) struct CorrelateArgs { pub reference_time: String, pub window_minutes: Option, pub severity_min: Option, - pub hostname: Option, - pub source_ip: Option, + pub host: Option, + pub source: Option, pub query: Option, pub limit: Option, pub json: bool, @@ -439,20 +443,20 @@ pub(crate) struct SourceIpsArgs { pub(crate) struct TimelineArgs { pub bucket: Option, pub group_by: Option, - pub from: Option, - pub to: Option, - pub hostname: Option, - pub app_name: Option, + pub since: Option, + pub until: Option, + pub host: Option, + pub app: Option, pub severity_min: Option, pub json: bool, } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct PatternsArgs { - pub from: Option, - pub to: Option, - pub hostname: Option, - pub app_name: Option, + pub since: Option, + pub until: Option, + pub host: Option, + pub app: Option, pub severity_min: Option, pub scan_limit: Option, pub top_n: Option, diff --git a/src/cli/args/ai.rs b/src/cli/args/ai.rs index 51acf728..65eb7ad2 100644 --- a/src/cli/args/ai.rs +++ b/src/cli/args/ai.rs @@ -35,8 +35,8 @@ pub(crate) struct AiSearchArgs { pub query: String, pub project: Option, pub tool: Option, - pub from: Option, - pub to: Option, + pub since: Option, + pub until: Option, pub limit: Option, pub json: bool, } @@ -45,8 +45,8 @@ pub(crate) struct AiSearchArgs { pub(crate) struct AiAbuseArgs { pub project: Option, pub tool: Option, - pub from: Option, - pub to: Option, + pub since: Option, + pub until: Option, pub limit: Option, pub before: Option, pub after: Option, @@ -61,11 +61,11 @@ pub(crate) struct AiCorrelateArgs { pub session_id: Option, pub ai_query: Option, pub log_query: Option, - pub hostname: Option, - pub source_ip: Option, - pub app_name: Option, - pub from: Option, - pub to: Option, + pub host: Option, + pub source: Option, + pub app: Option, + pub since: Option, + pub until: Option, pub window_minutes: Option, pub severity_min: Option, pub limit: Option, @@ -77,8 +77,8 @@ pub(crate) struct AiCorrelateArgs { pub(crate) struct AiBlocksArgs { pub project: Option, pub tool: Option, - pub from: Option, - pub to: Option, + pub since: Option, + pub until: Option, pub limit: Option, pub detail: AiOutputDetail, pub json: bool, @@ -117,8 +117,8 @@ pub(crate) struct AiContextArgs { pub(crate) struct AiListArgs { pub project: Option, pub tool: Option, - pub from: Option, - pub to: Option, + pub since: Option, + pub until: Option, pub json: bool, } @@ -185,11 +185,11 @@ pub(crate) struct AiPruneCheckpointsArgs { #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct AiSimilarArgs { pub query: String, - pub hostname: Option, - pub app_name: Option, + pub host: Option, + pub app: Option, pub severity_min: Option, - pub from: Option, - pub to: Option, + pub since: Option, + pub until: Option, pub window_minutes: Option, pub limit: Option, pub json: bool, @@ -198,20 +198,20 @@ pub(crate) struct AiSimilarArgs { #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct AiAskHistoryArgs { pub query: String, - pub hostname: Option, - pub app_name: Option, - pub from: Option, - pub to: Option, + pub host: Option, + pub app: Option, + pub since: Option, + pub until: Option, pub limit: Option, pub json: bool, } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct AiIncidentContextArgs { - pub from: String, - pub to: String, - pub hostname: Option, - pub app_name: Option, + pub since: String, + pub until: String, + pub host: Option, + pub app: Option, pub query: Option, pub severity_min: Option, pub limit: Option, @@ -222,8 +222,8 @@ pub(crate) struct AiIncidentContextArgs { pub(crate) struct AiIncidentsArgs { pub project: Option, pub tool: Option, - pub from: Option, - pub to: Option, + pub since: Option, + pub until: Option, pub limit: Option, pub window_minutes: Option, pub terms: Vec, @@ -234,8 +234,8 @@ pub(crate) struct AiIncidentsArgs { pub(crate) struct AiInvestigateArgs { pub project: Option, pub tool: Option, - pub from: Option, - pub to: Option, + pub since: Option, + pub until: Option, pub limit: Option, pub window_minutes: Option, pub correlation_window_minutes: Option, @@ -253,8 +253,8 @@ pub(crate) struct AiAssessArgs { pub json: bool, pub project: Option, pub tool: Option, - pub from: Option, - pub to: Option, + pub since: Option, + pub until: Option, pub window_minutes: Option, pub correlation_window_minutes: Option, pub terms: Vec, diff --git a/src/cli/args/surface.rs b/src/cli/args/surface.rs index 60a65b58..737a7834 100644 --- a/src/cli/args/surface.rs +++ b/src/cli/args/surface.rs @@ -29,9 +29,9 @@ pub(crate) struct CompareArgs { #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct AppsArgs { - pub hostname: Option, - pub from: Option, - pub to: Option, + pub host: Option, + pub since: Option, + pub until: Option, pub limit: Option, pub offset: Option, pub json: bool, @@ -42,7 +42,7 @@ pub(crate) struct AppsArgs { #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct HostStateArgs { pub host_id: Option, - pub hostname: Option, + pub host: Option, pub since: Option, pub limit: Option, pub json: bool, diff --git a/src/cli/commands/apps.rs b/src/cli/commands/apps.rs index 162a00fd..cdfe29bf 100644 --- a/src/cli/commands/apps.rs +++ b/src/cli/commands/apps.rs @@ -14,12 +14,12 @@ pub(crate) fn parse_apps(args: &[String]) -> Result { while let Some(arg) = flags.next() { if arg == "--json" { parsed.json = true; - } else if let Some(v) = flags.match_value(&arg, "--hostname")? { - parsed.hostname = Some(v); - } else if let Some(v) = flags.match_value(&arg, "--from")? { - parsed.from = Some(v); - } else if let Some(v) = flags.match_value(&arg, "--to")? { - parsed.to = Some(v); + } else if let Some(v) = flags.match_value(&arg, "--host")? { + parsed.host = Some(v); + } else if let Some(v) = flags.match_value(&arg, "--since")? { + parsed.since = Some(v); + } else if let Some(v) = flags.match_value(&arg, "--until")? { + parsed.until = Some(v); } else if let Some(v) = flags.match_value(&arg, "--limit")? { parsed.limit = Some(parse_u32_flag("--limit", v)?); } else if let Some(v) = flags.match_value(&arg, "--offset")? { diff --git a/src/cli/commands/file_tails.rs b/src/cli/commands/file_tails.rs index 0122b63f..59462229 100644 --- a/src/cli/commands/file_tails.rs +++ b/src/cli/commands/file_tails.rs @@ -81,7 +81,7 @@ fn parse_add(args: &[String]) -> Result { id: String::new(), path: String::new(), tag: String::new(), - hostname: None, + host: None, facility: None, severity: None, start_at_end: true, @@ -102,9 +102,9 @@ fn parse_add(args: &[String]) -> Result { i += 1; out.tag = required(args, i, "--tag")?; } - "--hostname" => { + "--host" => { i += 1; - out.hostname = Some(required(args, i, "--hostname")?); + out.host = Some(required(args, i, "--host")?); } "--facility" => { i += 1; @@ -126,7 +126,7 @@ fn parse_add(args: &[String]) -> Result { "--id", "--path", "--tag", - "--hostname", + "--host", "--facility", "--severity", "--from-start", @@ -137,8 +137,8 @@ fn parse_add(args: &[String]) -> Result { } i += 1; } - if out.id.is_empty() || out.path.is_empty() || out.tag.is_empty() || out.hostname.is_none() { - bail!("file-tail add requires --id, --path, --tag, and --hostname"); + if out.id.is_empty() || out.path.is_empty() || out.tag.is_empty() || out.host.is_none() { + bail!("file-tail add requires --id, --path, --tag, and --host"); } Ok(out) } @@ -154,5 +154,5 @@ fn required(args: &[String], index: usize, flag: &str) -> Result { } fn usage() -> &'static str { - "Usage: cortex file-tail list [--json]\n cortex file-tail status [--json]\n cortex file-tail add --id ID --path PATH --tag TAG --hostname HOST [--facility FACILITY] [--severity SEVERITY] [--from-start] [--json]\n cortex file-tail remove --id ID [--json]\n cortex file-tail enable --id ID [--json]\n cortex file-tail disable --id ID [--json]" + "Usage: cortex file-tail list [--json]\n cortex file-tail status [--json]\n cortex file-tail add --id ID --path PATH --tag TAG --host HOST [--facility FACILITY] [--severity SEVERITY] [--from-start] [--json]\n cortex file-tail remove --id ID [--json]\n cortex file-tail enable --id ID [--json]\n cortex file-tail disable --id ID [--json]" } diff --git a/src/cli/commands/host_state.rs b/src/cli/commands/host_state.rs index 916d8115..d11afdca 100644 --- a/src/cli/commands/host_state.rs +++ b/src/cli/commands/host_state.rs @@ -16,8 +16,8 @@ pub(crate) fn parse_host_state(args: &[String]) -> Result { parsed.json = true; } else if let Some(v) = flags.match_value(&arg, "--host-id")? { parsed.host_id = Some(v); - } else if let Some(v) = flags.match_value(&arg, "--hostname")? { - parsed.hostname = Some(v); + } else if let Some(v) = flags.match_value(&arg, "--host")? { + parsed.host = Some(v); } else if let Some(v) = flags.match_value(&arg, "--since")? { parsed.since = Some(v); } else if let Some(v) = flags.match_value(&arg, "--limit")? { @@ -28,14 +28,14 @@ pub(crate) fn parse_host_state(args: &[String]) -> Result { super::super::suggest::unknown_option( "host-state", &arg, - &["--json", "--host-id", "--hostname", "--since", "--limit"], + &["--json", "--host-id", "--host", "--since", "--limit"], ) ); } } - if parsed.host_id.is_none() && parsed.hostname.is_none() { + if parsed.host_id.is_none() && parsed.host.is_none() { bail!( - "host-state requires --host-id ID or --hostname HOST\n\nUsage: cortex host-state [--host-id ID] [--hostname HOST] [--since TIME] [--limit N] [--json]" + "host-state requires --host-id ID or --host HOST\n\nUsage: cortex host-state [--host-id ID] [--host HOST] [--since TIME] [--limit N] [--json]" ); } Ok(CliCommand::HostState(parsed)) diff --git a/src/cli/complete.rs b/src/cli/complete.rs new file mode 100644 index 00000000..1f16911c --- /dev/null +++ b/src/cli/complete.rs @@ -0,0 +1,180 @@ +//! Candidate generator for shell completion. `cortex __complete ` prints +//! one `value\tdescription` line per candidate (description optional). +//! +//! Contexts: +//! - `actions` → CLI command names + descriptions +//! - `flags ` → that command's flags + help +//! - `value ` → values for a flag: fixed enums + relative-time hints +//! inline, or live hostnames/apps/source IDs from the DB (cached ~60s, hard +//! timeout, degrading to empty so completion never blocks or errors). + +use anyhow::{Result, bail}; +use cortex::mcp::ValueKind; +use std::time::{Duration, Instant, SystemTime}; + +const CACHE_TTL_SECS: u64 = 60; +const DYNAMIC_CANDIDATE_CAP: usize = 500; +const DB_BUSY_TIMEOUT_MS: u64 = 150; +/// Hard wall-clock deadline for the distinct-column scan itself. `busy_timeout` +/// only bounds lock-wait, not execution, so a progress handler enforces this to +/// keep Tab snappy even on a large DB. +const DB_QUERY_DEADLINE_MS: u64 = 150; + +/// Top-level completion entry. `args[0]` is the context kind. +pub(crate) fn complete(args: &[String]) -> Result> { + let (kind, rest) = args + .split_first() + .map(|(k, r)| (k.as_str(), r)) + .ok_or_else(|| anyhow::anyhow!("completion context required"))?; + match kind { + "actions" => Ok(action_candidates()), + "flags" => Ok(flag_candidates( + rest.first().map(|s| s.as_str()).unwrap_or(""), + )), + "value" => Ok(value_candidates( + rest.first().map(|s| s.as_str()).unwrap_or(""), + )), + other => bail!("unknown completion context '{other}'"), + } +} + +fn action_candidates() -> Vec { + crate::cli::registry_actions() + .into_iter() + .map(|(name, desc)| format!("{name}\t{desc}")) + .collect() +} + +fn flag_candidates(command: &str) -> Vec { + let mut out = Vec::new(); + for f in crate::cli::registry_flags(command) { + out.push(format!("{}\t{}", f.flag, f.help)); + if !f.short.is_empty() { + out.push(format!("{}\t{}", f.short, f.help)); + } + } + out +} + +fn value_candidates(flag: &str) -> Vec { + match value_kind_for_flag(flag) { + ValueKind::Enum(items) => items.iter().map(|s| s.to_string()).collect(), + ValueKind::Time => time_hints(), + ValueKind::Host => dynamic_cached("host", "hostname"), + ValueKind::App => dynamic_cached("app", "app_name"), + ValueKind::Source => dynamic_cached("source", "source_ip"), + ValueKind::None | ValueKind::Text => Vec::new(), + } +} + +/// Resolve a flag (long or short) to its completion kind by scanning the +/// registry. The canonical vocabulary maps each flag to one kind everywhere, +/// so the first match is authoritative. +fn value_kind_for_flag(flag: &str) -> ValueKind { + for (command, _) in crate::cli::registry_actions() { + for f in crate::cli::registry_flags(command) { + if f.flag == flag || (!f.short.is_empty() && f.short == flag) { + return f.value_kind; + } + } + } + ValueKind::Text +} + +fn time_hints() -> Vec { + ["15m", "30m", "1h", "6h", "1d", "2d", "yesterday", "today"] + .iter() + .map(|s| s.to_string()) + .collect() +} + +/// Live values for `kind` (cache key) sourced from DB column `column`. Reads a +/// ~60s tmp cache; on miss/expiry runs a bounded read-only query. ANY failure +/// (DB missing, locked, timeout) yields the stale cache if present, else an +/// empty Vec — completion silently degrades to static candidates and never +/// blocks or errors. +fn dynamic_cached(kind: &str, column: &str) -> Vec { + let path = cache_path(kind); + if let Some(fresh) = read_fresh(path.as_deref(), CACHE_TTL_SECS) { + return fresh; + } + match query_distinct(column) { + Ok(values) => { + if let Some(p) = path.as_deref() { + let _ = write_cache(p, &values); + } + values + } + Err(_) => read_any(path.as_deref()).unwrap_or_default(), + } +} + +/// Bounded, recent-biased, read-only distinct-column scan. `column` is one of a +/// fixed internal allowlist (hostname/app_name/source_ip) — never user input — +/// so the format! is not an injection vector. Capped at DYNAMIC_CANDIDATE_CAP. +fn query_distinct(column: &str) -> Result> { + let db = db_path(); + let conn = rusqlite::Connection::open_with_flags( + &db, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, + )?; + conn.busy_timeout(Duration::from_millis(DB_BUSY_TIMEOUT_MS))?; + // `busy_timeout` only bounds lock contention, not query execution. Enforce a + // real per-query deadline with a progress handler: it fires every ~1000 VM + // steps and aborts the statement (→ SQLITE_INTERRUPT → Err → caller falls + // back to cache/empty) once the deadline passes. + let deadline = Instant::now() + Duration::from_millis(DB_QUERY_DEADLINE_MS); + conn.progress_handler(1000, Some(move || Instant::now() >= deadline))?; + let sql = format!( + "SELECT DISTINCT {column} FROM \ + (SELECT {column} FROM logs ORDER BY id DESC LIMIT 100000) sub \ + WHERE {column} IS NOT NULL AND {column} != '' LIMIT {DYNAMIC_CANDIDATE_CAP}" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map([], |r| r.get::<_, String>(0))?; + Ok(rows.filter_map(|r| r.ok()).collect()) +} + +fn db_path() -> String { + std::env::var("CORTEX_DB_PATH").unwrap_or_else(|_| "data/cortex.db".to_string()) +} + +fn cache_path(kind: &str) -> Option { + let dir = std::env::var_os("XDG_RUNTIME_DIR") + .map(std::path::PathBuf::from) + .unwrap_or_else(std::env::temp_dir) + .join("cortex-complete"); + std::fs::create_dir_all(&dir).ok()?; + Some(dir.join(kind)) +} + +fn read_fresh(path: Option<&std::path::Path>, ttl_secs: u64) -> Option> { + let path = path?; + let meta = std::fs::metadata(path).ok()?; + let modified = meta.modified().ok()?; + let age = SystemTime::now().duration_since(modified).ok()?; + if age.as_secs() <= ttl_secs { + read_any(Some(path)) + } else { + None + } +} + +fn read_any(path: Option<&std::path::Path>) -> Option> { + let content = std::fs::read_to_string(path?).ok()?; + Some( + content + .lines() + .filter(|l| !l.is_empty()) + .map(|l| l.to_string()) + .collect(), + ) +} + +fn write_cache(path: &std::path::Path, values: &[String]) -> std::io::Result<()> { + std::fs::write(path, values.join("\n")) +} + +#[cfg(test)] +#[path = "complete_tests.rs"] +mod tests; diff --git a/src/cli/complete_tests.rs b/src/cli/complete_tests.rs new file mode 100644 index 00000000..4f00a302 --- /dev/null +++ b/src/cli/complete_tests.rs @@ -0,0 +1,70 @@ +use super::*; +use std::sync::{Mutex, OnceLock}; + +/// Serializes tests that mutate the process-global `CORTEX_DB_PATH` so parallel +/// execution can't race on it. +fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +#[test] +fn completes_action_names_with_descriptions() { + let out = complete(&["actions".into()]).unwrap(); + assert!(out.iter().any(|line| line.starts_with("search\t"))); + assert!(out.iter().any(|line| line.starts_with("tail\t"))); +} + +#[test] +fn completes_flags_for_action() { + let out = complete(&["flags".into(), "search".into()]).unwrap(); + assert!(out.iter().any(|l| l.starts_with("--host\t"))); + assert!(out.iter().any(|l| l.starts_with("--since\t"))); + // short alias is offered alongside the long flag + assert!(out.iter().any(|l| l.starts_with("-n\t"))); +} + +#[test] +fn completes_static_enum_values_for_severity() { + let out = complete(&["value".into(), "--severity".into()]).unwrap(); + assert!(out.iter().any(|l| l == "err")); + assert!(out.iter().any(|l| l == "warning")); +} + +#[test] +fn completes_time_hints() { + let out = complete(&["value".into(), "--since".into()]).unwrap(); + assert!(out.iter().any(|l| l == "1h")); + assert!(out.iter().any(|l| l == "yesterday")); +} + +#[test] +fn dynamic_value_degrades_to_ok_without_db() { + // Point at a nonexistent DB; host completion must return Ok (empty), never + // panic or error — completion degrades silently to static candidates. + let _guard = env_lock().lock().expect("env lock poisoned"); + // Restore CORTEX_DB_PATH on scope exit (including panic) so other tests that + // read it are unaffected. + struct RestoreDbPath(Option); + impl Drop for RestoreDbPath { + fn drop(&mut self) { + unsafe { + match self.0.take() { + Some(v) => std::env::set_var("CORTEX_DB_PATH", v), + None => std::env::remove_var("CORTEX_DB_PATH"), + } + } + } + } + let _restore = RestoreDbPath(std::env::var_os("CORTEX_DB_PATH")); + unsafe { + std::env::set_var("CORTEX_DB_PATH", "/nonexistent/cortex-complete-test.db"); + } + let out = complete(&["value".into(), "--host".into()]); + assert!(out.is_ok(), "dynamic completion must not error: {out:?}"); +} + +#[test] +fn unknown_context_errors() { + assert!(complete(&["bogus".into()]).is_err()); +} diff --git a/src/cli/completions.rs b/src/cli/completions.rs new file mode 100644 index 00000000..3ec9856c --- /dev/null +++ b/src/cli/completions.rs @@ -0,0 +1,21 @@ +//! Shell completion script generation (`cortex completions `). + +pub(crate) fn zsh_completion_script() -> &'static str { + include_str!("completions/_cortex.zsh") +} + +/// Print the completion script for `shell` to stdout, or error for unsupported +/// shells. +pub(crate) fn print_completions(shell: &str) -> anyhow::Result<()> { + match shell { + "zsh" => { + println!("{}", zsh_completion_script()); + Ok(()) + } + other => anyhow::bail!("unsupported shell '{other}'; supported: zsh"), + } +} + +#[cfg(test)] +#[path = "completions_tests.rs"] +mod tests; diff --git a/src/cli/completions/_cortex.zsh b/src/cli/completions/_cortex.zsh new file mode 100644 index 00000000..3be18cf0 --- /dev/null +++ b/src/cli/completions/_cortex.zsh @@ -0,0 +1,32 @@ +#compdef cortex +# cortex zsh completion — delegates to `cortex __complete`, which derives +# candidates from the single ACTION_SPECS registry (+ live DB values). +_cortex() { + local -a candidates + local cur=${words[CURRENT]} + local prev=${words[CURRENT-1]} + + # First positional: complete action/command names. + if (( CURRENT == 2 )); then + candidates=("${(@f)$(cortex __complete actions 2>/dev/null)}") + _describe -t actions 'cortex command' candidates + return + fi + + local action=${words[2]} + + # After a flag that takes a value, complete the value (live hosts/apps/etc.). + if [[ $prev == --* || $prev == -[a-z] ]]; then + local -a vals + vals=("${(@f)$(cortex __complete value $prev 2>/dev/null)}") + if (( ${#vals} )); then + compadd -- ${vals%%$'\t'*} + return + fi + fi + + # Otherwise complete this command's flags. + candidates=("${(@f)$(cortex __complete flags $action 2>/dev/null)}") + _describe -t flags 'flag' candidates +} +_cortex "$@" diff --git a/src/cli/completions_tests.rs b/src/cli/completions_tests.rs new file mode 100644 index 00000000..de54f6cc --- /dev/null +++ b/src/cli/completions_tests.rs @@ -0,0 +1,16 @@ +use super::*; + +#[test] +fn zsh_script_is_emitted_and_calls_complete() { + let script = zsh_completion_script(); + assert!(script.contains("#compdef cortex")); + assert!(script.contains("cortex __complete actions")); + assert!(script.contains("cortex __complete value")); + assert!(script.contains("cortex __complete flags")); +} + +#[test] +fn print_completions_rejects_unsupported_shell() { + assert!(print_completions("fish").is_err()); + assert!(print_completions("zsh").is_ok()); +} diff --git a/src/cli/dispatch.rs b/src/cli/dispatch.rs index 8e58f3f9..c2def140 100644 --- a/src/cli/dispatch.rs +++ b/src/cli/dispatch.rs @@ -59,17 +59,17 @@ impl SearchArgs { }; SearchLogsRequest { query, - hostname: self.hostname, - source_ip: self.source_ip, + host: self.host, + source: self.source, severity: self.severity, - app_name: self.app_name, + app: self.app, facility: self.facility, exclude_facility: self.exclude_facility, process_id: None, - from: self.from, - to: self.to, - received_from: self.received_from, - received_to: self.received_to, + since: self.since, + until: self.until, + received_since: self.received_since, + received_until: self.received_until, limit: self.limit, source_kind: None, tool: None, @@ -86,17 +86,17 @@ impl SearchArgs { impl FilterArgs { pub(crate) fn into_request(self) -> FilterLogsRequest { FilterLogsRequest { - hostname: self.hostname, - source_ip: self.source_ip, + host: self.host, + source: self.source, severity: self.severity, - app_name: self.app_name, + app: self.app, facility: self.facility, exclude_facility: self.exclude_facility, process_id: self.process_id, - from: self.from, - to: self.to, - received_from: self.received_from, - received_to: self.received_to, + since: self.since, + until: self.until, + received_since: self.received_since, + received_until: self.received_until, limit: self.limit, source_kind: self.source_kind, tool: self.tool, @@ -116,7 +116,7 @@ impl IncidentArgs { around: self.around, minutes: self.minutes, service: self.service, - hostname: self.hostname, + host: self.host, limit: self.limit, } } @@ -125,9 +125,9 @@ impl IncidentArgs { impl TailArgs { pub(crate) fn into_request(self) -> TailLogsRequest { TailLogsRequest { - hostname: self.hostname, - source_ip: self.source_ip, - app_name: self.app_name, + host: self.host, + source: self.source, + app: self.app, severity_min: None, n: self.n, } @@ -137,8 +137,8 @@ impl TailArgs { impl TimeRangeArgs { pub(crate) fn into_errors_request(self) -> GetErrorsRequest { GetErrorsRequest { - from: self.from, - to: self.to, + since: self.since, + until: self.until, group_by: None, limit: self.limit, } @@ -150,9 +150,9 @@ impl SessionsArgs { ListSessionsRequest { project: self.project, tool: self.tool, - hostname: self.hostname, - from: self.from, - to: self.to, + host: self.host, + since: self.since, + until: self.until, limit: self.limit, } } @@ -164,8 +164,8 @@ impl CorrelateArgs { reference_time: self.reference_time, window_minutes: self.window_minutes, severity_min: self.severity_min, - hostname: self.hostname, - source_ip: self.source_ip, + host: self.host, + source: self.source, query: self.query, limit: self.limit, } @@ -299,7 +299,7 @@ pub(crate) async fn run_file_tail(mode: &CliMode, command: FileTailCommand) -> R id: args.id, path: args.path, tag: args.tag, - hostname: args.hostname, + host: args.host, facility: args.facility, severity: args.severity, start_at_end: Some(args.start_at_end), diff --git a/src/cli/dispatch_ai.rs b/src/cli/dispatch_ai.rs index aa4467ec..5d123539 100644 --- a/src/cli/dispatch_ai.rs +++ b/src/cli/dispatch_ai.rs @@ -40,8 +40,8 @@ impl AiSearchArgs { query: self.query, project: self.project, tool: self.tool, - from: self.from, - to: self.to, + since: self.since, + until: self.until, limit: self.limit, } } @@ -52,8 +52,8 @@ impl AiAbuseArgs { AbuseSearchRequest { project: self.project, tool: self.tool, - from: self.from, - to: self.to, + since: self.since, + until: self.until, limit: self.limit, before: self.before, after: self.after, @@ -70,11 +70,11 @@ impl AiCorrelateArgs { session_id: self.session_id, ai_query: self.ai_query, log_query: self.log_query, - hostname: self.hostname, - source_ip: self.source_ip, - app_name: self.app_name, - from: self.from, - to: self.to, + host: self.host, + source: self.source, + app: self.app, + since: self.since, + until: self.until, window_minutes: self.window_minutes, severity_min: self.severity_min, limit: self.limit, @@ -88,8 +88,8 @@ impl AiBlocksArgs { UsageBlocksRequest { project: self.project, tool: self.tool, - from: self.from, - to: self.to, + since: self.since, + until: self.until, } } } @@ -108,16 +108,16 @@ impl AiListArgs { pub(crate) fn into_tools_request(self) -> ListAiToolsRequest { ListAiToolsRequest { project: self.project, - from: self.from, - to: self.to, + since: self.since, + until: self.until, } } pub(crate) fn into_projects_request(self) -> ListAiProjectsRequest { ListAiProjectsRequest { tool: self.tool, - from: self.from, - to: self.to, + since: self.since, + until: self.until, } } } @@ -152,11 +152,11 @@ impl AiSimilarArgs { pub(crate) fn into_request(self) -> SimilarIncidentsRequest { SimilarIncidentsRequest { query: self.query, - hostname: self.hostname, - app_name: self.app_name, + host: self.host, + app: self.app, severity_min: self.severity_min, - from: self.from, - to: self.to, + since: self.since, + until: self.until, window_minutes: self.window_minutes, limit: self.limit, } @@ -167,10 +167,10 @@ impl AiAskHistoryArgs { pub(crate) fn into_request(self) -> AskHistoryRequest { AskHistoryRequest { query: self.query, - hostname: self.hostname, - app_name: self.app_name, - from: self.from, - to: self.to, + host: self.host, + app: self.app, + since: self.since, + until: self.until, limit: self.limit, } } @@ -179,10 +179,10 @@ impl AiAskHistoryArgs { impl AiIncidentContextArgs { pub(crate) fn into_request(self) -> IncidentContextRequest { IncidentContextRequest { - from: self.from, - to: self.to, - hostname: self.hostname, - app_name: self.app_name, + since: self.since, + until: self.until, + host: self.host, + app: self.app, query: self.query, severity_min: self.severity_min, limit: self.limit, @@ -431,8 +431,8 @@ impl AiIncidentsArgs { AiIncidentRequest { project: self.project, tool: self.tool, - from: self.from, - to: self.to, + since: self.since, + until: self.until, limit: self.limit, window_minutes: self.window_minutes, terms: self.terms, @@ -446,8 +446,8 @@ impl AiInvestigateArgs { incident_id: None, project: self.project, tool: self.tool, - from: self.from, - to: self.to, + since: self.since, + until: self.until, limit: self.limit, window_minutes: self.window_minutes, correlation_window_minutes: self.correlation_window_minutes, @@ -493,8 +493,8 @@ pub(crate) async fn run_ai_assess(mode: &CliMode, args: AiAssessArgs) -> Result< model: args.model, project: args.project, tool: args.tool, - from: args.from, - to: args.to, + since: args.since, + until: args.until, window_minutes: args.window_minutes, correlation_window_minutes: args.correlation_window_minutes, terms: args.terms, diff --git a/src/cli/dispatch_ai_tests.rs b/src/cli/dispatch_ai_tests.rs index 82ea4b39..52118f22 100644 --- a/src/cli/dispatch_ai_tests.rs +++ b/src/cli/dispatch_ai_tests.rs @@ -4,8 +4,8 @@ fn ai_search_args_into_request_keeps_filters() { query: "error".to_string(), project: Some("/repo".to_string()), tool: Some("codex".to_string()), - from: Some("2026-01-01T00:00:00Z".to_string()), - to: None, + since: Some("2026-01-01T00:00:00Z".to_string()), + until: None, limit: Some(25), json: true, } @@ -14,5 +14,7 @@ fn ai_search_args_into_request_keeps_filters() { assert_eq!(req.query, "error"); assert_eq!(req.project.as_deref(), Some("/repo")); assert_eq!(req.tool.as_deref(), Some("codex")); + assert_eq!(req.since.as_deref(), Some("2026-01-01T00:00:00Z")); + assert_eq!(req.until, None); assert_eq!(req.limit, Some(25)); } diff --git a/src/cli/dispatch_surface.rs b/src/cli/dispatch_surface.rs index c5eb5aa4..a93703d4 100644 --- a/src/cli/dispatch_surface.rs +++ b/src/cli/dispatch_surface.rs @@ -28,16 +28,16 @@ impl SourceIpsArgs { impl TimelineArgs { pub(crate) fn into_request(self) -> TimelineRequest { // Default lookback is centralized in `CortexService::timeline` (bead dyqw): - // it applies a bucket-sized window only when neither `from` nor `to` is set. + // it applies a bucket-sized window only when neither `since` nor `until` is set. // Both CLI modes reach that service (local directly, HTTP via the server), - // so we pass `from`/`to` through verbatim — no per-binary duplication. + // so we pass `since`/`until` through verbatim — no per-binary duplication. TimelineRequest { bucket: self.bucket, group_by: self.group_by, - from: self.from, - to: self.to, - hostname: self.hostname, - app_name: self.app_name, + since: self.since, + until: self.until, + host: self.host, + app: self.app, severity_min: self.severity_min, } } @@ -46,10 +46,10 @@ impl TimelineArgs { impl PatternsArgs { pub(crate) fn into_request(self) -> PatternsRequest { PatternsRequest { - from: self.from, - to: self.to, - hostname: self.hostname, - app_name: self.app_name, + since: self.since, + until: self.until, + host: self.host, + app: self.app, severity_min: self.severity_min, scan_limit: self.scan_limit, top_n: self.top_n, diff --git a/src/cli/dispatch_surface_gap.rs b/src/cli/dispatch_surface_gap.rs index 703564d1..a864a304 100644 --- a/src/cli/dispatch_surface_gap.rs +++ b/src/cli/dispatch_surface_gap.rs @@ -68,9 +68,9 @@ impl CompareArgs { impl AppsArgs { pub(crate) fn into_request(self) -> ListAppsRequest { ListAppsRequest { - hostname: self.hostname, - from: self.from, - to: self.to, + host: self.host, + since: self.since, + until: self.until, limit: self.limit, offset: self.offset, } @@ -210,7 +210,7 @@ impl HostStateArgs { pub(crate) fn into_request(self) -> HostStateRequest { HostStateRequest { host_id: self.host_id, - hostname: self.hostname, + host: self.host, since: self.since, limit: self.limit, } diff --git a/src/cli/dispatch_surface_gap_tests.rs b/src/cli/dispatch_surface_gap_tests.rs index acc45484..78f81f21 100644 --- a/src/cli/dispatch_surface_gap_tests.rs +++ b/src/cli/dispatch_surface_gap_tests.rs @@ -38,17 +38,17 @@ fn basic_surface_args_map_to_requests() { assert_eq!(anomalies.baseline_minutes, Some(120)); let apps = AppsArgs { - hostname: Some("host-a".to_string()), - from: Some("from".to_string()), - to: Some("to".to_string()), + host: Some("host-a".to_string()), + since: Some("from".to_string()), + until: Some("to".to_string()), limit: Some(50), offset: Some(10), json: true, } .into_request(); - assert_eq!(apps.hostname.as_deref(), Some("host-a")); - assert_eq!(apps.from.as_deref(), Some("from")); - assert_eq!(apps.to.as_deref(), Some("to")); + assert_eq!(apps.host.as_deref(), Some("host-a")); + assert_eq!(apps.since.as_deref(), Some("from")); + assert_eq!(apps.until.as_deref(), Some("to")); assert_eq!(apps.limit, Some(50)); assert_eq!(apps.offset, Some(10)); } @@ -90,14 +90,14 @@ fn compare_and_correlate_state_require_reference_fields() { fn heartbeat_state_args_map_to_requests() { let host = HostStateArgs { host_id: Some("host-id".to_string()), - hostname: Some("host-a".to_string()), + host: Some("host-a".to_string()), since: Some("2026-06-13T00:00:00Z".to_string()), limit: Some(20), json: true, } .into_request(); assert_eq!(host.host_id.as_deref(), Some("host-id")); - assert_eq!(host.hostname.as_deref(), Some("host-a")); + assert_eq!(host.host.as_deref(), Some("host-a")); assert_eq!(host.since.as_deref(), Some("2026-06-13T00:00:00Z")); assert_eq!(host.limit, Some(20)); diff --git a/src/cli/dispatch_surface_tests.rs b/src/cli/dispatch_surface_tests.rs index df17fcf6..2ce11d13 100644 --- a/src/cli/dispatch_surface_tests.rs +++ b/src/cli/dispatch_surface_tests.rs @@ -23,31 +23,31 @@ fn source_ips_timeline_and_patterns_args_map_to_requests() { let timeline = crate::cli::TimelineArgs { bucket: Some("hour".to_string()), group_by: Some("app_name".to_string()), - from: Some("2026-06-13T00:00:00Z".to_string()), - to: Some("2026-06-13T01:00:00Z".to_string()), - hostname: Some("host-a".to_string()), - app_name: Some("nginx".to_string()), + since: Some("2026-06-13T00:00:00Z".to_string()), + until: Some("2026-06-13T01:00:00Z".to_string()), + host: Some("host-a".to_string()), + app: Some("nginx".to_string()), severity_min: Some("warning".to_string()), json: true, } .into_request(); assert_eq!(timeline.bucket.as_deref(), Some("hour")); assert_eq!(timeline.group_by.as_deref(), Some("app_name")); - assert_eq!(timeline.hostname.as_deref(), Some("host-a")); + assert_eq!(timeline.host.as_deref(), Some("host-a")); assert_eq!(timeline.severity_min.as_deref(), Some("warning")); let patterns = crate::cli::PatternsArgs { - from: Some("from".to_string()), - to: Some("to".to_string()), - hostname: Some("host-a".to_string()), - app_name: Some("cortex".to_string()), + since: Some("from".to_string()), + until: Some("to".to_string()), + host: Some("host-a".to_string()), + app: Some("cortex".to_string()), severity_min: Some("err".to_string()), scan_limit: Some(1000), top_n: Some(10), json: false, } .into_request(); - assert_eq!(patterns.app_name.as_deref(), Some("cortex")); + assert_eq!(patterns.app.as_deref(), Some("cortex")); assert_eq!(patterns.scan_limit, Some(1000)); assert_eq!(patterns.top_n, Some(10)); } diff --git a/src/cli/dispatch_tests.rs b/src/cli/dispatch_tests.rs index 6c51e1eb..d8ad6344 100644 --- a/src/cli/dispatch_tests.rs +++ b/src/cli/dispatch_tests.rs @@ -88,23 +88,23 @@ fn search_args_into_request_snapshot() { let args = SearchArgs { query: Some("foo".into()), grep: None, - hostname: Some("h1".into()), - source_ip: Some("10.0.0.1".into()), + host: Some("h1".into()), + source: Some("10.0.0.1".into()), severity: Some("error".into()), - app_name: Some("nginx".into()), + app: Some("nginx".into()), facility: Some("auth".into()), exclude_facility: Some("transcript".into()), - from: Some("2026-01-01T00:00:00Z".into()), - to: Some("2026-01-02T00:00:00Z".into()), - received_from: Some("2026-01-01T00:00:30Z".into()), - received_to: Some("2026-01-02T00:00:30Z".into()), + since: Some("2026-01-01T00:00:00Z".into()), + until: Some("2026-01-02T00:00:00Z".into()), + received_since: Some("2026-01-01T00:00:30Z".into()), + received_until: Some("2026-01-02T00:00:30Z".into()), limit: Some(50), json: true, // not propagated to Request — verified by snapshot below }; let req = args.into_request(); assert_eq!( format!("{req:?}"), - "SearchLogsRequest { query: Some(\"foo\"), hostname: Some(\"h1\"), source_ip: Some(\"10.0.0.1\"), severity: Some(\"error\"), app_name: Some(\"nginx\"), facility: Some(\"auth\"), exclude_facility: Some(\"transcript\"), process_id: None, from: Some(\"2026-01-01T00:00:00Z\"), to: Some(\"2026-01-02T00:00:00Z\"), received_from: Some(\"2026-01-01T00:00:30Z\"), received_to: Some(\"2026-01-02T00:00:30Z\"), limit: Some(50), source_kind: None, tool: None, project: None, session_id: None, container: None, docker_host: None, stream: None, event_action: None }" + "SearchLogsRequest { query: Some(\"foo\"), host: Some(\"h1\"), source: Some(\"10.0.0.1\"), severity: Some(\"error\"), app: Some(\"nginx\"), facility: Some(\"auth\"), exclude_facility: Some(\"transcript\"), process_id: None, since: Some(\"2026-01-01T00:00:00Z\"), until: Some(\"2026-01-02T00:00:00Z\"), received_since: Some(\"2026-01-01T00:00:30Z\"), received_until: Some(\"2026-01-02T00:00:30Z\"), limit: Some(50), source_kind: None, tool: None, project: None, session_id: None, container: None, docker_host: None, stream: None, event_action: None }" ); } @@ -126,23 +126,23 @@ fn filter_args_into_request_snapshot() { let req = args.into_request(); assert_eq!( format!("{req:?}"), - "FilterLogsRequest { hostname: None, source_ip: None, severity: None, app_name: None, facility: None, exclude_facility: None, process_id: None, from: None, to: None, received_from: None, received_to: None, limit: Some(25), source_kind: Some(\"docker-stream\"), tool: Some(\"claude\"), project: Some(\"/tmp/project\"), session_id: Some(\"abc123\"), container: Some(\"cortex\"), docker_host: Some(\"dookie\"), stream: Some(\"stdout\"), event_action: Some(\"die\") }" + "FilterLogsRequest { host: None, source: None, severity: None, app: None, facility: None, exclude_facility: None, process_id: None, since: None, until: None, received_since: None, received_until: None, limit: Some(25), source_kind: Some(\"docker-stream\"), tool: Some(\"claude\"), project: Some(\"/tmp/project\"), session_id: Some(\"abc123\"), container: Some(\"cortex\"), docker_host: Some(\"dookie\"), stream: Some(\"stdout\"), event_action: Some(\"die\") }" ); } #[test] fn tail_args_into_request_snapshot() { let args = TailArgs { - hostname: Some("h1".into()), - source_ip: None, - app_name: Some("docker".into()), + host: Some("h1".into()), + source: None, + app: Some("docker".into()), n: Some(100), json: false, }; let req = args.into_request(); assert_eq!( format!("{req:?}"), - "TailLogsRequest { hostname: Some(\"h1\"), source_ip: None, app_name: Some(\"docker\"), severity_min: None, n: Some(100) }" + "TailLogsRequest { host: Some(\"h1\"), source: None, app: Some(\"docker\"), severity_min: None, n: Some(100) }" ); } @@ -220,15 +220,15 @@ fn graph_evidence_args_into_request_snapshot() { #[test] fn errors_args_into_request_snapshot() { let args = TimeRangeArgs { - from: Some("2026-01-01T00:00:00Z".into()), - to: None, + since: Some("2026-01-01T00:00:00Z".into()), + until: None, limit: Some(10), json: false, }; let req = args.into_errors_request(); assert_eq!( format!("{req:?}"), - "GetErrorsRequest { from: Some(\"2026-01-01T00:00:00Z\"), to: None, group_by: None, limit: Some(10) }" + "GetErrorsRequest { since: Some(\"2026-01-01T00:00:00Z\"), until: None, group_by: None, limit: Some(10) }" ); } @@ -237,16 +237,16 @@ fn sessions_args_into_request_snapshot() { let args = SessionsArgs { project: Some("/home/me/proj".into()), tool: Some("claude".into()), - hostname: None, - from: None, - to: None, + host: None, + since: None, + until: None, limit: Some(20), json: false, }; let req = args.into_request(); assert_eq!( format!("{req:?}"), - "ListSessionsRequest { project: Some(\"/home/me/proj\"), tool: Some(\"claude\"), hostname: None, from: None, to: None, limit: Some(20) }" + "ListSessionsRequest { project: Some(\"/home/me/proj\"), tool: Some(\"claude\"), host: None, since: None, until: None, limit: Some(20) }" ); } @@ -256,8 +256,8 @@ fn correlate_args_into_request_snapshot() { reference_time: "2026-01-01T12:00:00Z".into(), window_minutes: Some(15), severity_min: Some("warning".into()), - hostname: Some("h1".into()), - source_ip: None, + host: Some("h1".into()), + source: None, query: Some("oom".into()), limit: Some(50), json: false, @@ -265,7 +265,7 @@ fn correlate_args_into_request_snapshot() { let req = args.into_request(); assert_eq!( format!("{req:?}"), - "CorrelateEventsRequest { reference_time: \"2026-01-01T12:00:00Z\", window_minutes: Some(15), severity_min: Some(\"warning\"), hostname: Some(\"h1\"), source_ip: None, query: Some(\"oom\"), limit: Some(50) }" + "CorrelateEventsRequest { reference_time: \"2026-01-01T12:00:00Z\", window_minutes: Some(15), severity_min: Some(\"warning\"), host: Some(\"h1\"), source: None, query: Some(\"oom\"), limit: Some(50) }" ); } @@ -481,7 +481,7 @@ async fn run_search_http_sends_expected_query_params() { .await; let args = SearchArgs { query: Some("foo".into()), - hostname: Some("h1".into()), + host: Some("h1".into()), severity: Some("error".into()), limit: Some(50), json: true, @@ -497,7 +497,7 @@ async fn run_search_http_sends_expected_query_params() { .expect("search request"); let qs = req.url.query().unwrap_or(""); assert!(qs.contains("query=foo"), "missing query=foo in {qs}"); - assert!(qs.contains("hostname=h1"), "missing hostname=h1 in {qs}"); + assert!(qs.contains("host=h1"), "missing host=h1 in {qs}"); assert!( qs.contains("severity=error"), "missing severity=error in {qs}" @@ -595,15 +595,15 @@ fn ai_search_args_into_request_snapshot() { query: "needle".into(), project: Some("/p".into()), tool: Some("claude".into()), - from: Some("2026-01-01T00:00:00Z".into()), - to: Some("2026-01-02T00:00:00Z".into()), + since: Some("2026-01-01T00:00:00Z".into()), + until: Some("2026-01-02T00:00:00Z".into()), limit: Some(25), json: true, }; let req = args.into_request(); assert_eq!( format!("{req:?}"), - "SearchSessionsRequest { query: \"needle\", project: Some(\"/p\"), tool: Some(\"claude\"), from: Some(\"2026-01-01T00:00:00Z\"), to: Some(\"2026-01-02T00:00:00Z\"), limit: Some(25) }" + "SearchSessionsRequest { query: \"needle\", project: Some(\"/p\"), tool: Some(\"claude\"), since: Some(\"2026-01-01T00:00:00Z\"), until: Some(\"2026-01-02T00:00:00Z\"), limit: Some(25) }" ); } @@ -612,8 +612,8 @@ fn ai_abuse_args_into_request_snapshot() { let args = AiAbuseArgs { project: Some("/p".into()), tool: Some("claude".into()), - from: None, - to: None, + since: None, + until: None, limit: Some(10), before: Some(3), after: Some(2), @@ -623,7 +623,7 @@ fn ai_abuse_args_into_request_snapshot() { let req = args.into_request(); assert_eq!( format!("{req:?}"), - "AbuseSearchRequest { project: Some(\"/p\"), tool: Some(\"claude\"), from: None, to: None, limit: Some(10), before: Some(3), after: Some(2), terms: [\"bad\", \"worse\"] }" + "AbuseSearchRequest { project: Some(\"/p\"), tool: Some(\"claude\"), since: None, until: None, limit: Some(10), before: Some(3), after: Some(2), terms: [\"bad\", \"worse\"] }" ); } @@ -635,11 +635,11 @@ fn ai_correlate_args_into_request_snapshot() { session_id: Some("s1".into()), ai_query: Some("ai".into()), log_query: Some("log".into()), - hostname: Some("h1".into()), - source_ip: Some("10.0.0.1".into()), - app_name: Some("nginx".into()), - from: Some("2026-01-01T00:00:00Z".into()), - to: Some("2026-01-02T00:00:00Z".into()), + host: Some("h1".into()), + source: Some("10.0.0.1".into()), + app: Some("nginx".into()), + since: Some("2026-01-01T00:00:00Z".into()), + until: Some("2026-01-02T00:00:00Z".into()), window_minutes: Some(15), severity_min: Some("warning".into()), limit: Some(50), @@ -649,7 +649,7 @@ fn ai_correlate_args_into_request_snapshot() { let req = args.into_request(); assert_eq!( format!("{req:?}"), - "AiCorrelateRequest { project: Some(\"/p\"), tool: Some(\"claude\"), session_id: Some(\"s1\"), ai_query: Some(\"ai\"), log_query: Some(\"log\"), hostname: Some(\"h1\"), source_ip: Some(\"10.0.0.1\"), app_name: Some(\"nginx\"), from: Some(\"2026-01-01T00:00:00Z\"), to: Some(\"2026-01-02T00:00:00Z\"), window_minutes: Some(15), severity_min: Some(\"warning\"), limit: Some(50), events_per_anchor: Some(20) }" + "AiCorrelateRequest { project: Some(\"/p\"), tool: Some(\"claude\"), session_id: Some(\"s1\"), ai_query: Some(\"ai\"), log_query: Some(\"log\"), host: Some(\"h1\"), source: Some(\"10.0.0.1\"), app: Some(\"nginx\"), since: Some(\"2026-01-01T00:00:00Z\"), until: Some(\"2026-01-02T00:00:00Z\"), window_minutes: Some(15), severity_min: Some(\"warning\"), limit: Some(50), events_per_anchor: Some(20) }" ); } @@ -658,15 +658,15 @@ fn ai_blocks_args_into_request_snapshot() { let args = AiBlocksArgs { project: Some("/p".into()), tool: None, - from: None, - to: None, + since: None, + until: None, json: false, ..Default::default() }; let req = args.into_request(); assert_eq!( format!("{req:?}"), - "UsageBlocksRequest { project: Some(\"/p\"), tool: None, from: None, to: None }" + "UsageBlocksRequest { project: Some(\"/p\"), tool: None, since: None, until: None }" ); } @@ -690,14 +690,14 @@ fn ai_tools_args_into_request_snapshot() { let args = AiListArgs { project: Some("/p".into()), tool: None, - from: Some("2026-01-01T00:00:00Z".into()), - to: None, + since: Some("2026-01-01T00:00:00Z".into()), + until: None, json: false, }; let req = args.into_tools_request(); assert_eq!( format!("{req:?}"), - "ListAiToolsRequest { project: Some(\"/p\"), from: Some(\"2026-01-01T00:00:00Z\"), to: None }" + "ListAiToolsRequest { project: Some(\"/p\"), since: Some(\"2026-01-01T00:00:00Z\"), until: None }" ); } @@ -706,14 +706,14 @@ fn ai_projects_args_into_request_snapshot() { let args = AiListArgs { project: None, tool: Some("claude".into()), - from: None, - to: Some("2026-01-02T00:00:00Z".into()), + since: None, + until: Some("2026-01-02T00:00:00Z".into()), json: false, }; let req = args.into_projects_request(); assert_eq!( format!("{req:?}"), - "ListAiProjectsRequest { tool: Some(\"claude\"), from: None, to: Some(\"2026-01-02T00:00:00Z\") }" + "ListAiProjectsRequest { tool: Some(\"claude\"), since: None, until: Some(\"2026-01-02T00:00:00Z\") }" ); } @@ -1488,21 +1488,21 @@ fn timeline_args_into_request_passes_time_range_through() { let args = TimelineArgs { bucket: Some("hour".to_string()), group_by: None, - from: None, - to: None, - hostname: None, - app_name: None, + since: None, + until: None, + host: None, + app: None, severity_min: None, json: false, }; let req = args.into_request(); assert_eq!(req.bucket.as_deref(), Some("hour")); assert!( - req.from.is_none(), + req.since.is_none(), "into_request must not inject a default `from`; the service applies it" ); assert!( - req.to.is_none(), + req.until.is_none(), "into_request must not inject a default `to`" ); } @@ -1513,16 +1513,16 @@ fn timeline_args_into_request_explicit_from_preserved() { let args = TimelineArgs { bucket: None, group_by: None, - from: Some("2025-01-01T00:00:00Z".to_string()), - to: None, - hostname: None, - app_name: None, + since: Some("2025-01-01T00:00:00Z".to_string()), + until: None, + host: None, + app: None, severity_min: None, json: false, }; let req = args.into_request(); assert_eq!( - req.from.as_deref(), + req.since.as_deref(), Some("2025-01-01T00:00:00Z"), "explicit from must not be overridden by the default" ); @@ -1534,7 +1534,7 @@ fn patterns_args_into_request_default() { let req = args.into_request(); assert_eq!( format!("{req:?}"), - "PatternsRequest { from: None, to: None, hostname: None, app_name: None, severity_min: None, scan_limit: None, top_n: None }" + "PatternsRequest { since: None, until: None, host: None, app: None, severity_min: None, scan_limit: None, top_n: None }" ); } diff --git a/src/cli/help.rs b/src/cli/help.rs index 30905b2d..075d2d6b 100644 --- a/src/cli/help.rs +++ b/src/cli/help.rs @@ -97,27 +97,25 @@ const CATALOG: &[CommandDoc] = &[ name: "search", summary: "Full-text search across all logs", usage: &[ - "cortex search [query] [--grep TEXT] [--hostname HOST] [--source-ip SOURCE] [--severity LEVEL] [--app-name APP] [--facility FACILITY] [--exclude-facility FACILITY] [--from TIME] [--to TIME] [--received-from TIME] [--received-to TIME] [--limit N] [--json] (TIME accepts 1h, 2d, yesterday, or RFC3339)", + "cortex search [query] [--grep TEXT] [--host HOST] [--source SOURCE] [--severity LEVEL] [--app APP] [--facility FACILITY] [--exclude-facility FACILITY] [--since TIME] [--until TIME] [--received-since TIME] [--received-until TIME] [--limit N] [--json] (TIME accepts 1h, 2d, yesterday, or RFC3339)", ], }, CommandDoc { name: "filter", summary: "Filter logs by structured fields (host, container, severity…)", usage: &[ - "cortex filter [--hostname HOST] [--source-ip SOURCE] [--source-kind KIND] [--tool TOOL] [--project PATH] [--session-id ID] [--container NAME] [--docker-host HOST] [--stream stdout|stderr] [--event-action ACTION] [--severity LEVEL] [--app-name APP] [--facility FACILITY] [--exclude-facility FACILITY] [--from TIME] [--to TIME] [--received-from TIME] [--received-to TIME] [--limit N] [--json]", + "cortex filter [--host HOST] [--source SOURCE] [--source-kind KIND] [--tool TOOL] [--project PATH] [--session-id ID] [--container NAME] [--docker-host HOST] [--stream stdout|stderr] [--event-action ACTION] [--severity LEVEL] [--app APP] [--facility FACILITY] [--exclude-facility FACILITY] [--since TIME] [--until TIME] [--received-since TIME] [--received-until TIME] [--limit N] [--json]", ], }, CommandDoc { name: "tail", summary: "Show the most recent log lines", - usage: &[ - "cortex tail [-n N] [--hostname HOST] [--source-ip SOURCE] [--app-name APP] [--json]", - ], + usage: &["cortex tail [-n N] [--host HOST] [--source SOURCE] [--app APP] [--json]"], }, CommandDoc { name: "errors", summary: "Recent error-level log entries", - usage: &["cortex errors [--from TIME] [--to TIME] [--limit N] [--json]"], + usage: &["cortex errors [--since TIME] [--until TIME] [--limit N] [--json]"], }, CommandDoc { name: "hosts", @@ -128,7 +126,7 @@ const CATALOG: &[CommandDoc] = &[ name: "sessions", summary: "List indexed AI sessions", usage: &[ - "cortex sessions [--project PATH] [--tool TOOL] [--hostname HOST] [--from TIME] [--to TIME] [--limit N] [--json]", + "cortex sessions [--project PATH] [--tool TOOL] [--host HOST] [--since TIME] [--until TIME] [--limit N] [--json]", ], }, CommandDoc { @@ -177,14 +175,14 @@ const CATALOG: &[CommandDoc] = &[ name: "timeline", summary: "Log volume over time, bucketed", usage: &[ - "cortex timeline [--bucket minute|hour|day] [--group-by FIELD] [--hostname HOST] [--app-name APP] [--severity-min LEVEL] [--from TIME] [--to TIME] [--json]", + "cortex timeline [--bucket minute|hour|day] [--group-by FIELD] [--host HOST] [--app APP] [--severity-min LEVEL] [--since TIME] [--until TIME] [--json]", ], }, CommandDoc { name: "patterns", summary: "Recurring message patterns", usage: &[ - "cortex patterns [--top-n N] [--scan-limit N] [--hostname HOST] [--app-name APP] [--severity-min LEVEL] [--from TIME] [--to TIME] [--json]", + "cortex patterns [--top-n N] [--scan-limit N] [--host HOST] [--app APP] [--severity-min LEVEL] [--since TIME] [--until TIME] [--json]", ], }, CommandDoc { @@ -196,21 +194,21 @@ const CATALOG: &[CommandDoc] = &[ name: "apps", summary: "Top application/program names by volume", usage: &[ - "cortex apps [--hostname HOST] [--from TIME] [--to TIME] [--limit N] [--offset N] [--json]", + "cortex apps [--host HOST] [--since TIME] [--until TIME] [--limit N] [--offset N] [--json]", ], }, CommandDoc { name: "correlate", summary: "Correlate events around a reference time", usage: &[ - "cortex correlate --reference-time TIME [--window-minutes N] [--severity-min LEVEL] [--hostname HOST] [--source-ip SOURCE] [--query FTS] [--limit N] [--json]", + "cortex correlate --reference-time TIME [--window-minutes N] [--severity-min LEVEL] [--host HOST] [--source SOURCE] [--query FTS] [--limit N] [--json]", ], }, CommandDoc { name: "host-state", summary: "Per-host health/pressure snapshot", usage: &[ - "cortex host-state [--host-id ID] [--hostname HOST] [--since TIME] [--limit N] [--json]", + "cortex host-state [--host-id ID] [--host HOST] [--since TIME] [--limit N] [--json]", ], }, CommandDoc { @@ -252,16 +250,16 @@ const CATALOG: &[CommandDoc] = &[ name: "ai", summary: "AI transcript search, correlation, and indexing", usage: &[ - "cortex ai search QUERY [--project PATH] [--tool TOOL] [--from TIME] [--to TIME] [--limit N] [--json]", - "cortex ai abuse [--project PATH] [--tool TOOL] [--from TIME] [--to TIME] [--limit N] [--before N] [--after N] [--term WORD] [--json]", - "cortex ai incidents [--project PATH] [--tool TOOL] [--from TIME] [--to TIME] [--limit N] [--window-minutes N] [--term WORD] [--json]", - "cortex ai investigate [--project PATH] [--tool TOOL] [--from TIME] [--to TIME] [--limit N] [--window-minutes N] [--correlation-window-minutes N] [--term WORD] [--detail compact|full] [--include-transcript] [--max-bytes N] [--json]", - "cortex ai assess INCIDENT_ID [--model MODEL] [--project PATH] [--tool TOOL] [--from TIME] [--to TIME] [--limit N] [--window-minutes N] [--correlation-window-minutes N] [--term WORD] [--json]", - "cortex ai correlate [--project PATH] [--tool TOOL] [--session-id ID] [--ai-query FTS] [--log-query FTS] [--hostname HOST] [--source-ip SOURCE] [--app-name APP] [--from TIME] [--to TIME] [--window-minutes N] [--severity-min LEVEL] [--limit N] [--events-per-anchor N] [--json]", - "cortex ai blocks [--project PATH] [--tool TOOL] [--from TIME] [--to TIME] [--limit N] [--detail compact|full] [--json]", + "cortex ai search QUERY [--project PATH] [--tool TOOL] [--since TIME] [--until TIME] [--limit N] [--json]", + "cortex ai abuse [--project PATH] [--tool TOOL] [--since TIME] [--until TIME] [--limit N] [--before N] [--after N] [--term WORD] [--json]", + "cortex ai incidents [--project PATH] [--tool TOOL] [--since TIME] [--until TIME] [--limit N] [--window-minutes N] [--term WORD] [--json]", + "cortex ai investigate [--project PATH] [--tool TOOL] [--since TIME] [--until TIME] [--limit N] [--window-minutes N] [--correlation-window-minutes N] [--term WORD] [--detail compact|full] [--include-transcript] [--max-bytes N] [--json]", + "cortex ai assess INCIDENT_ID [--model MODEL] [--project PATH] [--tool TOOL] [--since TIME] [--until TIME] [--limit N] [--window-minutes N] [--correlation-window-minutes N] [--term WORD] [--json]", + "cortex ai correlate [--project PATH] [--tool TOOL] [--session-id ID] [--ai-query FTS] [--log-query FTS] [--host HOST] [--source SOURCE] [--app APP] [--since TIME] [--until TIME] [--window-minutes N] [--severity-min LEVEL] [--limit N] [--events-per-anchor N] [--json]", + "cortex ai blocks [--project PATH] [--tool TOOL] [--since TIME] [--until TIME] [--limit N] [--detail compact|full] [--json]", "cortex ai context --project PATH [--tool TOOL] [--limit N] [--json]", - "cortex ai tools [--project PATH] [--from TIME] [--to TIME] [--json]", - "cortex ai projects [--tool TOOL] [--from TIME] [--to TIME] [--json]", + "cortex ai tools [--project PATH] [--since TIME] [--until TIME] [--json]", + "cortex ai projects [--tool TOOL] [--since TIME] [--until TIME] [--json]", "cortex ai index [--path PATH] [--since TIME] [--force] [--json]", "cortex ai add --file FILE [--force] [--json]", "cortex ai watch [--path PATH] [--debounce-ms N] [--settle-ms N] [--max-retries N] [--no-initial-scan] [--json]", @@ -329,7 +327,7 @@ const CATALOG: &[CommandDoc] = &[ usage: &[ "cortex file-tail list [--json]", "cortex file-tail status [--json]", - "cortex file-tail add --id ID --path PATH --tag TAG --hostname HOST [--facility FACILITY] [--severity SEVERITY] [--from-start] [--json]", + "cortex file-tail add --id ID --path PATH --tag TAG --host HOST [--facility FACILITY] [--severity SEVERITY] [--from-start] [--json]", "cortex file-tail remove --id ID [--json]", "cortex file-tail enable --id ID [--json]", "cortex file-tail disable --id ID [--json]", @@ -376,7 +374,7 @@ const CATALOG: &[CommandDoc] = &[ CommandDoc { name: "service", summary: "Inspect container service logs", - usage: &["cortex service logs SERVICE [--from TIME] [--to TIME] [--tail N] [--json]"], + usage: &["cortex service logs SERVICE [--since TIME] [--until TIME] [--tail N] [--json]"], }, CommandDoc { name: "setup", @@ -430,28 +428,28 @@ const NESTED_CATALOG: &[NestedCommandDoc] = &[ path: "ai search", summary: "Full-text search over indexed AI transcript sessions", usage: &[ - "cortex ai search QUERY [--project PATH] [--tool TOOL] [--from TIME] [--to TIME] [--limit N] [--json]", + "cortex ai search QUERY [--project PATH] [--tool TOOL] [--since TIME] [--until TIME] [--limit N] [--json]", ], }, NestedCommandDoc { path: "ai abuse", summary: "Find risky or failure-related transcript messages", usage: &[ - "cortex ai abuse [--project PATH] [--tool TOOL] [--from TIME] [--to TIME] [--limit N] [--before N] [--after N] [--term WORD] [--json]", + "cortex ai abuse [--project PATH] [--tool TOOL] [--since TIME] [--until TIME] [--limit N] [--before N] [--after N] [--term WORD] [--json]", ], }, NestedCommandDoc { path: "ai incidents", summary: "Cluster AI transcript abuse matches into incidents", usage: &[ - "cortex ai incidents [--project PATH] [--tool TOOL] [--from TIME] [--to TIME] [--limit N] [--window-minutes N] [--term WORD] [--json]", + "cortex ai incidents [--project PATH] [--tool TOOL] [--since TIME] [--until TIME] [--limit N] [--window-minutes N] [--term WORD] [--json]", ], }, NestedCommandDoc { path: "ai investigate", summary: "Expand AI incidents into evidence bundles", usage: &[ - "cortex ai investigate [--project PATH] [--tool TOOL] [--from TIME] [--to TIME] [--limit N] [--window-minutes N] [--correlation-window-minutes N] [--term WORD] [--detail compact|full] [--include-transcript] [--max-bytes N] [--json]", + "cortex ai investigate [--project PATH] [--tool TOOL] [--since TIME] [--until TIME] [--limit N] [--window-minutes N] [--correlation-window-minutes N] [--term WORD] [--detail compact|full] [--include-transcript] [--max-bytes N] [--json]", "Default output is compact; use --detail full for complete evidence.", ], }, @@ -459,21 +457,21 @@ const NESTED_CATALOG: &[NestedCommandDoc] = &[ path: "ai assess", summary: "Assess one AI incident with optional model context", usage: &[ - "cortex ai assess INCIDENT_ID [--model MODEL] [--project PATH] [--tool TOOL] [--from TIME] [--to TIME] [--limit N] [--window-minutes N] [--correlation-window-minutes N] [--term WORD] [--json]", + "cortex ai assess INCIDENT_ID [--model MODEL] [--project PATH] [--tool TOOL] [--since TIME] [--until TIME] [--limit N] [--window-minutes N] [--correlation-window-minutes N] [--term WORD] [--json]", ], }, NestedCommandDoc { path: "ai correlate", summary: "Correlate AI transcript anchors with non-AI logs", usage: &[ - "cortex ai correlate [--project PATH] [--tool TOOL] [--session-id ID] [--ai-query FTS] [--log-query FTS] [--hostname HOST] [--source-ip SOURCE] [--app-name APP] [--from TIME] [--to TIME] [--window-minutes N] [--severity-min LEVEL] [--limit N] [--events-per-anchor N] [--json]", + "cortex ai correlate [--project PATH] [--tool TOOL] [--session-id ID] [--ai-query FTS] [--log-query FTS] [--host HOST] [--source SOURCE] [--app APP] [--since TIME] [--until TIME] [--window-minutes N] [--severity-min LEVEL] [--limit N] [--events-per-anchor N] [--json]", ], }, NestedCommandDoc { path: "ai blocks", summary: "AI transcript activity grouped into 5-hour UTC blocks", usage: &[ - "cortex ai blocks [--project PATH] [--tool TOOL] [--from TIME] [--to TIME] [--limit N] [--detail compact|full] [--json]", + "cortex ai blocks [--project PATH] [--tool TOOL] [--since TIME] [--until TIME] [--limit N] [--detail compact|full] [--json]", "Default output is capped for interactive use; use --detail full for every block.", ], }, @@ -485,12 +483,12 @@ const NESTED_CATALOG: &[NestedCommandDoc] = &[ NestedCommandDoc { path: "ai tools", summary: "List AI tools present in transcript metadata", - usage: &["cortex ai tools [--project PATH] [--from TIME] [--to TIME] [--json]"], + usage: &["cortex ai tools [--project PATH] [--since TIME] [--until TIME] [--json]"], }, NestedCommandDoc { path: "ai projects", summary: "List AI projects present in transcript metadata", - usage: &["cortex ai projects [--tool TOOL] [--from TIME] [--to TIME] [--json]"], + usage: &["cortex ai projects [--tool TOOL] [--since TIME] [--until TIME] [--json]"], }, NestedCommandDoc { path: "ai index", @@ -543,21 +541,21 @@ const NESTED_CATALOG: &[NestedCommandDoc] = &[ path: "ai similar", summary: "Find incidents similar to a free-text query", usage: &[ - "cortex ai similar QUERY [--hostname HOST] [--app-name APP] [--severity-min LEVEL] [--from TIME] [--to TIME] [--window-minutes N] [--limit N] [--json]", + "cortex ai similar QUERY [--host HOST] [--app APP] [--severity-min LEVEL] [--since TIME] [--until TIME] [--window-minutes N] [--limit N] [--json]", ], }, NestedCommandDoc { path: "ai ask-history", summary: "Search historical AI sessions and nearby system logs", usage: &[ - "cortex ai ask-history QUERY [--hostname HOST] [--app-name APP] [--from TIME] [--to TIME] [--limit N] [--json]", + "cortex ai ask-history QUERY [--host HOST] [--app APP] [--since TIME] [--until TIME] [--limit N] [--json]", ], }, NestedCommandDoc { path: "ai incident-context", summary: "Build incident context from an explicit time window", usage: &[ - "cortex ai incident-context --from TIME --to TIME [--hostname HOST] [--app-name APP] [--query FTS] [--severity-min LEVEL] [--limit N] [--json]", + "cortex ai incident-context --since TIME --until TIME [--host HOST] [--app APP] [--query FTS] [--severity-min LEVEL] [--limit N] [--json]", ], }, NestedCommandDoc { @@ -715,7 +713,7 @@ const NESTED_CATALOG: &[NestedCommandDoc] = &[ path: "file-tail add", summary: "Add or update a managed file-tail source", usage: &[ - "cortex file-tail add --id ID --path PATH --tag TAG --hostname HOST [--facility FACILITY] [--severity SEVERITY] [--from-start] [--json]", + "cortex file-tail add --id ID --path PATH --tag TAG --host HOST [--facility FACILITY] [--severity SEVERITY] [--from-start] [--json]", ], }, NestedCommandDoc { @@ -778,7 +776,7 @@ const ENVIRONMENT: &[(&str, &str)] = &[ ]; const QUICK_START: &[&str] = &[ - "cortex search \"oom killer\" --hostname web-01 --from 1h", + "cortex search \"oom killer\" --host web-01 --since 1h", "cortex search --grep \"smoke-test\" # literal text, no FTS5 syntax", "cortex tail -n 50 --severity err", "cortex ai investigate --window-minutes 30", @@ -903,8 +901,10 @@ pub(crate) fn render_top_level(color: bool) -> String { /// Render per-command help, or `None` if the command is unknown. pub(crate) fn render_command(name: &str, color: bool) -> Option { + let mut out = String::with_capacity(512); + // Header + usage differ between nested (`db status`) and top-level commands, + // but both render the shared Examples block below. if let Some(doc) = nested_lookup(name) { - let mut out = String::with_capacity(512); out.push_str(&format!( " {} {}\n\n", heading(color, doc.path), @@ -914,18 +914,28 @@ pub(crate) fn render_command(name: &str, color: bool) -> Option { for line in doc.usage { out.push_str(&format!(" {}\n", paint(color, CYAN_ANSI, line))); } - return Some(out); + } else { + let doc = lookup(name)?; + out.push_str(&format!( + " {} {}\n\n", + heading(color, doc.name), + paint(color, MUTED_ANSI, doc.summary) + )); + out.push_str(&format!(" {}\n", heading(color, "Usage"))); + for line in doc.usage { + out.push_str(&format!(" {}\n", paint(color, CYAN_ANSI, line))); + } } - let doc = lookup(name)?; - let mut out = String::with_capacity(512); - out.push_str(&format!( - " {} {}\n\n", - heading(color, doc.name), - paint(color, MUTED_ANSI, doc.summary) - )); - out.push_str(&format!(" {}\n", heading(color, "Usage"))); - for line in doc.usage { - out.push_str(&format!(" {}\n", paint(color, CYAN_ANSI, line))); + // Copy-paste examples, sourced from the single ACTION_SPECS registry so they + // stay in lockstep with the canonical flags. Rendered for both nested and + // top-level help paths. + let examples = crate::cli::registry_examples(name); + if !examples.is_empty() { + out.push('\n'); + out.push_str(&format!(" {}\n", heading(color, "Examples"))); + for ex in examples { + out.push_str(&format!(" {}\n", paint(color, MUTED_ANSI, ex))); + } } Some(out) } diff --git a/src/cli/help_tests.rs b/src/cli/help_tests.rs index 99ed5ada..de71aa73 100644 --- a/src/cli/help_tests.rs +++ b/src/cli/help_tests.rs @@ -235,3 +235,16 @@ fn classify_help_distinguishes_top_level_command_and_none() { HelpRequest::None ); } + +#[test] +fn command_help_includes_registry_examples() { + let body = render_command("search", false).expect("search help renders"); + assert!( + body.contains("Examples"), + "search help should have an Examples block" + ); + assert!( + body.contains("cortex search"), + "search help should include a registry example: {body}" + ); +} diff --git a/src/cli/http_client_tests.rs b/src/cli/http_client_tests.rs index f80ef64b..d56f5246 100644 --- a/src/cli/http_client_tests.rs +++ b/src/cli/http_client_tests.rs @@ -512,8 +512,8 @@ async fn ai_abuse_request_round_trips_through_serde_qs() { let req = AbuseSearchRequest { project: Some("proj".into()), tool: Some("Bash".into()), - from: None, - to: None, + since: None, + until: None, limit: Some(25), before: Some(2), after: Some(3), @@ -603,11 +603,11 @@ async fn similar_incidents_round_trips_typed_response() { let req = SimilarIncidentsRequest { query: "disk full".into(), - hostname: None, - app_name: None, + host: None, + app: None, severity_min: None, - from: None, - to: None, + since: None, + until: None, window_minutes: None, limit: None, }; @@ -661,8 +661,8 @@ async fn incident_context_round_trips_typed_response() { Mock::given(method("GET")) .and(path("/api/incident-context")) .and(header("authorization", "Bearer test-value")) - .and(query_param("from", "2026-05-01T00:00:00Z")) - .and(query_param("to", "2026-05-01T01:00:00Z")) + .and(query_param("since", "2026-05-01T00:00:00Z")) + .and(query_param("until", "2026-05-01T01:00:00Z")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "window_from": "2026-05-01T00:00:00Z", "window_to": "2026-05-01T01:00:00Z", @@ -678,8 +678,8 @@ async fn incident_context_round_trips_typed_response() { .await; let req = IncidentContextRequest { - from: "2026-05-01T00:00:00Z".into(), - to: "2026-05-01T01:00:00Z".into(), + since: "2026-05-01T00:00:00Z".into(), + until: "2026-05-01T01:00:00Z".into(), ..Default::default() }; let resp = client diff --git a/src/cli/output_ai_more.rs b/src/cli/output_ai_more.rs index a25bb136..43bb3318 100644 --- a/src/cli/output_ai_more.rs +++ b/src/cli/output_ai_more.rs @@ -180,7 +180,7 @@ pub(crate) fn print_ai_incidents_response(response: &AiIncidentResponse, json: b }, if response.candidate_window_truncated { format!( - "\n{}: candidate scan capped at {} rows; narrow with --project/--tool/--from/--to", + "\n{}: candidate scan capped at {} rows; narrow with --project/--tool/--since/--until", warn("warning"), cyan(&response.candidate_cap.to_string()) ) diff --git a/src/cli/output_logs.rs b/src/cli/output_logs.rs index 334dd49e..80e4ea73 100644 --- a/src/cli/output_logs.rs +++ b/src/cli/output_logs.rs @@ -104,7 +104,7 @@ pub(crate) fn print_search_sessions_response( ); if response.candidate_window_truncated { println!( - "search window capped at {} matching rows; use --project, --tool, --from, or --to to narrow exact grouping", + "search window capped at {} matching rows; use --project, --tool, --since, or --until to narrow exact grouping", cyan(&response.candidate_cap.to_string()) ); } @@ -147,7 +147,7 @@ pub(crate) fn print_abuse_search_response( ); if response.candidate_window_truncated { println!( - "abuse scan capped at {} candidate rows; use --project, --tool, --from, or --to to narrow it", + "abuse scan capped at {} candidate rows; use --project, --tool, --since, or --until to narrow it", cyan(&response.candidate_cap.to_string()) ); } diff --git a/src/cli/parse.rs b/src/cli/parse.rs index c3bc9bde..ea8b74d7 100644 --- a/src/cli/parse.rs +++ b/src/cli/parse.rs @@ -9,7 +9,7 @@ use super::parse_logs::{ }; use super::{CliCommand, commands, parse_config, suggest}; -const TOP_LEVEL_COMMANDS: &[&str] = &[ +pub(crate) const TOP_LEVEL_COMMANDS: &[&str] = &[ "search", "filter", "tail", @@ -44,6 +44,7 @@ const TOP_LEVEL_COMMANDS: &[&str] = &[ "fleet-state", "correlate-state", "file-tail", + "completions", ]; pub(crate) fn parse_command(args: Vec) -> Result { @@ -89,6 +90,8 @@ pub(crate) fn parse_command(args: Vec) -> Result { "fleet-state" => commands::fleet_state::parse_fleet_state(rest), "correlate-state" => commands::correlate_state::parse_correlate_state(rest), "file-tail" => commands::file_tails::parse_file_tail(rest), + "__complete" => Ok(CliCommand::Complete(rest.to_vec())), + "completions" => Ok(CliCommand::Completions(rest.to_vec())), _ => bail!( "{}", suggest::unknown_command("CLI command", command, TOP_LEVEL_COMMANDS) diff --git a/src/cli/parse_admin.rs b/src/cli/parse_admin.rs index 718003ac..0233d3f7 100644 --- a/src/cli/parse_admin.rs +++ b/src/cli/parse_admin.rs @@ -39,13 +39,15 @@ pub(crate) fn parse_service_logs(args: &[String]) -> Result { while let Some(arg) = flags.next() { match arg.as_str() { "--json" => parsed.json = true, - "--from" => parsed.from = Some(flags.value("--from")?), - "--to" => parsed.to = Some(flags.value("--to")?), + "--since" => parsed.since = Some(flags.value("--since")?), + "--until" => parsed.until = Some(flags.value("--until")?), "--tail" | "-n" => parsed.tail = Some(parse_u32_flag(&arg, flags.value(&arg)?)?), - _ if arg.starts_with("--from=") => { - parsed.from = Some(value_after_equals(arg, "--from")?) + _ if arg.starts_with("--since=") => { + parsed.since = Some(value_after_equals(arg, "--since")?) + } + _ if arg.starts_with("--until=") => { + parsed.until = Some(value_after_equals(arg, "--until")?) } - _ if arg.starts_with("--to=") => parsed.to = Some(value_after_equals(arg, "--to")?), _ if arg.starts_with("--tail=") => { parsed.tail = Some(parse_u32_flag( "--tail", diff --git a/src/cli/parse_admin_tests.rs b/src/cli/parse_admin_tests.rs index 25791c23..feca0a96 100644 --- a/src/cli/parse_admin_tests.rs +++ b/src/cli/parse_admin_tests.rs @@ -22,8 +22,8 @@ fn parse_service_and_db_commands_dispatch_expected_subcommands() { let service = parse_service(&strings(&[ "logs", "cortex", - "--from=t0", - "--to=t1", + "--since=t0", + "--until=t1", "--tail=20", "--json", ])) @@ -31,7 +31,8 @@ fn parse_service_and_db_commands_dispatch_expected_subcommands() { match service { crate::cli::CliCommand::Service(crate::cli::ServiceCommand::Logs(args)) => { assert_eq!(args.service, "cortex"); - assert_eq!(args.from.as_deref(), Some("t0")); + assert_eq!(args.since.as_deref(), Some("t0")); + assert_eq!(args.until.as_deref(), Some("t1")); assert_eq!(args.tail, Some(20)); assert!(args.json); } diff --git a/src/cli/parse_ai.rs b/src/cli/parse_ai.rs index 5874056e..3e984dad 100644 --- a/src/cli/parse_ai.rs +++ b/src/cli/parse_ai.rs @@ -87,8 +87,8 @@ pub(crate) fn parse_ai_search(args: &[String]) -> Result { "--json" => parsed.json = true, "--project" => parsed.project = Some(flags.value("--project")?), "--tool" => parsed.tool = Some(flags.value("--tool")?), - "--from" => parsed.from = Some(flags.value("--from")?), - "--to" => parsed.to = Some(flags.value("--to")?), + "--since" => parsed.since = Some(flags.value("--since")?), + "--until" => parsed.until = Some(flags.value("--until")?), "--limit" => parsed.limit = Some(parse_u32_flag("--limit", flags.value("--limit")?)?), _ if arg.starts_with("--project=") => { parsed.project = Some(value_after_equals(arg, "--project")?) @@ -96,10 +96,12 @@ pub(crate) fn parse_ai_search(args: &[String]) -> Result { _ if arg.starts_with("--tool=") => { parsed.tool = Some(value_after_equals(arg, "--tool")?) } - _ if arg.starts_with("--from=") => { - parsed.from = Some(value_after_equals(arg, "--from")?) + _ if arg.starts_with("--since=") => { + parsed.since = Some(value_after_equals(arg, "--since")?) + } + _ if arg.starts_with("--until=") => { + parsed.until = Some(value_after_equals(arg, "--until")?) } - _ if arg.starts_with("--to=") => parsed.to = Some(value_after_equals(arg, "--to")?), _ if arg.starts_with("--limit=") => { parsed.limit = Some(parse_u32_flag( "--limit", @@ -125,8 +127,8 @@ pub(crate) fn parse_ai_abuse(args: &[String]) -> Result { "--json" => parsed.json = true, "--project" => parsed.project = Some(flags.value("--project")?), "--tool" => parsed.tool = Some(flags.value("--tool")?), - "--from" => parsed.from = Some(flags.value("--from")?), - "--to" => parsed.to = Some(flags.value("--to")?), + "--since" => parsed.since = Some(flags.value("--since")?), + "--until" => parsed.until = Some(flags.value("--until")?), "--limit" => parsed.limit = Some(parse_u32_flag("--limit", flags.value("--limit")?)?), "--before" => { parsed.before = Some(parse_u32_flag("--before", flags.value("--before")?)?) @@ -139,10 +141,12 @@ pub(crate) fn parse_ai_abuse(args: &[String]) -> Result { _ if arg.starts_with("--tool=") => { parsed.tool = Some(value_after_equals(arg, "--tool")?) } - _ if arg.starts_with("--from=") => { - parsed.from = Some(value_after_equals(arg, "--from")?) + _ if arg.starts_with("--since=") => { + parsed.since = Some(value_after_equals(arg, "--since")?) + } + _ if arg.starts_with("--until=") => { + parsed.until = Some(value_after_equals(arg, "--until")?) } - _ if arg.starts_with("--to=") => parsed.to = Some(value_after_equals(arg, "--to")?), _ if arg.starts_with("--limit=") => { parsed.limit = Some(parse_u32_flag( "--limit", @@ -182,11 +186,11 @@ pub(crate) fn parse_ai_correlate(args: &[String]) -> Result { "--session-id" => parsed.session_id = Some(flags.value("--session-id")?), "--ai-query" => parsed.ai_query = Some(flags.value("--ai-query")?), "--log-query" => parsed.log_query = Some(flags.value("--log-query")?), - "--hostname" => parsed.hostname = Some(flags.value("--hostname")?), - "--source-ip" => parsed.source_ip = Some(flags.value("--source-ip")?), - "--app-name" => parsed.app_name = Some(flags.value("--app-name")?), - "--from" => parsed.from = Some(flags.value("--from")?), - "--to" => parsed.to = Some(flags.value("--to")?), + "--host" => parsed.host = Some(flags.value("--host")?), + "--source" => parsed.source = Some(flags.value("--source")?), + "--app" => parsed.app = Some(flags.value("--app")?), + "--since" => parsed.since = Some(flags.value("--since")?), + "--until" => parsed.until = Some(flags.value("--until")?), "--window-minutes" => { parsed.window_minutes = Some(parse_u32_flag( "--window-minutes", @@ -216,19 +220,19 @@ pub(crate) fn parse_ai_correlate(args: &[String]) -> Result { _ if arg.starts_with("--log-query=") => { parsed.log_query = Some(value_after_equals(arg, "--log-query")?) } - _ if arg.starts_with("--hostname=") => { - parsed.hostname = Some(value_after_equals(arg, "--hostname")?) + _ if arg.starts_with("--host=") => { + parsed.host = Some(value_after_equals(arg, "--host")?) } - _ if arg.starts_with("--source-ip=") => { - parsed.source_ip = Some(value_after_equals(arg, "--source-ip")?) + _ if arg.starts_with("--source=") => { + parsed.source = Some(value_after_equals(arg, "--source")?) } - _ if arg.starts_with("--app-name=") => { - parsed.app_name = Some(value_after_equals(arg, "--app-name")?) + _ if arg.starts_with("--app=") => parsed.app = Some(value_after_equals(arg, "--app")?), + _ if arg.starts_with("--since=") => { + parsed.since = Some(value_after_equals(arg, "--since")?) } - _ if arg.starts_with("--from=") => { - parsed.from = Some(value_after_equals(arg, "--from")?) + _ if arg.starts_with("--until=") => { + parsed.until = Some(value_after_equals(arg, "--until")?) } - _ if arg.starts_with("--to=") => parsed.to = Some(value_after_equals(arg, "--to")?), _ if arg.starts_with("--window-minutes=") => { parsed.window_minutes = Some(parse_u32_flag( "--window-minutes", @@ -265,8 +269,8 @@ pub(crate) fn parse_ai_blocks(args: &[String]) -> Result { "--json" => parsed.json = true, "--project" => parsed.project = Some(flags.value("--project")?), "--tool" => parsed.tool = Some(flags.value("--tool")?), - "--from" => parsed.from = Some(flags.value("--from")?), - "--to" => parsed.to = Some(flags.value("--to")?), + "--since" => parsed.since = Some(flags.value("--since")?), + "--until" => parsed.until = Some(flags.value("--until")?), "--limit" => { parsed.limit = Some(parse_u32_flag("--limit", flags.value("--limit")?)? as usize) } @@ -279,10 +283,12 @@ pub(crate) fn parse_ai_blocks(args: &[String]) -> Result { _ if arg.starts_with("--tool=") => { parsed.tool = Some(value_after_equals(arg, "--tool")?) } - _ if arg.starts_with("--from=") => { - parsed.from = Some(value_after_equals(arg, "--from")?) + _ if arg.starts_with("--since=") => { + parsed.since = Some(value_after_equals(arg, "--since")?) + } + _ if arg.starts_with("--until=") => { + parsed.until = Some(value_after_equals(arg, "--until")?) } - _ if arg.starts_with("--to=") => parsed.to = Some(value_after_equals(arg, "--to")?), _ if arg.starts_with("--limit=") => { parsed.limit = Some(parse_u32_flag("--limit", value_after_equals(arg, "--limit")?)? as usize) @@ -300,8 +306,8 @@ pub(crate) fn parse_ai_blocks(args: &[String]) -> Result { "--json", "--project", "--tool", - "--from", - "--to", + "--since", + "--until", "--limit", "--detail", ], @@ -352,15 +358,17 @@ pub(crate) fn parse_ai_tools(args: &[String]) -> Result { match arg.as_str() { "--json" => parsed.json = true, "--project" => parsed.project = Some(flags.value("--project")?), - "--from" => parsed.from = Some(flags.value("--from")?), - "--to" => parsed.to = Some(flags.value("--to")?), + "--since" => parsed.since = Some(flags.value("--since")?), + "--until" => parsed.until = Some(flags.value("--until")?), _ if arg.starts_with("--project=") => { parsed.project = Some(value_after_equals(arg, "--project")?) } - _ if arg.starts_with("--from=") => { - parsed.from = Some(value_after_equals(arg, "--from")?) + _ if arg.starts_with("--since=") => { + parsed.since = Some(value_after_equals(arg, "--since")?) + } + _ if arg.starts_with("--until=") => { + parsed.until = Some(value_after_equals(arg, "--until")?) } - _ if arg.starts_with("--to=") => parsed.to = Some(value_after_equals(arg, "--to")?), _ => bail!("unknown ai tools option: {arg}"), } } @@ -374,15 +382,17 @@ pub(crate) fn parse_ai_projects(args: &[String]) -> Result { match arg.as_str() { "--json" => parsed.json = true, "--tool" => parsed.tool = Some(flags.value("--tool")?), - "--from" => parsed.from = Some(flags.value("--from")?), - "--to" => parsed.to = Some(flags.value("--to")?), + "--since" => parsed.since = Some(flags.value("--since")?), + "--until" => parsed.until = Some(flags.value("--until")?), _ if arg.starts_with("--tool=") => { parsed.tool = Some(value_after_equals(arg, "--tool")?) } - _ if arg.starts_with("--from=") => { - parsed.from = Some(value_after_equals(arg, "--from")?) + _ if arg.starts_with("--since=") => { + parsed.since = Some(value_after_equals(arg, "--since")?) + } + _ if arg.starts_with("--until=") => { + parsed.until = Some(value_after_equals(arg, "--until")?) } - _ if arg.starts_with("--to=") => parsed.to = Some(value_after_equals(arg, "--to")?), _ => bail!("unknown ai projects option: {arg}"), } } diff --git a/src/cli/parse_ai_more.rs b/src/cli/parse_ai_more.rs index 0f79953d..7006e2bb 100644 --- a/src/cli/parse_ai_more.rs +++ b/src/cli/parse_ai_more.rs @@ -12,11 +12,11 @@ pub(crate) fn parse_ai_similar(args: &[String]) -> Result { while let Some(arg) = flags.next() { match arg.as_str() { "--json" => parsed.json = true, - "--hostname" => parsed.hostname = Some(flags.value("--hostname")?), - "--app-name" => parsed.app_name = Some(flags.value("--app-name")?), + "--host" => parsed.host = Some(flags.value("--host")?), + "--app" => parsed.app = Some(flags.value("--app")?), "--severity-min" => parsed.severity_min = Some(flags.value("--severity-min")?), - "--from" => parsed.from = Some(flags.value("--from")?), - "--to" => parsed.to = Some(flags.value("--to")?), + "--since" => parsed.since = Some(flags.value("--since")?), + "--until" => parsed.until = Some(flags.value("--until")?), "--window-minutes" => { parsed.window_minutes = Some(parse_u32_flag( "--window-minutes", @@ -24,19 +24,19 @@ pub(crate) fn parse_ai_similar(args: &[String]) -> Result { )?) } "--limit" => parsed.limit = Some(parse_u32_flag("--limit", flags.value("--limit")?)?), - _ if arg.starts_with("--hostname=") => { - parsed.hostname = Some(value_after_equals(arg, "--hostname")?) - } - _ if arg.starts_with("--app-name=") => { - parsed.app_name = Some(value_after_equals(arg, "--app-name")?) + _ if arg.starts_with("--host=") => { + parsed.host = Some(value_after_equals(arg, "--host")?) } + _ if arg.starts_with("--app=") => parsed.app = Some(value_after_equals(arg, "--app")?), _ if arg.starts_with("--severity-min=") => { parsed.severity_min = Some(value_after_equals(arg, "--severity-min")?) } - _ if arg.starts_with("--from=") => { - parsed.from = Some(value_after_equals(arg, "--from")?) + _ if arg.starts_with("--since=") => { + parsed.since = Some(value_after_equals(arg, "--since")?) + } + _ if arg.starts_with("--until=") => { + parsed.until = Some(value_after_equals(arg, "--until")?) } - _ if arg.starts_with("--to=") => parsed.to = Some(value_after_equals(arg, "--to")?), _ if arg.starts_with("--window-minutes=") => { parsed.window_minutes = Some(parse_u32_flag( "--window-minutes", @@ -67,21 +67,21 @@ pub(crate) fn parse_ai_ask_history(args: &[String]) -> Result { while let Some(arg) = flags.next() { match arg.as_str() { "--json" => parsed.json = true, - "--hostname" => parsed.hostname = Some(flags.value("--hostname")?), - "--app-name" => parsed.app_name = Some(flags.value("--app-name")?), - "--from" => parsed.from = Some(flags.value("--from")?), - "--to" => parsed.to = Some(flags.value("--to")?), + "--host" => parsed.host = Some(flags.value("--host")?), + "--app" => parsed.app = Some(flags.value("--app")?), + "--since" => parsed.since = Some(flags.value("--since")?), + "--until" => parsed.until = Some(flags.value("--until")?), "--limit" => parsed.limit = Some(parse_u32_flag("--limit", flags.value("--limit")?)?), - _ if arg.starts_with("--hostname=") => { - parsed.hostname = Some(value_after_equals(arg, "--hostname")?) + _ if arg.starts_with("--host=") => { + parsed.host = Some(value_after_equals(arg, "--host")?) } - _ if arg.starts_with("--app-name=") => { - parsed.app_name = Some(value_after_equals(arg, "--app-name")?) + _ if arg.starts_with("--app=") => parsed.app = Some(value_after_equals(arg, "--app")?), + _ if arg.starts_with("--since=") => { + parsed.since = Some(value_after_equals(arg, "--since")?) } - _ if arg.starts_with("--from=") => { - parsed.from = Some(value_after_equals(arg, "--from")?) + _ if arg.starts_with("--until=") => { + parsed.until = Some(value_after_equals(arg, "--until")?) } - _ if arg.starts_with("--to=") => parsed.to = Some(value_after_equals(arg, "--to")?), _ if arg.starts_with("--limit=") => { parsed.limit = Some(parse_u32_flag( "--limit", @@ -105,21 +105,19 @@ pub(crate) fn parse_ai_incident_context(args: &[String]) -> Result { while let Some(arg) = flags.next() { match arg.as_str() { "--json" => parsed.json = true, - "--from" => parsed.from = flags.value("--from")?, - "--to" => parsed.to = flags.value("--to")?, - "--hostname" => parsed.hostname = Some(flags.value("--hostname")?), - "--app-name" => parsed.app_name = Some(flags.value("--app-name")?), + "--since" => parsed.since = flags.value("--since")?, + "--until" => parsed.until = flags.value("--until")?, + "--host" => parsed.host = Some(flags.value("--host")?), + "--app" => parsed.app = Some(flags.value("--app")?), "--query" => parsed.query = Some(flags.value("--query")?), "--severity-min" => parsed.severity_min = Some(flags.value("--severity-min")?), "--limit" => parsed.limit = Some(parse_u32_flag("--limit", flags.value("--limit")?)?), - _ if arg.starts_with("--from=") => parsed.from = value_after_equals(arg, "--from")?, - _ if arg.starts_with("--to=") => parsed.to = value_after_equals(arg, "--to")?, - _ if arg.starts_with("--hostname=") => { - parsed.hostname = Some(value_after_equals(arg, "--hostname")?) - } - _ if arg.starts_with("--app-name=") => { - parsed.app_name = Some(value_after_equals(arg, "--app-name")?) + _ if arg.starts_with("--since=") => parsed.since = value_after_equals(arg, "--since")?, + _ if arg.starts_with("--until=") => parsed.until = value_after_equals(arg, "--until")?, + _ if arg.starts_with("--host=") => { + parsed.host = Some(value_after_equals(arg, "--host")?) } + _ if arg.starts_with("--app=") => parsed.app = Some(value_after_equals(arg, "--app")?), _ if arg.starts_with("--query=") => { parsed.query = Some(value_after_equals(arg, "--query")?) } @@ -136,11 +134,11 @@ pub(crate) fn parse_ai_incident_context(args: &[String]) -> Result { _ => bail!("unexpected positional argument for ai incident-context: {arg}"), } } - if parsed.from.is_empty() { - bail!("ai incident-context requires --from"); + if parsed.since.is_empty() { + bail!("ai incident-context requires --since"); } - if parsed.to.is_empty() { - bail!("ai incident-context requires --to"); + if parsed.until.is_empty() { + bail!("ai incident-context requires --until"); } Ok(CliCommand::Ai(AiCommand::IncidentContext(parsed))) } @@ -153,8 +151,8 @@ pub(crate) fn parse_ai_incidents(args: &[String]) -> Result { "--json" => parsed.json = true, "--project" => parsed.project = Some(flags.value("--project")?), "--tool" => parsed.tool = Some(flags.value("--tool")?), - "--from" => parsed.from = Some(flags.value("--from")?), - "--to" => parsed.to = Some(flags.value("--to")?), + "--since" => parsed.since = Some(flags.value("--since")?), + "--until" => parsed.until = Some(flags.value("--until")?), "--limit" => parsed.limit = Some(parse_u32_flag("--limit", flags.value("--limit")?)?), "--window-minutes" => { parsed.window_minutes = Some(parse_u32_flag( @@ -169,10 +167,12 @@ pub(crate) fn parse_ai_incidents(args: &[String]) -> Result { _ if arg.starts_with("--tool=") => { parsed.tool = Some(value_after_equals(arg, "--tool")?) } - _ if arg.starts_with("--from=") => { - parsed.from = Some(value_after_equals(arg, "--from")?) + _ if arg.starts_with("--since=") => { + parsed.since = Some(value_after_equals(arg, "--since")?) + } + _ if arg.starts_with("--until=") => { + parsed.until = Some(value_after_equals(arg, "--until")?) } - _ if arg.starts_with("--to=") => parsed.to = Some(value_after_equals(arg, "--to")?), _ if arg.starts_with("--limit=") => { parsed.limit = Some(parse_u32_flag( "--limit", @@ -203,8 +203,8 @@ pub(crate) fn parse_ai_investigate(args: &[String]) -> Result { "--json" => parsed.json = true, "--project" => parsed.project = Some(flags.value("--project")?), "--tool" => parsed.tool = Some(flags.value("--tool")?), - "--from" => parsed.from = Some(flags.value("--from")?), - "--to" => parsed.to = Some(flags.value("--to")?), + "--since" => parsed.since = Some(flags.value("--since")?), + "--until" => parsed.until = Some(flags.value("--until")?), "--limit" => parsed.limit = Some(parse_u32_flag("--limit", flags.value("--limit")?)?), "--window-minutes" => { parsed.window_minutes = Some(parse_u32_flag( @@ -233,10 +233,12 @@ pub(crate) fn parse_ai_investigate(args: &[String]) -> Result { _ if arg.starts_with("--tool=") => { parsed.tool = Some(value_after_equals(arg, "--tool")?) } - _ if arg.starts_with("--from=") => { - parsed.from = Some(value_after_equals(arg, "--from")?) + _ if arg.starts_with("--since=") => { + parsed.since = Some(value_after_equals(arg, "--since")?) + } + _ if arg.starts_with("--until=") => { + parsed.until = Some(value_after_equals(arg, "--until")?) } - _ if arg.starts_with("--to=") => parsed.to = Some(value_after_equals(arg, "--to")?), _ if arg.starts_with("--limit=") => { parsed.limit = Some(parse_u32_flag( "--limit", @@ -277,8 +279,8 @@ pub(crate) fn parse_ai_investigate(args: &[String]) -> Result { "--json", "--project", "--tool", - "--from", - "--to", + "--since", + "--until", "--limit", "--window-minutes", "--correlation-window-minutes", @@ -314,8 +316,8 @@ pub(crate) fn parse_ai_assess(args: &[String]) -> Result { "--model" => model = Some(flags.value("--model")?), "--project" => project = Some(flags.value("--project")?), "--tool" => tool = Some(flags.value("--tool")?), - "--from" => from = Some(flags.value("--from")?), - "--to" => to = Some(flags.value("--to")?), + "--since" => from = Some(flags.value("--since")?), + "--until" => to = Some(flags.value("--until")?), "--limit" => limit = Some(parse_u32_flag("--limit", flags.value("--limit")?)?), "--window-minutes" => { window_minutes = Some(parse_u32_flag( @@ -335,8 +337,8 @@ pub(crate) fn parse_ai_assess(args: &[String]) -> Result { project = Some(value_after_equals(arg, "--project")?) } _ if arg.starts_with("--tool=") => tool = Some(value_after_equals(arg, "--tool")?), - _ if arg.starts_with("--from=") => from = Some(value_after_equals(arg, "--from")?), - _ if arg.starts_with("--to=") => to = Some(value_after_equals(arg, "--to")?), + _ if arg.starts_with("--since=") => from = Some(value_after_equals(arg, "--since")?), + _ if arg.starts_with("--until=") => to = Some(value_after_equals(arg, "--until")?), _ if arg.starts_with("--limit=") => { limit = Some(parse_u32_flag( "--limit", @@ -373,8 +375,8 @@ pub(crate) fn parse_ai_assess(args: &[String]) -> Result { json, project, tool, - from, - to, + since: from, + until: to, window_minutes, correlation_window_minutes, terms, diff --git a/src/cli/parse_ai_more_tests.rs b/src/cli/parse_ai_more_tests.rs index 1f1b94d0..71ab95d9 100644 --- a/src/cli/parse_ai_more_tests.rs +++ b/src/cli/parse_ai_more_tests.rs @@ -2,14 +2,14 @@ use super::*; #[test] fn parse_ai_similar_collects_query_and_filters() { - let args = strings(&["disk", "full", "--hostname", "host1", "--limit=7", "--json"]); + let args = strings(&["disk", "full", "--host", "host1", "--limit=7", "--json"]); let command = parse_ai_similar(&args).unwrap(); match command { crate::cli::CliCommand::Ai(crate::cli::AiCommand::SimilarIncidents(args)) => { assert_eq!(args.query, "disk full"); - assert_eq!(args.hostname.as_deref(), Some("host1")); + assert_eq!(args.host.as_deref(), Some("host1")); assert_eq!(args.limit, Some(7)); assert!(args.json); } @@ -20,11 +20,11 @@ fn parse_ai_similar_collects_query_and_filters() { #[test] fn parse_ai_similar_and_ask_history_accept_all_filters() { let similar = parse_ai_similar(&strings(&[ - "--hostname=host1", - "--app-name=cortex", + "--host=host1", + "--app=cortex", "--severity-min=err", - "--from=t0", - "--to=t1", + "--since=t0", + "--until=t1", "--window-minutes=45", "--limit=8", "disk", @@ -32,7 +32,7 @@ fn parse_ai_similar_and_ask_history_accept_all_filters() { .unwrap(); match similar { crate::cli::CliCommand::Ai(crate::cli::AiCommand::SimilarIncidents(args)) => { - assert_eq!(args.app_name.as_deref(), Some("cortex")); + assert_eq!(args.app.as_deref(), Some("cortex")); assert_eq!(args.severity_min.as_deref(), Some("err")); assert_eq!(args.window_minutes, Some(45)); assert_eq!(args.query, "disk"); @@ -41,10 +41,10 @@ fn parse_ai_similar_and_ask_history_accept_all_filters() { } let ask = parse_ai_ask_history(&strings(&[ - "--hostname=host1", - "--app-name=cortex", - "--from=t0", - "--to=t1", + "--host=host1", + "--app=cortex", + "--since=t0", + "--until=t1", "--limit=5", "--json", "why", @@ -54,7 +54,7 @@ fn parse_ai_similar_and_ask_history_accept_all_filters() { match ask { crate::cli::CliCommand::Ai(crate::cli::AiCommand::AskHistory(args)) => { assert_eq!(args.query, "why failed"); - assert_eq!(args.hostname.as_deref(), Some("host1")); + assert_eq!(args.host.as_deref(), Some("host1")); assert_eq!(args.limit, Some(5)); assert!(args.json); } @@ -64,20 +64,20 @@ fn parse_ai_similar_and_ask_history_accept_all_filters() { #[test] fn parse_ai_incident_context_requires_from_and_to() { - let args = strings(&["--from", "2026-01-01T00:00:00Z"]); + let args = strings(&["--since", "2026-01-01T00:00:00Z"]); let err = parse_ai_incident_context(&args).unwrap_err().to_string(); - assert!(err.contains("requires --to")); + assert!(err.contains("requires --until")); } #[test] fn parse_ai_incident_context_accepts_full_filter_set() { let command = parse_ai_incident_context(&strings(&[ - "--from=2026-01-01T00:00:00Z", - "--to=2026-01-01T00:10:00Z", - "--hostname=host1", - "--app-name=cortex", + "--since=2026-01-01T00:00:00Z", + "--until=2026-01-01T00:10:00Z", + "--host=host1", + "--app=cortex", "--query=panic", "--severity-min=warn", "--limit=12", @@ -87,8 +87,8 @@ fn parse_ai_incident_context_accepts_full_filter_set() { match command { crate::cli::CliCommand::Ai(crate::cli::AiCommand::IncidentContext(args)) => { - assert_eq!(args.from, "2026-01-01T00:00:00Z"); - assert_eq!(args.to, "2026-01-01T00:10:00Z"); + assert_eq!(args.since, "2026-01-01T00:00:00Z"); + assert_eq!(args.until, "2026-01-01T00:10:00Z"); assert_eq!(args.query.as_deref(), Some("panic")); assert_eq!(args.limit, Some(12)); assert!(args.json); @@ -102,8 +102,8 @@ fn parse_ai_incidents_accepts_terms_and_window_filters() { let command = parse_ai_incidents(&strings(&[ "--project=/repo", "--tool=Bash", - "--from=t0", - "--to=t1", + "--since=t0", + "--until=t1", "--limit=13", "--window-minutes=60", "--term=panic", @@ -153,8 +153,8 @@ fn parse_ai_investigate_accepts_incident_filters_and_limits() { let command = parse_ai_investigate(&strings(&[ "--project=/repo", "--tool=Edit", - "--from=t0", - "--to=t1", + "--since=t0", + "--until=t1", "--limit=21", "--window-minutes=30", "--correlation-window-minutes=7", @@ -185,8 +185,8 @@ fn parse_ai_assess_accepts_incident_and_investigation_filters() { "--model=gemini-test", "--project=/repo", "--tool=Bash", - "--from=t0", - "--to=t1", + "--since=t0", + "--until=t1", "--limit=34", "--window-minutes=44", "--correlation-window-minutes=9", @@ -221,7 +221,7 @@ fn parse_ai_more_reports_required_query_and_unexpected_argument_errors() { (parse_ai_ask_history, vec!["--limit=1"], "requires a query"), ( parse_ai_incident_context, - vec!["--from=t0", "--to=t1", "extra"], + vec!["--since=t0", "--until=t1", "extra"], "unexpected positional argument", ), ( diff --git a/src/cli/parse_ai_tests.rs b/src/cli/parse_ai_tests.rs index 7008063c..24ed16d6 100644 --- a/src/cli/parse_ai_tests.rs +++ b/src/cli/parse_ai_tests.rs @@ -58,8 +58,8 @@ fn parse_ai_search_abuse_and_correlate_accept_equals_forms() { let search = parse_ai_search(&strings(&[ "--project=/repo", "--tool=Edit", - "--from=2026-01-01T00:00:00Z", - "--to=2026-01-02T00:00:00Z", + "--since=2026-01-01T00:00:00Z", + "--until=2026-01-02T00:00:00Z", "--limit=9", "--json", "disk", @@ -80,8 +80,8 @@ fn parse_ai_search_abuse_and_correlate_accept_equals_forms() { let abuse = parse_ai_abuse(&strings(&[ "--project=/repo", "--tool=Bash", - "--from=old", - "--to=new", + "--since=old", + "--until=new", "--limit=10", "--before=2", "--after=3", @@ -105,11 +105,11 @@ fn parse_ai_search_abuse_and_correlate_accept_equals_forms() { "--session-id=s1", "--ai-query=build", "--log-query=error", - "--hostname=host1", - "--source-ip=10.0.0.8", - "--app-name=cortex", - "--from=t0", - "--to=t1", + "--host=host1", + "--source=10.0.0.8", + "--app=cortex", + "--since=t0", + "--until=t1", "--window-minutes=15", "--severity-min=warn", "--limit=20", @@ -121,7 +121,7 @@ fn parse_ai_search_abuse_and_correlate_accept_equals_forms() { assert_eq!(args.session_id.as_deref(), Some("s1")); assert_eq!(args.window_minutes, Some(15)); assert_eq!(args.events_per_anchor, Some(4)); - assert_eq!(args.source_ip.as_deref(), Some("10.0.0.8")); + assert_eq!(args.source.as_deref(), Some("10.0.0.8")); } other => panic!("unexpected command: {other:?}"), } @@ -142,8 +142,8 @@ fn parse_ai_inventory_and_indexing_commands_accept_flags() { let tools = parse_ai_tools(&strings(&[ "--project=/repo", - "--from=a", - "--to=b", + "--since=a", + "--until=b", "--json", ])) .unwrap(); @@ -155,11 +155,12 @@ fn parse_ai_inventory_and_indexing_commands_accept_flags() { other => panic!("unexpected command: {other:?}"), } - let projects = parse_ai_projects(&strings(&["--tool=Write", "--from=a", "--to=b"])).unwrap(); + let projects = + parse_ai_projects(&strings(&["--tool=Write", "--since=a", "--until=b"])).unwrap(); match projects { crate::cli::CliCommand::Ai(crate::cli::AiCommand::Projects(args)) => { assert_eq!(args.tool.as_deref(), Some("Write")); - assert_eq!(args.to.as_deref(), Some("b")); + assert_eq!(args.until.as_deref(), Some("b")); } other => panic!("unexpected command: {other:?}"), } diff --git a/src/cli/parse_logs.rs b/src/cli/parse_logs.rs index 807f28e9..011b1b00 100644 --- a/src/cli/parse_logs.rs +++ b/src/cli/parse_logs.rs @@ -19,51 +19,53 @@ pub(crate) fn parse_search(args: &[String]) -> Result { while let Some(arg) = flags.next() { match arg.as_str() { "--json" => parsed.json = true, - "--hostname" => parsed.hostname = Some(flags.value("--hostname")?), - "--source-ip" => parsed.source_ip = Some(flags.value("--source-ip")?), + "--host" => parsed.host = Some(flags.value("--host")?), + "--source" => parsed.source = Some(flags.value("--source")?), "--severity" => parsed.severity = Some(flags.value("--severity")?), - "--app-name" => parsed.app_name = Some(flags.value("--app-name")?), + "--app" => parsed.app = Some(flags.value("--app")?), "--facility" => parsed.facility = Some(flags.value("--facility")?), "--exclude-facility" => { parsed.exclude_facility = Some(flags.value("--exclude-facility")?) } - "--from" => parsed.from = Some(norm_time(flags.value("--from")?)?), - "--to" => parsed.to = Some(norm_time(flags.value("--to")?)?), - "--received-from" => { - parsed.received_from = Some(norm_time(flags.value("--received-from")?)?) + "--since" => parsed.since = Some(norm_time(flags.value("--since")?)?), + "--until" => parsed.until = Some(norm_time(flags.value("--until")?)?), + "--received-since" => { + parsed.received_since = Some(norm_time(flags.value("--received-since")?)?) + } + "--received-until" => { + parsed.received_until = Some(norm_time(flags.value("--received-until")?)?) } - "--received-to" => parsed.received_to = Some(norm_time(flags.value("--received-to")?)?), "--limit" => parsed.limit = Some(parse_u32_flag("--limit", flags.value("--limit")?)?), "-h" | "--help" => bail!("use `cortex --help` for usage"), - _ if arg.starts_with("--hostname=") => { - parsed.hostname = Some(value_after_equals(arg, "--hostname")?) + _ if arg.starts_with("--host=") => { + parsed.host = Some(value_after_equals(arg, "--host")?) } - _ if arg.starts_with("--source-ip=") => { - parsed.source_ip = Some(value_after_equals(arg, "--source-ip")?) + _ if arg.starts_with("--source=") => { + parsed.source = Some(value_after_equals(arg, "--source")?) } _ if arg.starts_with("--severity=") => { parsed.severity = Some(value_after_equals(arg, "--severity")?) } - _ if arg.starts_with("--app-name=") => { - parsed.app_name = Some(value_after_equals(arg, "--app-name")?) - } + _ if arg.starts_with("--app=") => parsed.app = Some(value_after_equals(arg, "--app")?), _ if arg.starts_with("--facility=") => { parsed.facility = Some(value_after_equals(arg, "--facility")?) } _ if arg.starts_with("--exclude-facility=") => { parsed.exclude_facility = Some(value_after_equals(arg, "--exclude-facility")?) } - _ if arg.starts_with("--from=") => { - parsed.from = Some(norm_time(value_after_equals(arg, "--from")?)?) + _ if arg.starts_with("--since=") => { + parsed.since = Some(norm_time(value_after_equals(arg, "--since")?)?) } - _ if arg.starts_with("--to=") => { - parsed.to = Some(norm_time(value_after_equals(arg, "--to")?)?) + _ if arg.starts_with("--until=") => { + parsed.until = Some(norm_time(value_after_equals(arg, "--until")?)?) } - _ if arg.starts_with("--received-from=") => { - parsed.received_from = Some(norm_time(value_after_equals(arg, "--received-from")?)?) + _ if arg.starts_with("--received-since=") => { + parsed.received_since = + Some(norm_time(value_after_equals(arg, "--received-since")?)?) } - _ if arg.starts_with("--received-to=") => { - parsed.received_to = Some(norm_time(value_after_equals(arg, "--received-to")?)?) + _ if arg.starts_with("--received-until=") => { + parsed.received_until = + Some(norm_time(value_after_equals(arg, "--received-until")?)?) } _ if arg.starts_with("--limit=") => { parsed.limit = Some(parse_u32_flag( @@ -95,21 +97,23 @@ pub(crate) fn parse_filter(args: &[String]) -> Result { while let Some(arg) = flags.next() { match arg.as_str() { "--json" => parsed.json = true, - "--hostname" => parsed.hostname = Some(flags.value("--hostname")?), - "--source-ip" => parsed.source_ip = Some(flags.value("--source-ip")?), + "--host" => parsed.host = Some(flags.value("--host")?), + "--source" => parsed.source = Some(flags.value("--source")?), "--severity" => parsed.severity = Some(flags.value("--severity")?), - "--app-name" => parsed.app_name = Some(flags.value("--app-name")?), + "--app" => parsed.app = Some(flags.value("--app")?), "--facility" => parsed.facility = Some(flags.value("--facility")?), "--exclude-facility" => { parsed.exclude_facility = Some(flags.value("--exclude-facility")?) } "--process-id" => parsed.process_id = Some(flags.value("--process-id")?), - "--from" => parsed.from = Some(norm_time(flags.value("--from")?)?), - "--to" => parsed.to = Some(norm_time(flags.value("--to")?)?), - "--received-from" => { - parsed.received_from = Some(norm_time(flags.value("--received-from")?)?) + "--since" => parsed.since = Some(norm_time(flags.value("--since")?)?), + "--until" => parsed.until = Some(norm_time(flags.value("--until")?)?), + "--received-since" => { + parsed.received_since = Some(norm_time(flags.value("--received-since")?)?) + } + "--received-until" => { + parsed.received_until = Some(norm_time(flags.value("--received-until")?)?) } - "--received-to" => parsed.received_to = Some(norm_time(flags.value("--received-to")?)?), "--limit" => parsed.limit = Some(parse_u32_flag("--limit", flags.value("--limit")?)?), "--source-kind" => parsed.source_kind = Some(flags.value("--source-kind")?), "--tool" => parsed.tool = Some(flags.value("--tool")?), @@ -120,18 +124,16 @@ pub(crate) fn parse_filter(args: &[String]) -> Result { "--stream" => parsed.stream = Some(flags.value("--stream")?), "--event-action" => parsed.event_action = Some(flags.value("--event-action")?), "-h" | "--help" => bail!("use `cortex --help` for usage"), - _ if arg.starts_with("--hostname=") => { - parsed.hostname = Some(value_after_equals(arg, "--hostname")?) + _ if arg.starts_with("--host=") => { + parsed.host = Some(value_after_equals(arg, "--host")?) } - _ if arg.starts_with("--source-ip=") => { - parsed.source_ip = Some(value_after_equals(arg, "--source-ip")?) + _ if arg.starts_with("--source=") => { + parsed.source = Some(value_after_equals(arg, "--source")?) } _ if arg.starts_with("--severity=") => { parsed.severity = Some(value_after_equals(arg, "--severity")?) } - _ if arg.starts_with("--app-name=") => { - parsed.app_name = Some(value_after_equals(arg, "--app-name")?) - } + _ if arg.starts_with("--app=") => parsed.app = Some(value_after_equals(arg, "--app")?), _ if arg.starts_with("--facility=") => { parsed.facility = Some(value_after_equals(arg, "--facility")?) } @@ -141,17 +143,19 @@ pub(crate) fn parse_filter(args: &[String]) -> Result { _ if arg.starts_with("--process-id=") => { parsed.process_id = Some(value_after_equals(arg, "--process-id")?) } - _ if arg.starts_with("--from=") => { - parsed.from = Some(norm_time(value_after_equals(arg, "--from")?)?) + _ if arg.starts_with("--since=") => { + parsed.since = Some(norm_time(value_after_equals(arg, "--since")?)?) } - _ if arg.starts_with("--to=") => { - parsed.to = Some(norm_time(value_after_equals(arg, "--to")?)?) + _ if arg.starts_with("--until=") => { + parsed.until = Some(norm_time(value_after_equals(arg, "--until")?)?) } - _ if arg.starts_with("--received-from=") => { - parsed.received_from = Some(norm_time(value_after_equals(arg, "--received-from")?)?) + _ if arg.starts_with("--received-since=") => { + parsed.received_since = + Some(norm_time(value_after_equals(arg, "--received-since")?)?) } - _ if arg.starts_with("--received-to=") => { - parsed.received_to = Some(norm_time(value_after_equals(arg, "--received-to")?)?) + _ if arg.starts_with("--received-until=") => { + parsed.received_until = + Some(norm_time(value_after_equals(arg, "--received-until")?)?) } _ if arg.starts_with("--limit=") => { parsed.limit = Some(parse_u32_flag( @@ -198,19 +202,17 @@ pub(crate) fn parse_tail(args: &[String]) -> Result { while let Some(arg) = flags.next() { match arg.as_str() { "--json" => parsed.json = true, - "--hostname" => parsed.hostname = Some(flags.value("--hostname")?), - "--source-ip" => parsed.source_ip = Some(flags.value("--source-ip")?), - "--app-name" => parsed.app_name = Some(flags.value("--app-name")?), + "--host" => parsed.host = Some(flags.value("--host")?), + "--source" => parsed.source = Some(flags.value("--source")?), + "--app" => parsed.app = Some(flags.value("--app")?), "--n" | "-n" => parsed.n = Some(parse_u32_flag(&arg, flags.value(&arg)?)?), - _ if arg.starts_with("--hostname=") => { - parsed.hostname = Some(value_after_equals(arg, "--hostname")?) - } - _ if arg.starts_with("--source-ip=") => { - parsed.source_ip = Some(value_after_equals(arg, "--source-ip")?) + _ if arg.starts_with("--host=") => { + parsed.host = Some(value_after_equals(arg, "--host")?) } - _ if arg.starts_with("--app-name=") => { - parsed.app_name = Some(value_after_equals(arg, "--app-name")?) + _ if arg.starts_with("--source=") => { + parsed.source = Some(value_after_equals(arg, "--source")?) } + _ if arg.starts_with("--app=") => parsed.app = Some(value_after_equals(arg, "--app")?), _ if arg.starts_with("--n=") => { parsed.n = Some(parse_u32_flag("--n", value_after_equals(arg, "--n")?)?) } @@ -227,14 +229,14 @@ pub(crate) fn parse_errors(args: &[String]) -> Result { while let Some(arg) = flags.next() { match arg.as_str() { "--json" => parsed.json = true, - "--from" => parsed.from = Some(norm_time(flags.value("--from")?)?), - "--to" => parsed.to = Some(norm_time(flags.value("--to")?)?), + "--since" => parsed.since = Some(norm_time(flags.value("--since")?)?), + "--until" => parsed.until = Some(norm_time(flags.value("--until")?)?), "--limit" => parsed.limit = Some(parse_u32_flag("--limit", flags.value("--limit")?)?), - _ if arg.starts_with("--from=") => { - parsed.from = Some(norm_time(value_after_equals(arg, "--from")?)?) + _ if arg.starts_with("--since=") => { + parsed.since = Some(norm_time(value_after_equals(arg, "--since")?)?) } - _ if arg.starts_with("--to=") => { - parsed.to = Some(norm_time(value_after_equals(arg, "--to")?)?) + _ if arg.starts_with("--until=") => { + parsed.until = Some(norm_time(value_after_equals(arg, "--until")?)?) } _ if arg.starts_with("--limit=") => { parsed.limit = Some(parse_u32_flag( @@ -260,9 +262,9 @@ pub(crate) fn parse_sessions(args: &[String]) -> Result { "--json" => parsed.json = true, "--project" => parsed.project = Some(flags.value("--project")?), "--tool" => parsed.tool = Some(flags.value("--tool")?), - "--hostname" => parsed.hostname = Some(flags.value("--hostname")?), - "--from" => parsed.from = Some(norm_time(flags.value("--from")?)?), - "--to" => parsed.to = Some(norm_time(flags.value("--to")?)?), + "--host" => parsed.host = Some(flags.value("--host")?), + "--since" => parsed.since = Some(norm_time(flags.value("--since")?)?), + "--until" => parsed.until = Some(norm_time(flags.value("--until")?)?), "--limit" => parsed.limit = Some(parse_u32_flag("--limit", flags.value("--limit")?)?), _ if arg.starts_with("--project=") => { parsed.project = Some(value_after_equals(arg, "--project")?) @@ -270,14 +272,14 @@ pub(crate) fn parse_sessions(args: &[String]) -> Result { _ if arg.starts_with("--tool=") => { parsed.tool = Some(value_after_equals(arg, "--tool")?) } - _ if arg.starts_with("--hostname=") => { - parsed.hostname = Some(value_after_equals(arg, "--hostname")?) + _ if arg.starts_with("--host=") => { + parsed.host = Some(value_after_equals(arg, "--host")?) } - _ if arg.starts_with("--from=") => { - parsed.from = Some(norm_time(value_after_equals(arg, "--from")?)?) + _ if arg.starts_with("--since=") => { + parsed.since = Some(norm_time(value_after_equals(arg, "--since")?)?) } - _ if arg.starts_with("--to=") => { - parsed.to = Some(norm_time(value_after_equals(arg, "--to")?)?) + _ if arg.starts_with("--until=") => { + parsed.until = Some(norm_time(value_after_equals(arg, "--until")?)?) } _ if arg.starts_with("--limit=") => { parsed.limit = Some(parse_u32_flag( @@ -307,7 +309,7 @@ pub(crate) fn parse_incident(args: &[String]) -> Result { parsed.minutes = Some(parse_u32_flag("--minutes", flags.value("--minutes")?)?) } "--service" => parsed.service = Some(flags.value("--service")?), - "--hostname" | "--host" => parsed.hostname = Some(flags.value(&arg)?), + "--host" => parsed.host = Some(flags.value(&arg)?), "--limit" => parsed.limit = Some(parse_u32_flag("--limit", flags.value("--limit")?)?), _ if arg.starts_with("--around=") => { parsed.around = norm_time(value_after_equals(arg, "--around")?)? @@ -321,11 +323,8 @@ pub(crate) fn parse_incident(args: &[String]) -> Result { _ if arg.starts_with("--service=") => { parsed.service = Some(value_after_equals(arg, "--service")?) } - _ if arg.starts_with("--hostname=") => { - parsed.hostname = Some(value_after_equals(arg, "--hostname")?) - } _ if arg.starts_with("--host=") => { - parsed.hostname = Some(value_after_equals(arg, "--host")?) + parsed.host = Some(value_after_equals(arg, "--host")?) } _ if arg.starts_with("--limit=") => { parsed.limit = Some(parse_u32_flag( @@ -359,8 +358,8 @@ pub(crate) fn parse_correlate(args: &[String]) -> Result { )?) } "--severity-min" => parsed.severity_min = Some(flags.value("--severity-min")?), - "--hostname" => parsed.hostname = Some(flags.value("--hostname")?), - "--source-ip" => parsed.source_ip = Some(flags.value("--source-ip")?), + "--host" => parsed.host = Some(flags.value("--host")?), + "--source" => parsed.source = Some(flags.value("--source")?), "--query" => parsed.query = Some(flags.value("--query")?), "--limit" => parsed.limit = Some(parse_u32_flag("--limit", flags.value("--limit")?)?), _ if arg.starts_with("--reference-time=") => { @@ -375,11 +374,11 @@ pub(crate) fn parse_correlate(args: &[String]) -> Result { _ if arg.starts_with("--severity-min=") => { parsed.severity_min = Some(value_after_equals(arg, "--severity-min")?) } - _ if arg.starts_with("--hostname=") => { - parsed.hostname = Some(value_after_equals(arg, "--hostname")?) + _ if arg.starts_with("--host=") => { + parsed.host = Some(value_after_equals(arg, "--host")?) } - _ if arg.starts_with("--source-ip=") => { - parsed.source_ip = Some(value_after_equals(arg, "--source-ip")?) + _ if arg.starts_with("--source=") => { + parsed.source = Some(value_after_equals(arg, "--source")?) } _ if arg.starts_with("--query=") => { parsed.query = Some(value_after_equals(arg, "--query")?) @@ -428,14 +427,14 @@ pub(crate) fn parse_timeline(args: &[String]) -> Result { parsed.bucket = Some(v); } else if let Some(v) = flags.match_value(&arg, "--group-by")? { parsed.group_by = Some(v); - } else if let Some(v) = flags.match_value(&arg, "--from")? { - parsed.from = Some(norm_time(v)?); - } else if let Some(v) = flags.match_value(&arg, "--to")? { - parsed.to = Some(norm_time(v)?); - } else if let Some(v) = flags.match_value(&arg, "--hostname")? { - parsed.hostname = Some(v); - } else if let Some(v) = flags.match_value(&arg, "--app-name")? { - parsed.app_name = Some(v); + } else if let Some(v) = flags.match_value(&arg, "--since")? { + parsed.since = Some(norm_time(v)?); + } else if let Some(v) = flags.match_value(&arg, "--until")? { + parsed.until = Some(norm_time(v)?); + } else if let Some(v) = flags.match_value(&arg, "--host")? { + parsed.host = Some(v); + } else if let Some(v) = flags.match_value(&arg, "--app")? { + parsed.app = Some(v); } else if let Some(v) = flags.match_value(&arg, "--severity-min")? { parsed.severity_min = Some(v); } else { @@ -451,14 +450,14 @@ pub(crate) fn parse_patterns(args: &[String]) -> Result { while let Some(arg) = flags.next() { if arg == "--json" { parsed.json = true; - } else if let Some(v) = flags.match_value(&arg, "--from")? { - parsed.from = Some(norm_time(v)?); - } else if let Some(v) = flags.match_value(&arg, "--to")? { - parsed.to = Some(norm_time(v)?); - } else if let Some(v) = flags.match_value(&arg, "--hostname")? { - parsed.hostname = Some(v); - } else if let Some(v) = flags.match_value(&arg, "--app-name")? { - parsed.app_name = Some(v); + } else if let Some(v) = flags.match_value(&arg, "--since")? { + parsed.since = Some(norm_time(v)?); + } else if let Some(v) = flags.match_value(&arg, "--until")? { + parsed.until = Some(norm_time(v)?); + } else if let Some(v) = flags.match_value(&arg, "--host")? { + parsed.host = Some(v); + } else if let Some(v) = flags.match_value(&arg, "--app")? { + parsed.app = Some(v); } else if let Some(v) = flags.match_value(&arg, "--severity-min")? { parsed.severity_min = Some(v); } else if let Some(v) = flags.match_value(&arg, "--scan-limit")? { diff --git a/src/cli/parse_logs_tests.rs b/src/cli/parse_logs_tests.rs index 86b165d2..74f4dd77 100644 --- a/src/cli/parse_logs_tests.rs +++ b/src/cli/parse_logs_tests.rs @@ -7,7 +7,7 @@ fn parse_timeline_collects_bucket_group_and_filters() { "hour", "--group-by", "hostname", - "--hostname=host1", + "--host=host1", "--json", ]); @@ -17,7 +17,7 @@ fn parse_timeline_collects_bucket_group_and_filters() { crate::cli::CliCommand::Timeline(args) => { assert_eq!(args.bucket.as_deref(), Some("hour")); assert_eq!(args.group_by.as_deref(), Some("hostname")); - assert_eq!(args.hostname.as_deref(), Some("host1")); + assert_eq!(args.host.as_deref(), Some("host1")); assert!(args.json); } other => panic!("unexpected command: {other:?}"), @@ -41,13 +41,13 @@ fn parse_source_ips_accepts_limit_and_offset() { #[test] fn parse_errors_accepts_limit_for_bounded_agent_output() { - let args = strings(&["--from=2026-01-01T00:00:00Z", "--limit", "10", "--json"]); + let args = strings(&["--since=2026-01-01T00:00:00Z", "--limit", "10", "--json"]); let command = parse_errors(&args).unwrap(); match command { crate::cli::CliCommand::Errors(args) => { - assert_eq!(args.from.as_deref(), Some("2026-01-01T00:00:00+00:00")); + assert_eq!(args.since.as_deref(), Some("2026-01-01T00:00:00+00:00")); assert_eq!(args.limit, Some(10)); assert!(args.json); } @@ -103,16 +103,16 @@ fn parse_filter_collects_structured_filters_and_rejects_query_terms() { #[test] fn parse_search_tail_sessions_incident_and_correlate_cover_common_filters() { let search = parse_search(&strings(&[ - "--hostname=host1", - "--source-ip=10.0.0.1", + "--host=host1", + "--source=10.0.0.1", "--severity=err", - "--app-name=cortex", + "--app=cortex", "--facility=daemon", "--exclude-facility=kern", - "--from=2026-01-01T00:00:00Z", - "--to=2026-01-02T00:00:00Z", - "--received-from=2026-01-03T00:00:00Z", - "--received-to=2026-01-04T00:00:00Z", + "--since=2026-01-01T00:00:00Z", + "--until=2026-01-02T00:00:00Z", + "--received-since=2026-01-03T00:00:00Z", + "--received-until=2026-01-04T00:00:00Z", "--limit=30", "--json", "disk", @@ -122,9 +122,9 @@ fn parse_search_tail_sessions_incident_and_correlate_cover_common_filters() { match search { crate::cli::CliCommand::Search(args) => { assert_eq!(args.query.as_deref(), Some("disk full")); - assert_eq!(args.hostname.as_deref(), Some("host1")); + assert_eq!(args.host.as_deref(), Some("host1")); assert_eq!( - args.received_to.as_deref(), + args.received_until.as_deref(), Some("2026-01-04T00:00:00+00:00") ); assert_eq!(args.limit, Some(30)); @@ -133,16 +133,10 @@ fn parse_search_tail_sessions_incident_and_correlate_cover_common_filters() { other => panic!("unexpected command: {other:?}"), } - let tail = parse_tail(&strings(&[ - "--hostname=host1", - "--source-ip=10.0.0.1", - "-n", - "12", - ])) - .unwrap(); + let tail = parse_tail(&strings(&["--host=host1", "--source=10.0.0.1", "-n", "12"])).unwrap(); match tail { crate::cli::CliCommand::Tail(args) => { - assert_eq!(args.hostname.as_deref(), Some("host1")); + assert_eq!(args.host.as_deref(), Some("host1")); assert_eq!(args.n, Some(12)); } other => panic!("unexpected command: {other:?}"), @@ -151,9 +145,9 @@ fn parse_search_tail_sessions_incident_and_correlate_cover_common_filters() { let sessions = parse_sessions(&strings(&[ "--project=/repo", "--tool=Bash", - "--hostname=host1", - "--from=2026-01-01T00:00:00Z", - "--to=2026-01-02T00:00:00Z", + "--host=host1", + "--since=2026-01-01T00:00:00Z", + "--until=2026-01-02T00:00:00Z", "--limit=4", ])) .unwrap(); @@ -178,7 +172,7 @@ fn parse_search_tail_sessions_incident_and_correlate_cover_common_filters() { // The time value is normalized to RFC3339 at parse time. assert_eq!(args.around, "2026-01-01T00:00:00+00:00"); assert_eq!(args.minutes, Some(10)); - assert_eq!(args.hostname.as_deref(), Some("host1")); + assert_eq!(args.host.as_deref(), Some("host1")); } other => panic!("unexpected command: {other:?}"), } @@ -187,8 +181,8 @@ fn parse_search_tail_sessions_incident_and_correlate_cover_common_filters() { "--reference-time=2026-01-01T00:00:00Z", "--window-minutes=5", "--severity-min=warn", - "--hostname=host1", - "--source-ip=10.0.0.1", + "--host=host1", + "--source=10.0.0.1", "--query=panic", "--limit=99", ])) @@ -263,11 +257,11 @@ fn strings(values: &[&str]) -> Vec { #[test] fn search_normalizes_relative_from() { - let cmd = parse_search(&strings(&["error", "--from", "1h"])).unwrap(); + let cmd = parse_search(&strings(&["error", "--since", "1h"])).unwrap(); let crate::cli::CliCommand::Search(args) = cmd else { panic!("expected Search"); }; - let from = args.from.expect("from set"); + let from = args.since.expect("from set"); // Relative input is normalized to an absolute RFC3339 timestamp at parse time. assert!( from.contains('T') && from.ends_with("+00:00"), @@ -303,54 +297,54 @@ fn search_grep_equals_form_and_rejects_empty() { #[test] fn filter_and_sessions_normalize_relative_from() { - let filter = parse_filter(&strings(&["--from", "2d"])).unwrap(); + let filter = parse_filter(&strings(&["--since", "2d"])).unwrap(); let crate::cli::CliCommand::Filter(args) = filter else { panic!("expected Filter"); }; assert!( - args.from.as_deref().unwrap().ends_with("+00:00"), - "filter --from should normalize: {:?}", - args.from + args.since.as_deref().unwrap().ends_with("+00:00"), + "filter --since should normalize: {:?}", + args.since ); // Equals form is normalized too. - let sessions = parse_sessions(&strings(&["--from=1h"])).unwrap(); + let sessions = parse_sessions(&strings(&["--since=1h"])).unwrap(); let crate::cli::CliCommand::Sessions(args) = sessions else { panic!("expected Sessions"); }; assert!( - args.from.as_deref().unwrap().ends_with("+00:00"), - "sessions --from= should normalize: {:?}", - args.from + args.since.as_deref().unwrap().ends_with("+00:00"), + "sessions --since= should normalize: {:?}", + args.since ); } #[test] fn timeline_patterns_incident_correlate_normalize_relative_time() { - // timeline --from/--to accept relative values like the other time flags. - let timeline = parse_timeline(&strings(&["--from", "2d", "--to=1h"])).unwrap(); + // timeline --since/--until accept relative values like the other time flags. + let timeline = parse_timeline(&strings(&["--since", "2d", "--until=1h"])).unwrap(); let crate::cli::CliCommand::Timeline(args) = timeline else { panic!("expected Timeline"); }; assert!( - args.from.as_deref().unwrap().ends_with("+00:00"), - "timeline --from should normalize: {:?}", - args.from + args.since.as_deref().unwrap().ends_with("+00:00"), + "timeline --since should normalize: {:?}", + args.since ); assert!( - args.to.as_deref().unwrap().ends_with("+00:00"), - "timeline --to= should normalize: {:?}", - args.to + args.until.as_deref().unwrap().ends_with("+00:00"), + "timeline --until= should normalize: {:?}", + args.until ); - let patterns = parse_patterns(&strings(&["--from=yesterday"])).unwrap(); + let patterns = parse_patterns(&strings(&["--since=yesterday"])).unwrap(); let crate::cli::CliCommand::Patterns(args) = patterns else { panic!("expected Patterns"); }; assert!( - args.from.as_deref().unwrap().ends_with("+00:00"), - "patterns --from= should normalize: {:?}", - args.from + args.since.as_deref().unwrap().ends_with("+00:00"), + "patterns --since= should normalize: {:?}", + args.since ); // incident --around and correlate --reference-time normalize and reject garbage. diff --git a/src/cli/parse_tests.rs b/src/cli/parse_tests.rs index 9b0ce085..078396b0 100644 --- a/src/cli/parse_tests.rs +++ b/src/cli/parse_tests.rs @@ -23,7 +23,7 @@ fn parses_file_tail_add() { "/mnt/appdata/swag/log/nginx/access.log".into(), "--tag".into(), "swag-access".into(), - "--hostname".into(), + "--host".into(), "squirts".into(), "--facility".into(), "local4".into(), @@ -40,7 +40,7 @@ fn parses_file_tail_add() { id: "swag-access".into(), path: "/mnt/appdata/swag/log/nginx/access.log".into(), tag: "swag-access".into(), - hostname: Some("squirts".into()), + host: Some("squirts".into()), facility: Some("local4".into()), severity: Some("info".into()), start_at_end: false, @@ -63,7 +63,7 @@ fn file_tail_add_requires_hostname() { ]) .unwrap_err(); - assert!(err.to_string().contains("--hostname")); + assert!(err.to_string().contains("--host")); } #[test] @@ -253,7 +253,7 @@ fn parse_routes_host_state() { assert!(matches!( parse_command(vec![ "host-state".to_string(), - "--hostname".to_string(), + "--host".to_string(), "tootie".to_string(), "--json".to_string(), ]) @@ -269,7 +269,7 @@ fn parse_host_state_requires_host_selector_with_usage() { .to_string(); assert!( - err.contains("requires --host-id ID or --hostname HOST"), + err.contains("requires --host-id ID or --host HOST"), "got: {err}" ); assert!(err.contains("Usage: cortex host-state"), "got: {err}"); diff --git a/src/cli/run.rs b/src/cli/run.rs index 57f93717..30c45634 100644 --- a/src/cli/run.rs +++ b/src/cli/run.rs @@ -168,6 +168,11 @@ pub(crate) async fn run(mode: CliMode, command: CliCommand) -> Result<()> { "internal: inventory commands must be dispatched by main::run_cli before reaching cli::run()" ) } + CliCommand::Complete(_) | CliCommand::Completions(_) => { + bail!( + "internal: completion commands must be dispatched by main::run_cli before reaching cli::run()" + ) + } } } diff --git a/src/cli_tests.rs b/src/cli_tests.rs index 832b7278..88715832 100644 --- a/src/cli_tests.rs +++ b/src/cli_tests.rs @@ -38,20 +38,20 @@ fn parse_search_collects_query_and_filters() { "search", "disk", "full", - "--hostname", + "--host", "nas", - "--source-ip=10.0.0.5:514", + "--source=10.0.0.5:514", "--severity", "err", - "--app-name=kernel", + "--app=kernel", "--facility=auth", "--exclude-facility", "transcript", - "--from", + "--since", "2026-01-01T00:00:00Z", - "--to=2026-01-02T00:00:00Z", - "--received-from=2026-01-01T00:00:30Z", - "--received-to", + "--until=2026-01-02T00:00:00Z", + "--received-since=2026-01-01T00:00:30Z", + "--received-until", "2026-01-02T00:00:30Z", "--limit", "25", @@ -64,16 +64,16 @@ fn parse_search_collects_query_and_filters() { CliCommand::Search(SearchArgs { query: Some("disk full".into()), grep: None, - hostname: Some("nas".into()), - source_ip: Some("10.0.0.5:514".into()), + host: Some("nas".into()), + source: Some("10.0.0.5:514".into()), severity: Some("err".into()), - app_name: Some("kernel".into()), + app: Some("kernel".into()), facility: Some("auth".into()), exclude_facility: Some("transcript".into()), - from: Some("2026-01-01T00:00:00+00:00".into()), - to: Some("2026-01-02T00:00:00+00:00".into()), - received_from: Some("2026-01-01T00:00:30+00:00".into()), - received_to: Some("2026-01-02T00:00:30+00:00".into()), + since: Some("2026-01-01T00:00:00+00:00".into()), + until: Some("2026-01-02T00:00:00+00:00".into()), + received_since: Some("2026-01-01T00:00:30+00:00".into()), + received_until: Some("2026-01-02T00:00:30+00:00".into()), limit: Some(25), json: true, }) @@ -82,13 +82,13 @@ fn parse_search_collects_query_and_filters() { #[test] fn parse_tail_accepts_positional_count() { - let parsed = CliCommand::parse(strings(&["tail", "10", "--hostname", "router"])).unwrap(); + let parsed = CliCommand::parse(strings(&["tail", "10", "--host", "router"])).unwrap(); assert_eq!( parsed, CliCommand::Tail(TailArgs { n: Some(10), - hostname: Some("router".into()), + host: Some("router".into()), ..Default::default() }) ); @@ -100,9 +100,9 @@ fn parse_service_logs_accepts_time_range_and_json() { "service", "logs", "cortex-ai-watch", - "--from", + "--since", "2026-05-19 19:55:00", - "--to=2026-05-19 20:05:00", + "--until=2026-05-19 20:05:00", "--tail", "50", "--json", @@ -113,8 +113,8 @@ fn parse_service_logs_accepts_time_range_and_json() { parsed, CliCommand::Service(ServiceCommand::Logs(ServiceLogsArgs { service: "cortex-ai-watch".into(), - from: Some("2026-05-19 19:55:00".into()), - to: Some("2026-05-19 20:05:00".into()), + since: Some("2026-05-19 19:55:00".into()), + until: Some("2026-05-19 20:05:00".into()), tail: Some(50), json: true, })) @@ -146,7 +146,7 @@ fn parse_incident_accepts_window_service_and_json() { around: "2026-05-20T04:00:00+00:00".into(), minutes: Some(10), service: Some("cortex-ai-watch".into()), - hostname: Some("dookie".into()), + host: Some("dookie".into()), limit: Some(25), json: true, }) @@ -304,9 +304,9 @@ fn parse_ai_correlate_collects_cross_reference_filters() { "--ai-query", "deploy", "--log-query=error", - "--hostname", + "--host", "host-a", - "--app-name", + "--app", "dockerd", "--window-minutes", "15", @@ -325,8 +325,8 @@ fn parse_ai_correlate_collects_cross_reference_filters() { session_id: Some("sess-1".into()), ai_query: Some("deploy".into()), log_query: Some("error".into()), - hostname: Some("host-a".into()), - app_name: Some("dockerd".into()), + host: Some("host-a".into()), + app: Some("dockerd".into()), window_minutes: Some(15), severity_min: Some("err".into()), events_per_anchor: Some(12), @@ -1521,11 +1521,11 @@ fn parse_compare_with_ranges() { #[test] fn parse_apps_with_hostname_limit() { - let cmd = CliCommand::parse(strings(&["apps", "--hostname", "dookie", "--limit", "50"])) + let cmd = CliCommand::parse(strings(&["apps", "--host", "dookie", "--limit", "50"])) .expect("parse apps"); match cmd { CliCommand::Apps(args) => { - assert_eq!(args.hostname.as_deref(), Some("dookie")); + assert_eq!(args.host.as_deref(), Some("dookie")); assert_eq!(args.limit, Some(50)); } other => panic!("expected Apps, got {other:?}"), diff --git a/src/db/analytics.rs b/src/db/analytics.rs index 2bca6d3c..568dda05 100644 --- a/src/db/analytics.rs +++ b/src/db/analytics.rs @@ -373,15 +373,15 @@ pub fn get_ai_usage_blocks( bindings.push(rusqlite::types::Value::Text(tool.clone())); idx += 1; } - if let Some(from) = ¶ms.from { + if let Some(from) = ¶ms.since { sql.push_str(&format!(" AND timestamp >= ?{idx}")); bindings.push(rusqlite::types::Value::Text(from.clone())); idx += 1; } - if let Some(to) = ¶ms.to { + if let Some(to) = ¶ms.until { sql.push_str(&format!(" AND timestamp <= ?{idx}")); bindings.push(rusqlite::types::Value::Text(to.clone())); - } else if params.from.is_none() { + } else if params.since.is_none() { sql.push_str(&format!( " AND timestamp >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-{DEFAULT_LOOKBACK_DAYS} days')" )); diff --git a/src/db/analytics_tests.rs b/src/db/analytics_tests.rs index 38d8fce0..8e826181 100644 --- a/src/db/analytics_tests.rs +++ b/src/db/analytics_tests.rs @@ -707,8 +707,8 @@ fn usage_blocks_group_into_five_hour_windows() { let result = get_ai_usage_blocks( &pool, &AiUsageBlocksParams { - from: Some("2026-01-01T00:00:00Z".into()), - to: Some("2026-01-01T06:00:00Z".into()), + since: Some("2026-01-01T00:00:00Z".into()), + until: Some("2026-01-01T06:00:00Z".into()), ..Default::default() }, ) @@ -738,8 +738,8 @@ fn usage_blocks_total_blocks_equals_len_when_truncated() { let result = get_ai_usage_blocks( &pool, &AiUsageBlocksParams { - from: Some("2026-01-01T00:00:00Z".into()), - to: Some("2026-07-31T00:00:00Z".into()), + since: Some("2026-01-01T00:00:00Z".into()), + until: Some("2026-07-31T00:00:00Z".into()), ..Default::default() }, ) diff --git a/src/db/models.rs b/src/db/models.rs index df43e6b8..466dfb1e 100644 --- a/src/db/models.rs +++ b/src/db/models.rs @@ -60,9 +60,9 @@ pub struct DockerCheckpoint { pub struct ListAiSessionsParams { pub ai_project: Option, pub ai_tool: Option, - pub hostname: Option, - pub from: Option, - pub to: Option, + pub host: Option, + pub since: Option, + pub until: Option, pub limit: Option, } @@ -84,11 +84,11 @@ pub struct SearchAiSessionsParams { pub ai_project: Option, pub ai_tool: Option, /// Filter AI transcript sessions to those where the session's host matches. - pub hostname: Option, + pub host: Option, /// Filter AI transcript sessions to those where the session's app matches. - pub app_name: Option, - pub from: Option, - pub to: Option, + pub app: Option, + pub since: Option, + pub until: Option, pub limit: Option, } @@ -165,8 +165,8 @@ pub struct SearchAiSessionsResult { pub struct AiAbuseParams { pub ai_project: Option, pub ai_tool: Option, - pub from: Option, - pub to: Option, + pub since: Option, + pub until: Option, pub limit: Option, pub before: Option, pub after: Option, @@ -197,8 +197,8 @@ pub struct AiCorrelateParams { pub ai_tool: Option, pub ai_session_id: Option, pub ai_query: Option, - pub from: Option, - pub to: Option, + pub since: Option, + pub until: Option, pub limit: Option, } @@ -213,10 +213,10 @@ pub struct AiRelatedWindow { pub struct AiRelatedLogsParams { pub windows: Vec, pub query: Option, - pub hostname: Option, - pub source_ip: Option, + pub host: Option, + pub source: Option, pub severity_in: Vec, - pub app_name: Option, + pub app: Option, pub limit_per_anchor: u32, } @@ -231,8 +231,8 @@ pub struct AiRelatedLogsForAnchor { pub struct AiUsageBlocksParams { pub ai_project: Option, pub ai_tool: Option, - pub from: Option, - pub to: Option, + pub since: Option, + pub until: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -275,8 +275,8 @@ pub struct AiProjectContext { #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ListAiToolsParams { pub ai_project: Option, - pub from: Option, - pub to: Option, + pub since: Option, + pub until: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -298,8 +298,8 @@ pub struct ListAiToolsResult { #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ListAiProjectsParams { pub ai_tool: Option, - pub from: Option, - pub to: Option, + pub since: Option, + pub until: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -376,11 +376,11 @@ pub struct SearchParams { /// Full-text search query (FTS5 syntax) pub query: Option, /// Filter by hostname - pub hostname: Option, + pub host: Option, /// Filter by source identifier. Syslog uses verified network sender address /// (IP:port); OTLP uses peer IP; Docker ingest uses /// docker://host/container/stream or docker-event://host/container/action. - pub source_ip: Option, + pub source: Option, /// Filter by source identifier prefix using an indexed range predicate. pub source_ip_prefix: Option, /// Filter by severity (exact match: emerg, alert, crit, err, warning, notice, info, debug) @@ -388,7 +388,7 @@ pub struct SearchParams { /// Filter by one of a set of severity levels (for threshold queries) pub severity_in: Option>, /// Filter by app name - pub app_name: Option, + pub app: Option, /// Filter by syslog facility name (e.g. `kern`, `auth`, `daemon`) pub facility: Option, /// Exclude a syslog facility while keeping rows with unknown facility. @@ -396,13 +396,13 @@ pub struct SearchParams { /// Filter by process_id (exact match) pub process_id: Option, /// Start of time range (ISO 8601) - pub from: Option, + pub since: Option, /// End of time range (ISO 8601) - pub to: Option, + pub until: Option, /// Start of receive-time range (ISO 8601) - pub received_from: Option, + pub received_since: Option, /// End of receive-time range (ISO 8601) - pub received_to: Option, + pub received_until: Option, /// Max results to return pub limit: Option, pub ai_tool: Option, @@ -419,7 +419,7 @@ impl SearchParams { /// search uses this to choose the index-led intersect plan — which leads /// with the filter's composite index and intersects the FTS match set — /// instead of scanning the entire match set and filtering post-hoc (the - /// pathology that made `search --hostname ` ~200s). + /// pathology that made `search --host ` ~200s). /// /// `severity`/`severity_in` are deliberately EXCLUDED: a single severity /// can be >90% of the table, so leading with `idx_logs_sev_time` for a @@ -428,10 +428,10 @@ impl SearchParams { /// path instead; severity combined with a selective filter still uses the /// fast path via the selective column's index. pub(crate) fn has_indexed_equality_filter(&self) -> bool { - self.hostname.is_some() - || self.source_ip.is_some() + self.host.is_some() + || self.source.is_some() || self.source_ip_prefix.is_some() - || self.app_name.is_some() + || self.app.is_some() || self.event_action.is_some() || self.ai_project.is_some() } @@ -445,8 +445,8 @@ impl SearchParams { pub struct AiIncidentParams { pub ai_project: Option, pub ai_tool: Option, - pub from: Option, - pub to: Option, + pub since: Option, + pub until: Option, /// Max incidents to return. Default 20, clamp 1..=100. pub limit: Option, /// Grouping window in minutes. Default 10, clamp 1..=120. @@ -497,8 +497,8 @@ pub struct AiInvestigateParams { pub incident_id: Option, pub ai_project: Option, pub ai_tool: Option, - pub from: Option, - pub to: Option, + pub since: Option, + pub until: Option, /// Max incidents to investigate. Default 3, clamp 1..=10. pub limit: Option, /// Incident grouping window minutes. Default 10, clamp 1..=120. @@ -540,12 +540,12 @@ pub struct AiInvestigateResult { #[derive(Debug, Clone, Default)] pub struct SimilarIncidentsParams { pub query: String, - pub hostname: Option, - pub app_name: Option, + pub host: Option, + pub app: Option, /// Minimum severity (e.g. "warning"). None = all severities. pub severity_min: Option, - pub from: Option, - pub to: Option, + pub since: Option, + pub until: Option, /// Cluster grouping window in minutes. Default 30, clamp 5..=120. pub window_minutes: Option, /// Max clusters to return. Default 10, clamp 1..=50. @@ -590,10 +590,10 @@ pub struct SimilarIncidentsResult { #[derive(Debug, Clone, Default)] pub struct AskHistoryParams { pub query: String, - pub hostname: Option, - pub app_name: Option, - pub from: Option, - pub to: Option, + pub host: Option, + pub app: Option, + pub since: Option, + pub until: Option, /// Max sessions to return. Default 10, clamp 1..=50. pub limit: Option, } @@ -611,10 +611,10 @@ pub struct AskHistoryResult { #[derive(Debug, Clone, Default)] pub struct IncidentContextParams { - pub from: String, - pub to: String, - pub hostname: Option, - pub app_name: Option, + pub since: String, + pub until: String, + pub host: Option, + pub app: Option, // `query` is accepted at the app layer (IncidentContextRequest) but // deferred to v2 where it will apply FTS5 filtering on error_logs. pub severity_min: Option, diff --git a/src/db/queries.rs b/src/db/queries.rs index 0fc14c8e..10be91ef 100644 --- a/src/db/queries.rs +++ b/src/db/queries.rs @@ -484,7 +484,7 @@ pub fn list_ai_sessions( pool: &DbPool, params: &ListAiSessionsParams, ) -> Result> { - let time_filtered = params.from.is_some() || params.to.is_some(); + let time_filtered = params.since.is_some() || params.until.is_some(); if !time_filtered && ai_session_rollup_is_populated(pool)? { return list_ai_sessions_from_rollup(pool, params); } @@ -527,17 +527,17 @@ pub fn list_ai_sessions_live( bindings.push(rusqlite::types::Value::Text(tool.clone())); idx += 1; } - if let Some(hostname) = ¶ms.hostname { + if let Some(hostname) = ¶ms.host { sql.push_str(&format!(" AND hostname = ?{idx}")); bindings.push(rusqlite::types::Value::Text(hostname.clone())); idx += 1; } - if let Some(from) = ¶ms.from { + if let Some(from) = ¶ms.since { sql.push_str(&format!(" AND timestamp >= ?{idx}")); bindings.push(rusqlite::types::Value::Text(from.clone())); idx += 1; } - if let Some(to) = ¶ms.to { + if let Some(to) = ¶ms.until { sql.push_str(&format!(" AND timestamp <= ?{idx}")); bindings.push(rusqlite::types::Value::Text(to.clone())); } @@ -589,7 +589,7 @@ fn list_ai_sessions_from_rollup( bindings.push(rusqlite::types::Value::Text(tool.clone())); idx += 1; } - if let Some(hostname) = ¶ms.hostname { + if let Some(hostname) = ¶ms.host { sql.push_str(&format!(" AND hostname = ?{idx}")); bindings.push(rusqlite::types::Value::Text(hostname.clone())); } @@ -1103,14 +1103,14 @@ fn search_ai_sessions_sql( "l", ¶ms.ai_project, ¶ms.ai_tool, - ¶ms.from, - ¶ms.to, + ¶ms.since, + ¶ms.until, ); - if let Some(hostname) = ¶ms.hostname { + if let Some(hostname) = ¶ms.host { let idx = query_params.push_text(hostname.clone()); sql.push_str(&format!(" AND l.hostname = ?{idx}")); } - if let Some(app_name) = ¶ms.app_name { + if let Some(app_name) = ¶ms.app { let idx = query_params.push_text(app_name.clone()); sql.push_str(&format!(" AND l.app_name = ?{idx}")); } @@ -1207,12 +1207,12 @@ pub fn search_ai_anchors(pool: &DbPool, params: &AiCorrelateParams) -> Result= ?{idx}")); bindings.push(rusqlite::types::Value::Text(from.clone())); idx += 1; } - if let Some(to) = ¶ms.to { + if let Some(to) = ¶ms.until { filters.push_str(&format!(" AND l.timestamp <= ?{idx}")); bindings.push(rusqlite::types::Value::Text(to.clone())); } @@ -1295,19 +1295,19 @@ pub fn search_ai_related_logs( let mut sql_params = SqlParams::new(first_filter_idx); let search_params = SearchParams { query: None, - hostname: params.hostname.clone(), - source_ip: params.source_ip.clone(), + host: params.host.clone(), + source: params.source.clone(), source_ip_prefix: None, severity: None, severity_in: Some(params.severity_in.clone()), - app_name: params.app_name.clone(), + app: params.app.clone(), facility: None, exclude_facility: None, process_id: None, - from: None, - to: None, - received_from: None, - received_to: None, + since: None, + until: None, + received_since: None, + received_until: None, limit: None, ai_tool: None, ai_project: None, @@ -1415,12 +1415,12 @@ pub fn search_ai_abuse(pool: &DbPool, params: &AiAbuseParams) -> Result= ?{idx}")); bindings.push(rusqlite::types::Value::Text(from.clone())); idx += 1; } - if let Some(to) = ¶ms.to { + if let Some(to) = ¶ms.until { sql.push_str(&format!(" AND l.timestamp <= ?{idx}")); bindings.push(rusqlite::types::Value::Text(to.clone())); } @@ -1491,12 +1491,12 @@ pub fn list_ai_tools(pool: &DbPool, params: &ListAiToolsParams) -> Result= ?{idx}")); bindings.push(rusqlite::types::Value::Text(from.clone())); idx += 1; } - if let Some(to) = ¶ms.to { + if let Some(to) = ¶ms.until { sql.push_str(&format!(" AND timestamp <= ?{idx}")); bindings.push(rusqlite::types::Value::Text(to.clone())); } @@ -1550,12 +1550,12 @@ pub fn list_ai_projects( bindings.push(rusqlite::types::Value::Text(tool.clone())); idx += 1; } - if let Some(from) = ¶ms.from { + if let Some(from) = ¶ms.since { sql.push_str(&format!(" AND timestamp >= ?{idx}")); bindings.push(rusqlite::types::Value::Text(from.clone())); idx += 1; } - if let Some(to) = ¶ms.to { + if let Some(to) = ¶ms.until { sql.push_str(&format!(" AND timestamp <= ?{idx}")); bindings.push(rusqlite::types::Value::Text(to.clone())); } @@ -1815,12 +1815,12 @@ fn ai_incident_anchor_sql( bindings.push(rusqlite::types::Value::Text(tool.clone())); idx += 1; } - if let Some(from) = ¶ms.from { + if let Some(from) = ¶ms.since { sql.push_str(&format!(" AND l.timestamp >= ?{idx}")); bindings.push(rusqlite::types::Value::Text(from.clone())); idx += 1; } - if let Some(to) = ¶ms.to { + if let Some(to) = ¶ms.until { sql.push_str(&format!(" AND l.timestamp <= ?{idx}")); bindings.push(rusqlite::types::Value::Text(to.clone())); } @@ -1857,8 +1857,8 @@ pub fn investigate_ai_incidents( &AiIncidentParams { ai_project: params.ai_project.clone(), ai_tool: params.ai_tool.clone(), - from: params.from.clone(), - to: params.to.clone(), + since: params.since.clone(), + until: params.until.clone(), limit: Some(incident_lookup_limit), window_minutes: params.window_minutes, terms: params.terms.clone(), @@ -2313,12 +2313,12 @@ fn append_filters( idx: &mut usize, params: &SearchParams, ) { - if let Some(ref h) = params.hostname { + if let Some(ref h) = params.host { sql.push_str(&format!(" AND l.hostname = ?{}", *idx)); bindings.push(rusqlite::types::Value::Text(h.clone())); *idx += 1; } - if let Some(ref source_ip) = params.source_ip { + if let Some(ref source_ip) = params.source { sql.push_str(&format!(" AND l.source_ip = ?{}", *idx)); bindings.push(rusqlite::types::Value::Text(source_ip.clone())); *idx += 1; @@ -2352,7 +2352,7 @@ fn append_filters( } } } - if let Some(ref a) = params.app_name { + if let Some(ref a) = params.app { sql.push_str(&format!(" AND l.app_name = ?{}", *idx)); bindings.push(rusqlite::types::Value::Text(a.clone())); *idx += 1; @@ -2375,22 +2375,22 @@ fn append_filters( bindings.push(rusqlite::types::Value::Text(pid.clone())); *idx += 1; } - if let Some(ref from) = params.from { + if let Some(ref from) = params.since { sql.push_str(&format!(" AND l.timestamp >= ?{}", *idx)); bindings.push(rusqlite::types::Value::Text(from.clone())); *idx += 1; } - if let Some(ref to) = params.to { + if let Some(ref to) = params.until { sql.push_str(&format!(" AND l.timestamp <= ?{}", *idx)); bindings.push(rusqlite::types::Value::Text(to.clone())); *idx += 1; } - if let Some(ref from) = params.received_from { + if let Some(ref from) = params.received_since { sql.push_str(&format!(" AND l.received_at >= ?{}", *idx)); bindings.push(rusqlite::types::Value::Text(from.clone())); *idx += 1; } - if let Some(ref to) = params.received_to { + if let Some(ref to) = params.received_until { sql.push_str(&format!(" AND l.received_at <= ?{}", *idx)); bindings.push(rusqlite::types::Value::Text(to.clone())); *idx += 1; @@ -2540,19 +2540,19 @@ pub fn similar_incidents_clusters( .bindings .push(rusqlite::types::Value::Text(params.query.clone())); - if let Some(hostname) = ¶ms.hostname { + if let Some(hostname) = ¶ms.host { let idx = query_params.push_text(hostname.clone()); sql.push_str(&format!(" AND l.hostname = ?{idx}")); } - if let Some(app_name) = ¶ms.app_name { + if let Some(app_name) = ¶ms.app { let idx = query_params.push_text(app_name.clone()); sql.push_str(&format!(" AND l.app_name = ?{idx}")); } - if let Some(from) = ¶ms.from { + if let Some(from) = ¶ms.since { let idx = query_params.push_text(from.clone()); sql.push_str(&format!(" AND l.timestamp >= ?{idx}")); } - if let Some(to) = ¶ms.to { + if let Some(to) = ¶ms.until { let idx = query_params.push_text(to.clone()); sql.push_str(&format!(" AND l.timestamp <= ?{idx}")); } @@ -2813,10 +2813,10 @@ pub fn ask_history_sessions(pool: &DbPool, params: &AskHistoryParams) -> Result< query: params.query.clone(), ai_project: None, ai_tool: None, - hostname: params.hostname.clone(), - app_name: params.app_name.clone(), - from: params.from.clone(), - to: params.to.clone(), + host: params.host.clone(), + app: params.app.clone(), + since: params.since.clone(), + until: params.until.clone(), limit: Some(params.limit.unwrap_or(10).clamp(1, 50)), }; let session_result = search_ai_sessions(pool, &ai_params)?; @@ -2843,11 +2843,11 @@ pub fn ask_history_sessions(pool: &DbPool, params: &AskHistoryParams) -> Result< WHERE (ai_project IS NULL OR ai_project = '') AND timestamp BETWEEN ?1 AND ?2", ); - if let Some(hostname) = ¶ms.hostname { + if let Some(hostname) = ¶ms.host { let idx = ctx_params.push_text(hostname.clone()); ctx_sql.push_str(&format!(" AND hostname = ?{idx}")); } - if let Some(app_name) = ¶ms.app_name { + if let Some(app_name) = ¶ms.app { let idx = ctx_params.push_text(app_name.clone()); ctx_sql.push_str(&format!(" AND app_name = ?{idx}")); } @@ -2910,17 +2910,17 @@ pub fn incident_context_summary( let mut agg_params = SqlParams::new(3); agg_params .bindings - .push(rusqlite::types::Value::Text(params.from.clone())); + .push(rusqlite::types::Value::Text(params.since.clone())); agg_params .bindings - .push(rusqlite::types::Value::Text(params.to.clone())); + .push(rusqlite::types::Value::Text(params.until.clone())); let mut agg_host_clause = String::new(); let mut agg_app_clause = String::new(); - if let Some(hostname) = ¶ms.hostname { + if let Some(hostname) = ¶ms.host { let idx = agg_params.push_text(hostname.clone()); agg_host_clause = format!(" AND hostname = ?{idx}"); } - if let Some(app_name) = ¶ms.app_name { + if let Some(app_name) = ¶ms.app { let idx = agg_params.push_text(app_name.clone()); agg_app_clause = format!(" AND app_name = ?{idx}"); } @@ -2996,10 +2996,10 @@ pub fn incident_context_summary( let mut err_params = SqlParams::new(3); err_params .bindings - .push(rusqlite::types::Value::Text(params.from.clone())); + .push(rusqlite::types::Value::Text(params.since.clone())); err_params .bindings - .push(rusqlite::types::Value::Text(params.to.clone())); + .push(rusqlite::types::Value::Text(params.until.clone())); let sev_placeholders: Vec = error_severities .iter() @@ -3021,11 +3021,11 @@ pub fn incident_context_summary( sev_placeholders.join(", ") ); - if let Some(hostname) = ¶ms.hostname { + if let Some(hostname) = ¶ms.host { let idx = err_params.push_text(hostname.clone()); err_sql.push_str(&format!(" AND hostname = ?{idx}")); } - if let Some(app_name) = ¶ms.app_name { + if let Some(app_name) = ¶ms.app { let idx = err_params.push_text(app_name.clone()); err_sql.push_str(&format!(" AND app_name = ?{idx}")); } @@ -3067,10 +3067,10 @@ pub fn incident_context_summary( AND timestamp BETWEEN ?1 AND ?2", ); let mut ai_bindings: Vec = vec![ - rusqlite::types::Value::Text(params.from.clone()), - rusqlite::types::Value::Text(params.to.clone()), + rusqlite::types::Value::Text(params.since.clone()), + rusqlite::types::Value::Text(params.until.clone()), ]; - if let Some(hostname) = ¶ms.hostname { + if let Some(hostname) = ¶ms.host { ai_bindings.push(rusqlite::types::Value::Text(hostname.clone())); ai_sql.push_str(&format!(" AND hostname = ?{}", ai_bindings.len())); } @@ -3099,8 +3099,8 @@ pub fn incident_context_summary( }; Ok(IncidentContextResult { - window_from: params.from.clone(), - window_to: params.to.clone(), + window_from: params.since.clone(), + window_to: params.until.clone(), total_logs, by_severity, by_app, diff --git a/src/db/queries_tests.rs b/src/db/queries_tests.rs index bebf8528..72b91f48 100644 --- a/src/db/queries_tests.rs +++ b/src/db/queries_tests.rs @@ -251,7 +251,7 @@ fn search_fts_hostname_filter_returns_only_that_host() { let params = SearchParams { query: Some("panic".to_string()), - hostname: Some("host-a".to_string()), + host: Some("host-a".to_string()), limit: Some(1000), ..Default::default() }; @@ -281,7 +281,7 @@ fn search_fts_plan_selection_branches_on_indexed_filter() { // bounded match-set subquery (full-review PH1: the non-correlated IN // subquery is materialized in full, so it must carry a cap). let filtered = SearchParams { - hostname: Some("host-a".to_string()), + host: Some("host-a".to_string()), ..plain.clone() }; assert!(filtered.has_indexed_equality_filter()); @@ -336,7 +336,7 @@ fn search_fts_severity_only_filter_uses_capped_candidate_plan() { let combined = SearchParams { query: Some("disk".to_string()), severity: Some("info".to_string()), - hostname: Some("host-a".to_string()), + host: Some("host-a".to_string()), ..Default::default() }; assert!(combined.has_indexed_equality_filter()); @@ -496,7 +496,7 @@ fn test_search_timestamp_range_filtering() { // from only let params = SearchParams { - from: Some("2026-06-01T00:00:00Z".into()), + since: Some("2026-06-01T00:00:00Z".into()), ..Default::default() }; let results = search_logs(&pool, ¶ms).unwrap(); @@ -504,7 +504,7 @@ fn test_search_timestamp_range_filtering() { // to only let params = SearchParams { - to: Some("2026-06-30T00:00:00Z".into()), + until: Some("2026-06-30T00:00:00Z".into()), ..Default::default() }; let results = search_logs(&pool, ¶ms).unwrap(); @@ -512,8 +512,8 @@ fn test_search_timestamp_range_filtering() { // from + to (narrow window) let params = SearchParams { - from: Some("2026-06-01T00:00:00Z".into()), - to: Some("2026-06-30T00:00:00Z".into()), + since: Some("2026-06-01T00:00:00Z".into()), + until: Some("2026-06-30T00:00:00Z".into()), ..Default::default() }; let results = search_logs(&pool, ¶ms).unwrap(); @@ -555,8 +555,8 @@ fn test_search_received_at_range_filtering() { let results = search_logs( &pool, &SearchParams { - received_from: Some("2026-01-01T00:15:00Z".into()), - received_to: Some("2026-01-01T00:45:00Z".into()), + received_since: Some("2026-01-01T00:15:00Z".into()), + received_until: Some("2026-01-01T00:45:00Z".into()), ..Default::default() }, ) @@ -1429,9 +1429,9 @@ fn ai_session_queries_respect_filters() { &ListAiSessionsParams { ai_project: Some("/tmp/a".into()), ai_tool: Some("claude".into()), - hostname: Some("host-a".into()), - from: Some("2026-01-01T00:00:00Z".into()), - to: Some("2026-01-01T23:59:59Z".into()), + host: Some("host-a".into()), + since: Some("2026-01-01T00:00:00Z".into()), + until: Some("2026-01-01T23:59:59Z".into()), limit: Some(10), }, ) @@ -1445,10 +1445,10 @@ fn ai_session_queries_respect_filters() { query: "needle".into(), ai_project: Some("/tmp/b".into()), ai_tool: Some("codex".into()), - hostname: None, - app_name: None, - from: Some("2026-01-01T00:30:00Z".into()), - to: Some("2026-01-01T01:30:00Z".into()), + host: None, + app: None, + since: Some("2026-01-01T00:30:00Z".into()), + until: Some("2026-01-01T01:30:00Z".into()), limit: Some(10), }, ) @@ -1649,9 +1649,9 @@ fn default_session_params() -> ListAiSessionsParams { ListAiSessionsParams { ai_project: None, ai_tool: None, - hostname: None, - from: None, - to: None, + host: None, + since: None, + until: None, limit: Some(100), } } @@ -2046,8 +2046,8 @@ fn time_windowed_sessions_always_use_live_path() { .unwrap(); let windowed = ListAiSessionsParams { - from: Some("2026-06-01T00:00:00Z".into()), - to: Some("2026-06-02T00:00:00Z".into()), + since: Some("2026-06-01T00:00:00Z".into()), + until: Some("2026-06-02T00:00:00Z".into()), ..default_session_params() }; let rows = list_ai_sessions(&pool, &windowed).unwrap(); @@ -2301,11 +2301,11 @@ fn similar_incidents_clusters_returns_clusters_for_matching_logs() { let params = SimilarIncidentsParams { query: "upstream".into(), - hostname: None, - app_name: None, + host: None, + app: None, severity_min: None, - from: None, - to: None, + since: None, + until: None, window_minutes: Some(30), limit: Some(10), }; @@ -2343,7 +2343,7 @@ fn similar_incidents_clusters_filters_by_hostname() { let params = SimilarIncidentsParams { query: "upstream".into(), - hostname: Some("web-01".into()), + host: Some("web-01".into()), ..Default::default() }; let result = similar_incidents_clusters(&pool, ¶ms).unwrap(); @@ -2373,10 +2373,10 @@ fn incident_context_summary_returns_window_stats() { insert_logs_batch(&pool, &logs).unwrap(); let params = IncidentContextParams { - from: "2024-02-01T07:00:00Z".into(), - to: "2024-02-01T09:00:00Z".into(), - hostname: None, - app_name: None, + since: "2024-02-01T07:00:00Z".into(), + until: "2024-02-01T09:00:00Z".into(), + host: None, + app: None, severity_min: Some("err".into()), limit: Some(10), }; @@ -2393,8 +2393,8 @@ fn incident_context_summary_empty_window_returns_zero() { let (pool, _dir) = test_pool(); let params = IncidentContextParams { - from: "2020-01-01T00:00:00Z".into(), - to: "2020-01-02T00:00:00Z".into(), + since: "2020-01-01T00:00:00Z".into(), + until: "2020-01-02T00:00:00Z".into(), ..Default::default() }; let result = incident_context_summary(&pool, ¶ms).unwrap(); @@ -2438,10 +2438,10 @@ fn ask_history_sessions_returns_session_hits() { let params = AskHistoryParams { query: "certificate".into(), - hostname: None, - app_name: None, - from: None, - to: None, + host: None, + app: None, + since: None, + until: None, limit: Some(5), }; let result = ask_history_sessions(&pool, ¶ms).unwrap(); @@ -2620,9 +2620,9 @@ fn bench_stats_and_sessions() { let params = ListAiSessionsParams { ai_project: None, ai_tool: None, - hostname: None, - from: None, - to: None, + host: None, + since: None, + until: None, limit: Some(100), }; let mut live_rows = 0usize; diff --git a/src/file_tail/models.rs b/src/file_tail/models.rs index 891a5e0a..07ce760f 100644 --- a/src/file_tail/models.rs +++ b/src/file_tail/models.rs @@ -66,7 +66,7 @@ pub struct FileTailRequest { pub id: Option, pub path: Option, pub tag: Option, - pub hostname: Option, + pub host: Option, pub facility: Option, pub severity: Option, pub start_at_end: Option, @@ -78,7 +78,7 @@ pub struct FileTailAddRequest { pub id: String, pub path: String, pub tag: String, - pub hostname: Option, + pub host: Option, pub facility: Option, pub severity: Option, pub start_at_end: Option, @@ -104,8 +104,8 @@ pub struct FileTailResponse { impl FileTailSource { pub(crate) fn from_add(req: FileTailAddRequest, now: &str) -> Result { validate_id(&req.id)?; - if req.path.is_empty() || req.tag.is_empty() || req.hostname.is_none() { - return Err("file_tails op=add requires id, path, tag, and hostname".into()); + if req.path.is_empty() || req.tag.is_empty() || req.host.is_none() { + return Err("file_tails op=add requires id, path, tag, and host".into()); } if let Some(facility) = req.facility.as_deref() { validate_facility(facility)?; @@ -122,9 +122,9 @@ impl FileTailSource { .unwrap_or_else(|| "info".to_string()); let hostname = req - .hostname + .host .as_deref() - .ok_or_else(|| "file_tails op=add requires id, path, tag, and hostname".to_string()) + .ok_or_else(|| "file_tails op=add requires id, path, tag, and host".to_string()) .and_then(normalize_hostname)?; Ok(Self { @@ -163,7 +163,7 @@ impl FileTailRequest { id: None, path: None, tag: None, - hostname: None, + host: None, facility: None, severity: None, start_at_end: None, @@ -183,7 +183,7 @@ impl FileTailRequest { id: Some(id), path: None, tag: None, - hostname: None, + host: None, facility: None, severity: None, start_at_end: None, @@ -196,7 +196,7 @@ impl FileTailRequest { id: Some(add.id), path: Some(add.path), tag: Some(add.tag), - hostname: add.hostname, + host: add.host, facility: add.facility, severity: add.severity, start_at_end: add.start_at_end, @@ -218,7 +218,7 @@ impl FileTailRequest { if self.id.is_some() || self.path.is_some() || self.tag.is_some() - || self.hostname.is_some() + || self.host.is_some() || self.facility.is_some() || self.severity.is_some() || self.start_at_end.is_some() @@ -235,7 +235,7 @@ impl FileTailRequest { FileTailOp::Remove | FileTailOp::Enable | FileTailOp::Disable => { if self.path.is_some() || self.tag.is_some() - || self.hostname.is_some() + || self.host.is_some() || self.facility.is_some() || self.severity.is_some() || self.start_at_end.is_some() @@ -252,25 +252,25 @@ impl FileTailRequest { pub(crate) fn into_add(self) -> Result { let id = self .id - .ok_or_else(|| "file_tails op=add requires id, path, tag, and hostname".to_string())?; + .ok_or_else(|| "file_tails op=add requires id, path, tag, and host".to_string())?; validate_id(&id)?; let path = self .path - .ok_or_else(|| "file_tails op=add requires id, path, tag, and hostname".to_string())?; + .ok_or_else(|| "file_tails op=add requires id, path, tag, and host".to_string())?; let tag = self .tag - .ok_or_else(|| "file_tails op=add requires id, path, tag, and hostname".to_string())?; + .ok_or_else(|| "file_tails op=add requires id, path, tag, and host".to_string())?; let hostname = self - .hostname - .ok_or_else(|| "file_tails op=add requires id, path, tag, and hostname".to_string())?; + .host + .ok_or_else(|| "file_tails op=add requires id, path, tag, and host".to_string())?; if path.is_empty() || tag.is_empty() || hostname.trim().is_empty() { - return Err("file_tails op=add requires id, path, tag, and hostname".into()); + return Err("file_tails op=add requires id, path, tag, and host".into()); } Ok(FileTailAddRequest { id, path, tag, - hostname: Some(hostname), + host: Some(hostname), facility: self.facility, severity: self.severity, start_at_end: self.start_at_end, diff --git a/src/file_tail/models_tests.rs b/src/file_tail/models_tests.rs index b4a2848d..6a1ad218 100644 --- a/src/file_tail/models_tests.rs +++ b/src/file_tail/models_tests.rs @@ -6,7 +6,7 @@ fn add_request_builds_enabled_source_with_defaults() { id: "swag-access".into(), path: "/mnt/appdata/swag/log/nginx/access.log".into(), tag: "swag-access".into(), - hostname: Some("squirts".into()), + host: Some("squirts".into()), facility: None, severity: None, start_at_end: None, @@ -33,7 +33,7 @@ fn add_request_normalizes_and_validates_hostname() { id: "swag-access".into(), path: "/mnt/appdata/swag/log/nginx/access.log".into(), tag: "swag-access".into(), - hostname: Some(" Squirts.LOCAL ".into()), + host: Some(" Squirts.LOCAL ".into()), facility: None, severity: None, start_at_end: None, @@ -48,7 +48,7 @@ fn add_request_normalizes_and_validates_hostname() { id: "bad-host".into(), path: "/mnt/appdata/swag/log/nginx/access.log".into(), tag: "bad-host".into(), - hostname: Some("bad host/name".into()), + host: Some("bad host/name".into()), facility: None, severity: None, start_at_end: None, @@ -66,7 +66,7 @@ fn file_tail_request_rejects_missing_fields_for_add() { id: None, path: None, tag: None, - hostname: None, + host: None, facility: None, severity: None, start_at_end: None, @@ -74,7 +74,7 @@ fn file_tail_request_rejects_missing_fields_for_add() { assert_eq!( req.into_add().unwrap_err(), - "file_tails op=add requires id, path, tag, and hostname" + "file_tails op=add requires id, path, tag, and host" ); } @@ -85,7 +85,7 @@ fn add_request_rejects_missing_hostname() { id: "swag-access".into(), path: "/mnt/appdata/swag/log/nginx/access.log".into(), tag: "swag-access".into(), - hostname: None, + host: None, facility: None, severity: None, start_at_end: None, @@ -94,10 +94,7 @@ fn add_request_rejects_missing_hostname() { ) .unwrap_err(); - assert_eq!( - err, - "file_tails op=add requires id, path, tag, and hostname" - ); + assert_eq!(err, "file_tails op=add requires id, path, tag, and host"); } #[test] @@ -107,7 +104,7 @@ fn file_tail_request_rejects_path_traversal_ids() { id: Some("../swag".into()), path: None, tag: None, - hostname: None, + host: None, facility: None, severity: None, start_at_end: None, diff --git a/src/file_tail/registry_tests.rs b/src/file_tail/registry_tests.rs index c87bac36..716f1d08 100644 --- a/src/file_tail/registry_tests.rs +++ b/src/file_tail/registry_tests.rs @@ -10,7 +10,7 @@ fn registry_adds_lists_and_removes_sources() { id: "swag-access".into(), path: "/tmp/access.log".into(), tag: "swag-access".into(), - hostname: Some("squirts".into()), + host: Some("squirts".into()), facility: None, severity: None, start_at_end: None, @@ -38,7 +38,7 @@ fn registry_persists_across_instances() { id: "authelia".into(), path: "/tmp/authelia.log".into(), tag: "authelia".into(), - hostname: Some("squirts".into()), + host: Some("squirts".into()), facility: Some("local5".into()), severity: Some("info".into()), start_at_end: Some(false), diff --git a/src/main.rs b/src/main.rs index dcf7e958..485108c1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -103,6 +103,14 @@ async fn run_cli(invocation: CliInvocation) -> Result<()> { return cli::run_config(command); } + if let cli::CliCommand::Completions(args) = command { + return cli::run_completions(&args); + } + + if let cli::CliCommand::Complete(args) = command { + return cli::run_complete(&args); + } + if let cli::CliCommand::Inventory(command) = command { if let Some(trigger) = flags.http_flag_trigger() { anyhow::bail!( @@ -646,6 +654,8 @@ impl Mode { | "fleet-state" | "correlate-state" | "file-tail" + | "__complete" + | "completions" ) => { let mut cli_args = Vec::with_capacity(rest.len() + 1); diff --git a/src/main_tests.rs b/src/main_tests.rs index 2b36d871..94a4c147 100644 --- a/src/main_tests.rs +++ b/src/main_tests.rs @@ -27,7 +27,7 @@ fn mode_parse_accepts_heartbeat_state_commands() { assert!(matches!( Mode::parse(vec![ "host-state".into(), - "--hostname".into(), + "--host".into(), "tootie".into(), "--json".into() ]) diff --git a/src/mcp.rs b/src/mcp.rs index a7a7e3de..cacf9d48 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -7,6 +7,7 @@ use crate::config::{McpConfig, NotificationsConfig}; use crate::observability::RuntimeObservability; use crate::otlp::OtlpCounters; +mod action_flags; mod actions; mod prompts; mod rmcp_server; @@ -14,6 +15,8 @@ mod routes; mod schemas; mod tools; +pub use action_flags::{FlagSpec, ValueKind}; +pub use actions::{description_for, examples_for, flags_for}; pub use rmcp_server::{ CortexRmcpServer, rmcp_server, streamable_http_config, streamable_http_service, }; diff --git a/src/mcp/action_flags.rs b/src/mcp/action_flags.rs new file mode 100644 index 00000000..274252bf --- /dev/null +++ b/src/mcp/action_flags.rs @@ -0,0 +1,89 @@ +//! Canonical CLI flag metadata, shared by the parser, completion, and help. + +/// One CLI flag for an action. `value_kind` drives dynamic completion. +#[derive(Debug, Clone, Copy)] +pub struct FlagSpec { + /// Canonical long flag, including leading dashes, e.g. "--host". + pub flag: &'static str, + /// Optional short alias, e.g. "-n". Empty string = none. + pub short: &'static str, + /// One-line help. + pub help: &'static str, + /// Completion source for the flag's value. + pub value_kind: ValueKind, +} + +/// What completes after a flag. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ValueKind { + /// No value (boolean flag). + None, + /// Free text (no candidates). + Text, + /// Live hostnames from the DB. + Host, + /// Live app names from the DB. + App, + /// Live source identifiers from the DB. + Source, + /// Fixed enum candidates. + Enum(&'static [&'static str]), + /// A time value (offers relative hints). + Time, +} + +pub(super) const SEVERITIES: &[&str] = &[ + "emerg", "alert", "crit", "err", "warning", "notice", "info", "debug", +]; + +/// Flags shared by the log-query actions (search/filter/tail/errors/...). +pub(super) const COMMON_LOG_FLAGS: &[FlagSpec] = &[ + FlagSpec { + flag: "--host", + short: "", + help: "Filter by hostname", + value_kind: ValueKind::Host, + }, + FlagSpec { + flag: "--app", + short: "", + help: "Filter by app/program name", + value_kind: ValueKind::App, + }, + FlagSpec { + flag: "--source", + short: "", + help: "Filter by source id (IP:port or docker://...)", + value_kind: ValueKind::Source, + }, + FlagSpec { + flag: "--severity", + short: "-s", + help: "Minimum severity", + value_kind: ValueKind::Enum(SEVERITIES), + }, + FlagSpec { + flag: "--since", + short: "", + help: "Start of window (1h, 2d, yesterday, RFC3339)", + value_kind: ValueKind::Time, + }, + FlagSpec { + flag: "--until", + short: "", + help: "End of window", + value_kind: ValueKind::Time, + }, + FlagSpec { + flag: "--limit", + short: "-n", + help: "Max results", + value_kind: ValueKind::Text, + }, + FlagSpec { + flag: "--json", + short: "", + help: "JSON output", + value_kind: ValueKind::None, + }, +]; diff --git a/src/mcp/actions.rs b/src/mcp/actions.rs index aec40441..6aa7e58b 100644 --- a/src/mcp/actions.rs +++ b/src/mcp/actions.rs @@ -9,6 +9,8 @@ //! Now there is one metadata table: [`ACTION_SPECS`]. The schema, scope gates, //! help text, and action metadata are computed from it. +use super::action_flags::{COMMON_LOG_FLAGS, FlagSpec}; + /// The scope required to invoke a given action. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum Scope { @@ -111,18 +113,33 @@ pub(super) struct ActionSpec { pub cost: Cost, /// Registry-owned executable handler for this action. pub handler: ActionHandler, + /// CLI flags for this action (canonical names). Drives completion + help. + pub flags: &'static [FlagSpec], + /// Copy-paste example invocations. + pub examples: &'static [&'static str], } macro_rules! action_spec { - ($name:literal, $scope:ident, $description:literal, $cost:ident, $handler:ident) => { + // Full form: with flag + example metadata. + ($name:literal, $scope:ident, $description:literal, $cost:ident, $handler:ident, + flags: $flags:expr, examples: $examples:expr) => { ActionSpec { name: $name, scope: Scope::$scope, description: $description, cost: Cost::$cost, handler: ActionHandler::$handler, + flags: $flags, + examples: $examples, } }; + // Short form: no flag/example metadata yet (defaults to empty). + ($name:literal, $scope:ident, $description:literal, $cost:ident, $handler:ident) => { + action_spec!( + $name, $scope, $description, $cost, $handler, + flags: &[], examples: &[] + ) + }; } /// The single authoritative table of all supported MCP actions. @@ -138,35 +155,48 @@ pub(super) const ACTION_SPECS: &[ActionSpec] = &[ Read, "Full-text search over syslog messages", Cheap, - SearchLogs + SearchLogs, + flags: COMMON_LOG_FLAGS, + examples: &[ + "cortex search \"oom killer\" --host dookie --since 1h", + "cortex search --grep \"smoke-test\" --limit 20", + ] ), action_spec!( "filter", Read, "Filter logs by indexed fields without a full-text query", Cheap, - FilterLogs + FilterLogs, + flags: COMMON_LOG_FLAGS, + examples: &["cortex filter --host tootie --severity err --since 6h"] ), action_spec!( "tail", Read, "Stream the most recent log entries", Cheap, - TailLogs + TailLogs, + flags: COMMON_LOG_FLAGS, + examples: &["cortex tail --host dookie -n 100"] ), action_spec!( "errors", Read, "List recent error-level log entries", Cheap, - GetErrors + GetErrors, + flags: COMMON_LOG_FLAGS, + examples: &["cortex errors --since 1h"] ), action_spec!( "hosts", Read, "Enumerate all known source hostnames", Cheap, - ListHosts + ListHosts, + flags: &[], + examples: &["cortex hosts"] ), action_spec!( "map", @@ -194,7 +224,9 @@ pub(super) const ACTION_SPECS: &[ActionSpec] = &[ Read, "Correlate events across hosts/services", Moderate, - CorrelateEvents + CorrelateEvents, + flags: &[], + examples: &["cortex correlate --reference-time 2026-06-16T04:00:00Z --window-minutes 15"] ), action_spec!( "correlate_state", @@ -208,21 +240,27 @@ pub(super) const ACTION_SPECS: &[ActionSpec] = &[ Read, "Aggregate log statistics", Expensive, - GetStats + GetStats, + flags: &[], + examples: &["cortex stats"] ), action_spec!( "status", Read, "Server health and ingestion status", Cheap, - GetStatus + GetStatus, + flags: &[], + examples: &["cortex status"] ), action_spec!( "apps", Read, "Enumerate all known application names", Cheap, - ListApps + ListApps, + flags: &[], + examples: &["cortex apps"] ), action_spec!( "sessions", @@ -299,21 +337,27 @@ pub(super) const ACTION_SPECS: &[ActionSpec] = &[ Read, "Enumerate unique source IP addresses", Cheap, - ListSourceIps + ListSourceIps, + flags: &[], + examples: &["cortex source-ips"] ), action_spec!( "timeline", Read, "Log volume over time (bucketed)", Cheap, - Timeline + Timeline, + flags: &[], + examples: &["cortex timeline --bucket hour --host dookie"] ), action_spec!( "patterns", Read, "Recurring message patterns", Expensive, - Patterns + Patterns, + flags: &[], + examples: &["cortex patterns --limit 20"] ), action_spec!( "context", @@ -468,6 +512,30 @@ pub(super) fn handler_for(action: &str) -> Option { .map(|s| s.handler) } +/// CLI flag metadata for an action (canonical names), or `None` if unknown. +pub fn flags_for(action: &str) -> Option<&'static [FlagSpec]> { + ACTION_SPECS + .iter() + .find(|s| s.name == action) + .map(|s| s.flags) +} + +/// Copy-paste example invocations for an action, or `None` if unknown. +pub fn examples_for(action: &str) -> Option<&'static [&'static str]> { + ACTION_SPECS + .iter() + .find(|s| s.name == action) + .map(|s| s.examples) +} + +/// One-line description for an action, or `None` if unknown. +pub fn description_for(action: &str) -> Option<&'static str> { + ACTION_SPECS + .iter() + .find(|s| s.name == action) + .map(|s| s.description) +} + /// Map an action name to its required MCP scope string. /// /// - `None` for `InfoOnly` actions (auth context required when Mounted, but no @@ -486,3 +554,7 @@ pub(super) fn required_scope_for(action: &str) -> Option<&'static str> { None => Some("cortex:__deny__"), } } + +#[cfg(test)] +#[path = "actions_tests.rs"] +mod tests; diff --git a/src/mcp/actions_tests.rs b/src/mcp/actions_tests.rs new file mode 100644 index 00000000..9ac4ae68 --- /dev/null +++ b/src/mcp/actions_tests.rs @@ -0,0 +1,44 @@ +use super::*; + +#[test] +fn every_action_has_nonempty_description() { + for spec in ACTION_SPECS { + assert!( + !spec.description.is_empty(), + "{} missing description", + spec.name + ); + } +} + +#[test] +fn search_action_exposes_common_flags_and_examples() { + let flags = flags_for("search").expect("search has flags"); + assert!(flags.iter().any(|f| f.flag == "--host")); + assert!(flags.iter().any(|f| f.flag == "--since")); + let ex = examples_for("search").expect("search has examples"); + assert!(!ex.is_empty(), "search should ship at least one example"); +} + +#[test] +fn all_cli_query_actions_have_examples() { + for name in [ + "search", + "filter", + "tail", + "errors", + "hosts", + "apps", + "timeline", + "patterns", + "correlate", + "source_ips", + "stats", + "status", + ] { + assert!( + examples_for(name).map(|e| !e.is_empty()).unwrap_or(false), + "{name} needs an example" + ); + } +} diff --git a/src/mcp/schemas.rs b/src/mcp/schemas.rs index 77dfce42..40a5aa16 100644 --- a/src/mcp/schemas.rs +++ b/src/mcp/schemas.rs @@ -88,17 +88,13 @@ pub(super) fn tool_definitions() -> Vec { "type": "string", "description": "For action=graph: alias value to resolve. Ambiguous aliases return candidates instead of guessing." }, - "hostname": { - "type": "string", - "description": "For action=search, filter, tail, correlate, ai_correlate, apps, sessions, timeline, patterns, context, similar_incidents, ask_history, or incident_context: exact hostname filter. For action=host_state: resolve a host by unique hostname when host_id is omitted. Use action=hosts to enumerate." - }, "host_id": { "type": "string", - "description": "For action=host_state: exact heartbeat host_id. Takes precedence over hostname and is authoritative over hostname metadata." + "description": "For action=host_state: exact heartbeat host_id. Takes precedence over host and is authoritative over host metadata." }, "host": { "type": "string", - "description": "For action=correlate_state: optional host filter accepting a host_id or a unique hostname. When omitted, a bounded cross-host plan is used over all hosts with heartbeats in the window. For action=map mode=host_services or service_dependencies: target host." + "description": "For action=search, filter, tail, correlate, ai_correlate, apps, sessions, timeline, patterns, context, similar_incidents, ask_history, or incident_context: exact hostname filter (use action=hosts to enumerate). For action=host_state: resolve a host by unique hostname when host_id is omitted. For action=correlate_state: optional host filter accepting a host_id or a unique hostname; when omitted, a bounded cross-host plan is used over all hosts with heartbeats in the window. For action=map mode=host_services or service_dependencies: target host. For action=file_tails op=add: required source host assigned to tailed lines." }, "domain": { "type": "string", @@ -126,7 +122,7 @@ pub(super) fn tool_definitions() -> Vec { "enum": ["claude", "codex", "gemini"], "description": "For action=filter, sessions, search_sessions, abuse, ai_correlate, usage_blocks, project_context, or list_ai_projects: AI tool filter." }, - "source_ip": { + "source": { "type": "string", "description": "For action=search, filter, tail, correlate, or ai_correlate: exact source identifier. Syslog uses IP:port; OTLP uses peer IP; Docker stream rows use docker://host/container/stream; Docker lifecycle rows use docker-event://host/container/action." }, @@ -145,7 +141,7 @@ pub(super) fn tool_definitions() -> Vec { "enum": SEVERITY_LEVELS, "description": "For action=tail, correlate, correlate_state, ai_correlate, timeline, patterns, similar_incidents, or incident_context: minimum severity to include. Defaults to 'warning' for incident_context and 'info' for correlate_state." }, - "app_name": { + "app": { "type": "string", "description": "For action=search, filter, tail, ai_correlate, timeline, patterns, similar_incidents, ask_history, or incident_context: application name filter, e.g. sshd, dockerd, kernel." }, @@ -173,11 +169,11 @@ pub(super) fn tool_definitions() -> Vec { "type": "string", "description": "For action=search or filter: exact process_id filter." }, - "received_from": { + "received_since": { "type": "string", "description": "For action=search or filter: filter rows with received_at >= this timestamp." }, - "received_to": { + "received_until": { "type": "string", "description": "For action=search or filter: filter rows with received_at <= this timestamp." }, @@ -198,13 +194,9 @@ pub(super) fn tool_definitions() -> Vec { "type": "string", "description": "For action=filter: Docker lifecycle/enrichment event action filter." }, - "from": { - "type": "string", - "description": "For action=search, filter, sessions, search_sessions, abuse, abuse_incidents, abuse_investigate, ai_correlate, usage_blocks, list_ai_tools, list_ai_projects, errors, timeline, patterns, apps, similar_incidents, or ask_history: start of time range as ISO 8601/RFC3339. Required for incident_context. For action=timeline: when both from and to are omitted, a bucket-sized default lookback window applies (≈7 days for hour, 30 for day, longer for week/month) — no full-history scan. Strongly recommended for patterns — omitting from/to causes a full-history scan." - }, - "to": { + "until": { "type": "string", - "description": "For action=search, filter, sessions, search_sessions, abuse, abuse_incidents, abuse_investigate, ai_correlate, usage_blocks, list_ai_tools, list_ai_projects, errors, timeline, patterns, apps, similar_incidents, or ask_history: end of time range as ISO 8601/RFC3339. Required for incident_context. For action=timeline: a bucket-sized default lookback bounds the query when from/to are omitted (no full-history scan). Strongly recommended for patterns — omitting from/to causes a full-history scan." + "description": "For action=search, filter, sessions, search_sessions, abuse, abuse_incidents, abuse_investigate, ai_correlate, usage_blocks, list_ai_tools, list_ai_projects, errors, timeline, patterns, apps, similar_incidents, or ask_history: end of time range as ISO 8601/RFC3339. Required for incident_context. For action=timeline: a bucket-sized default lookback bounds the query when since/until are omitted (no full-history scan). Strongly recommended for patterns — omitting since/until causes a full-history scan." }, "limit": { "type": "integer", @@ -335,10 +327,6 @@ pub(super) fn tool_definitions() -> Vec { "type": "string", "description": "For action=file_tails op=add: app/tag stored as app_name for tailed lines." }, - "hostname": { - "type": "string", - "description": "For action=file_tails op=add: required source hostname assigned to tailed lines." - }, "start_at_end": { "type": "boolean", "description": "For action=file_tails op=add: true starts at EOF, false backfills existing file content." @@ -353,7 +341,7 @@ pub(super) fn tool_definitions() -> Vec { }, "since": { "type": "string", - "description": "For action=clock_skew: sample entries with received_at >= since. Use `limit` to cap returned host rows. For action=host_state: only include heartbeat samples with sampled_at >= this ISO 8601/RFC3339 timestamp." + "description": "For action=search, filter, sessions, search_sessions, abuse, abuse_incidents, abuse_investigate, ai_correlate, usage_blocks, list_ai_tools, list_ai_projects, errors, timeline, patterns, apps, similar_incidents, or ask_history: start of time range as ISO 8601/RFC3339. Required for incident_context. For action=timeline: when both since and until are omitted, a bucket-sized default lookback window applies (≈7 days for hour, 30 for day, longer for week/month) — no full-history scan. Strongly recommended for patterns — omitting since/until causes a full-history scan. For action=clock_skew: sample entries with received_at >= since; use `limit` to cap returned host rows. For action=host_state: only include heartbeat samples with sampled_at >= this timestamp. For action=notifications_recent: lower time bound." }, "recent_minutes": { "type": "integer", @@ -550,7 +538,7 @@ pub(super) fn tool_definitions() -> Vec { "required": ["op"] }, "then": { - "required": ["id", "path", "tag", "hostname"] + "required": ["id", "path", "tag", "host"] } }, { diff --git a/src/mcp/schemas_tests.rs b/src/mcp/schemas_tests.rs index f3baf9e6..cc94aaa5 100644 --- a/src/mcp/schemas_tests.rs +++ b/src/mcp/schemas_tests.rs @@ -119,7 +119,7 @@ fn schema_includes_file_tails_action() { assert!(nested.iter().any(|rule| { let required = rule["then"]["required"].as_array().unwrap(); rule["if"]["properties"]["op"]["const"] == "add" - && ["id", "path", "tag", "hostname"] + && ["id", "path", "tag", "host"] .iter() .all(|name| required.iter().any(|value| value == name)) })); @@ -139,8 +139,8 @@ fn schema_includes_file_tails_action() { fn schema_apps_exposes_pagination_and_total() { let tools = tool_definitions(); let props = &tools[0]["inputSchema"]["properties"]; - let from_desc = props["from"]["description"].as_str().unwrap(); - let to_desc = props["to"]["description"].as_str().unwrap(); + let from_desc = props["since"]["description"].as_str().unwrap(); + let to_desc = props["until"]["description"].as_str().unwrap(); let limit_desc = props["limit"]["description"].as_str().unwrap(); let offset_desc = props["offset"]["description"].as_str().unwrap(); assert!( @@ -289,7 +289,7 @@ fn schema_map_findings_exposes_findings_arguments() { fn schema_timeline_and_patterns_warn_on_full_history_scan() { let tools = tool_definitions(); let props = &tools[0]["inputSchema"]["properties"]; - let from_desc = props["from"]["description"].as_str().unwrap(); + let from_desc = props["since"]["description"].as_str().unwrap(); assert_eq!( props["scan_limit"]["maximum"], crate::db::PATTERN_SCAN_LIMIT_MAX diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index fd5c4022..16541620 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -304,7 +304,7 @@ async fn tool_compose_doctor(args: Value) -> anyhow::Result { async fn tool_timeline(state: &AppState, args: Value) -> anyhow::Result { // Default lookback is centralized in `CortexService::timeline` (bead dyqw): - // it applies a bucket-sized window only when neither `from` nor `to` is set, + // it applies a bucket-sized window only when neither `since` nor `until` is set, // preventing full table scans without recreating the logic per transport. let req: TimelineRequest = action_payload(args, "timeline")?; let response = state.service.timeline(req).await?; @@ -573,7 +573,7 @@ const ADMIN_ACTION_HELP: &[AdminActionHelp] = &[ "`id` (string, required for add/remove/enable/disable) — stable file-tail source id", "`path` (string, required for add) — local log file path", "`tag` (string, required for add) — app/tag stored on ingested rows", - "`hostname`, `facility`, `severity`, `start_at_end` (optional) — row envelope defaults", + "`host`, `facility`, `severity`, `start_at_end` (optional) — row envelope defaults", ], }, AdminActionHelp { @@ -647,17 +647,17 @@ phrase matching with quotes, prefix matching with *. **Parameters:** - `query` (string) — FTS5 search query, e.g. `'kernel panic'`, `'OOM AND killer'`, `'"connection refused"'`, `'error*'` -- `hostname` (string, optional) — filter by hostname (exact match); use `cortex hosts` to enumerate -- `source_ip` (string, optional) — filter by exact source identifier. Syslog uses verified `IP:port`; OTLP uses verified peer IP; Docker stream rows use `docker://host/container/stream`; Docker lifecycle rows use `docker-event://host/container/action`. +- `host` (string, optional) — filter by hostname (exact match); use `cortex hosts` to enumerate +- `source` (string, optional) — filter by exact source identifier. Syslog uses verified `IP:port`; OTLP uses verified peer IP; Docker stream rows use `docker://host/container/stream`; Docker lifecycle rows use `docker-event://host/container/action`. - `severity` (string, optional) — one of: `emerg`, `alert`, `crit`, `err`, `warning`, `notice`, `info`, `debug` -- `app_name` (string, optional) — filter by application name, e.g. `sshd`, `dockerd`, `kernel` +- `app` (string, optional) — filter by application name, e.g. `sshd`, `dockerd`, `kernel` - `facility` (string, optional) — filter by syslog facility name (e.g. `kern`, `auth`, `daemon`) - `exclude_facility` (string, optional) — exclude a syslog facility name (e.g. `kern` to suppress kernel noise) - `process_id` (string, optional) — filter by process_id (exact match) -- `from` (string, optional) — start of time range (ISO 8601 / RFC3339, e.g. `2025-01-15T00:00:00Z`) -- `to` (string, optional) — end of time range (ISO 8601) -- `received_from` (string, optional) — restrict to entries received after this time (server-side ingestion clock, ISO 8601) -- `received_to` (string, optional) — restrict to entries received before this time (server-side ingestion clock, ISO 8601) +- `since` (string, optional) — start of time range (ISO 8601 / RFC3339, e.g. `2025-01-15T00:00:00Z`) +- `until` (string, optional) — end of time range (ISO 8601) +- `received_since` (string, optional) — restrict to entries received after this time (server-side ingestion clock, ISO 8601) +- `received_until` (string, optional) — restrict to entries received before this time (server-side ingestion clock, ISO 8601) - `limit` (integer, optional) — max results (default 100, max 1000) --- @@ -667,14 +667,14 @@ Filter log rows by structured fields only. This action never accepts `query`; use `search` for message-body FTS5 queries. **Parameters:** -- `hostname` (string, optional) — filter by hostname (exact match) -- `source_ip` (string, optional) — filter by exact source identifier +- `host` (string, optional) — filter by hostname (exact match) +- `source` (string, optional) — filter by exact source identifier - `severity` (string, optional) — one of: `emerg`, `alert`, `crit`, `err`, `warning`, `notice`, `info`, `debug` -- `app_name` (string, optional) — filter by application/container name +- `app` (string, optional) — filter by application/container name - `facility` / `exclude_facility` (string, optional) — include or exclude syslog facility - `process_id` (string, optional) — filter by process_id -- `from` / `to` (string, optional) — event timestamp window -- `received_from` / `received_to` (string, optional) — ingest timestamp window +- `since` / `until` (string, optional) — event timestamp window +- `received_since` / `received_until` (string, optional) — ingest timestamp window - `source_kind` (string, optional) — `docker-stream`, `docker-event`, `agent-command`, `shell-history`, `transcript`, `claude`, `codex`, or `gemini` - `tool`, `project`, `session_id` (string, optional) — AI transcript filters - `docker_host`, `container`, `stream`, `event_action` (string, optional) — Docker refiners @@ -687,9 +687,9 @@ Get the N most recent log entries, optionally filtered by host, application, and Equivalent to `tail -f` across all hosts. **Parameters:** -- `hostname` (string, optional) — filter to a specific host -- `source_ip` (string, optional) — filter by exact source identifier. Syslog uses verified `IP:port`; OTLP uses verified peer IP; Docker stream rows use `docker://host/container/stream`; Docker lifecycle rows use `docker-event://host/container/action`. -- `app_name` (string, optional) — filter to a specific application +- `host` (string, optional) — filter to a specific host +- `source` (string, optional) — filter by exact source identifier. Syslog uses verified `IP:port`; OTLP uses verified peer IP; Docker stream rows use `docker://host/container/stream`; Docker lifecycle rows use `docker-event://host/container/action`. +- `app` (string, optional) — filter to a specific application - `severity_min` (string, optional) — only return entries at or above this severity (e.g. `warning` returns warning + worse) - `n` (integer, optional) — number of recent entries (default 50, max 500) @@ -700,8 +700,8 @@ Get a summary of errors and warnings across all hosts in a time window. Groups by hostname and severity level (and optionally app_name), showing counts. **Parameters:** -- `from` (string, optional) — start of time range (ISO 8601); defaults to all time -- `to` (string, optional) — end of time range (ISO 8601); defaults to now +- `since` (string, optional) — start of time range (ISO 8601); defaults to all time +- `until` (string, optional) — end of time range (ISO 8601); defaults to now - `group_by` (string, optional) — secondary grouping key. Currently `app_name` is supported; default groups only by hostname+severity. - `limit` (integer, optional) — cap summary rows returned (max 100) @@ -748,7 +748,7 @@ Return the latest bounded heartbeat state for one host. **Parameters:** - `host_id` (string, optional) — authoritative heartbeat host identity -- `hostname` (string, optional) — self-reported hostname fallback; must resolve to exactly one host_id +- `host` (string, optional) — self-reported hostname fallback; must resolve to exactly one host_id - `since` (string, optional) — minimum sampled_at timestamp (ISO 8601) - `limit` (integer, optional) — number of samples to return (default 1, max 100) @@ -780,10 +780,10 @@ Response includes the resolved `window`, per-host `heartbeat_summary` plus match ## cortex apps List distinct application names with log counts, host counts, and first/last seen timestamps. -Mirror of `cortex hosts` for the `app_name` dimension. +Mirror of `cortex hosts` for the `app` dimension. **Parameters:** -- `hostname` (string, optional) — restrict to apps seen on this host +- `host` (string, optional) — restrict to apps seen on this host --- @@ -793,8 +793,8 @@ Lists AI transcript sessions grouped by project/tool/session/host. **Parameters:** - `project` (string, optional) — exact project path, e.g. `/home/jmagar/workspace/cortex` - `tool` (string, optional) — AI tool filter: `claude`, `codex`, or `gemini` -- `hostname` (string, optional) — restrict to one host -- `from`, `to` (string, optional) — time range (ISO 8601) +- `host` (string, optional) — restrict to one host +- `since`, `until` (string, optional) — time range (ISO 8601) - `limit` (integer, optional) — max sessions (default 100, max 1000) --- @@ -806,7 +806,7 @@ Session-ranked full-text search across AI transcript rows. Returns grouped sessi - `query` (string, **required**) — FTS5 search query - `project` (string, optional) — exact project path filter - `tool` (string, optional) — AI tool filter: `claude`, `codex`, or `gemini` -- `from`, `to` (string, optional) — time range (ISO 8601) +- `since`, `until` (string, optional) — time range (ISO 8601) - `limit` (integer, optional) — max grouped sessions (default 20, max 100) --- @@ -817,7 +817,7 @@ Detects abuse in AI transcript rows and returns each hit with surrounding rows f **Parameters:** - `project` (string, optional) — exact project path filter - `tool` (string, optional) — AI tool filter -- `from`, `to` (string, optional) — time range (ISO 8601) +- `since`, `until` (string, optional) — time range (ISO 8601) - `limit` (integer, optional) — max matches (default 20, max 100) - `before`, `after` (integer, optional) — same-session context rows around each hit (default 2, max 20) - `terms` (array of strings, optional) — custom detector terms; replaces the built-in list @@ -830,7 +830,7 @@ Groups AI transcript abuse hits into scored incident candidates. Returns inciden **Parameters:** - `project` (string, optional) — exact project path filter - `tool` (string, optional) — AI tool filter -- `from`, `to` (string, optional) — time range (ISO 8601) +- `since`, `until` (string, optional) — time range (ISO 8601) - `limit` (integer, optional) — max incidents (default 20, max 100) - `window_minutes` (integer, optional) — grouping window (default 10, max 120) - `terms` (array of strings, optional) — custom detector terms @@ -843,7 +843,7 @@ Expands top abuse incidents into deterministic evidence bundles. Each bundle inc **Parameters:** - `project` (string, optional) — exact project path filter - `tool` (string, optional) — AI tool filter -- `from`, `to` (string, optional) — time range (ISO 8601) +- `since`, `until` (string, optional) — time range (ISO 8601) - `limit` (integer, optional) — max incidents to expand (default 3, max 10) - `window_minutes` (integer, optional) — grouping window (default 10, max 120) - `correlation_window_minutes` (integer, optional) — minutes before/after incident for nearby log correlation (default 5, max 120) @@ -861,8 +861,8 @@ Related rows explicitly exclude AI transcript rows, so the result surfaces host, - `session_id` (string, optional) — exact AI session id filter - `ai_query` (string, optional) — FTS5 query over AI transcript anchor rows - `log_query` (string, optional) — FTS5 query over related non-AI logs -- `hostname`, `source_ip`, `app_name` (string, optional) — related log filters -- `from`, `to` (string, optional) — AI anchor time range (ISO 8601) +- `host`, `source`, `app` (string, optional) — related log filters +- `since`, `until` (string, optional) — AI anchor time range (ISO 8601) - `window_minutes` (integer, optional) — minutes before and after each AI anchor (default 5, max 120) - `severity_min` (string, optional) — minimum related log severity (default `warning`) - `limit` (integer, optional) — max AI anchors (default 10, max 50) @@ -876,7 +876,7 @@ AI activity bucketed into deterministic 5-hour UTC windows. **Parameters:** - `project` (string, optional) — exact project path filter - `tool` (string, optional) — AI tool filter -- `from`, `to` (string, optional) — time range (ISO 8601) +- `since`, `until` (string, optional) — time range (ISO 8601) --- @@ -895,7 +895,7 @@ Distinct AI tools with counts and first/last seen timestamps. **Parameters:** - `project` (string, optional) — exact project path filter -- `from`, `to` (string, optional) — time range (ISO 8601) +- `since`, `until` (string, optional) — time range (ISO 8601) --- @@ -904,7 +904,7 @@ Distinct AI projects with counts, tools used, and first/last seen timestamps. **Parameters:** - `tool` (string, optional) — AI tool filter -- `from`, `to` (string, optional) — time range (ISO 8601) +- `since`, `until` (string, optional) — time range (ISO 8601) --- @@ -914,7 +914,7 @@ peer IP for OTLP input, `docker://host/container/stream` for Docker stream ingest, or `docker-event://host/container/action` for Docker lifecycle ingest) with log counts, the number of distinct hostnames each sender claims, and up to 10 top hostnames per sender. -`source_ip` is the only network-verified identity — useful for spoof detection +`source` is the only network-verified identity — useful for spoof detection on hostname-spoofable formats (e.g. UniFi CEF). **Parameters:** none @@ -930,8 +930,8 @@ of a reference timestamp. Results are grouped by host and ordered by time. - `reference_time` (string, **required**) — center timestamp (ISO 8601, e.g. `2025-01-15T14:30:00Z`) - `window_minutes` (integer, optional) — minutes before and after reference_time to search (default 5, max 60) - `severity_min` (string, optional) — minimum severity to include (default `warning`); `debug` returns everything -- `hostname` (string, optional) — limit correlation to a specific host -- `source_ip` (string, optional) — limit correlation to an exact source identifier. Syslog uses verified `IP:port`; OTLP uses verified peer IP; Docker stream rows use `docker://host/container/stream`; Docker lifecycle rows use `docker-event://host/container/action`. +- `host` (string, optional) — limit correlation to a specific host +- `source` (string, optional) — limit correlation to an exact source identifier. Syslog uses verified `IP:port`; OTLP uses verified peer IP; Docker stream rows use `docker://host/container/stream`; Docker lifecycle rows use `docker-event://host/container/action`. - `query` (string, optional) — optional FTS query to narrow results - `limit` (integer, optional) — max total events to return (default 500, max 999) @@ -944,10 +944,10 @@ or "is the incident still active". Each point reports `{bucket, group?, count}`. **Parameters:** - `bucket` (string, optional) — `minute`, `hour` (default), `day`, `week`, or `month` - `group_by` (string, optional) — split each bucket by `hostname`, `severity`, or `app_name` -- `from` (string, optional) — start of time range (ISO 8601) -- `to` (string, optional) — end of time range (ISO 8601) -- `hostname` (string, optional) — restrict to one host -- `app_name` (string, optional) — restrict to one app +- `since` (string, optional) — start of time range (ISO 8601) +- `until` (string, optional) — end of time range (ISO 8601) +- `host` (string, optional) — restrict to one host +- `app` (string, optional) — restrict to one app - `severity_min` (string, optional) — only count entries at or above this severity --- @@ -959,8 +959,8 @@ messages aggregate. Returns top templates with counts, sample message, and host distribution. **Parameters:** -- `from` / `to` (string, optional) — time range (ISO 8601) -- `hostname`, `app_name` (string, optional) — narrow the population +- `since` / `until` (string, optional) — time range (ISO 8601) +- `host`, `app` (string, optional) — narrow the population - `severity_min` (string, optional) — only cluster entries at or above this severity - `scan_limit` (integer, optional) — max messages to read (default 10000, max 10000) - `top_n` (integer, optional) — max templates to return (default 20, max 200) @@ -971,11 +971,11 @@ host distribution. ## cortex context Surrounding logs around a single point of interest, on the same host. Pass either `log_id` (preferred — uses (timestamp, id) for stable ordering) or both -`hostname` + `timestamp` to anchor on a synthetic reference. +`host` + `timestamp` to anchor on a synthetic reference. **Parameters:** - `log_id` (integer, optional) — id of an existing log entry (e.g. from `search`) -- `hostname` (string, optional) — required when `log_id` is not given +- `host` (string, optional) — required when `log_id` is not given - `timestamp` (string, optional) — required when `log_id` is not given (ISO 8601) - `before` (integer, optional) — entries before the reference (default 10, max 500) - `after` (integer, optional) — entries after the reference (default 10, max 500) @@ -1108,11 +1108,11 @@ log hits first) with representative message snippets and correlated AI sessions whose transcript timestamps overlap the cluster window. **Required:** `query` (FTS5 syntax, e.g. `nginx upstream error` or `OOM killed`) -**Optional:** `hostname`, `app_name`, `severity_min`, `from`, `to`, +**Optional:** `host`, `app`, `severity_min`, `since`, `until`, `window_minutes` (cluster window, default 30, clamp 5..=120), `limit` (default 10, max 50) -Example: `{"action":"similar_incidents","query":"upstream connect error","app_name":"nginx"}` +Example: `{"action":"similar_incidents","query":"upstream connect error","app":"nginx"}` Response fields: `query`, `total_clusters`, `truncated`, `clusters` where each cluster has: `hostname`, `app_name`, `window_start`, `window_end`, `log_count`, @@ -1127,7 +1127,7 @@ ranked by match count with system log context from the top session's time window Use this to answer "what did an AI agent work on related to X?". **Required:** `query` (FTS5 syntax, e.g. `nginx ssl certificate` or `OOM postgres`) -**Optional:** `hostname`, `app_name`, `from`, `to`, `limit` (default 10, max 50) +**Optional:** `host`, `app`, `since`, `until`, `limit` (default 10, max 50) Example: `{"action":"ask_history","query":"nginx ssl certificate"}` @@ -1143,13 +1143,13 @@ Return full context for a known time window: log counts by severity and app, error-level log rows, and AI sessions active in that window. Useful for post-incident review of a time range you already know was problematic. -**Required:** `from`, `to` (ISO 8601/RFC3339) -**Optional:** `hostname`, `app_name`, `severity_min` (default warning), +**Required:** `since`, `until` (ISO 8601/RFC3339) +**Optional:** `host`, `app`, `severity_min` (default warning), `limit` (max error log rows, default 50, max 200) **Note:** `query` is accepted but reserved for v2 FTS5 filtering; it is currently ignored — omit it for incident_context. -Example: `{"action":"incident_context","from":"2024-01-15T10:00:00Z","to":"2024-01-15T11:00:00Z"}` +Example: `{"action":"incident_context","since":"2024-01-15T10:00:00Z","until":"2024-01-15T11:00:00Z"}` Response fields: `window_from`, `window_to`, `total_logs`, `by_severity` (array), `by_app` (array, top 20), `error_logs` (array), `error_logs_truncated`, `ai_sessions`. diff --git a/src/mcp/tools_tests.rs b/src/mcp/tools_tests.rs index c87c5391..af293e66 100644 --- a/src/mcp/tools_tests.rs +++ b/src/mcp/tools_tests.rs @@ -312,7 +312,7 @@ async fn host_state_action_returns_bounded_heartbeat_state() { let value = execute_tool( &h.state, "cortex", - json!({"action": "host_state", "hostname": "tootie", "limit": 1}), + json!({"action": "host_state", "host": "tootie", "limit": 1}), None, ) .await @@ -1045,7 +1045,7 @@ async fn host_state_action_reports_ambiguous_hostname() { let error = execute_tool( &h.state, "cortex", - json!({"action": "host_state", "hostname": "shared"}), + json!({"action": "host_state", "host": "shared"}), None, ) .await @@ -1099,7 +1099,7 @@ fn sample_args_for_action(action: &str) -> Option { "ai_correlate" => json!({"action": action, "project": "/tmp/project"}), "project_context" => json!({"action": action, "project": "/tmp/project"}), "context" => { - json!({"action": action, "hostname": "schema-test-host", "timestamp": "2026-01-01T00:00:00Z"}) + json!({"action": action, "host": "schema-test-host", "timestamp": "2026-01-01T00:00:00Z"}) } "get" => json!({"action": action, "id": 1}), "compare" => { @@ -1110,10 +1110,10 @@ fn sample_args_for_action(action: &str) -> Option { } "similar_incidents" | "ask_history" => json!({"action": action, "query": "test"}), "incident_context" => { - json!({"action": action, "from": "2026-01-01T00:00:00Z", "to": "2026-01-01T01:00:00Z"}) + json!({"action": action, "since": "2026-01-01T00:00:00Z", "until": "2026-01-01T01:00:00Z"}) } "file_tails" => json!({"action": action, "op": "status"}), - "filter" => json!({"action": action, "hostname": "schema-test-host"}), + "filter" => json!({"action": action, "host": "schema-test-host"}), "map" => json!({"action": action, "mode": "snapshot"}), "abuse" => json!({"action": action, "terms": ["schema"]}), "fleet_state" diff --git a/tests/test_live.sh b/tests/test_live.sh index 7e7735d8..f5d50912 100755 --- a/tests/test_live.sh +++ b/tests/test_live.sh @@ -518,7 +518,7 @@ phase_tools() { --arg id "${source_id}" \ --arg path "${server_path}" \ --arg tag "${tag}" \ - '{"action":"file_tails","op":"add","id":$id,"path":$path,"tag":$tag,"hostname":"live-smoke","facility":"local7","severity":"info","start_at_end":true}')")" || add_result="" + '{"action":"file_tails","op":"add","id":$id,"path":$path,"tag":$tag,"host":"live-smoke","facility":"local7","severity":"info","start_at_end":true}')")" || add_result="" assert_jq "cortex file_tails — add smoke source" "${add_result}" '.sources | type == "array"' printf '%s\n' "${marker}" >> "${write_path}" count=0 @@ -526,7 +526,7 @@ phase_tools() { search_result="$(call_tool cortex "$(jq -nc \ --arg q "\"${marker}\"" \ --arg tag "${tag}" \ - '{"action":"search","query":$q,"source_kind":"file-tail","app_name":$tag,"limit":5}')")" || search_result="" + '{"action":"search","query":$q,"source_kind":"file-tail","app":$tag,"limit":5}')")" || search_result="" count="$(printf '%s' "${search_result}" | jq -r '.count // 0' 2>/dev/null)" || count=0 [[ "${count}" -ge 1 ]] && break sleep 0.5 @@ -1066,7 +1066,7 @@ phase_surface_parity_rest() { "GET /api/anomalies|hosts" "GET /api/apps?limit=10|apps" "GET /api/similar-incidents?query=test&window_minutes=30|clusters" - "GET /api/incident-context?from=2026-01-01T00:00:00Z&to=2026-12-31T23:59:59Z|error_logs" + "GET /api/incident-context?since=2026-01-01T00:00:00Z&until=2026-12-31T23:59:59Z|error_logs" "GET /api/ai/ask-history?query=test|sessions" "GET /api/ai/incidents?limit=5|incidents" "GET /api/ai/investigate?limit=5|evidence"