diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cda8a7..0f0536b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,69 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.1] - 2026-10-08 + +Support for Dakera server **v0.12.2** (Dakera-AI/dakera#916). Compatible with v0.11.108, +v0.12.0, v0.12.1 and v0.12.2 servers; the commands below that need v0.12.2 say so on an +older server (see the README compatibility table). Built on `dakera-client` 0.12.2. + +### Added + +- **`dk whoami`**: `GET /v1/auth/whoami` — the key the CLI authenticates with (scope, + grants, unrestricted, grant syntax version, inert grants). +- **`dk agent create `**: `POST /v1/agents` — create an agent before its first + memory; idempotent (an existing agent is reported and left unchanged). +- **`dk keys edit `** (alias `patch`): `PATCH /admin/keys/{id}`, or with + `-n ` `PATCH /v1/namespaces/{ns}/keys/{id}` — `--name`, `--namespaces a,b` + (`p*` prefix patterns allowed), `--all-namespaces` (sends `null`), `--no-namespaces` + (sends `[]`). Nothing to change exits 5 without a request. +- **`dk keys rotate --grace `**: the old key keeps working for up to 7 days; the + new key id and the old key's deadline are shown. +- **`dk session touch `**: `POST /v1/sessions/{id}/touch` — keep a quiet session + from being ended for inactivity (an ended session is reported, never re-opened). +- **`dk session start --idle-timeout `**: the session's own inactivity timeout + (`0` = never, at most 30 days). +- **`--preview `** on `dk agent memories`, `dk session memories` and + `dk knowledge full-graph` (`content_preview_chars`, 1-10000): the server cuts each content; + the listings add `CONTENT_LEN` and `TRUNCATED` columns. **`--include-derived`** on + `dk agent memories`. `--offset` on both memory listings, `--limit` on `dk session memories`. +- **`dk admin derivations-status`** and **`dk admin derivations-drain [--timeout]`**: + `GET /admin/derivations/status`, `POST /admin/derivations/drain` (global admin key). +- **`dk admin session-idle-timeout []`**: show or set the server-wide session + inactivity timeout (`session_idle_timeout_secs` of `/admin/config`). +- Durations accept `s`, `m`, `h`, `d` suffixes (`3600`, `90m`, `8h`, `7d`). +- `dk session list` shows `ended_reason` (`client` / `idle`). +- Shell completions list the new commands. + +### Changed + +- `dk session memories` reports the server's `total` (it printed 0) and reads the listing + through `dakera-client` 0.12.2. +- `ClientError::InvalidRequest` from the SDK exits 5 (invalid input). + +### Fixed + +- `dk keys rotate` printed no new key: it looked for `key`, the server answers `new_key`. + +### Server behaviour changes you may hit (Dakera v0.12.2) + +- **Sessions are authorized by their agent**: keys no longer need a `_dakera_sessions` grant + (such an entry is inert, see `dk whoami`); a key without grants lists no sessions; + `dk session end` with a read-only key gets `403` (exit 4), and ending a session of an + agent the key cannot reach returns the empty idempotent answer. +- **Sessions auto-end after 4 h idle by default** (`ended_reason: idle`). Use + `dk session touch`, `dk session start --idle-timeout`, or + `dk admin session-idle-timeout`. Storing into an ended session still succeeds. +- **Stricter validation** (`400`, exit 5, the message names the field): key namespace + lists, agent ids (at most 241 bytes), memory metadata, reserved markers, TTLs, imports. +- **The memory content limit is in bytes** (UTF-8, default 100000), also on `dk memory update`. +- **Listings exclude derived records unless `include_derived=true`**: `dk agent memories` + no longer shows the sentence sub-memories; add `--include-derived`. + +### Dependency + +- `dakera-client` 0.12.2 (crates.io). + ## [0.8.0] - 2026-10-01 Support for Dakera server v0.12.0. Compatible with v0.11.108 and v0.12.0 servers; diff --git a/Cargo.lock b/Cargo.lock index e9024e5..fdec38a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -345,7 +345,7 @@ dependencies = [ [[package]] name = "dakera-cli" -version = "0.8.0" +version = "0.8.1" dependencies = [ "anyhow", "assert_cmd", @@ -368,9 +368,9 @@ dependencies = [ [[package]] name = "dakera-client" -version = "0.12.0" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae9381b5359879fc76969e80b9db79a0fedf8affb160e09e5b76ed0b0e01763" +checksum = "6e8f082a3d37049a29bf8e89eb548dca7d52fde6b68c3bb12cb07c2332420a3d" dependencies = [ "futures-util", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 5522643..d262e33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dakera-cli" -version = "0.8.0" +version = "0.8.1" edition = "2021" license = "MIT" description = "Command-line interface for Dakera AI Agent Memory Platform" @@ -19,7 +19,7 @@ path = "src/main.rs" clap = { version = "4.6", default-features = false, features = ["std", "help", "usage", "error-context", "env"] } # Dakera client SDK -dakera-client = "0.12" +dakera-client = "0.12.2" # Async runtime tokio = { version = "1.52", features = ["full"] } diff --git a/README.md b/README.md index 184d9a0..921e737 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,20 @@ dk --format json capabilities --- +### `dk whoami` + +The API key this CLI authenticates with, as the server reads it +(`GET /v1/auth/whoami`, Dakera v0.12.2): scope, namespace grants, whether it is +unrestricted, its grant syntax version and the grants that match nothing +(`inert`: a pre-v0.12.2 `foo*`, or `_dakera_sessions`). + +```bash +dk whoami +dk --format json whoami +``` + +--- + ### `dk attachment` Files that agent memories can point at (Dakera v0.12, **opt-in**: the server @@ -306,12 +320,20 @@ Manage agent sessions. ```bash dk session start my-agent +dk session start my-agent --idle-timeout 8h # v0.12.2: own inactivity timeout (0 = never) +dk session touch sess-abc123 # v0.12.2: keep a quiet session open dk session end sess-abc123 dk session list --agent-id my-agent --active-only dk session get sess-abc123 -dk session memories sess-abc123 +dk session memories sess-abc123 --preview 200 --limit 20 ``` +Since Dakera v0.12.2 the server ends a session after **4 hours** without +activity (storing, recalling or `dk session touch`); `dk session list` shows +`ended_reason` (`client` or `idle`). Sessions are authorized by their agent: a +key needs Write on the agent to start, touch or end its sessions, and +`dk session end` with a read-only key is refused (`403`). + --- ### `dk agent` @@ -320,11 +342,19 @@ View and manage agents. ```bash dk agent list +dk agent create mlx-dev # v0.12.2: create before the first memory (idempotent) dk agent stats my-agent dk agent memories my-agent --type episodic --limit 20 +dk agent memories my-agent --preview 200 # v0.12.2: content previews (CONTENT_LEN, TRUNCATED columns) +dk agent memories my-agent --include-derived # v0.12.2: also list the derived sentence sub-memories dk agent sessions my-agent --active-only ``` +Since Dakera v0.12.2 `dk agent memories` leaves the derived sentence +sub-memories out unless `--include-derived` is given. With `--preview N` each +memory's content is cut to N characters by the server; `dk memory get` shows a +whole memory. + --- ### `dk knowledge` @@ -336,7 +366,7 @@ Knowledge graph management and memory summarization. dk knowledge graph my-agent --memory-id mem-abc123 --depth 3 # Full knowledge graph for an agent -dk knowledge full-graph my-agent --max-nodes 100 +dk knowledge full-graph my-agent --max-nodes 100 --preview 200 # Summarize a set of memories into a new memory dk knowledge summarize my-agent --memory-ids m1,m2,m3 --dry-run @@ -369,8 +399,20 @@ dk keys list dk keys create my-key --permissions read,write dk keys delete key-abc123 dk keys usage key-abc123 + +# Dakera v0.12.2 +dk keys edit dk_key_1a2b --name ci # rename (alias: dk keys patch) +dk keys edit dk_key_1a2b --namespaces '_dakera_agent_mlx-*,docs' # replace the grants (p* prefix patterns) +dk keys edit dk_key_1a2b --all-namespaces # every namespace (sends null) +dk keys edit dk_key_1a2b --no-namespaces # no namespace (sends []) +dk keys edit dk_key_1a2b -n team-a --namespaces 'team-a*' # as a namespace admin +dk keys rotate dk_key_1a2b --grace 1h # the old key keeps working for 1h (at most 7d) ``` +`dk keys edit` needs an unrestricted `super_admin` key (or, with `-n`, an admin +of that namespace whose grants contain the new ones). Saving the namespaces of a +key created before v0.12.2 activates its `foo*` entries. + --- ### `dk admin` @@ -423,6 +465,12 @@ dk admin backup-upload nightly.json.gz # super_admin dk admin backup-restore -n my-ns --wait # super_admin; add --overwrite --yes for a point-in-time restore dk admin backup-restore-status dk admin backup-delete + +# Dakera v0.12.2 +dk admin derivations-status # derived data owed (sub-memories, full-text, graph edges) +dk admin derivations-drain --timeout 10m # derive everything owed now +dk admin session-idle-timeout # show the server-wide session idle timeout +dk admin session-idle-timeout 8h # set it (0 = never; at most 30d) ``` All of these need a **global** key. A key pinned to namespaces gets `403` on @@ -461,9 +509,9 @@ dk completion powershell ## Dakera v0.12 and compatibility -`dk` 0.8 works against Dakera **v0.11.108 and v0.12.0** servers (it uses -`dakera-client` 0.12). The new commands need the server release that has the -route or field: +`dk` 0.8 works against Dakera **v0.11.108, v0.12.0, v0.12.1 and v0.12.2** servers +(it uses `dakera-client` 0.12). The new commands need the server release that has +the route or field: | Command | v0.11.108 | v0.12.0 | |---|---|---| @@ -476,6 +524,17 @@ route or field: | `dk admin embed-migration`, `encryption-status`, `encryption-reseal` | no | yes | | other `dk admin` commands (`encryption-rotate`, `backup-*`, `quotas-*`, ...) | routes that v0.11.108 already had | same routes; v0.12 changes who may call them and what they do (below) | +The 0.8.1 commands need a **v0.12.2** server; on v0.12.0 / v0.12.1 they fail +(`404`/`405`, exit 3 or 1) or are ignored, as noted: + +| Command (0.8.1) | v0.12.0 / v0.12.1 | v0.12.2 | +|---|---|---| +| `dk whoami`, `dk agent create`, `dk session touch`, `dk keys edit`, `dk admin derivations-*` | no route | yes | +| `dk keys rotate --grace` | the old key is deactivated at once (`dk` warns) | yes | +| `dk session start --idle-timeout` | ignored (sessions never time out) | yes | +| `dk admin session-idle-timeout` | says the server predates it | yes | +| `--preview`, `--include-derived` (`agent memories`, `session memories`, `knowledge full-graph`) | ignored (full content; derived records always listed) | yes | + ### Permissions (v0.12) * **Keys pinned to namespaces get `403`** on node-wide routes (`/admin/*`: diff --git a/src/cli.rs b/src/cli.rs index eadc4b2..1d8d39b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -49,6 +49,7 @@ pub fn build_cli() -> Command { ) .subcommand(build_health_command()) .subcommand(build_capabilities_command()) + .subcommand(build_whoami_command()) .subcommand(build_attachment_command()) .subcommand(build_namespace_command()) .subcommand(build_index_command()) @@ -63,6 +64,44 @@ pub fn build_cli() -> Command { .subcommand(build_text_command()) } +/// A duration in seconds: a plain number, or a number with an `s`, `m`, `h` +/// or `d` suffix (`3600`, `90m`, `4h`, `7d`). +pub fn parse_duration_secs(value: &str) -> Result { + let v = value.trim(); + let (digits, unit) = match v.char_indices().last() { + Some((i, c)) if c.is_ascii_alphabetic() => (&v[..i], c.to_ascii_lowercase()), + _ => (v, 's'), + }; + let n: u64 = digits + .trim() + .parse() + .map_err(|_| format!("'{value}' is not a duration (e.g. 3600, 90m, 4h, 7d)"))?; + let mult = match unit { + 's' => 1, + 'm' => 60, + 'h' => 3600, + 'd' => 86_400, + _ => return Err(format!("'{value}': unknown unit (use s, m, h or d)")), + }; + n.checked_mul(mult) + .ok_or_else(|| format!("'{value}' is too large")) +} + +/// `--preview N`: the server cuts each memory's content to N characters +/// (server v0.12.2, `content_preview_chars`). +fn preview_arg() -> Arg { + Arg::new("preview") + .long("preview") + .value_name("CHARS") + .value_parser(value_parser!(u32).range(1..=10_000)) + .help("Ask the server for a content preview of at most CHARS characters (1-10000; server v0.12.2)") +} + +pub fn build_whoami_command() -> Command { + Command::new("whoami") + .about("Show the API key this CLI authenticates with (GET /v1/auth/whoami, server v0.12.2)") +} + pub fn build_health_command() -> Command { let ready = Command::new("ready") .about("Readiness probe (GET /health/ready); exits 6 while the server answers 503"); @@ -822,8 +861,20 @@ pub fn build_session_command() -> Command { .short('m') .long("metadata") .help("Session metadata as JSON string"), + ) + .arg( + Arg::new("idle-timeout") + .long("idle-timeout") + .value_name("DURATION") + .value_parser(parse_duration_secs) + .help("End the session after this much inactivity (e.g. 7200, 90m, 8h; 0 = never; at most 30d; server v0.12.2, default 4h)"), ), ) + .subcommand( + Command::new("touch") + .about("Record activity so the server does not end the session for inactivity (server v0.12.2)") + .arg(Arg::new("session_id").required(true).help("Session ID")), + ) .subcommand( Command::new("end") .about("End an active session") @@ -867,7 +918,21 @@ pub fn build_session_command() -> Command { .subcommand( Command::new("memories") .about("Get memories for a session") - .arg(Arg::new("session_id").required(true).help("Session ID")), + .arg(Arg::new("session_id").required(true).help("Session ID")) + .arg( + Arg::new("limit") + .short('l') + .long("limit") + .value_parser(value_parser!(u32)) + .help("Maximum number of memories to return (server default 50)"), + ) + .arg( + Arg::new("offset") + .long("offset") + .value_parser(value_parser!(u32)) + .help("Memories to skip"), + ) + .arg(preview_arg()), ) } @@ -875,6 +940,11 @@ pub fn build_agent_command() -> Command { Command::new("agent") .about("Manage agents") .subcommand(Command::new("list").about("List all agents")) + .subcommand( + Command::new("create") + .about("Create an agent (its memory namespace) before its first memory (server v0.12.2)") + .arg(Arg::new("agent_id").required(true).help("Agent ID")), + ) .subcommand( Command::new("memories") .about("Get memories for an agent") @@ -893,7 +963,20 @@ pub fn build_agent_command() -> Command { .default_value("50") .value_parser(value_parser!(u32)) .help("Maximum number of memories to return"), - ), + ) + .arg( + Arg::new("offset") + .long("offset") + .value_parser(value_parser!(u32)) + .help("Memories to skip"), + ) + .arg( + Arg::new("include-derived") + .long("include-derived") + .action(ArgAction::SetTrue) + .help("Also list derived sentence sub-memories (left out by default since server v0.12.2)"), + ) + .arg(preview_arg()), ) .subcommand( Command::new("stats") @@ -978,7 +1061,8 @@ pub fn build_knowledge_command() -> Command { .long("max-edges") .value_parser(value_parser!(u32)) .help("Maximum edges per node"), - ), + ) + .arg(preview_arg()), ) .subcommand( Command::new("summarize") @@ -1112,6 +1196,30 @@ pub fn build_admin_command() -> Command { .subcommand(build_encryption_rotate_command()) .subcommand(build_encryption_reseal_command()) .subcommand(build_embed_migration_command()) + .subcommand( + Command::new("derivations-status") + .about("Show the derived data the server owes: sentence sub-memories, full-text entries, graph edges (global admin key; server v0.12.2)"), + ) + .subcommand( + Command::new("derivations-drain") + .about("Derive everything owed now, until settled or the timeout (global admin key; server v0.12.2)") + .arg( + Arg::new("timeout") + .long("timeout") + .value_name("DURATION") + .value_parser(parse_duration_secs) + .help("Stop after this long (e.g. 600, 10m; server default 600s)"), + ), + ) + .subcommand( + Command::new("session-idle-timeout") + .about("Show or set the server-wide session inactivity timeout (server v0.12.2, default 4h)") + .arg( + Arg::new("duration") + .value_parser(parse_duration_secs) + .help("New timeout (e.g. 14400, 4h, 1d; 0 = sessions without their own timeout never end for inactivity; at most 30d). Omit to show it."), + ), + ) .subcommand( Command::new("backup-delete").about("Delete a backup").arg( Arg::new("backup_id") @@ -1344,7 +1452,52 @@ pub fn build_keys_command() -> Command { .subcommand( Command::new("rotate") .about("Rotate an API key (generate new secret)") - .arg(Arg::new("key_id").required(true).help("API key ID")), + .arg(Arg::new("key_id").required(true).help("API key ID")) + .arg( + Arg::new("grace") + .long("grace") + .value_name("DURATION") + .value_parser(parse_duration_secs) + .help("Keep the old key working this long (e.g. 3600, 1h, 7d; at most 7d; server v0.12.2)"), + ), + ) + .subcommand( + Command::new("edit") + .visible_alias("patch") + .about("Rename a key or replace its namespaces (PATCH; server v0.12.2)") + .arg(Arg::new("key_id").required(true).help("API key ID")) + .arg( + Arg::new("name") + .long("name") + .help("New name"), + ) + .arg( + Arg::new("namespaces") + .long("namespaces") + .value_name("LIST") + .value_delimiter(',') + .conflicts_with_all(["all-namespaces", "no-namespaces"]) + .help("Replace the namespace grants (comma-separated; `p*` prefix patterns allowed, e.g. _dakera_agent_mlx-*)"), + ) + .arg( + Arg::new("all-namespaces") + .long("all-namespaces") + .action(ArgAction::SetTrue) + .conflicts_with("no-namespaces") + .help("Grant every namespace (sends namespaces: null; unrestricted callers only)"), + ) + .arg( + Arg::new("no-namespaces") + .long("no-namespaces") + .action(ArgAction::SetTrue) + .help("Grant no namespace (sends namespaces: [])"), + ) + .arg( + Arg::new("namespace") + .short('n') + .long("namespace") + .help("Edit through PATCH /v1/namespaces/{namespace}/keys/{key_id} (namespace admins) instead of /admin/keys/{key_id}"), + ), ) .subcommand( Command::new("usage") diff --git a/src/commands/admin.rs b/src/commands/admin.rs index ebc6262..120a71a 100644 --- a/src/commands/admin.rs +++ b/src/commands/admin.rs @@ -202,6 +202,9 @@ pub async fn execute(ctx: &Ctx, matches: &ArgMatches) -> Result<()> { Some(("encryption-rotate", sub)) => admin_v012::encryption_rotate(ctx, sub).await?, Some(("encryption-reseal", sub)) => admin_v012::encryption_reseal(ctx, sub).await?, Some(("embed-migration", _)) => admin_v012::embed_migration(ctx).await?, + Some(("derivations-status", _)) => admin_v012::derivations_status(ctx).await?, + Some(("derivations-drain", sub)) => admin_v012::derivations_drain(ctx, sub).await?, + Some(("session-idle-timeout", sub)) => admin_v012::session_idle_timeout(ctx, sub).await?, Some(("backup-list", _sub)) => { let path = "/admin/backups"; diff --git a/src/commands/admin_v012.rs b/src/commands/admin_v012.rs index 83a47ba..212e3a1 100644 --- a/src/commands/admin_v012.rs +++ b/src/commands/admin_v012.rs @@ -1,6 +1,7 @@ //! Operator commands added for Dakera v0.12, all under `dk admin`: -//! the embed migration, encryption status and keyring rotation, and the -//! backup routes (create, get, download, upload, restore, schedule). +//! the embed migration, encryption status and keyring rotation, the +//! backup routes (create, get, download, upload, restore, schedule) and, for +//! v0.12.2, the derived-data status / drain and the session idle timeout. //! //! Permissions (v0.12): every route here needs a *global* key. A key pinned to //! namespaces gets `403` even with the admin scope, and backup download, @@ -482,6 +483,193 @@ pub async fn backup_schedule(ctx: &Context, sub: &ArgMatches) -> Result<()> { Ok(()) } +// --------------------------------------------------------------------------- +// Derived data and session idle timeout (server v0.12.2) +// --------------------------------------------------------------------------- + +/// Format seconds as a short duration (`4h`, `90m`, `45s`, `0 (never)`). +pub(crate) fn human_secs(secs: u64) -> String { + match secs { + 0 => "0 (never)".to_string(), + s if s % 86_400 == 0 => format!("{}d", s / 86_400), + s if s % 3600 == 0 => format!("{}h", s / 3600), + s if s % 60 == 0 => format!("{}m", s / 60), + s => format!("{s}s"), + } +} + +/// The owed counts of a derivation status as key/value pairs. +fn derivation_pairs(s: &dakera_client::DerivationStatus) -> Vec<(&'static str, String)> { + let reconciler = match s.reconciler.last_tick_at { + Some(at) => format!("{} (last tick {at})", s.reconciler.state), + None => s.reconciler.state.clone(), + }; + let heal = match &s.heal { + None => "not loaded".to_string(), + Some(h) if h.complete => format!("complete ({} parents healed)", h.parents_healed), + Some(h) => format!( + "in progress at {} ({} parents healed)", + h.namespace.as_deref().unwrap_or("-"), + h.parents_healed + ), + }; + vec![ + ("Settled", s.settled.to_string()), + ("Pending sentences", s.pending_sentences.to_string()), + ("Pending parents", s.pending_parents.to_string()), + ("Unmarked parents", s.unmarked_parents.to_string()), + ("Stale children", s.stale_children.to_string()), + ("Orphan children", s.orphan_children.to_string()), + ("Duplicate children", s.duplicate_children.to_string()), + ("Legacy children", s.legacy_children.to_string()), + ("Children to re-tag", s.remeta_children.to_string()), + ("Full-text entries missing", s.bm25_missing.to_string()), + ("Graph edges owed", s.graph_owed.to_string()), + ("Graph edges queued", s.graph_queue_owed.to_string()), + ("Runs in flight", s.in_flight.to_string()), + ("Agent namespaces", s.namespaces.to_string()), + ("Heal", heal), + ("Reconciler", reconciler), + ] +} + +fn list_warnings(s: &dakera_client::DerivationStatus) { + if !s.unreadable_namespaces.is_empty() { + output::warning(&format!( + "Not counted (index unreadable): {}", + s.unreadable_namespaces.join(", ") + )); + } + if !s.dirty_namespaces.is_empty() { + println!( + " Marked for the reconciler: {}", + s.dirty_namespaces.join(", ") + ); + } +} + +/// `dk admin derivations-status` — `GET /admin/derivations/status`. +pub async fn derivations_status(ctx: &Context) -> Result<()> { + let client = dakera_client::DakeraClient::new(&ctx.url)?; + let t = ctx.log_request("GET", "/admin/derivations/status"); + let status = client.derivations_status().await; + ctx.log_response(t, if status.is_ok() { "200 OK" } else { "ERR" }); + let status = status?; + if !is_table(ctx) { + output::print_item(&status, ctx.format); + return Ok(()); + } + if status.settled { + output::success("Derived data is settled: nothing owed"); + } else { + output::info("Derived data still owed (the reconciler derives it in the background; `dk admin derivations-drain` does it now)"); + } + output::print_kv(&derivation_pairs(&status), ctx.format); + list_warnings(&status); + Ok(()) +} + +/// `dk admin derivations-drain` — `POST /admin/derivations/drain`. +pub async fn derivations_drain(ctx: &Context, sub: &ArgMatches) -> Result<()> { + let timeout = sub.get_one::("timeout").copied(); + // The request lasts as long as the drain: give the HTTP client the drain's + // timeout plus a margin (the server default is 600 s). + let client = dakera_client::DakeraClient::builder(&ctx.url) + .timeout_secs(timeout.unwrap_or(600).saturating_add(60)) + .build()?; + let t = ctx.log_request("POST", "/admin/derivations/drain"); + let drained = client.drain_derivations(timeout).await; + ctx.log_response(t, if drained.is_ok() { "200 OK" } else { "ERR" }); + let drained = drained?; + if !is_table(ctx) { + output::print_item(&drained, ctx.format); + return Ok(()); + } + if drained.settled { + output::success(&format!( + "Drained in {} ms ({} rounds): nothing owed", + drained.elapsed_ms, drained.rounds + )); + } else if drained.timed_out { + output::warning(&format!( + "The drain stopped on its timeout after {} ms; derived data is still owed", + drained.elapsed_ms + )); + } else { + output::warning("The drain finished but derived data is still owed"); + } + output::print_kv( + &[ + ("Parents run", drained.parents_run.to_string()), + ("Sentences left pending", drained.pending_left.to_string()), + ("Children deleted", drained.deleted.to_string()), + ( + "Full-text entries restored", + drained.bm25_restored.to_string(), + ), + ("Graph rebuilds queued", drained.graph_queued.to_string()), + ], + ctx.format, + ); + if !drained.settled { + println!(); + output::print_kv(&derivation_pairs(&drained.status), ctx.format); + list_warnings(&drained.status); + } + Ok(()) +} + +/// `dk admin session-idle-timeout [DURATION]` — `GET /admin/config`, or +/// `PUT /admin/config` with `session_idle_timeout_secs`. +pub async fn session_idle_timeout(ctx: &Context, sub: &ArgMatches) -> Result<()> { + let client = dakera_client::DakeraClient::new(&ctx.url)?; + let Some(secs) = sub.get_one::("duration").copied() else { + let t = ctx.log_request("GET", "/admin/config"); + let config = client.get_config().await; + ctx.log_response(t, if config.is_ok() { "200 OK" } else { "ERR" }); + let config = config?; + let Some(current) = config.session_idle_timeout_secs else { + return Err(anyhow::anyhow!( + "this server does not report session_idle_timeout_secs (it predates v0.12.2, \ + where sessions are never ended for inactivity)" + )); + }; + if is_table(ctx) { + output::print_kv(&[("Session idle timeout", human_secs(current))], ctx.format); + } else { + output::print_item(&json!({ "session_idle_timeout_secs": current }), ctx.format); + } + return Ok(()); + }; + if secs > dakera_client::memory::MAX_SESSION_IDLE_TIMEOUT_SECS { + return Err(input_error(format!( + "the session idle timeout is at most 30d ({} seconds), got {secs}", + dakera_client::memory::MAX_SESSION_IDLE_TIMEOUT_SECS + ))); + } + let t = ctx.log_request("PUT", "/admin/config"); + let updated = client.set_session_idle_timeout(secs).await; + ctx.log_response(t, if updated.is_ok() { "200 OK" } else { "ERR" }); + let updated = updated?; + if !is_table(ctx) { + output::print_item(&updated, ctx.format); + return Ok(()); + } + match updated.config.session_idle_timeout_secs { + Some(now) => output::success(&format!( + "Session idle timeout set to {} (applies at the reaper's next tick; persisted as a runtime override)", + human_secs(now) + )), + None => output::warning( + "The server accepted the request but does not report session_idle_timeout_secs: it predates v0.12.2 and ignores the setting", + ), + } + for w in &updated.warnings { + output::warning(w); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -566,4 +754,30 @@ mod tests { assert_eq!(backup_path("abc-123"), "/admin/backups/abc-123"); assert_eq!(restore_path("r 1"), "/admin/backups/restore/r%201"); } + + #[test] + fn human_secs_picks_the_largest_unit() { + assert_eq!(super::human_secs(0), "0 (never)"); + assert_eq!(super::human_secs(14_400), "4h"); + assert_eq!(super::human_secs(86_400), "1d"); + assert_eq!(super::human_secs(5_400), "90m"); + assert_eq!(super::human_secs(61), "61s"); + } + + #[test] + fn admin_v0122_commands_parse() { + let m = crate::cli::build_admin_command() + .try_get_matches_from(["admin", "derivations-drain", "--timeout", "10m"]) + .unwrap(); + let sub = m.subcommand_matches("derivations-drain").unwrap(); + assert_eq!(*sub.get_one::("timeout").unwrap(), 600); + let m = crate::cli::build_admin_command() + .try_get_matches_from(["admin", "session-idle-timeout", "4h"]) + .unwrap(); + let sub = m.subcommand_matches("session-idle-timeout").unwrap(); + assert_eq!(*sub.get_one::("duration").unwrap(), 14_400); + assert!(crate::cli::build_admin_command() + .try_get_matches_from(["admin", "derivations-status"]) + .is_ok()); + } } diff --git a/src/commands/agent.rs b/src/commands/agent.rs index f90510e..6a0d4a2 100644 --- a/src/commands/agent.rs +++ b/src/commands/agent.rs @@ -2,7 +2,7 @@ use anyhow::Result; use clap::ArgMatches; -use dakera_client::DakeraClient; +use dakera_client::{AgentMemoriesOptions, DakeraClient}; use serde::Serialize; use crate::api; @@ -23,6 +23,12 @@ pub struct AgentMemoryRow { pub content: String, pub memory_type: String, pub importance: f32, + /// Full length of the content in characters (with `--preview` only). + #[serde(skip_serializing_if = "Option::is_none")] + pub content_len: Option, + /// Whether `content` is a cut preview (with `--preview` only). + #[serde(skip_serializing_if = "Option::is_none")] + pub truncated: Option, } #[derive(Debug, Serialize)] @@ -61,13 +67,58 @@ pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { } } + Some(("create", sub_matches)) => { + let agent_id = sub_matches.get_one::("agent_id").unwrap(); + + let t = ctx.log_request("POST", "/v1/agents"); + let created = client.create_agent(agent_id).await; + ctx.log_response(t, if created.is_ok() { "200 OK" } else { "ERR" }); + let created = created?; + + if !matches!(ctx.format, crate::OutputFormat::Table) { + output::print_item(&created, ctx.format); + return Ok(()); + } + if created.created { + output::success(&format!("Agent '{}' created", created.agent_id)); + } else { + output::info(&format!( + "Agent '{}' already exists (left unchanged)", + created.agent_id + )); + } + output::print_kv( + &[ + ("Agent ID", created.agent_id.clone()), + ("Namespace", created.namespace.clone()), + ( + "Dimension", + created + .dimension + .map(|d| d.to_string()) + .unwrap_or_else(|| "-".to_string()), + ), + ( + "Model", + created.model.clone().unwrap_or_else(|| "-".to_string()), + ), + ], + ctx.format, + ); + } + Some(("memories", sub_matches)) => { let agent_id = sub_matches.get_one::("agent_id").unwrap(); - let memory_type = sub_matches.get_one::("type").map(|s| s.as_str()); - let limit = sub_matches.get_one::("limit").copied(); + let options = AgentMemoriesOptions { + memory_type: sub_matches.get_one::("type").cloned(), + limit: sub_matches.get_one::("limit").copied(), + offset: sub_matches.get_one::("offset").copied(), + include_derived: sub_matches.get_flag("include-derived").then_some(true), + content_preview_chars: sub_matches.get_one::("preview").copied(), + }; let t = ctx.log_request("GET", &format!("/v1/agents/{}/memories", agent_id)); - let memories = client.agent_memories(agent_id, memory_type, limit).await; + let memories = client.agent_memories_with(agent_id, &options).await; match &memories { Ok(_) => ctx.log_response(t, "200 OK"), Err(_) => ctx.log_response(t, "ERR"), @@ -82,6 +133,10 @@ pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { memories.len(), agent_id )); + let truncated = memories + .iter() + .filter(|m| m.content_truncated == Some(true)) + .count(); let rows: Vec = memories .into_iter() .map(|m| AgentMemoryRow { @@ -89,8 +144,15 @@ pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { content: m.content, memory_type: format!("{:?}", m.memory_type), importance: m.importance, + content_len: m.content_len, + truncated: m.content_truncated, }) .collect(); + if truncated > 0 && matches!(ctx.format, crate::OutputFormat::Table) { + output::info(&format!( + "{truncated} memories are previews; `dk memory get` shows the full text" + )); + } output::print_data(&rows, ctx.format); } } @@ -232,6 +294,36 @@ mod tests { assert_eq!(*sub.get_one::("limit").unwrap(), 50u32); } + #[test] + fn agent_create_requires_agent_id() { + assert!(build_agent_command() + .try_get_matches_from(["agent", "create"]) + .is_err()); + build_agent_command() + .try_get_matches_from(["agent", "create", "mlx-dev"]) + .expect("agent create should parse"); + } + + #[test] + fn agent_memories_include_derived_and_preview() { + let m = build_agent_command() + .try_get_matches_from([ + "agent", + "memories", + "a", + "--include-derived", + "--preview", + "200", + "--offset", + "50", + ]) + .unwrap(); + let sub = m.subcommand_matches("memories").unwrap(); + assert!(sub.get_flag("include-derived")); + assert_eq!(*sub.get_one::("preview").unwrap(), 200); + assert_eq!(*sub.get_one::("offset").unwrap(), 50); + } + #[test] fn agent_sessions_active_only_flag_works() { let m = build_agent_command() diff --git a/src/commands/completion.rs b/src/commands/completion.rs index e7a7bfb..4e1ec83 100644 --- a/src/commands/completion.rs +++ b/src/commands/completion.rs @@ -9,7 +9,7 @@ use crate::output; /// All top-level `dk` subcommands (used in every shell script). const TOP_LEVEL_CMDS: &str = - "init health capabilities attachment namespace index memory session agent knowledge admin keys config completion"; + "init health capabilities whoami attachment namespace index memory session agent knowledge admin keys config completion"; // ─── Bash ──────────────────────────────────────────────────────────────────── @@ -102,10 +102,10 @@ _dk() {{ "store batch-store recall get update forget search extract importance consolidate feedback" -- "$cur")) ;; session) - [[ -z "$sub" ]] && COMPREPLY=($(compgen -W "start end get list memories" -- "$cur")) + [[ -z "$sub" ]] && COMPREPLY=($(compgen -W "start touch end get list memories" -- "$cur")) ;; agent) - [[ -z "$sub" ]] && COMPREPLY=($(compgen -W "list memories stats sessions" -- "$cur")) + [[ -z "$sub" ]] && COMPREPLY=($(compgen -W "list create memories stats sessions" -- "$cur")) ;; knowledge) [[ -z "$sub" ]] && COMPREPLY=($(compgen -W \ @@ -117,7 +117,8 @@ _dk() {{ cache-stats cache-clear config-get config-set quotas-get quotas-set \ slow-queries backup-create backup-list backup-get backup-download backup-upload \ backup-restore backup-restore-status backup-schedule backup-delete \ - encryption-status encryption-rotate encryption-reseal embed-migration" \ + encryption-status encryption-rotate encryption-reseal embed-migration \ + derivations-status derivations-drain session-idle-timeout" \ -- "$cur")) ;; attachment) @@ -126,7 +127,7 @@ _dk() {{ ;; keys) [[ -z "$sub" ]] && COMPREPLY=($(compgen -W \ - "create list get delete deactivate rotate usage" -- "$cur")) + "create list get edit patch delete deactivate rotate usage" -- "$cur")) ;; completion) [[ -z "$sub" ]] && COMPREPLY=($(compgen -W "bash zsh fish" -- "$cur")) @@ -195,6 +196,7 @@ _dk() { 'init:Interactive setup wizard' 'health:Check server health' 'capabilities:Show what the server supports' + 'whoami:Show the API key in use' 'attachment:Attachments: upload, transcribe, index' 'namespace:Manage namespaces' 'index:Index management' @@ -259,6 +261,7 @@ _dk() { session) local s_cmds=( 'start:Start a new session' + 'touch:Keep a session from ending for inactivity' 'end:End active session' 'get:Get session details' 'list:List sessions' @@ -271,6 +274,7 @@ _dk() { agent) local a_cmds=( 'list:List all agents' + 'create:Create an agent' 'memories:Get memories for agent' 'stats:Get agent statistics' 'sessions:Get sessions for agent' @@ -316,6 +320,9 @@ _dk() { 'encryption-rotate:Rotate the encryption key' 'encryption-reseal:Run a re-seal pass' 'embed-migration:Show the background re-embed' + 'derivations-status:Show the derived data owed' + 'derivations-drain:Derive everything owed now' + 'session-idle-timeout:Show or set the session idle timeout' ) _arguments '1: :->subcmd' \ '(--namespace -n)'{--namespace,-n}'[Namespace]:namespace:_dk_namespaces' @@ -326,6 +333,7 @@ _dk() { 'create:Create new API key' 'list:List all keys' 'get:Get key details' + 'edit:Rename a key or replace its namespaces' 'delete:Delete/revoke key' 'deactivate:Deactivate key' 'rotate:Rotate key' @@ -414,6 +422,7 @@ complete -c dk -s v -l verbose -d 'Enable verbose output' complete -c dk -f -n '__dk_no_subcommand' -a 'init' -d 'Interactive setup wizard' complete -c dk -f -n '__dk_no_subcommand' -a 'health' -d 'Check server health' complete -c dk -f -n '__dk_no_subcommand' -a 'capabilities' -d 'Show what the server supports' +complete -c dk -f -n '__dk_no_subcommand' -a 'whoami' -d 'Show the API key in use' complete -c dk -f -n '__dk_no_subcommand' -a 'attachment' -d 'Attachments: upload, transcribe, index' complete -c dk -f -n '__dk_no_subcommand' -a 'namespace' -d 'Manage namespaces' complete -c dk -f -n '__dk_no_subcommand' -a 'index' -d 'Index management' @@ -454,6 +463,7 @@ complete -c dk -n '__dk_using_subcommand memory' -l agent-id -d 'Agent ID' -r - # session subcommands complete -c dk -f -n '__dk_using_subcommand session' -a 'start' -d 'Start a new session' +complete -c dk -f -n '__dk_using_subcommand session' -a 'touch' -d 'Keep a session from ending for inactivity' complete -c dk -f -n '__dk_using_subcommand session' -a 'end' -d 'End active session' complete -c dk -f -n '__dk_using_subcommand session' -a 'get' -d 'Get session details' complete -c dk -f -n '__dk_using_subcommand session' -a 'list' -d 'List sessions' @@ -462,6 +472,7 @@ complete -c dk -n '__dk_using_subcommand session' -l agent-id -d 'Agent ID' -r - # agent subcommands complete -c dk -f -n '__dk_using_subcommand agent' -a 'list' -d 'List all agents' +complete -c dk -f -n '__dk_using_subcommand agent' -a 'create' -d 'Create an agent' complete -c dk -f -n '__dk_using_subcommand agent' -a 'memories' -d 'Get memories for agent' complete -c dk -f -n '__dk_using_subcommand agent' -a 'stats' -d 'Get agent statistics' complete -c dk -f -n '__dk_using_subcommand agent' -a 'sessions' -d 'Get sessions for agent' @@ -498,6 +509,9 @@ complete -c dk -f -n '__dk_using_subcommand admin' -a 'encryption-status' -d 'Sh complete -c dk -f -n '__dk_using_subcommand admin' -a 'encryption-rotate' -d 'Rotate the encryption key' complete -c dk -f -n '__dk_using_subcommand admin' -a 'encryption-reseal' -d 'Run a re-seal pass' complete -c dk -f -n '__dk_using_subcommand admin' -a 'embed-migration' -d 'Show the background re-embed' +complete -c dk -f -n '__dk_using_subcommand admin' -a 'derivations-status' -d 'Show the derived data owed' +complete -c dk -f -n '__dk_using_subcommand admin' -a 'derivations-drain' -d 'Derive everything owed now' +complete -c dk -f -n '__dk_using_subcommand admin' -a 'session-idle-timeout' -d 'Show or set the session idle timeout' complete -c dk -f -n '__dk_using_subcommand health' -a 'ready' -d 'Readiness probe' complete -c dk -f -n '__dk_using_subcommand health' -a 'live' -d 'Liveness probe' complete -c dk -f -n '__dk_using_subcommand attachment' -a 'upload' -d 'Upload a file' @@ -514,6 +528,7 @@ complete -c dk -n '__dk_using_subcommand admin' -l namespace -s n -d 'Namespace' complete -c dk -f -n '__dk_using_subcommand keys' -a 'create' -d 'Create new API key' complete -c dk -f -n '__dk_using_subcommand keys' -a 'list' -d 'List all keys' complete -c dk -f -n '__dk_using_subcommand keys' -a 'get' -d 'Get key details' +complete -c dk -f -n '__dk_using_subcommand keys' -a 'edit' -d 'Rename a key or replace its namespaces' complete -c dk -f -n '__dk_using_subcommand keys' -a 'delete' -d 'Delete/revoke key' complete -c dk -f -n '__dk_using_subcommand keys' -a 'deactivate' -d 'Deactivate key' complete -c dk -f -n '__dk_using_subcommand keys' -a 'rotate' -d 'Rotate key' diff --git a/src/commands/keys.rs b/src/commands/keys.rs index ed8beae..e22be15 100644 --- a/src/commands/keys.rs +++ b/src/commands/keys.rs @@ -2,10 +2,121 @@ use anyhow::{Context, Result}; use clap::ArgMatches; +use dakera_client::{DakeraClient, KeyInfo, UpdateKeyRequest, WhoamiResponse}; use serde_json::Value; use crate::context::Context as Ctx; +use crate::error::input_error; use crate::output; +use crate::OutputFormat; + +/// A namespace grant list as one cell: `all` for `None` (every namespace), +/// `none` for `[]`. +fn grants_cell(namespaces: Option<&Vec>) -> String { + match namespaces { + None => "all".to_string(), + Some(list) if list.is_empty() => "none".to_string(), + Some(list) => list.join(", "), + } +} + +/// The `UpdateKeyRequest` the `keys edit` flags describe. +pub(crate) fn update_request(sub: &ArgMatches) -> Result { + let mut req = UpdateKeyRequest::new(); + if let Some(name) = sub.get_one::("name") { + req = req.with_name(name.clone()); + } + if sub.get_flag("all-namespaces") { + req = req.with_all_namespaces(); + } else if sub.get_flag("no-namespaces") { + req = req.with_namespaces(Vec::new()); + } else if let Some(list) = sub.get_many::("namespaces") { + let list: Vec = list + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + if list.is_empty() { + return Err(input_error( + "--namespaces needs at least one name (use --no-namespaces to grant none)", + )); + } + req = req.with_namespaces(list); + } + if req.is_empty() { + return Err(input_error( + "nothing to change: pass --name, --namespaces, --all-namespaces or --no-namespaces", + )); + } + Ok(req) +} + +/// Print a key (table: key/value pairs; json: the server's fields). +fn print_key(key: &KeyInfo, format: OutputFormat) { + if !matches!(format, OutputFormat::Table) { + output::print_item(key, format); + return; + } + let mut pairs = vec![ + ("Key ID", key.key_id.clone()), + ("Name", key.name.clone()), + ("Scope", key.scope.clone()), + ("Namespaces", grants_cell(key.namespaces.as_ref())), + ("Active", key.active.to_string()), + ( + "Expires at", + key.expires_at + .map(|t| t.to_string()) + .unwrap_or_else(|| "-".to_string()), + ), + ]; + if let Some(v) = key.grants_version { + pairs.push(("Grants version", v.to_string())); + } + if !key.inert_namespaces.is_empty() { + pairs.push(("Inert grants", key.inert_namespaces.join(", "))); + } + output::print_kv(&pairs, format); +} + +/// `dk whoami` — `GET /v1/auth/whoami` (server v0.12.2). +pub async fn whoami(ctx: &Ctx) -> Result<()> { + let client = DakeraClient::new(&ctx.url)?; + let t = ctx.log_request("GET", "/v1/auth/whoami"); + let who = client.whoami().await; + ctx.log_response(t, if who.is_ok() { "200 OK" } else { "ERR" }); + let who: WhoamiResponse = who?; + if !matches!(ctx.format, OutputFormat::Table) { + output::print_item(&who, ctx.format); + return Ok(()); + } + if !who.auth_enabled { + output::warning("Authentication is off on this server: every caller is super_admin"); + } + let mut pairs = vec![ + ("Key ID", who.key_id.clone()), + ("Name", who.name.clone()), + ("Scope", who.scope.clone()), + ("Namespaces", grants_cell(who.namespaces.as_ref())), + ("Unrestricted", who.unrestricted.to_string()), + ( + "Expires at", + who.expires_at + .map(|t| t.to_string()) + .unwrap_or_else(|| "-".to_string()), + ), + ("Grants version", who.grants_version.to_string()), + ]; + if !who.inert_namespaces.is_empty() { + pairs.push(("Inert grants", who.inert_namespaces.join(", "))); + } + output::print_kv(&pairs, ctx.format); + if who.grants_version == 0 && who.inert_namespaces.iter().any(|g| g.ends_with('*')) { + output::info( + "This key predates prefix patterns: its `*` grants match nothing until its namespaces are saved again (dk keys edit --namespaces ...)", + ); + } + Ok(()) +} async fn keys_get(url: &str, path: &str) -> Result { let client = super::authed_client(); @@ -145,22 +256,67 @@ pub async fn execute(ctx: &Ctx, matches: &ArgMatches) -> Result<()> { Some(("rotate", sub)) => { let key_id = sub.get_one::("key_id").unwrap(); + let grace = sub.get_one::("grace").copied(); + if let Some(g) = grace { + if g > dakera_client::MAX_ROTATION_GRACE_SECS { + return Err(input_error(format!( + "--grace is at most 7d ({} seconds), got {g}", + dakera_client::MAX_ROTATION_GRACE_SECS + ))); + } + } + let client = DakeraClient::new(&ctx.url)?; let path = format!("/admin/keys/{}/rotate", key_id); let t = ctx.log_request("POST", &path); - let result = keys_post(&ctx.url, &path, None).await; + let result = match grace { + Some(g) => client.rotate_key_with_grace(key_id, g).await, + None => client.rotate_key(key_id).await, + }; ctx.log_response(t, if result.is_ok() { "200 OK" } else { "ERR" }); let result = result?; - if let Some(new_key) = result.get("key").and_then(|k| k.as_str()) { - output::success(&format!("API key '{}' rotated", key_id)); - output::warning("Save the new key now - it will not be shown again!"); - println!(); - println!(" New Key: {}", new_key); - println!(); - } else { - output::success(&format!("API key '{}' rotated", key_id)); + if !matches!(ctx.format, OutputFormat::Table) { output::print_item(&result, ctx.format); + return Ok(()); + } + output::success(&format!("API key '{}' rotated", key_id)); + output::warning("Save the new key now - it will not be shown again!"); + println!(); + println!(" New Key: {}", result.new_key); + println!(" New Key ID: {}", result.key_id); + match (grace, result.old_key_id.as_deref(), result.old_key_expires_at) { + (_, Some(old), Some(at)) => { + println!(" Old key {old} keeps working until {at} (Unix seconds)") + } + (Some(g), None, _) if g > 0 => output::warning( + "The server did not report a grace period (it predates v0.12.2): the old key was deactivated at once", + ), + (_, Some(old), None) => println!(" Old key {old} was deactivated"), + _ => {} + } + println!(); + } + + Some(("edit", sub)) => { + let key_id = sub.get_one::("key_id").unwrap(); + let request = update_request(sub)?; + let client = DakeraClient::new(&ctx.url)?; + let namespace = sub.get_one::("namespace"); + let path = match namespace { + Some(ns) => format!("/v1/namespaces/{ns}/keys/{key_id}"), + None => format!("/admin/keys/{key_id}"), + }; + let t = ctx.log_request("PATCH", &path); + let result = match namespace { + Some(ns) => client.update_namespace_key(ns, key_id, &request).await, + None => client.update_key(key_id, &request).await, + }; + ctx.log_response(t, if result.is_ok() { "200 OK" } else { "ERR" }); + let key = result?; + if matches!(ctx.format, OutputFormat::Table) { + output::success(&format!("API key '{}' updated", key_id)); } + print_key(&key, ctx.format); } Some(("usage", sub)) => { @@ -215,6 +371,52 @@ mod tests { ); } + #[test] + fn keys_rotate_grace_accepts_durations() { + let m = build_keys_command() + .try_get_matches_from(["keys", "rotate", "dk_key_1", "--grace", "1h"]) + .unwrap(); + let sub = m.subcommand_matches("rotate").unwrap(); + assert_eq!(*sub.get_one::("grace").unwrap(), 3600); + assert!(build_keys_command() + .try_get_matches_from(["keys", "rotate", "dk_key_1", "--grace", "soon"]) + .is_err()); + } + + fn edit(args: &[&str]) -> Result { + let mut argv = vec!["keys", "edit", "dk_key_1"]; + argv.extend_from_slice(args); + let m = build_keys_command() + .try_get_matches_from(argv) + .map_err(|e| e.to_string())?; + super::update_request(m.subcommand_matches("edit").unwrap()).map_err(|e| e.to_string()) + } + + #[test] + fn keys_edit_builds_absent_null_and_list() { + let r = edit(&["--name", "ci"]).unwrap(); + assert_eq!(serde_json::to_string(&r).unwrap(), r#"{"name":"ci"}"#); + let r = edit(&["--all-namespaces"]).unwrap(); + assert_eq!(serde_json::to_string(&r).unwrap(), r#"{"namespaces":null}"#); + let r = edit(&["--no-namespaces"]).unwrap(); + assert_eq!(serde_json::to_string(&r).unwrap(), r#"{"namespaces":[]}"#); + let r = edit(&["--namespaces", "team-*,docs"]).unwrap(); + assert_eq!( + serde_json::to_string(&r).unwrap(), + r#"{"namespaces":["team-*","docs"]}"# + ); + } + + #[test] + fn keys_edit_refuses_nothing_and_conflicts() { + assert!(edit(&[]).is_err()); + assert!(edit(&["--all-namespaces", "--namespaces", "a"]).is_err()); + assert!(edit(&["--all-namespaces", "--no-namespaces"]).is_err()); + assert!(build_keys_command() + .try_get_matches_from(["keys", "patch", "dk_key_1", "--name", "x"]) + .is_ok()); + } + #[test] fn keys_create_with_expiry_in_days() { let m = build_keys_command() diff --git a/src/commands/knowledge.rs b/src/commands/knowledge.rs index af76735..bb2634d 100644 --- a/src/commands/knowledge.rs +++ b/src/commands/knowledge.rs @@ -178,6 +178,9 @@ pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { if let Some(v) = sub_matches.get_one::("max-edges") { body["max_edges_per_node"] = json!(v); } + if let Some(v) = sub_matches.get_one::("preview") { + body["content_preview_chars"] = json!(v); + } let path = "/v1/knowledge/graph/full"; let result = api::request_json_logged(ctx, Method::POST, path, Some(&body)).await?; if matches!(ctx.format, OutputFormat::Json) { diff --git a/src/commands/session.rs b/src/commands/session.rs index 3c8bde0..bf5f6e3 100644 --- a/src/commands/session.rs +++ b/src/commands/session.rs @@ -2,11 +2,16 @@ use anyhow::{Context, Result}; use clap::ArgMatches; +use dakera_client::memory::{ + SessionMemoriesOptions, SessionStartRequest, MAX_SESSION_IDLE_TIMEOUT_SECS, +}; use dakera_client::DakeraClient; use serde::Serialize; use crate::context::Context as Ctx; +use crate::error::input_error; use crate::output; +use crate::OutputFormat; #[derive(Debug, Serialize)] pub struct SessionRow { @@ -14,6 +19,8 @@ pub struct SessionRow { pub agent_id: String, pub started_at: u64, pub ended_at: String, + /// `client` or `idle` (server v0.12.2); `-` while open or from older servers. + pub ended_reason: String, } #[derive(Debug, Serialize)] @@ -22,6 +29,12 @@ pub struct SessionMemoryRow { pub content: String, pub importance: f32, pub score: f32, + /// Full length of the content in characters (with `--preview` only). + #[serde(skip_serializing_if = "Option::is_none")] + pub content_len: Option, + /// Whether `content` is a cut preview (with `--preview` only). + #[serde(skip_serializing_if = "Option::is_none")] + pub truncated: Option, } pub async fn execute(ctx: &Ctx, matches: &ArgMatches) -> Result<()> { @@ -31,18 +44,27 @@ pub async fn execute(ctx: &Ctx, matches: &ArgMatches) -> Result<()> { Some(("start", sub_matches)) => { let agent_id = sub_matches.get_one::("agent_id").unwrap(); let metadata_str = sub_matches.get_one::("metadata"); + let idle_timeout = sub_matches.get_one::("idle-timeout").copied(); - let t = ctx.log_request("POST", "/v1/sessions/start"); - let session = if let Some(m) = metadata_str { + let mut request = SessionStartRequest::new(agent_id.as_str()); + if let Some(m) = metadata_str { let metadata: serde_json::Value = serde_json::from_str(m).context("Invalid metadata JSON")?; - client - .start_session_with_metadata(agent_id, metadata) - .await? - } else { - client.start_session(agent_id).await? - }; - ctx.log_response(t, "200 OK"); + request = request.with_metadata(metadata); + } + if let Some(secs) = idle_timeout { + if secs > MAX_SESSION_IDLE_TIMEOUT_SECS { + return Err(input_error(format!( + "--idle-timeout is at most 30d ({MAX_SESSION_IDLE_TIMEOUT_SECS} seconds), got {secs}" + ))); + } + request = request.with_idle_timeout_secs(secs); + } + + let t = ctx.log_request("POST", "/v1/sessions/start"); + let session = client.start_session_with(&request).await; + ctx.log_response(t, if session.is_ok() { "200 OK" } else { "ERR" }); + let session = session?; output::success(&format!( "Session started (id: {}, agent: {})", @@ -52,6 +74,38 @@ pub async fn execute(ctx: &Ctx, matches: &ArgMatches) -> Result<()> { output::print_item(&session, ctx.format); } + Some(("touch", sub_matches)) => { + let session_id = sub_matches.get_one::("session_id").unwrap(); + + let t = ctx.log_request("POST", &format!("/v1/sessions/{}/touch", session_id)); + let response = client.touch_session(session_id).await; + ctx.log_response(t, if response.is_ok() { "200 OK" } else { "ERR" }); + let response = response?; + + if !matches!(ctx.format, OutputFormat::Table) { + output::print_item(&response, ctx.format); + return Ok(()); + } + if response.session_state == "ended" { + output::warning(&format!( + "Session '{}' has already ended ({}); a touch does not re-open it", + session_id, + response.session.ended_reason.as_deref().unwrap_or("ended") + )); + } else { + output::success(&format!("Session '{}' touched", session_id)); + } + let opt = |v: Option| v.map(|t| t.to_string()).unwrap_or_else(|| "-".into()); + output::print_kv( + &[ + ("State", response.session_state.clone()), + ("Last activity", opt(response.session.last_activity_at)), + ("Idle deadline", opt(response.idle_deadline_at)), + ], + ctx.format, + ); + } + Some(("end", sub_matches)) => { let session_id = sub_matches.get_one::("session_id").unwrap(); let summary = sub_matches.get_one::("summary").cloned(); @@ -131,6 +185,11 @@ pub async fn execute(ctx: &Ctx, matches: &ArgMatches) -> Result<()> { .and_then(|v| v.as_u64()) .map(|t| t.to_string()) .unwrap_or_else(|| "active".to_string()), + ended_reason: s + .get("ended_reason") + .and_then(|v| v.as_str()) + .unwrap_or("-") + .to_string(), }) }) .collect(); @@ -146,9 +205,14 @@ pub async fn execute(ctx: &Ctx, matches: &ArgMatches) -> Result<()> { Some(("memories", sub_matches)) => { let session_id = sub_matches.get_one::("session_id").unwrap(); + let options = SessionMemoriesOptions { + limit: sub_matches.get_one::("limit").copied(), + offset: sub_matches.get_one::("offset").copied(), + content_preview_chars: sub_matches.get_one::("preview").copied(), + }; let t = ctx.log_request("GET", &format!("/v1/sessions/{}/memories", session_id)); - let response = client.session_memories(session_id).await; + let response = client.session_memories_with(session_id, &options).await; match &response { Ok(_) => ctx.log_response(t, "200 OK"), Err(_) => ctx.log_response(t, "ERR"), @@ -158,12 +222,18 @@ pub async fn execute(ctx: &Ctx, matches: &ArgMatches) -> Result<()> { if response.memories.is_empty() { output::info(&format!("No memories found for session '{}'", session_id)); } else { + let total = response.total.unwrap_or(response.memories.len()); output::info(&format!( "Found {} memories in session '{}' (total: {})", response.memories.len(), session_id, - response.total_found + total )); + let truncated = response + .memories + .iter() + .filter(|m| m.content_truncated == Some(true)) + .count(); let rows: Vec = response .memories .into_iter() @@ -172,8 +242,15 @@ pub async fn execute(ctx: &Ctx, matches: &ArgMatches) -> Result<()> { content: m.content, importance: m.importance, score: m.score, + content_len: m.content_len, + truncated: m.content_truncated, }) .collect(); + if truncated > 0 && matches!(ctx.format, OutputFormat::Table) { + output::info(&format!( + "{truncated} memories are previews; `dk memory get` shows the full text" + )); + } output::print_data(&rows, ctx.format); } } @@ -220,6 +297,40 @@ mod tests { assert_eq!(*sub.get_one::("limit").unwrap(), 50u32); } + #[test] + fn session_start_idle_timeout_parses_durations() { + let m = build_session_command() + .try_get_matches_from(["session", "start", "a", "--idle-timeout", "8h"]) + .unwrap(); + let sub = m.subcommand_matches("start").unwrap(); + assert_eq!(*sub.get_one::("idle-timeout").unwrap(), 8 * 3600); + let m = build_session_command() + .try_get_matches_from(["session", "start", "a", "--idle-timeout", "0"]) + .unwrap(); + let sub = m.subcommand_matches("start").unwrap(); + assert_eq!(*sub.get_one::("idle-timeout").unwrap(), 0); + } + + #[test] + fn session_touch_requires_session_id() { + assert!(build_session_command() + .try_get_matches_from(["session", "touch"]) + .is_err()); + } + + #[test] + fn session_memories_preview_range_is_checked() { + assert!(build_session_command() + .try_get_matches_from(["session", "memories", "s", "--preview", "0"]) + .is_err()); + assert!(build_session_command() + .try_get_matches_from(["session", "memories", "s", "--preview", "10001"]) + .is_err()); + assert!(build_session_command() + .try_get_matches_from(["session", "memories", "s", "--preview", "120"]) + .is_ok()); + } + #[test] fn session_end_with_summary_flag() { let m = build_session_command() diff --git a/src/config.rs b/src/config.rs index 5e664f2..4f91292 100644 --- a/src/config.rs +++ b/src/config.rs @@ -201,8 +201,10 @@ mod tests { #[test] fn test_config_file_roundtrip() { - let mut cfg = ConfigFile::default(); - cfg.active_profile = "prod".to_string(); + let mut cfg = ConfigFile { + active_profile: "prod".to_string(), + ..Default::default() + }; cfg.profiles.insert( "prod".to_string(), Profile { @@ -235,8 +237,10 @@ mod tests { #[test] fn test_config_file_multiple_profiles_roundtrip() { - let mut cfg = ConfigFile::default(); - cfg.active_profile = "staging".to_string(); + let mut cfg = ConfigFile { + active_profile: "staging".to_string(), + ..Default::default() + }; cfg.profiles.insert( "prod".to_string(), Profile { @@ -288,7 +292,7 @@ mod tests { "active_profile = \"missing\"\n[profiles.prod]\nurl = \"https://prod.example.com\"\n"; let cfg: ConfigFile = toml::from_str(toml_str).unwrap(); assert_eq!(cfg.active_profile, "missing"); - assert!(cfg.profiles.get("missing").is_none()); + assert!(!cfg.profiles.contains_key("missing")); } #[test] diff --git a/src/error.rs b/src/error.rs index 3db1ec9..4fdf2ff 100644 --- a/src/error.rs +++ b/src/error.rs @@ -135,6 +135,7 @@ pub fn classify(err: &anyhow::Error) -> CliError { return CliError::Server(msg) } E::NamespaceNotFound(_) | E::VectorNotFound(_) => return CliError::NotFound(msg), + E::InvalidRequest(_) => return CliError::Input(msg), E::Connection(_) | E::Timeout => return CliError::Connection(msg), E::Http(e) if e.is_connect() || e.is_timeout() => return CliError::Connection(msg), _ => {} diff --git a/src/main.rs b/src/main.rs index f2652c0..4157946 100644 --- a/src/main.rs +++ b/src/main.rs @@ -77,6 +77,48 @@ async fn main() { } } +async fn run(matches: clap::ArgMatches, format: OutputFormat, verbose: bool) -> anyhow::Result<()> { + let config = match matches.get_one::("profile") { + Some(p) => Config::load_with_profile(p), + None => Config::load(), + }; + + let cli_url = matches.get_one::("url").unwrap(); + let url = if cli_url != "http://localhost:3000" { + cli_url.clone() + } else { + config.server_url.clone() + }; + + let ctx = Context::new(url, format, verbose); + + match matches.subcommand() { + Some(("init", _)) => init::execute().await?, + Some(("health", sub_matches)) => health::execute(&ctx, sub_matches).await?, + Some(("capabilities", _)) => capabilities::execute(&ctx).await?, + Some(("whoami", _)) => keys::whoami(&ctx).await?, + Some(("attachment", sub_matches)) => attachment::execute(&ctx, sub_matches).await?, + Some(("namespace", sub_matches)) => namespace::execute(&ctx, sub_matches).await?, + Some(("index", sub_matches)) => index::execute(&ctx, sub_matches).await?, + Some(("memory", sub_matches)) => memory::execute(&ctx, sub_matches).await?, + Some(("session", sub_matches)) => session::execute(&ctx, sub_matches).await?, + Some(("agent", sub_matches)) => agent::execute(&ctx, sub_matches).await?, + Some(("knowledge", sub_matches)) => knowledge::execute(&ctx, sub_matches).await?, + Some(("keys", sub_matches)) => keys::execute(&ctx, sub_matches).await?, + Some(("admin", sub_matches)) => admin::execute(&ctx, sub_matches).await?, + Some(("completion", sub_matches)) => { + let shell = sub_matches.get_one::("shell").unwrap(); + let install = sub_matches.get_flag("install"); + completion::execute(shell, install)?; + } + Some(("config", sub_matches)) => config_cmd::execute(sub_matches).await?, + Some(("text", sub_matches)) => text::execute(&ctx, sub_matches).await?, + _ => build_cli().print_help()?, + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -118,44 +160,3 @@ mod tests { )); } } - -async fn run(matches: clap::ArgMatches, format: OutputFormat, verbose: bool) -> anyhow::Result<()> { - let config = match matches.get_one::("profile") { - Some(p) => Config::load_with_profile(p), - None => Config::load(), - }; - - let cli_url = matches.get_one::("url").unwrap(); - let url = if cli_url != "http://localhost:3000" { - cli_url.clone() - } else { - config.server_url.clone() - }; - - let ctx = Context::new(url, format, verbose); - - match matches.subcommand() { - Some(("init", _)) => init::execute().await?, - Some(("health", sub_matches)) => health::execute(&ctx, sub_matches).await?, - Some(("capabilities", _)) => capabilities::execute(&ctx).await?, - Some(("attachment", sub_matches)) => attachment::execute(&ctx, sub_matches).await?, - Some(("namespace", sub_matches)) => namespace::execute(&ctx, sub_matches).await?, - Some(("index", sub_matches)) => index::execute(&ctx, sub_matches).await?, - Some(("memory", sub_matches)) => memory::execute(&ctx, sub_matches).await?, - Some(("session", sub_matches)) => session::execute(&ctx, sub_matches).await?, - Some(("agent", sub_matches)) => agent::execute(&ctx, sub_matches).await?, - Some(("knowledge", sub_matches)) => knowledge::execute(&ctx, sub_matches).await?, - Some(("keys", sub_matches)) => keys::execute(&ctx, sub_matches).await?, - Some(("admin", sub_matches)) => admin::execute(&ctx, sub_matches).await?, - Some(("completion", sub_matches)) => { - let shell = sub_matches.get_one::("shell").unwrap(); - let install = sub_matches.get_flag("install"); - completion::execute(shell, install)?; - } - Some(("config", sub_matches)) => config_cmd::execute(sub_matches).await?, - Some(("text", sub_matches)) => text::execute(&ctx, sub_matches).await?, - _ => build_cli().print_help()?, - } - - Ok(()) -} diff --git a/src/output.rs b/src/output.rs index 7ed2508..35341d0 100644 --- a/src/output.rs +++ b/src/output.rs @@ -232,7 +232,7 @@ mod tests { #[test] fn test_kv_json_serialization_roundtrip() { - let pairs = vec![("key", "value".to_string())]; + let pairs = [("key", "value".to_string())]; let map: std::collections::HashMap<&str, &str> = pairs.iter().map(|(k, v)| (*k, v.as_str())).collect(); let json_str = serde_json::to_string_pretty(&map).unwrap(); diff --git a/tests/route_audit.rs b/tests/route_audit.rs index 9baa209..527aec6 100644 --- a/tests/route_audit.rs +++ b/tests/route_audit.rs @@ -1,4 +1,5 @@ -//! Every server path `dk` calls must be a route of the Dakera v0.12.0 server. +//! Every server path `dk` calls must be a route of the Dakera v0.12 server +//! (v0.12.0 plus the routes v0.12.2 adds). //! //! The route list (`tests/server_routes_v0.12.txt`) comes from the server's //! router (`crates/api/src/lib.rs`); the paths `dk` calls are the string diff --git a/tests/server_routes_v0.12.txt b/tests/server_routes_v0.12.txt index 23958a8..9993bac 100644 --- a/tests/server_routes_v0.12.txt +++ b/tests/server_routes_v0.12.txt @@ -1,4 +1,4 @@ -# Routes of the Dakera v0.12.0 server (crates/api/src/lib.rs + keys_router + attachment routes), params as *. +# Routes of the Dakera v0.12.0 server (plus the v0.12.2 routes) (crates/api/src/lib.rs + keys_router + attachment routes), params as *. # Admin routes are served under /admin and /v1/admin. Regenerate when the server's router changes. DELETE /admin/backups/* DELETE /admin/keys/* @@ -285,3 +285,13 @@ PUT /v1/memory/update/* PUT /v1/namespaces/* PUT /v1/namespaces/*/config PUT /v1/namespaces/*/memory_policy +# Added in v0.12.2 +GET /admin/derivations/status +GET /v1/admin/derivations/status +GET /v1/auth/whoami +PATCH /admin/keys/* +PATCH /v1/namespaces/*/keys/* +POST /admin/derivations/drain +POST /v1/admin/derivations/drain +POST /v1/agents +POST /v1/sessions/*/touch diff --git a/tests/v0122.rs b/tests/v0122.rs new file mode 100644 index 0000000..c2dff45 --- /dev/null +++ b/tests/v0122.rs @@ -0,0 +1,641 @@ +//! Integration tests for the Dakera v0.12.2 commands of `dk`. +//! +//! Like `v012.rs`, each test starts a local [`httpmock`] server and runs the +//! compiled `dk` binary against it; no Dakera server is needed. +//! +//! Covered: `agent create`, `agent memories --preview --include-derived`, +//! `session start --idle-timeout`, `session touch`, `session memories +//! --preview`, `keys edit`, `keys rotate --grace`, `whoami`, +//! `admin derivations-status|derivations-drain|session-idle-timeout` and +//! `knowledge full-graph --preview`, plus what a pre-v0.12.2 server answers. + +use assert_cmd::Command; +use httpmock::prelude::*; +use predicates::prelude::*; +use serde_json::json; + +fn dk() -> Command { + let mut cmd = Command::cargo_bin("dk").expect("dk binary not found — run `cargo build` first"); + cmd.env_remove("DAKERA_API_KEY"); + cmd +} + +const KEY_INFO: &str = r#"{"key_id":"dk_key_1","name":"ci","scope":"write","namespaces":["team-*"], + "created_at":1791392203,"expires_at":null,"active":true,"grants_version":1}"#; + +// --------------------------------------------------------------------------- +// agent create / memories +// --------------------------------------------------------------------------- + +#[test] +fn agent_create_reports_created() { + let server = MockServer::start(); + let m = server.mock(|when, then| { + when.method(POST) + .path("/v1/agents") + .json_body(json!({"agent_id": "mlx-dev"})); + then.status(201).json_body(json!({ + "agent_id": "mlx-dev", "namespace": "_dakera_agent_mlx-dev", + "created": true, "dimension": 1024, "model": "bge-large" + })); + }); + dk().args(["--url", &server.base_url(), "agent", "create", "mlx-dev"]) + .assert() + .success() + .stdout(predicate::str::contains("Agent 'mlx-dev' created")) + .stdout(predicate::str::contains("_dakera_agent_mlx-dev")) + .stdout(predicate::str::contains("bge-large")); + m.assert(); +} + +#[test] +fn agent_create_existing_says_unchanged_and_json_prints_answer() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/agents"); + then.status(200).json_body(json!({ + "agent_id": "mlx-dev", "namespace": "_dakera_agent_mlx-dev", + "created": false, "dimension": null, "model": "bge-large" + })); + }); + dk().args(["--url", &server.base_url(), "agent", "create", "mlx-dev"]) + .assert() + .success() + .stdout(predicate::str::contains("already exists")); + dk().args([ + "--url", + &server.base_url(), + "--format", + "json", + "agent", + "create", + "mlx-dev", + ]) + .assert() + .success() + .stdout(predicate::str::contains("\"created\": false")); +} + +#[test] +fn agent_create_on_a_pre_v0122_server_exits_with_an_error() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/agents"); + then.status(405).body(""); + }); + dk().args(["--url", &server.base_url(), "agent", "create", "mlx-dev"]) + .assert() + .failure(); +} + +#[test] +fn agent_memories_sends_preview_and_include_derived() { + let server = MockServer::start(); + let m = server.mock(|when, then| { + when.method(GET) + .path("/v1/agents/a/memories") + .query_param("limit", "50") + .query_param("include_derived", "true") + .query_param("content_preview_chars", "5"); + then.status(200).json_body(json!([{ + "id": "m1", "agent_id": "a", "content": "Hello", "memory_type": "semantic", + "importance": 0.5, "created_at": 1, "last_accessed_at": 1, "access_count": 0, + "content_len": 42, "content_truncated": true + }])); + }); + dk().args([ + "--url", + &server.base_url(), + "agent", + "memories", + "a", + "--include-derived", + "--preview", + "5", + ]) + .assert() + .success() + .stdout(predicate::str::contains("CONTENT_LEN")) + .stdout(predicate::str::contains("42")) + .stdout(predicate::str::contains("1 memories are previews")); + m.assert(); +} + +#[test] +fn agent_memories_without_flags_sends_no_new_parameters() { + let server = MockServer::start(); + let m = server.mock(|when, then| { + when.method(GET) + .path("/v1/agents/a/memories") + .query_param_missing("include_derived") + .query_param_missing("content_preview_chars"); + then.status(200).json_body(json!([{ + "id": "m1", "content": "full", "memory_type": "semantic", "importance": 0.5, + "created_at": 1, "last_accessed_at": 1, "access_count": 0 + }])); + }); + dk().args(["--url", &server.base_url(), "agent", "memories", "a"]) + .assert() + .success() + .stdout(predicate::str::contains("CONTENT_LEN").not()); + m.assert(); +} + +// --------------------------------------------------------------------------- +// sessions +// --------------------------------------------------------------------------- + +#[test] +fn session_start_sends_idle_timeout() { + let server = MockServer::start(); + let m = server.mock(|when, then| { + when.method(POST) + .path("/v1/sessions/start") + .json_body(json!({"agent_id": "a", "idle_timeout_secs": 7200})); + then.status(200).json_body(json!({"session": { + "id": "sess_1", "agent_id": "a", "started_at": 10, "memory_count": 0, + "last_activity_at": 10, "idle_timeout_secs": 7200 + }})); + }); + dk().args([ + "--url", + &server.base_url(), + "session", + "start", + "a", + "--idle-timeout", + "2h", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Session started (id: sess_1")); + m.assert(); +} + +#[test] +fn session_start_idle_timeout_over_30_days_exits_5_without_a_request() { + let server = MockServer::start(); + let m = server.mock(|when, then| { + when.method(POST).path("/v1/sessions/start"); + then.status(200); + }); + dk().args([ + "--url", + &server.base_url(), + "session", + "start", + "a", + "--idle-timeout", + "31d", + ]) + .assert() + .code(5); + m.assert_calls(0); +} + +#[test] +fn session_touch_active_and_ended() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/sessions/sess_a/touch"); + then.status(200).json_body(json!({ + "session": {"id": "sess_a", "agent_id": "a", "started_at": 1, "last_activity_at": 1791393000}, + "session_state": "active", "idle_deadline_at": 1791407400 + })); + }); + server.mock(|when, then| { + when.method(POST).path("/v1/sessions/sess_b/touch"); + then.status(200).json_body(json!({ + "session": {"id": "sess_b", "agent_id": "a", "started_at": 1, "ended_at": 5, + "ended_reason": "idle", "idle_since": 2, "last_activity_at": 2}, + "session_state": "ended" + })); + }); + dk().args(["--url", &server.base_url(), "session", "touch", "sess_a"]) + .assert() + .success() + .stdout(predicate::str::contains("touched")) + .stdout(predicate::str::contains("1791407400")); + dk().args(["--url", &server.base_url(), "session", "touch", "sess_b"]) + .assert() + .success() + .stdout(predicate::str::contains("already ended (idle)")); +} + +#[test] +fn session_touch_unknown_session_exits_3() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/sessions/nope/touch"); + then.status(404).json_body(json!({ + "error": "Session not found: nope", "code": "NOT_FOUND", "status": 404 + })); + }); + dk().args(["--url", &server.base_url(), "session", "touch", "nope"]) + .assert() + .code(3); +} + +#[test] +fn session_memories_sends_preview_and_shows_total() { + let server = MockServer::start(); + let m = server.mock(|when, then| { + when.method(GET) + .path("/v1/sessions/sess_1/memories") + .query_param("content_preview_chars", "3") + .query_param("limit", "1"); + then.status(200).json_body(json!({ + "session": {"id": "sess_1", "agent_id": "a", "started_at": 1}, + "memories": [{"id": "m1", "agent_id": "a", "content": "abc", "memory_type": "episodic", + "importance": 0.5, "created_at": 1, "last_accessed_at": 1, "access_count": 0, + "content_len": 300, "content_truncated": true}], + "total": 7 + })); + }); + dk().args([ + "--url", + &server.base_url(), + "session", + "memories", + "sess_1", + "--preview", + "3", + "--limit", + "1", + ]) + .assert() + .success() + .stdout(predicate::str::contains("(total: 7)")) + .stdout(predicate::str::contains("300")); + m.assert(); +} + +#[test] +fn session_list_shows_ended_reason() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/v1/sessions"); + then.status(200).json_body(json!({ + "sessions": [{"id": "sess_1", "agent_id": "a", "started_at": 1, "ended_at": 9, "ended_reason": "idle"}], + "total": 1 + })); + }); + dk().args(["--url", &server.base_url(), "session", "list"]) + .assert() + .success() + .stdout(predicate::str::contains("ENDED_REASON")) + .stdout(predicate::str::contains("idle")); +} + +// --------------------------------------------------------------------------- +// keys +// --------------------------------------------------------------------------- + +#[test] +fn keys_edit_all_namespaces_sends_null() { + let server = MockServer::start(); + let m = server.mock(|when, then| { + when.method("PATCH") + .path("/admin/keys/dk_key_1") + .json_body(json!({"namespaces": null})); + then.status(200).body(KEY_INFO); + }); + dk().args([ + "--url", + &server.base_url(), + "keys", + "edit", + "dk_key_1", + "--all-namespaces", + ]) + .assert() + .success() + .stdout(predicate::str::contains("API key 'dk_key_1' updated")) + .stdout(predicate::str::contains("team-*")); + m.assert(); +} + +#[test] +fn keys_patch_through_a_namespace_sends_name_and_list() { + let server = MockServer::start(); + let m = server.mock(|when, then| { + when.method("PATCH") + .path("/v1/namespaces/team-a/keys/dk_key_1") + .json_body(json!({"name": "ci", "namespaces": ["team-a*", "docs"]})); + then.status(200).body(KEY_INFO); + }); + dk().args([ + "--url", + &server.base_url(), + "keys", + "patch", + "dk_key_1", + "-n", + "team-a", + "--name", + "ci", + "--namespaces", + "team-a*,docs", + ]) + .assert() + .success(); + m.assert(); +} + +#[test] +fn keys_edit_without_changes_exits_5_without_a_request() { + let server = MockServer::start(); + let m = server.mock(|when, then| { + when.method("PATCH").path("/admin/keys/dk_key_1"); + then.status(200).body(KEY_INFO); + }); + dk().args(["--url", &server.base_url(), "keys", "edit", "dk_key_1"]) + .assert() + .code(5) + .stderr(predicate::str::contains("nothing to change")); + m.assert_calls(0); +} + +#[test] +fn keys_edit_conflict_409_exits_5() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method("PATCH").path("/admin/keys/dk_key_1"); + then.status(409).json_body(json!({ + "error": "API key 'dk_key_1' is inactive", "code": "CONFLICT", "status": 409 + })); + }); + dk().args([ + "--url", + &server.base_url(), + "keys", + "edit", + "dk_key_1", + "--name", + "x", + ]) + .assert() + .code(5); +} + +#[test] +fn keys_rotate_with_grace_sends_grace_and_shows_old_key_deadline() { + let server = MockServer::start(); + let m = server.mock(|when, then| { + when.method(POST) + .path("/admin/keys/dk_key_old/rotate") + .json_body(json!({"grace_secs": 3600})); + then.status(200).json_body(json!({ + "new_key": "dk_newsecret", "key_id": "dk_key_new", "old_key_id": "dk_key_old", + "old_key_expires_at": 1791395803, "warning": "Save this new key now!" + })); + }); + dk().args([ + "--url", + &server.base_url(), + "keys", + "rotate", + "dk_key_old", + "--grace", + "1h", + ]) + .assert() + .success() + .stdout(predicate::str::contains("New Key: dk_newsecret")) + .stdout(predicate::str::contains("New Key ID: dk_key_new")) + .stdout(predicate::str::contains("keeps working until 1791395803")); + m.assert(); +} + +#[test] +fn keys_rotate_without_grace_shows_the_new_key_of_a_v0121_answer() { + let server = MockServer::start(); + let m = server.mock(|when, then| { + when.method(POST) + .path("/admin/keys/dk_key_old/rotate") + .body(""); + then.status(200).json_body(json!({ + "new_key": "dk_newsecret", "key_id": "dk_key_new", "warning": "w" + })); + }); + dk().args(["--url", &server.base_url(), "keys", "rotate", "dk_key_old"]) + .assert() + .success() + .stdout(predicate::str::contains("New Key: dk_newsecret")); + m.assert(); +} + +#[test] +fn keys_rotate_grace_over_seven_days_exits_5() { + dk().args([ + "--url", + "http://127.0.0.1:1", + "keys", + "rotate", + "k", + "--grace", + "8d", + ]) + .assert() + .code(5); +} + +#[test] +fn whoami_table_and_json() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/v1/auth/whoami"); + then.status(200).json_body(json!({ + "key_id": "dk_key_1", "name": "dev", "scope": "write", "namespaces": ["foo*", "docs"], + "unrestricted": false, "expires_at": null, "grants_version": 0, + "inert_namespaces": ["foo*"], "auth_enabled": true + })); + }); + dk().args(["--url", &server.base_url(), "whoami"]) + .assert() + .success() + .stdout(predicate::str::contains("dk_key_1")) + .stdout(predicate::str::contains("foo*, docs")) + .stdout(predicate::str::contains("Inert grants")) + .stdout(predicate::str::contains("predates prefix patterns")); + dk().args(["--url", &server.base_url(), "--format", "json", "whoami"]) + .assert() + .success() + .stdout(predicate::str::contains("\"grants_version\": 0")); +} + +#[test] +fn whoami_on_a_pre_v0122_server_exits_3() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/v1/auth/whoami"); + then.status(404) + .json_body(json!({"error": "Not found", "code": "NOT_FOUND", "status": 404})); + }); + dk().args(["--url", &server.base_url(), "whoami"]) + .assert() + .code(3); +} + +// --------------------------------------------------------------------------- +// admin +// --------------------------------------------------------------------------- + +fn derivation_status(settled: bool) -> serde_json::Value { + json!({ + "settled": settled, "pending_sentences": if settled { 0 } else { 4 }, "pending_parents": 0, + "unmarked_parents": 0, "stale_children": 0, "orphan_children": 0, "remeta_children": 0, + "duplicate_children": 0, "legacy_children": 0, "bm25_missing": 0, "graph_owed": 0, + "in_flight": 0, "graph_queue_owed": 0, "dirty_namespaces": [], "namespaces": 3, + "unreadable_namespaces": ["_dakera_agent_z"], + "heal": {"version": 1, "complete": true, "namespace": null, "cursor": null, + "parents_healed": 12, "graph_adopted": 0, "started_at": 1, "completed_at": 2}, + "reconciler": {"state": "sleeping", "last_tick_at": 100, "ticks": 7, "next_namespace": null}, + "counters": {"derived": 5} + }) +} + +#[test] +fn admin_derivations_status_shows_owed_counts() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/v1/admin/derivations/status"); + then.status(200).json_body(derivation_status(false)); + }); + dk().args(["--url", &server.base_url(), "admin", "derivations-status"]) + .assert() + .success() + .stdout(predicate::str::contains("still owed")) + .stdout(predicate::str::is_match(r"Pending sentences\S*: 4").unwrap()) + .stdout(predicate::str::contains("complete (12 parents healed)")) + .stdout(predicate::str::contains("_dakera_agent_z")); +} + +#[test] +fn admin_derivations_drain_sends_timeout() { + let server = MockServer::start(); + let m = server.mock(|when, then| { + when.method(POST) + .path("/v1/admin/derivations/drain") + .json_body(json!({"timeout_secs": 60})); + then.status(200).json_body(json!({ + "settled": true, "timed_out": false, "rounds": 1, "elapsed_ms": 1234, + "parents_run": 17, "pending_left": 0, "deleted": 3, "bm25_restored": 0, + "graph_queued": 1, "status": derivation_status(true) + })); + }); + dk().args([ + "--url", + &server.base_url(), + "admin", + "derivations-drain", + "--timeout", + "1m", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Drained in 1234 ms")) + .stdout(predicate::str::is_match(r"Parents run\S*: 17").unwrap()); + m.assert(); +} + +#[test] +fn admin_derivations_drain_conflict_exits_5() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/admin/derivations/drain"); + then.status(409).json_body(json!({ + "error": "a derivation drain is already running", "code": "CONFLICT", "status": 409 + })); + }); + dk().args(["--url", &server.base_url(), "admin", "derivations-drain"]) + .assert() + .code(5); +} + +fn config(timeout: Option) -> serde_json::Value { + let mut c = json!({ + "default_index_type": "hnsw", "cache_enabled": true, "cache_max_size_bytes": 1, + "rate_limit_enabled": false, "rate_limit_rps": 0, "query_timeout_ms": 1000 + }); + if let Some(t) = timeout { + c["session_idle_timeout_secs"] = json!(t); + } + c +} + +#[test] +fn admin_session_idle_timeout_show_and_set() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/v1/admin/config"); + then.status(200).json_body(config(Some(14_400))); + }); + let put = server.mock(|when, then| { + when.method(PUT) + .path("/v1/admin/config") + .json_body(json!({"session_idle_timeout_secs": 28_800})); + then.status(200).json_body(json!({ + "success": true, "config": config(Some(28_800)), "message": "ok" + })); + }); + dk().args(["--url", &server.base_url(), "admin", "session-idle-timeout"]) + .assert() + .success() + .stdout(predicate::str::is_match(r"Session idle timeout\S*: 4h").unwrap()); + dk().args([ + "--url", + &server.base_url(), + "admin", + "session-idle-timeout", + "8h", + ]) + .assert() + .success() + .stdout(predicate::str::contains("set to 8h")); + put.assert(); +} + +#[test] +fn admin_session_idle_timeout_on_a_pre_v0122_server_says_so() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/v1/admin/config"); + then.status(200).json_body(config(None)); + }); + dk().args(["--url", &server.base_url(), "admin", "session-idle-timeout"]) + .assert() + .failure() + .stderr(predicate::str::contains("predates v0.12.2")); +} + +// --------------------------------------------------------------------------- +// knowledge +// --------------------------------------------------------------------------- + +#[test] +fn knowledge_full_graph_sends_preview() { + let server = MockServer::start(); + let m = server.mock(|when, then| { + when.method(POST) + .path("/v1/knowledge/graph/full") + .json_body(json!({"agent_id": "a", "content_preview_chars": 200})); + then.status(200).json_body(json!({ + "nodes": [], "edges": [], "clusters": [], + "stats": {"total_memories": 0, "included_memories": 0, "total_edges": 0, + "cluster_count": 0, "density": 0.0, "hub_memory_id": null} + })); + }); + dk().args([ + "--url", + &server.base_url(), + "knowledge", + "full-graph", + "a", + "--preview", + "200", + ]) + .assert() + .success(); + m.assert(); +}