From 2d8113e00116572bfebf72c66601045b4da0a2e7 Mon Sep 17 00:00:00 2001 From: Joachim Rosskopf Date: Sat, 30 May 2026 15:46:06 +0200 Subject: [PATCH 1/6] feat(client): event-sourcing + validate RPCs on escurel-client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the five agent-surface RPCs the typed client was missing so downstream consumers (and the forthcoming CLI/TUI) can drive the M7 event triad and the validate dry-run, not just read/browse + chat: - validate — dry-run the indexer pipeline over draft content - capture_event — append an event to the global inbox - list_inbox — unprocessed events - list_events — an instance's processed event history - assign_event — bind an inbox event to an instance (→ processed) Each mirrors the existing authed() one-liner pattern; re-export the new request/response types plus Event so callers never pin escurel-proto. Test plan (no-mock, real gateway via escurel-test-support): crates/escurel-client/tests/client_roundtrip.rs - validate_accepts_well_formed_page - capture_inbox_assign_list_events_round_trip (full CRM flow: capture → inbox → assign → list_events, + inbox-cleared assertion) - list_inbox_respects_limit All 13 client_roundtrip tests pass; clippy -D warnings clean. Co-Authored-By: Claude Opus 4.8 --- crates/escurel-client/src/lib.rs | 60 ++++++-- .../escurel-client/tests/client_roundtrip.rs | 145 +++++++++++++++++- 2 files changed, 194 insertions(+), 11 deletions(-) diff --git a/crates/escurel-client/src/lib.rs b/crates/escurel-client/src/lib.rs index 17201fb..835d6e1 100644 --- a/crates/escurel-client/src/lib.rs +++ b/crates/escurel-client/src/lib.rs @@ -43,16 +43,19 @@ mod error; pub use error::Error; // Re-export the request/response types the downstream caller needs -// so they never pin `escurel-proto` directly. The set tracks the -// signature list in `docs/spec/dx.md` §"Client crate for the app's -// backend" one-to-one. +// so they never pin `escurel-proto` directly. Covers the agent +// surface from `docs/spec/dx.md` §"Client crate for the app's +// backend" plus the M7 event-sourcing RPCs (capture / inbox / events / +// assign) and `validate`. pub use escurel_proto::v1::{ - AppendMessageRequest, AppendMessageResponse, ChatMessage, Edge, ExpandBlock, ExpandRequest, - ExpandResponse, InstanceInfo, ListInstancesRequest, ListInstancesResponse, ListMessagesRequest, - ListMessagesResponse, ListSkillsRequest, ListSkillsResponse, NeighboursRequest, - NeighboursResponse, PageRef, ResolveRequest, ResolveResponse, RunStoredQueryRequest, - RunStoredQueryResponse, SearchHit, SearchRequest, SearchResponse, Skill, StoredQueryColumn, - UpdatePageRequest, UpdatePageResponse, ValidationIssue, WikilinkParsed, + AppendMessageRequest, AppendMessageResponse, AssignEventRequest, AssignEventResponse, + CaptureEventRequest, ChatMessage, Edge, Event, ExpandBlock, ExpandRequest, ExpandResponse, + InstanceInfo, ListEventsRequest, ListEventsResponse, ListInboxRequest, ListInboxResponse, + ListInstancesRequest, ListInstancesResponse, ListMessagesRequest, ListMessagesResponse, + ListSkillsRequest, ListSkillsResponse, NeighboursRequest, NeighboursResponse, PageRef, + ResolveRequest, ResolveResponse, RunStoredQueryRequest, RunStoredQueryResponse, SearchHit, + SearchRequest, SearchResponse, Skill, StoredQueryColumn, UpdatePageRequest, UpdatePageResponse, + ValidateRequest, ValidateResponse, ValidationIssue, WikilinkParsed, }; // Re-exported so callers don't need to depend on `secrecy` directly // just to spell out a token. Keeping the version in sync with this @@ -200,6 +203,45 @@ impl Client { Ok(client.list_messages(self.authed(req)).await?.into_inner()) } + /// Dry-run the indexer's validation pipeline over draft `content` + /// without committing — returns the issue list the write path + /// would surface. See `protocol.md` §validate. + pub async fn validate(&self, req: ValidateRequest) -> Result { + let mut client = self.inner.clone(); + Ok(client.validate(self.authed(req)).await?.into_inner()) + } + + /// Append an event to the global inbox (M7 event sourcing). An + /// empty `event_id` lets the server mint a ULID; the returned + /// [`Event`] echoes the stored row, including its `status` + /// (`inbox`). + pub async fn capture_event(&self, req: CaptureEventRequest) -> Result { + let mut client = self.inner.clone(); + Ok(client.capture_event(self.authed(req)).await?.into_inner()) + } + + /// List unprocessed inbox events, newest first. `limit` of 0 means + /// no limit. + pub async fn list_inbox(&self, req: ListInboxRequest) -> Result { + let mut client = self.inner.clone(); + Ok(client.list_inbox(self.authed(req)).await?.into_inner()) + } + + /// List an instance's processed event history, oldest first. + pub async fn list_events(&self, req: ListEventsRequest) -> Result { + let mut client = self.inner.clone(); + Ok(client.list_events(self.authed(req)).await?.into_inner()) + } + + /// Bind an inbox event to an instance and mark it processed. + pub async fn assign_event( + &self, + req: AssignEventRequest, + ) -> Result { + let mut client = self.inner.clone(); + Ok(client.assign_event(self.authed(req)).await?.into_inner()) + } + /// Wrap a request body in a tonic `Request` with the bearer /// metadata attached. Cloning a `MetadataValue` is cheap /// (it's a bytes-backed handle). diff --git a/crates/escurel-client/tests/client_roundtrip.rs b/crates/escurel-client/tests/client_roundtrip.rs index 4515a00..c48d951 100644 --- a/crates/escurel-client/tests/client_roundtrip.rs +++ b/crates/escurel-client/tests/client_roundtrip.rs @@ -6,8 +6,9 @@ //! mocks at the boundary the test exercises (CLAUDE principle 2). use escurel_client::{ - AppendMessageRequest, Client, ExpandRequest, ListMessagesRequest, ListSkillsRequest, - ResolveRequest, SearchRequest, SecretString, UpdatePageRequest, + AppendMessageRequest, AssignEventRequest, CaptureEventRequest, Client, ExpandRequest, + ListEventsRequest, ListInboxRequest, ListMessagesRequest, ListSkillsRequest, ResolveRequest, + SearchRequest, SecretString, UpdatePageRequest, ValidateRequest, }; use escurel_test_support::{AuthMode, ConfigOverrides, EscurelProcess, FixtureBuilder, Opts, Role}; @@ -66,6 +67,20 @@ async fn authed_client(p: &EscurelProcess) -> Client { .unwrap() } +/// Resolve a `[[wikilink]]` to its concrete `page_id`. +async fn resolve_page_id(client: &Client, wikilink: &str) -> String { + client + .resolve(ResolveRequest { + wikilink: wikilink.to_owned(), + ..Default::default() + }) + .await + .unwrap() + .page + .expect("page present") + .page_id +} + #[tokio::test] async fn connect_succeeds_against_running_gateway() { let p = start().await; @@ -252,6 +267,132 @@ async fn append_then_list_messages_round_trip() { p.shutdown().await; } +/// `validate` dry-runs the indexer pipeline over draft content and +/// returns the same issue list the write path would surface — without +/// committing. A well-formed instance page validates clean. +#[tokio::test] +async fn validate_accepts_well_formed_page() { + let p = start().await; + let client = authed_client(&p).await; + let page_id = resolve_page_id(&client, "[[customer::acme]]").await; + let resp = client + .validate(ValidateRequest { + page_id, + content: ACME_INSTANCE.to_owned(), + }) + .await + .unwrap(); + assert!( + resp.ok, + "expected clean validation, issues: {:?}", + resp.issues + ); + p.shutdown().await; +} + +/// Realistic CRM flow exercising the whole M7 event quartet end to end: +/// capture an event (lands in the inbox) → see it in `list_inbox` → +/// `assign_event` it to the Acme customer instance → read it back from +/// that instance's processed history via `list_events`. +#[tokio::test] +async fn capture_inbox_assign_list_events_round_trip() { + let p = start().await; + let client = authed_client(&p).await; + let acme = resolve_page_id(&client, "[[customer::acme]]").await; + + let captured = client + .capture_event(CaptureEventRequest { + source: "manual".to_owned(), + mime: "text/plain".to_owned(), + label_skill: "note".to_owned(), + title: "Renewal call".to_owned(), + body: "Acme wants to renew the gold tier.".to_owned(), + ..Default::default() + }) + .await + .unwrap(); + assert!( + !captured.event_id.is_empty(), + "server should mint a ULID event_id" + ); + assert_eq!(captured.status, "inbox"); + let event_id = captured.event_id.clone(); + + // Unassigned event is visible in the global inbox. + let inbox = client + .list_inbox(ListInboxRequest { limit: 0 }) + .await + .unwrap(); + assert!( + inbox.events.iter().any(|e| e.event_id == event_id), + "captured event {event_id} not found in inbox" + ); + + // Assign it to the Acme instance; the ack echoes the binding. + let ack = client + .assign_event(AssignEventRequest { + event_id: event_id.clone(), + instance_page_id: acme.clone(), + }) + .await + .unwrap(); + assert_eq!(ack.event_id, event_id); + assert_eq!(ack.instance_page_id, acme); + + // It now appears in the instance's processed event history and has + // left the inbox. + let events = client + .list_events(ListEventsRequest { + instance_page_id: acme.clone(), + limit: 0, + }) + .await + .unwrap(); + let assigned = events + .events + .iter() + .find(|e| e.event_id == event_id) + .expect("assigned event in instance history"); + assert_eq!(assigned.status, "processed"); + assert_eq!(assigned.instance_page_id, acme); + + let inbox_after = client + .list_inbox(ListInboxRequest { limit: 0 }) + .await + .unwrap(); + assert!( + !inbox_after.events.iter().any(|e| e.event_id == event_id), + "assigned event should no longer be in the inbox" + ); + p.shutdown().await; +} + +/// `list_inbox` honours its `limit` cap. +#[tokio::test] +async fn list_inbox_respects_limit() { + let p = start().await; + let client = authed_client(&p).await; + for i in 0..3 { + client + .capture_event(CaptureEventRequest { + source: "manual".to_owned(), + mime: "text/plain".to_owned(), + label_skill: "note".to_owned(), + title: format!("evt-{i}"), + body: "body".to_owned(), + ..Default::default() + }) + .await + .unwrap(); + } + let limited = client + .list_inbox(ListInboxRequest { limit: 2 }) + .await + .unwrap(); + assert_eq!(limited.events.len(), 2, "limit=2 should cap the inbox read"); + p.shutdown().await; +} + #[tokio::test] async fn invalid_endpoint_url_returns_error() { // Not a URL at all — must surface as `InvalidEndpoint`, never From 26696dafc12b342caf079fb1603c4590016ecbfb Mon Sep 17 00:00:00 2001 From: Joachim Rosskopf Date: Sat, 30 May 2026 16:39:55 +0200 Subject: [PATCH 2/6] feat(client): admin + streaming RPCs (AdminClient, live_session, export/import) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the escurel-client surface beyond the agent read/write/event methods so the forthcoming CLI/TUI can drive the operator surface and streaming RPCs through the same typed, secret-safe path. AdminClient (new handle over EscurelAdminClient, mirrors Client's connect + SecretString custody + Debug-no-leak invariant): - health (no admin role required — substrate probe) - tenant_create / _list / _get / _update / _delete - audit, quota_get, delete_chat_history - attach_external, embedding_reload - rebuild, compact_lanes, tenant_export (server-stream) - tenant_import (client-stream) Client (agent surface): - live_session — bidi CRDT co-edit stream Streaming methods return tonic::Streaming (re-exported) and take impl futures_core::Stream inputs; futures-core (trait-only) is the one new runtime dep, keeping the crate a leaf. Test plan (no-mock, real gateway via escurel-test-support): admin_roundtrip.rs (6): health, tenant CRUD lifecycle (on-disk provisioning verified), audit clean-drift, quota_get budget, delete_chat_history GDPR flow, agent-role-rejected. streaming_roundtrip.rs (2): rebuild streams to done==total; tenant_export -> tenant_import round-trip (bytes + on-disk markdown). live_session has no client-layer e2e — a pure-gRPC client can't open a CRDT session (open_session is HTTP-MCP-only); wire behaviour is covered server-side in grpc_live_session.rs. Full client suite: 21 passed (13 agent + 6 admin + 2 streaming); fmt + clippy -D warnings clean. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 6 + crates/escurel-client/Cargo.toml | 16 +- crates/escurel-client/src/admin.rs | 226 +++++++++++++ crates/escurel-client/src/lib.rs | 43 ++- .../escurel-client/tests/admin_roundtrip.rs | 299 ++++++++++++++++++ .../tests/streaming_roundtrip.rs | 183 +++++++++++ 6 files changed, 768 insertions(+), 5 deletions(-) create mode 100644 crates/escurel-client/src/admin.rs create mode 100644 crates/escurel-client/tests/admin_roundtrip.rs create mode 100644 crates/escurel-client/tests/streaming_roundtrip.rs diff --git a/Cargo.lock b/Cargo.lock index fe3a344..74c3140 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2194,11 +2194,17 @@ dependencies = [ name = "escurel-client" version = "1.0.0" dependencies = [ + "escurel-admin", "escurel-proto", + "escurel-quota", "escurel-test-support", + "futures", + "futures-core", "secrecy", + "tempfile", "thiserror 2.0.18", "tokio", + "tokio-stream", "tonic 0.12.3", ] diff --git a/crates/escurel-client/Cargo.toml b/crates/escurel-client/Cargo.toml index 0f85831..e331424 100644 --- a/crates/escurel-client/Cargo.toml +++ b/crates/escurel-client/Cargo.toml @@ -11,6 +11,10 @@ homepage.workspace = true [dependencies] escurel-proto = { path = "../escurel-proto" } +# Trait-only crate providing the `Stream` trait the streaming RPCs +# (`live_session`, `tenant_import`) are generic over. No runtime — +# keeps the leaf-crate footprint tiny. +futures-core = "0.3" secrecy = "0.10" thiserror = "2" tokio = { version = "1", default-features = false, features = ["rt"] } @@ -21,8 +25,18 @@ tonic = { version = "0.12", default-features = false, features = [ ] } [dev-dependencies] +# Admin e2e needs a real TenantStore + quota config to drive the admin +# surface against a live gateway. These are dev-only — the published +# crate stays a leaf (escurel-proto + tonic + secrecy). +escurel-admin = { path = "../escurel-admin" } +escurel-quota = { path = "../escurel-quota" } escurel-test-support = { path = "../escurel-test-support" } -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +# Streaming e2e: build input streams (tokio_stream::iter) and drain +# output streams (StreamExt). +futures = "0.3" +tempfile = "3" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync"] } +tokio-stream = "0.1" tonic = { version = "0.12", default-features = false, features = [ "codegen", "prost", diff --git a/crates/escurel-client/src/admin.rs b/crates/escurel-client/src/admin.rs new file mode 100644 index 0000000..0278a60 --- /dev/null +++ b/crates/escurel-client/src/admin.rs @@ -0,0 +1,226 @@ +//! Typed gRPC client for the Escurel **admin** surface +//! (`escurel.v1.EscurelAdmin`). +//! +//! This is the operator counterpart to [`crate::Client`]: tenant +//! lifecycle, drift audit, quota inspection, and chat-history erasure. +//! It is a separate handle wrapping the generated +//! [`EscurelAdminClient`] because the admin service is a distinct gRPC +//! service from the agent surface — but the connect + secret-custody +//! pattern is identical to [`crate::Client`]. +//! +//! Every RPC except [`AdminClient::health`] requires an **admin-role** +//! bearer token; the server rejects an agent-role token with +//! `PermissionDenied` (surfaced here as [`Error::Rpc`]). `health` is +//! the substrate liveness probe and works for any caller (and +//! unauthenticated in dev mode). +//! +//! The streaming admin RPCs (`tenant_export` / `tenant_import` / +//! `rebuild` / `compact_lanes`) are intentionally **not** on this +//! handle yet — they land with the streaming surface in a follow-up. + +use escurel_proto::v1::escurel_admin_client::EscurelAdminClient; +use secrecy::{ExposeSecret as _, SecretString}; +use tonic::metadata::{Ascii, MetadataValue}; +use tonic::transport::Channel; + +use crate::Error; + +pub use escurel_proto::v1::{ + AttachExternalRequest, AttachExternalResponse, AuditRequest, AuditResponse, + CompactLanesRequest, CompactProgress, DeleteChatHistoryRequest, DeleteChatHistoryResponse, + EmbeddingReloadRequest, EmbeddingReloadResponse, HealthRequest, HealthResponse, + QuotaGetRequest, QuotaGetResponse, RebuildProgress, RebuildRequest, TenantCreateRequest, + TenantCreateResponse, TenantDeleteRequest, TenantDeleteResponse, TenantExportChunk, + TenantExportRequest, TenantGetRequest, TenantGetResponse, TenantImportChunk, + TenantImportResponse, TenantListRequest, TenantListResponse, TenantSpec, TenantUpdateRequest, + TenantUpdateResponse, +}; + +/// Typed gRPC client for the Escurel v1 **admin** surface. +/// +/// Opaque on purpose, exactly like [`crate::Client`]: the channel and +/// the bearer token are private; the bearer lives inside a +/// [`SecretString`] and is never returned by an accessor nor printed +/// in `Debug` output. +#[derive(Clone)] +pub struct AdminClient { + inner: EscurelAdminClient, + bearer: MetadataValue, + _token: SecretString, +} + +impl std::fmt::Debug for AdminClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Never print `bearer` / `_token` — same leak invariant as + // `crate::Client`. + f.debug_struct("AdminClient").finish_non_exhaustive() + } +} + +impl AdminClient { + /// Dial the gateway at `endpoint` (e.g. `http://127.0.0.1:8081`) + /// and authenticate subsequent admin RPCs with `token`. + /// + /// Errors mirror [`crate::Client::connect`]: + /// [`Error::InvalidEndpoint`], [`Error::InvalidToken`], + /// [`Error::Connect`]. + pub async fn connect(endpoint: &str, token: SecretString) -> Result { + let bearer: MetadataValue = format!("Bearer {}", token.expose_secret()) + .parse() + .map_err(|_| Error::InvalidToken)?; + let channel = Channel::from_shared(endpoint.to_owned()) + .map_err(|_| Error::InvalidEndpoint(endpoint.to_owned()))? + .connect() + .await + .map_err(Error::Connect)?; + Ok(Self { + inner: EscurelAdminClient::new(channel), + bearer, + _token: token, + }) + } + + /// Substrate liveness probe — returns the gateway version. Works + /// for any authenticated caller (admin role not required). + pub async fn health(&self, req: HealthRequest) -> Result { + let mut client = self.inner.clone(); + Ok(client.health(self.authed(req)).await?.into_inner()) + } + + /// Create a tenant (provisions its directory + DuckDB file). + pub async fn tenant_create( + &self, + req: TenantCreateRequest, + ) -> Result { + let mut client = self.inner.clone(); + Ok(client.tenant_create(self.authed(req)).await?.into_inner()) + } + + /// List all tenants. + pub async fn tenant_list(&self, req: TenantListRequest) -> Result { + let mut client = self.inner.clone(); + Ok(client.tenant_list(self.authed(req)).await?.into_inner()) + } + + /// Fetch one tenant's spec. + pub async fn tenant_get(&self, req: TenantGetRequest) -> Result { + let mut client = self.inner.clone(); + Ok(client.tenant_get(self.authed(req)).await?.into_inner()) + } + + /// Update a tenant's spec (e.g. its display name). + pub async fn tenant_update( + &self, + req: TenantUpdateRequest, + ) -> Result { + let mut client = self.inner.clone(); + Ok(client.tenant_update(self.authed(req)).await?.into_inner()) + } + + /// Delete a tenant and its on-disk state. + pub async fn tenant_delete( + &self, + req: TenantDeleteRequest, + ) -> Result { + let mut client = self.inner.clone(); + Ok(client.tenant_delete(self.authed(req)).await?.into_inner()) + } + + /// Report drift between canonical markdown and the DuckDB index. + pub async fn audit(&self, req: AuditRequest) -> Result { + let mut client = self.inner.clone(); + Ok(client.audit(self.authed(req)).await?.into_inner()) + } + + /// Snapshot a tenant's remaining quota budget. + pub async fn quota_get(&self, req: QuotaGetRequest) -> Result { + let mut client = self.inner.clone(); + Ok(client.quota_get(self.authed(req)).await?.into_inner()) + } + + /// GDPR erasure / retention prune of chat history. The + /// `chat_group_id`, `before_ts` and `author` filters compose with + /// AND; all empty means a full-tenant wipe. + pub async fn delete_chat_history( + &self, + req: DeleteChatHistoryRequest, + ) -> Result { + let mut client = self.inner.clone(); + Ok(client + .delete_chat_history(self.authed(req)) + .await? + .into_inner()) + } + + /// Attach an external read-only source to a tenant. + pub async fn attach_external( + &self, + req: AttachExternalRequest, + ) -> Result { + let mut client = self.inner.clone(); + Ok(client.attach_external(self.authed(req)).await?.into_inner()) + } + + /// Hot-reload the embedding model. + pub async fn embedding_reload( + &self, + req: EmbeddingReloadRequest, + ) -> Result { + let mut client = self.inner.clone(); + Ok(client + .embedding_reload(self.authed(req)) + .await? + .into_inner()) + } + + /// Rebuild a tenant's index, streaming one [`RebuildProgress`] per + /// page (terminator chunk has `done == total`). Server-streaming. + pub async fn rebuild( + &self, + req: RebuildRequest, + ) -> Result, Error> { + let mut client = self.inner.clone(); + Ok(client.rebuild(self.authed(req)).await?.into_inner()) + } + + /// Compact a tenant's CRDT op lanes, streaming a [`CompactProgress`] + /// per page swept. Server-streaming; requires a CRDT backend. + pub async fn compact_lanes( + &self, + req: CompactLanesRequest, + ) -> Result, Error> { + let mut client = self.inner.clone(); + Ok(client.compact_lanes(self.authed(req)).await?.into_inner()) + } + + /// Export a tenant as a stream of tar+gz [`TenantExportChunk`]s. + /// Server-streaming. + pub async fn tenant_export( + &self, + req: TenantExportRequest, + ) -> Result, Error> { + let mut client = self.inner.clone(); + Ok(client.tenant_export(self.authed(req)).await?.into_inner()) + } + + /// Import a tenant from a stream of tar+gz [`TenantImportChunk`]s. + /// The first chunk must carry the target `tenant_id`. Client- + /// streaming; returns the byte count once the stream is drained. + pub async fn tenant_import(&self, chunks: S) -> Result + where + S: futures_core::Stream + Send + 'static, + { + let mut client = self.inner.clone(); + Ok(client + .tenant_import(self.authed(chunks)) + .await? + .into_inner()) + } + + fn authed(&self, body: T) -> tonic::Request { + let mut req = tonic::Request::new(body); + req.metadata_mut() + .insert("authorization", self.bearer.clone()); + req + } +} diff --git a/crates/escurel-client/src/lib.rs b/crates/escurel-client/src/lib.rs index 835d6e1..0ffcb16 100644 --- a/crates/escurel-client/src/lib.rs +++ b/crates/escurel-client/src/lib.rs @@ -38,8 +38,22 @@ //! # } //! ``` +mod admin; mod error; +pub use admin::AdminClient; +// Admin-surface request/response types, re-exported so operators never +// pin `escurel-proto` directly (parallels the agent re-exports below). +pub use admin::{ + AttachExternalRequest, AttachExternalResponse, AuditRequest, AuditResponse, + CompactLanesRequest, CompactProgress, DeleteChatHistoryRequest, DeleteChatHistoryResponse, + EmbeddingReloadRequest, EmbeddingReloadResponse, HealthRequest, HealthResponse, + QuotaGetRequest, QuotaGetResponse, RebuildProgress, RebuildRequest, TenantCreateRequest, + TenantCreateResponse, TenantDeleteRequest, TenantDeleteResponse, TenantExportChunk, + TenantExportRequest, TenantGetRequest, TenantGetResponse, TenantImportChunk, + TenantImportResponse, TenantListRequest, TenantListResponse, TenantSpec, TenantUpdateRequest, + TenantUpdateResponse, +}; pub use error::Error; // Re-export the request/response types the downstream caller needs @@ -52,11 +66,16 @@ pub use escurel_proto::v1::{ CaptureEventRequest, ChatMessage, Edge, Event, ExpandBlock, ExpandRequest, ExpandResponse, InstanceInfo, ListEventsRequest, ListEventsResponse, ListInboxRequest, ListInboxResponse, ListInstancesRequest, ListInstancesResponse, ListMessagesRequest, ListMessagesResponse, - ListSkillsRequest, ListSkillsResponse, NeighboursRequest, NeighboursResponse, PageRef, - ResolveRequest, ResolveResponse, RunStoredQueryRequest, RunStoredQueryResponse, SearchHit, - SearchRequest, SearchResponse, Skill, StoredQueryColumn, UpdatePageRequest, UpdatePageResponse, - ValidateRequest, ValidateResponse, ValidationIssue, WikilinkParsed, + ListSkillsRequest, ListSkillsResponse, LiveAck, LiveOp, NeighboursRequest, NeighboursResponse, + PageRef, ResolveRequest, ResolveResponse, RunStoredQueryRequest, RunStoredQueryResponse, + SearchHit, SearchRequest, SearchResponse, Skill, StoredQueryColumn, UpdatePageRequest, + UpdatePageResponse, ValidateRequest, ValidateResponse, ValidationIssue, WikilinkParsed, }; +// `tonic::Streaming` is part of the public signature of the +// streaming RPCs (`live_session` plus the admin streams). Re-export it +// so callers consume the stream without pinning `tonic` themselves — +// same rationale as re-exporting `SecretString`. +pub use tonic::Streaming; // Re-exported so callers don't need to depend on `secrecy` directly // just to spell out a token. Keeping the version in sync with this // crate's `Cargo.toml` is part of the semver contract. @@ -242,6 +261,22 @@ impl Client { Ok(client.assign_event(self.authed(req)).await?.into_inner()) } + /// Open a live CRDT co-editing session (bidi stream). The caller + /// drives `ops`: the **first** [`LiveOp`] attaches the session by + /// `page_id` (empty `op_b64`); later frames carry base64 Loro op + /// blobs. The returned [`Streaming`] yields one [`LiveAck`] per + /// frame — the attach ack carries the `session_id`. + /// + /// This is the wire path the demo's live-edit surface speaks; the + /// gateway must have a CRDT backend wired or the stream is rejected. + pub async fn live_session(&self, ops: S) -> Result, Error> + where + S: futures_core::Stream + Send + 'static, + { + let mut client = self.inner.clone(); + Ok(client.live_session(self.authed(ops)).await?.into_inner()) + } + /// Wrap a request body in a tonic `Request` with the bearer /// metadata attached. Cloning a `MetadataValue` is cheap /// (it's a bytes-backed handle). diff --git a/crates/escurel-client/tests/admin_roundtrip.rs b/crates/escurel-client/tests/admin_roundtrip.rs new file mode 100644 index 0000000..bdd4a25 --- /dev/null +++ b/crates/escurel-client/tests/admin_roundtrip.rs @@ -0,0 +1,299 @@ +//! End-to-end tests for the `escurel-client` **admin** wrapper +//! ([`AdminClient`]). +//! +//! Real gateway via `escurel-test-support`, real tonic transport, +//! real `OidcVerifier`, real tempdir-backed `FsTenantStore`, real +//! `Indexer` with a real DuckDB file. No mocks at the boundary the +//! test exercises (CLAUDE principle 2). Each test drives the +//! production admin code path verbatim through the typed client an +//! operator (CLI / dashboard) would use. + +use std::path::PathBuf; +use std::sync::Arc; + +use escurel_admin::{FsTenantStore, TenantStore}; +use escurel_client::{ + AdminClient, AppendMessageRequest, AuditRequest, Client, DeleteChatHistoryRequest, + HealthRequest, ListMessagesRequest, QuotaGetRequest, SecretString, TenantCreateRequest, + TenantDeleteRequest, TenantGetRequest, TenantListRequest, TenantSpec, TenantUpdateRequest, +}; +use escurel_quota::{QuotaConfig, QuotaManager}; +use escurel_test_support::{AuthMode, ConfigOverrides, EscurelProcess, FixtureBuilder, Opts, Role}; +use tempfile::TempDir; + +const TENANT: &str = "acme"; + +const CUSTOMER_SKILL: &str = "---\ntype: skill\nid: customer\ndescription: x\n---\n# customer\n"; + +/// The per-minute budget the quota-wired gateway starts with. A fresh +/// tenant that has made no agent calls reports exactly these as +/// remaining. +const QUOTA_QUERIES: u32 = 100; +const QUOTA_WRITES: u32 = 50; +const QUOTA_EMBEDS: u32 = 25; + +struct Harness { + process: EscurelProcess, + tenants_root: PathBuf, + _tenants_dir: TempDir, +} + +/// Spawn a gateway with a real tempdir-backed `FsTenantStore` so the +/// tenant-CRUD + audit RPCs are fully wired. +async fn start_with_store() -> Harness { + let tenants_dir = TempDir::new().unwrap(); + let tenants_root = tenants_dir.path().to_path_buf(); + let tenant_store: Arc = Arc::new(FsTenantStore::new(tenants_root.clone())); + let process = EscurelProcess::spawn(Opts { + auth: AuthMode::TestIssuer, + fixtures: Some( + FixtureBuilder::new() + .tenant(TENANT) + .skill("customer", CUSTOMER_SKILL) + .done(), + ), + config_overrides: ConfigOverrides { + gateway_version: Some("1.0.0-test".to_owned()), + tenant_store: Some(tenant_store), + ..Default::default() + }, + }) + .await; + Harness { + process, + tenants_root, + _tenants_dir: tenants_dir, + } +} + +/// Spawn a gateway with quota enforcement wired so `quota_get` returns +/// a meaningful snapshot. +async fn start_with_quota() -> EscurelProcess { + let quota = Arc::new(QuotaManager::new(QuotaConfig { + queries_per_minute: QUOTA_QUERIES, + writes_per_minute: QUOTA_WRITES, + embeds_per_minute: QUOTA_EMBEDS, + concurrent_sessions: 4, + })); + EscurelProcess::spawn(Opts { + auth: AuthMode::TestIssuer, + fixtures: Some( + FixtureBuilder::new() + .tenant(TENANT) + .skill("customer", CUSTOMER_SKILL) + .done(), + ), + config_overrides: ConfigOverrides { + quota: Some(quota), + ..Default::default() + }, + }) + .await +} + +async fn admin_client(p: &EscurelProcess) -> AdminClient { + let endpoint = p.grpc_endpoint().expect("grpc endpoint").to_owned(); + let token = p.mint_token(TENANT, Role::Admin); + AdminClient::connect(&endpoint, SecretString::from(token)) + .await + .unwrap() +} + +async fn agent_admin_client(p: &EscurelProcess) -> AdminClient { + let endpoint = p.grpc_endpoint().expect("grpc endpoint").to_owned(); + let token = p.mint_token(TENANT, Role::Agent); + AdminClient::connect(&endpoint, SecretString::from(token)) + .await + .unwrap() +} + +async fn agent_client(p: &EscurelProcess) -> Client { + let endpoint = p.grpc_endpoint().expect("grpc endpoint").to_owned(); + let token = p.mint_token(TENANT, Role::Agent); + Client::connect(&endpoint, SecretString::from(token)) + .await + .unwrap() +} + +fn spec(id: &str, name: &str) -> TenantSpec { + TenantSpec { + tenant_id: id.to_owned(), + display_name: name.to_owned(), + } +} + +// --- tests --------------------------------------------------------- + +#[tokio::test] +async fn health_returns_configured_version() { + let h = start_with_store().await; + let client = admin_client(&h.process).await; + let resp = client.health(HealthRequest::default()).await.unwrap(); + assert_eq!(resp.version, "1.0.0-test"); + assert!(!resp.status.is_empty()); + h.process.shutdown().await; +} + +/// Full operator lifecycle through the typed client: create → get → +/// list → update → delete, with on-disk provisioning verified. +#[tokio::test] +async fn tenant_crud_lifecycle_round_trips() { + let h = start_with_store().await; + let client = admin_client(&h.process).await; + + let created = client + .tenant_create(TenantCreateRequest { + spec: Some(spec("globex", "Globex Corp")), + }) + .await + .unwrap(); + assert_eq!(created.spec.as_ref().unwrap().tenant_id, "globex"); + assert!(h.tenants_root.join("globex").join("tenant.json").is_file()); + assert!( + h.tenants_root + .join("globex") + .join("db") + .join("escurel.duckdb") + .is_file() + ); + + let got = client + .tenant_get(TenantGetRequest { + tenant_id: "globex".to_owned(), + }) + .await + .unwrap(); + assert_eq!(got.spec.unwrap().display_name, "Globex Corp"); + + let listed = client + .tenant_list(TenantListRequest::default()) + .await + .unwrap(); + assert!(listed.tenants.iter().any(|t| t.tenant_id == "globex")); + + let updated = client + .tenant_update(TenantUpdateRequest { + spec: Some(spec("globex", "Globex Renamed")), + }) + .await + .unwrap(); + assert_eq!(updated.spec.unwrap().display_name, "Globex Renamed"); + + let deleted = client + .tenant_delete(TenantDeleteRequest { + tenant_id: "globex".to_owned(), + }) + .await + .unwrap(); + assert!(deleted.deleted); + assert!(!h.tenants_root.join("globex").exists()); + + h.process.shutdown().await; +} + +#[tokio::test] +async fn audit_reports_clean_drift_for_seeded_tenant() { + let h = start_with_store().await; + let client = admin_client(&h.process).await; + let resp = client + .audit(AuditRequest { + tenant_id: TENANT.to_owned(), + scope: String::new(), + }) + .await + .unwrap(); + assert!(resp.markdown_not_in_duckdb.is_empty()); + assert!(resp.indexed_but_no_markdown.is_empty()); + h.process.shutdown().await; +} + +#[tokio::test] +async fn quota_get_snapshots_remaining_budget() { + let p = start_with_quota().await; + let client = admin_client(&p).await; + let snap = client + .quota_get(QuotaGetRequest { + tenant_id: TENANT.to_owned(), + }) + .await + .unwrap(); + // Fresh tenant, no agent calls made: full configured budget. + assert_eq!(snap.queries_remaining, QUOTA_QUERIES); + assert_eq!(snap.writes_remaining, QUOTA_WRITES); + assert_eq!(snap.embeds_remaining, QUOTA_EMBEDS); + p.shutdown().await; +} + +/// Realistic GDPR flow: an agent writes chat history, the operator +/// erases that group through the admin client, and it's gone. +#[tokio::test] +async fn delete_chat_history_erases_a_group() { + let p = start_with_quota().await; + let agent = agent_client(&p).await; + let admin = admin_client(&p).await; + + for content in ["first", "second"] { + agent + .append_message(AppendMessageRequest { + chat_group_id: "room-gdpr".to_owned(), + role: "user".to_owned(), + content: content.to_owned(), + author: "alice".to_owned(), + ts: String::new(), + metadata_json: String::new(), + msg_id: String::new(), + embed: false, + }) + .await + .unwrap(); + } + + let resp = admin + .delete_chat_history(DeleteChatHistoryRequest { + tenant_id: TENANT.to_owned(), + chat_group_id: "room-gdpr".to_owned(), + before_ts: String::new(), + author: String::new(), + }) + .await + .unwrap(); + assert_eq!(resp.deleted, 2, "both messages should be erased"); + + let after = agent + .list_messages(ListMessagesRequest { + chat_group_id: "room-gdpr".to_owned(), + since: String::new(), + until: String::new(), + limit: 0, + cursor: String::new(), + direction: "asc".to_owned(), + }) + .await + .unwrap(); + assert!( + after.messages.is_empty(), + "group should be empty post-erase" + ); + p.shutdown().await; +} + +#[tokio::test] +async fn agent_role_is_rejected_on_admin_rpc() { + let h = start_with_store().await; + let client = agent_admin_client(&h.process).await; + let err = client + .tenant_list(TenantListRequest::default()) + .await + .unwrap_err(); + match err { + escurel_client::Error::Rpc(status) => { + assert_eq!( + status.code(), + tonic::Code::PermissionDenied, + "status: {status:?}" + ); + } + other => panic!("expected Error::Rpc(PermissionDenied), got {other:?}"), + } + h.process.shutdown().await; +} diff --git a/crates/escurel-client/tests/streaming_roundtrip.rs b/crates/escurel-client/tests/streaming_roundtrip.rs new file mode 100644 index 0000000..60d4b04 --- /dev/null +++ b/crates/escurel-client/tests/streaming_roundtrip.rs @@ -0,0 +1,183 @@ +//! End-to-end tests for the `escurel-client` **admin streaming** +//! surface: `rebuild` (server-stream) and `tenant_export` / +//! `tenant_import` (server- and client-stream). +//! +//! Real gateway via `escurel-test-support`, real tonic transport, +//! real `OidcVerifier`, real tempdir-backed `FsTenantStore`, real +//! `Indexer` over a real DuckDB file. No mocks at the boundary the +//! test exercises (CLAUDE principle 2). +//! +//! `Client::live_session` (the agent bidi stream) is intentionally not +//! e2e-tested here: a pure-gRPC client cannot *open* a CRDT session — +//! `open_session` is an HTTP-MCP-only tool — so the bidi stream can +//! only ever *attach* to a session opened over HTTP. The bidi wire +//! behaviour is covered at the server layer in +//! `escurel-server/tests/grpc_live_session.rs`; the client wrapper is a +//! thin passthrough over the same generated stub the admin streams use +//! (exercised here), so it shares their transport coverage. + +use std::path::PathBuf; +use std::sync::Arc; + +use escurel_admin::{FsTenantStore, TenantSpec as AdminTenantSpec, TenantStore}; +use escurel_client::{ + AdminClient, RebuildRequest, SecretString, TenantCreateRequest, TenantExportRequest, + TenantImportChunk, TenantSpec, +}; +use escurel_test_support::{AuthMode, ConfigOverrides, EscurelProcess, FixtureBuilder, Opts, Role}; +use futures::StreamExt; +use tempfile::TempDir; + +const TENANT: &str = "acme"; + +const PAGES: &[(&str, &str)] = &[ + ( + "markdown/skills/customer.md", + "---\ntype: skill\nid: customer\ndescription: x\n---\n# customer\n", + ), + ( + "markdown/instances/customer/acme.md", + "---\ntype: instance\nskill: customer\nid: acme\n---\n# Acme\n", + ), +]; + +struct AdminHarness { + process: EscurelProcess, + tenants_root: PathBuf, + _tenants_dir: TempDir, +} + +/// Mirror the seeded markdown into `//markdown/...` so +/// the `tenant_export` RPC (which walks the on-disk tree) has bytes. +async fn mirror_to_tenants_root(tenants_root: &std::path::Path) { + let md_root = tenants_root.join(TENANT).join("markdown"); + for (path, body) in PAGES { + let abs = md_root.join(path.strip_prefix("markdown/").unwrap()); + if let Some(parent) = abs.parent() { + tokio::fs::create_dir_all(parent).await.unwrap(); + } + tokio::fs::write(&abs, body).await.unwrap(); + } +} + +async fn start_admin() -> AdminHarness { + let tenants_dir = TempDir::new().unwrap(); + let tenants_root = tenants_dir.path().to_path_buf(); + let tenant_store: Arc = Arc::new(FsTenantStore::new(tenants_root.clone())); + tenant_store + .create(&AdminTenantSpec { + tenant_id: TENANT.to_owned(), + display_name: "Acme".to_owned(), + }) + .await + .unwrap(); + mirror_to_tenants_root(&tenants_root).await; + + let mut fixtures = FixtureBuilder::new().tenant(TENANT); + for (path, body) in PAGES { + fixtures = fixtures.page(path, *body); + } + let process = EscurelProcess::spawn(Opts { + auth: AuthMode::TestIssuer, + fixtures: Some(fixtures.done()), + config_overrides: ConfigOverrides { + gateway_version: Some("1.0.0-test".to_owned()), + tenant_store: Some(tenant_store), + ..Default::default() + }, + }) + .await; + AdminHarness { + process, + tenants_root, + _tenants_dir: tenants_dir, + } +} + +async fn admin_client(p: &EscurelProcess) -> AdminClient { + let endpoint = p.grpc_endpoint().expect("grpc endpoint").to_owned(); + let token = p.mint_token(TENANT, Role::Admin); + AdminClient::connect(&endpoint, SecretString::from(token)) + .await + .unwrap() +} + +/// `rebuild` streams progress chunks and the terminator has +/// `done == total` with a non-zero total. +#[tokio::test] +async fn rebuild_streams_progress_to_completion() { + let h = start_admin().await; + let client = admin_client(&h.process).await; + let mut stream = client + .rebuild(RebuildRequest { + tenant_id: TENANT.to_owned(), + scope: String::new(), + }) + .await + .unwrap(); + let mut last = None; + while let Some(msg) = stream.next().await { + last = Some(msg.expect("progress chunk ok")); + } + let last = last.expect("at least one progress chunk"); + assert!(last.total > 0, "rebuild should report a page total"); + assert_eq!(last.done, last.total, "terminator: done == total"); + h.process.shutdown().await; +} + +/// Realistic operator backup/restore: export the tenant to a tar+gz +/// byte stream through the typed client, then stream those bytes back +/// into a freshly-created tenant via `tenant_import`. +#[tokio::test] +async fn tenant_export_then_import_round_trips() { + let h = start_admin().await; + let client = admin_client(&h.process).await; + + // Drain the export stream into one buffer. + let mut export = client + .tenant_export(TenantExportRequest { + tenant_id: TENANT.to_owned(), + }) + .await + .unwrap(); + let mut bytes = Vec::new(); + while let Some(chunk) = export.next().await { + bytes.extend_from_slice(&chunk.expect("export chunk ok").data); + } + assert!(!bytes.is_empty(), "export should produce tarball bytes"); + + // Create a destination tenant on disk so import has somewhere to + // land (mirrors the server's existence check). + client + .tenant_create(TenantCreateRequest { + spec: Some(TenantSpec { + tenant_id: "globex".to_owned(), + display_name: "Globex".to_owned(), + }), + }) + .await + .unwrap(); + + // Stream the bytes back in. The first chunk carries the target id. + let chunks = tokio_stream::iter(vec![TenantImportChunk { + tenant_id: "globex".to_owned(), + data: bytes.clone(), + }]); + let resp = client.tenant_import(chunks).await.unwrap(); + assert_eq!( + resp.bytes_imported as usize, + bytes.len(), + "import should account for every exported byte" + ); + // The imported markdown is now under the destination tenant. + assert!( + h.tenants_root + .join("globex") + .join("markdown") + .join("skills") + .join("customer.md") + .is_file(), + "imported tree should contain the exported markdown" + ); + h.process.shutdown().await; +} From 0d0e8989b7a1cb27f9e34f8ebf1f12cd12565c66 Mon Sep 17 00:00:00 2001 From: Joachim Rosskopf Date: Sat, 30 May 2026 17:23:50 +0200 Subject: [PATCH 3/6] feat(cli): rebuild escurel CLI on escurel-client with gh-style commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-rolled tonic CLI with a thin presentation layer over escurel-client (one typed boundary; adding an RPC means editing the client once). Restructure into gh/aws-style noun-verb groups for discoverability by humans and LLM agents: escurel skill list escurel instance list --skill [--order-by-at --limit] escurel resolve escurel page expand|validate|update escurel link neighbours [--direction --link-skill --limit] escurel search [--k --page-type --skill] escurel event capture|inbox|list|assign (M7 CRM event triad) escurel query run --params '{…}' escurel chat append|list escurel admin health|tenant|audit|quota|delete-chat-history |attach-external|embedding-reload|rebuild|compact-lanes |tenant export|import New: every previously-missing capability now has a command — the event quartet + page validate (agent surface) and the entire admin surface incl. the streaming RPCs (rebuild/compact stream progress; tenant export/import stream bytes to/from a file). This is full CLI parity with the Flutter demo's backend reach. Output: global --format json|table (JSON default — the stable contract for scripts/agents; generic table renderer for humans). Errors emit JSON on stderr with a non-zero exit so callers can branch on them. Layout: src/{main,agent,admin,convert,output}.rs. main dispatches the admin group to AdminClient and everything else to Client. Test plan (no-mock, real gateway via escurel-test-support; every command + its common switches, both --format modes, stdin paths, auth on/off): tests/cli_e2e.rs (14) — agent surface incl. the realistic CRM flow capture → inbox → assign → list, and the JSON-on-stderr error. tests/cli_admin_e2e.rs (6) — health, tenant CRUD lifecycle, audit, quota, rebuild progress stream, tenant export→import round-trip, agent-role rejection. 20 passed; fmt + clippy -D warnings clean. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 9 +- crates/escurel-cli/Cargo.toml | 21 +- crates/escurel-cli/src/admin.rs | 283 +++++++++++ crates/escurel-cli/src/agent.rs | 541 +++++++++++++++++++++ crates/escurel-cli/src/convert.rs | 52 ++ crates/escurel-cli/src/main.rs | 562 ++++------------------ crates/escurel-cli/src/output.rs | 114 +++++ crates/escurel-cli/tests/cli_admin_e2e.rs | 256 ++++++++++ crates/escurel-cli/tests/cli_e2e.rs | 494 ++++++++++++------- 9 files changed, 1680 insertions(+), 652 deletions(-) create mode 100644 crates/escurel-cli/src/admin.rs create mode 100644 crates/escurel-cli/src/agent.rs create mode 100644 crates/escurel-cli/src/convert.rs create mode 100644 crates/escurel-cli/src/output.rs create mode 100644 crates/escurel-cli/tests/cli_admin_e2e.rs diff --git a/Cargo.lock b/Cargo.lock index 74c3140..0123323 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2183,11 +2183,15 @@ dependencies = [ "anyhow", "assert_cmd", "clap", - "escurel-proto", + "escurel-admin", + "escurel-client", + "escurel-quota", "escurel-test-support", + "futures", "serde_json", + "tempfile", "tokio", - "tonic 0.12.3", + "tokio-stream", ] [[package]] @@ -5617,6 +5621,7 @@ version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ + "indexmap 2.14.0", "itoa", "memchr", "serde", diff --git a/crates/escurel-cli/Cargo.toml b/crates/escurel-cli/Cargo.toml index 27ef77f..3d7ca86 100644 --- a/crates/escurel-cli/Cargo.toml +++ b/crates/escurel-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "escurel-cli" -description = "Operator + agent-style CLI for the Escurel gateway (thin gRPC client)." +description = "Operator + agent-style CLI for the Escurel gateway, built on escurel-client." edition.workspace = true rust-version.workspace = true version.workspace = true @@ -16,15 +16,26 @@ path = "src/main.rs" [dependencies] anyhow = "1" clap = { version = "4", features = ["derive", "env"] } -escurel-proto = { path = "../escurel-proto" } -serde_json = "1" -tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "macros", "net", "io-std", "io-util", "sync", "time"] } -tonic = { version = "0.12", default-features = false, features = ["codegen", "prost", "transport"] } +# Single typed gateway abstraction. The CLI is pure presentation on top +# of it — no hand-rolled tonic, no direct escurel-proto dependency. +escurel-client = { path = "../escurel-client" } +# preserve_order keeps emitted JSON / table columns in the order the +# command authored them, not alphabetised. +serde_json = { version = "1", features = ["preserve_order"] } +tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "macros", "io-std", "io-util"] } +# Draining server-streams (rebuild / compact / export) + building the +# import input stream for the admin streaming commands. +futures = "0.3" +tokio-stream = "0.1" [dev-dependencies] assert_cmd = "2" +# Admin e2e spawns a gateway with a real TenantStore + quota wired. +escurel-admin = { path = "../escurel-admin" } +escurel-quota = { path = "../escurel-quota" } escurel-test-support = { path = "../escurel-test-support" } serde_json = "1" +tempfile = "3" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } [lints] diff --git a/crates/escurel-cli/src/admin.rs b/crates/escurel-cli/src/admin.rs new file mode 100644 index 0000000..b27a8c7 --- /dev/null +++ b/crates/escurel-cli/src/admin.rs @@ -0,0 +1,283 @@ +//! Operator-surface commands (the `EscurelAdmin` service). All require +//! an admin-role bearer except `health`. Streaming RPCs (`rebuild`, +//! `compact-lanes`, `tenant export`) drain to a collected JSON array; +//! `tenant import` streams a file's bytes up in one chunk. + +use anyhow::{Context, Result}; +use clap::{Args, Subcommand}; +use escurel_client::{ + AdminClient, AttachExternalRequest, AuditRequest, CompactLanesRequest, + DeleteChatHistoryRequest, EmbeddingReloadRequest, HealthRequest, QuotaGetRequest, + RebuildRequest, TenantCreateRequest, TenantDeleteRequest, TenantExportRequest, + TenantGetRequest, TenantImportChunk, TenantListRequest, TenantSpec, TenantUpdateRequest, +}; +use futures::StreamExt as _; +use serde_json::{Value, json}; + +use crate::convert::opt; + +#[derive(Subcommand, Debug)] +pub enum AdminCmd { + /// Substrate liveness probe + gateway version (no admin role). + Health, + /// Tenant lifecycle. + #[command(subcommand)] + Tenant(TenantCmd), + /// Markdown ↔ DuckDB drift report. + Audit { + #[arg(long)] + tenant: String, + #[arg(long, default_value = "")] + scope: String, + }, + /// Per-tenant remaining quota snapshot. + Quota { + #[arg(long)] + tenant: String, + }, + /// GDPR erasure / retention prune of chat history. Empty filters + /// compose with AND; all empty = full-tenant wipe. + DeleteChatHistory(DeleteChatArgs), + /// Attach an external read-only source to a tenant. + AttachExternal { + #[arg(long)] + tenant: String, + #[arg(long)] + source_url: String, + }, + /// Hot-reload the embedding model. + EmbeddingReload, + /// Rebuild a tenant's index (streams progress). + Rebuild { + #[arg(long)] + tenant: String, + #[arg(long, default_value = "")] + scope: String, + }, + /// Compact a tenant's CRDT op lanes (streams progress). + CompactLanes { + #[arg(long)] + tenant: String, + }, +} + +#[derive(Subcommand, Debug)] +pub enum TenantCmd { + Create { + #[arg(long)] + id: String, + #[arg(long, default_value = "")] + name: String, + }, + List, + Get { + #[arg(long)] + id: String, + }, + Update { + #[arg(long)] + id: String, + #[arg(long)] + name: String, + }, + Delete { + #[arg(long)] + id: String, + }, + /// Export a tenant as a tar+gz stream to a file. + Export { + #[arg(long)] + id: String, + /// Output file path for the tarball bytes. + #[arg(long)] + out: String, + }, + /// Import a tenant from a tar+gz file. + Import { + #[arg(long)] + id: String, + /// Input tarball file path. + #[arg(long, name = "in")] + input: String, + }, +} + +#[derive(Args, Debug)] +pub struct DeleteChatArgs { + #[arg(long)] + pub tenant: String, + #[arg(long)] + pub group: Option, + #[arg(long)] + pub before_ts: Option, + #[arg(long)] + pub author: Option, +} + +fn tenant_json(s: Option) -> Value { + match s { + Some(s) => json!({ "tenant_id": s.tenant_id, "display_name": opt(&s.display_name) }), + None => Value::Null, + } +} + +pub async fn run(client: &AdminClient, cmd: AdminCmd) -> Result { + match cmd { + AdminCmd::Health => { + let r = client.health(HealthRequest::default()).await?; + Ok(json!({ "status": r.status, "version": r.version })) + } + AdminCmd::Tenant(t) => tenant(client, t).await, + AdminCmd::Audit { tenant, scope } => { + let r = client + .audit(AuditRequest { + tenant_id: tenant, + scope, + }) + .await?; + Ok(json!({ + "markdown_not_in_duckdb": r.markdown_not_in_duckdb, + "indexed_but_no_markdown": r.indexed_but_no_markdown, + })) + } + AdminCmd::Quota { tenant } => { + let r = client + .quota_get(QuotaGetRequest { tenant_id: tenant }) + .await?; + Ok(json!({ + "queries_remaining": r.queries_remaining, + "writes_remaining": r.writes_remaining, + "embeds_remaining": r.embeds_remaining, + "concurrent_sessions": r.concurrent_sessions, + })) + } + AdminCmd::DeleteChatHistory(a) => { + let r = client + .delete_chat_history(DeleteChatHistoryRequest { + tenant_id: a.tenant, + chat_group_id: a.group.unwrap_or_default(), + before_ts: a.before_ts.unwrap_or_default(), + author: a.author.unwrap_or_default(), + }) + .await?; + Ok(json!({ "deleted": r.deleted })) + } + AdminCmd::AttachExternal { tenant, source_url } => { + let r = client + .attach_external(AttachExternalRequest { + tenant_id: tenant, + source_url, + }) + .await?; + Ok(json!({ "source_id": r.source_id })) + } + AdminCmd::EmbeddingReload => { + let r = client + .embedding_reload(EmbeddingReloadRequest::default()) + .await?; + Ok(json!({ "model_revision": r.model_revision })) + } + AdminCmd::Rebuild { tenant, scope } => { + let mut stream = client + .rebuild(RebuildRequest { + tenant_id: tenant, + scope, + }) + .await?; + let mut progress = Vec::new(); + while let Some(msg) = stream.next().await { + let p = msg?; + progress.push(json!({ + "done": p.done, + "total": p.total, + "current_page": opt(&p.current_page), + })); + } + Ok(json!({ "progress": progress })) + } + AdminCmd::CompactLanes { tenant } => { + let mut stream = client + .compact_lanes(CompactLanesRequest { tenant_id: tenant }) + .await?; + let mut progress = Vec::new(); + while let Some(msg) = stream.next().await { + let p = msg?; + progress.push(json!({ + "ops_compacted": p.ops_compacted, + "bytes_reclaimed": p.bytes_reclaimed, + })); + } + Ok(json!({ "progress": progress })) + } + } +} + +async fn tenant(client: &AdminClient, cmd: TenantCmd) -> Result { + match cmd { + TenantCmd::Create { id, name } => { + let r = client + .tenant_create(TenantCreateRequest { + spec: Some(TenantSpec { + tenant_id: id, + display_name: name, + }), + }) + .await?; + Ok(json!({ "tenant": tenant_json(r.spec) })) + } + TenantCmd::List => { + let r = client.tenant_list(TenantListRequest::default()).await?; + Ok(json!({ + "tenants": r.tenants.into_iter().map(|s| json!({ + "tenant_id": s.tenant_id, + "display_name": opt(&s.display_name), + })).collect::>(), + })) + } + TenantCmd::Get { id } => { + let r = client + .tenant_get(TenantGetRequest { tenant_id: id }) + .await?; + Ok(json!({ "tenant": tenant_json(r.spec) })) + } + TenantCmd::Update { id, name } => { + let r = client + .tenant_update(TenantUpdateRequest { + spec: Some(TenantSpec { + tenant_id: id, + display_name: name, + }), + }) + .await?; + Ok(json!({ "tenant": tenant_json(r.spec) })) + } + TenantCmd::Delete { id } => { + let r = client + .tenant_delete(TenantDeleteRequest { tenant_id: id }) + .await?; + Ok(json!({ "deleted": r.deleted })) + } + TenantCmd::Export { id, out } => { + let mut stream = client + .tenant_export(TenantExportRequest { tenant_id: id }) + .await?; + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + bytes.extend_from_slice(&chunk?.data); + } + let n = bytes.len(); + std::fs::write(&out, &bytes).with_context(|| format!("write export to {out}"))?; + Ok(json!({ "bytes_exported": n, "path": out })) + } + TenantCmd::Import { id, input } => { + let bytes = + std::fs::read(&input).with_context(|| format!("read import from {input}"))?; + let chunks = futures::stream::iter(vec![TenantImportChunk { + tenant_id: id, + data: bytes, + }]); + let r = client.tenant_import(chunks).await?; + Ok(json!({ "bytes_imported": r.bytes_imported })) + } + } +} diff --git a/crates/escurel-cli/src/agent.rs b/crates/escurel-cli/src/agent.rs new file mode 100644 index 0000000..f7e51a9 --- /dev/null +++ b/crates/escurel-cli/src/agent.rs @@ -0,0 +1,541 @@ +//! Agent-surface commands (everything that speaks the `Escurel` +//! service). Each handler builds one request, calls one +//! [`escurel_client::Client`] method, and returns the JSON projection. + +use std::io::Read as _; + +use anyhow::{Context, Result, bail}; +use clap::{Args, Subcommand}; +use escurel_client::{ + AppendMessageRequest, AssignEventRequest, CaptureEventRequest, Client, ExpandRequest, + ListEventsRequest, ListInboxRequest, ListInstancesRequest, ListMessagesRequest, + ListSkillsRequest, NeighboursRequest, ResolveRequest, RunStoredQueryRequest, SearchRequest, + UpdatePageRequest, ValidateRequest, +}; +use serde_json::{Value, json}; + +use crate::Command; +use crate::convert::{event, json_or_null, opt, page_ref}; + +// --- argument groups ----------------------------------------------- + +#[derive(Args, Debug)] +pub struct SearchArgs { + /// Free-text query. + pub q: String, + /// Top-k hits. 0 → server default of 10. + #[arg(long, default_value_t = 10)] + pub k: u32, + /// "skill" | "instance" | "any" (default). + #[arg(long, default_value = "any")] + pub page_type: String, + /// Restrict to one skill. + #[arg(long)] + pub skill: Option, +} + +#[derive(Subcommand, Debug)] +pub enum SkillCmd { + /// Return the tenant's Tier-1 skill catalogue. + List, +} + +#[derive(Subcommand, Debug)] +pub enum InstanceCmd { + /// Enumerate instances of a skill. + List(InstanceListArgs), +} + +#[derive(Args, Debug)] +pub struct InstanceListArgs { + #[arg(long)] + pub skill: String, + /// "asc" | "desc"; empty for natural order. + #[arg(long, default_value = "")] + pub order_by_at: String, + /// 0 means no limit. + #[arg(long, default_value_t = 0)] + pub limit: u32, +} + +#[derive(Subcommand, Debug)] +pub enum PageCmd { + /// Fetch a page's frontmatter + body + outbound wikilinks. + Expand { page_id: String }, + /// Dry-run the indexer over a draft body (read from stdin) without + /// committing. + Validate { page_id: String }, + /// Upsert a markdown page. Body is read from stdin. + Update { page_id: String }, +} + +#[derive(Subcommand, Debug)] +pub enum LinkCmd { + /// Typed link-graph traversal. + Neighbours(NeighboursArgs), +} + +#[derive(Args, Debug)] +pub struct NeighboursArgs { + pub page_id: String, + /// "in" | "out" | "both" (default). + #[arg(long, default_value = "both")] + pub direction: String, + /// Filter to a specific link skill (e.g. "meeting"). + #[arg(long)] + pub link_skill: Option, + #[arg(long, default_value_t = 0)] + pub limit: u32, +} + +#[derive(Subcommand, Debug)] +pub enum EventCmd { + /// Append an event to the global inbox. Body is read from stdin + /// unless `--body` is given. + Capture(CaptureArgs), + /// List unprocessed inbox events. + Inbox { + /// 0 means no limit. + #[arg(long, default_value_t = 0)] + limit: u32, + }, + /// List an instance's processed event history. + List { + #[arg(long)] + instance: String, + /// 0 means no limit. + #[arg(long, default_value_t = 0)] + limit: u32, + }, + /// Bind an inbox event to an instance (→ processed). + Assign { + #[arg(long)] + event: String, + #[arg(long)] + instance: String, + }, +} + +#[derive(Args, Debug)] +pub struct CaptureArgs { + #[arg(long, default_value = "manual")] + pub source: String, + #[arg(long, default_value = "text/plain")] + pub mime: String, + /// Processing skill the event's label links to. + #[arg(long, default_value = "note")] + pub label_skill: String, + #[arg(long)] + pub title: String, + /// Event body. If absent, read from stdin. + #[arg(long)] + pub body: Option, + /// Candidate instance to pre-flag (stays in the inbox until + /// `event assign`). + #[arg(long)] + pub instance: Option, + /// Event time (RFC-3339 UTC). Undated when absent. + #[arg(long)] + pub at: Option, + /// Caller-supplied event id. Server mints a ULID when absent. + #[arg(long)] + pub event_id: Option, + /// Inline JSON provenance value. + #[arg(long)] + pub provenance: Option, +} + +#[derive(Subcommand, Debug)] +pub enum QueryCmd { + /// Execute a `[[query::]]` instance with named parameters. + Run(QueryRunArgs), +} + +#[derive(Args, Debug)] +pub struct QueryRunArgs { + pub query_id: String, + /// JSON object of parameters, e.g. `{"skill":"customer"}`. + #[arg(long, default_value = "{}")] + pub params: String, +} + +#[derive(Subcommand, Debug)] +pub enum ChatCmd { + /// Append a message to a chat group. Content is read from stdin + /// unless `--content` is given. + Append(ChatAppendArgs), + /// Read back a chat group's history. + List(ChatListArgs), +} + +#[derive(Args, Debug)] +pub struct ChatAppendArgs { + #[arg(long, short = 'g')] + pub group: String, + /// `user` | `assistant` | `system` | `tool`. + #[arg(long, default_value = "user")] + pub role: String, + /// Message content. If absent, read from stdin. + #[arg(long)] + pub content: Option, + #[arg(long)] + pub author: Option, + /// Event time (RFC-3339 UTC). Server stamps `CURRENT_TIMESTAMP` + /// when absent. + #[arg(long)] + pub ts: Option, + /// Inline JSON metadata. + #[arg(long)] + pub metadata: Option, + /// Caller-supplied message id. Server mints a ULID when absent. + #[arg(long)] + pub msg_id: Option, + /// Skip embedding. + #[arg(long)] + pub no_embed: bool, +} + +#[derive(Args, Debug)] +pub struct ChatListArgs { + #[arg(long, short = 'g')] + pub group: String, + /// Inclusive lower bound (RFC-3339). + #[arg(long)] + pub since: Option, + /// Exclusive upper bound (RFC-3339). + #[arg(long)] + pub until: Option, + /// 0 → server default (100); hard cap 1000. + #[arg(long, default_value_t = 0)] + pub limit: u32, + /// Opaque cursor from a previous `next_cursor`. + #[arg(long)] + pub cursor: Option, + /// `asc` | `desc` (default `desc`). + #[arg(long, default_value = "desc")] + pub direction: String, +} + +// --- dispatch ------------------------------------------------------ + +pub async fn run(client: &Client, cmd: Command) -> Result { + match cmd { + Command::Search(a) => search(client, a).await, + Command::Resolve { wikilink } => resolve(client, wikilink).await, + Command::Skill(SkillCmd::List) => list_skills(client).await, + Command::Instance(InstanceCmd::List(a)) => list_instances(client, a).await, + Command::Page(PageCmd::Expand { page_id }) => expand(client, page_id).await, + Command::Page(PageCmd::Validate { page_id }) => validate(client, page_id).await, + Command::Page(PageCmd::Update { page_id }) => update_page(client, page_id).await, + Command::Link(LinkCmd::Neighbours(a)) => neighbours(client, a).await, + Command::Event(c) => event_cmd(client, c).await, + Command::Query(QueryCmd::Run(a)) => run_query(client, a).await, + Command::Chat(ChatCmd::Append(a)) => chat_append(client, a).await, + Command::Chat(ChatCmd::List(a)) => chat_list(client, a).await, + // Admin is dispatched in main before reaching here. + Command::Admin(_) => unreachable!("admin handled by admin::run"), + } +} + +fn read_stdin(what: &str) -> Result { + let mut buf = String::new(); + std::io::stdin() + .read_to_string(&mut buf) + .with_context(|| format!("read {what} from stdin"))?; + if buf.is_empty() { + bail!("{what} is empty — pipe it into stdin"); + } + Ok(buf) +} + +async fn search(client: &Client, a: SearchArgs) -> Result { + let resp = client + .search(SearchRequest { + q: a.q, + k: a.k, + granularity: String::new(), + page_type: a.page_type, + skill: a.skill.unwrap_or_default(), + filter_json: String::new(), + ..Default::default() + }) + .await?; + let hits: Vec = resp + .hits + .into_iter() + .map(|h| { + json!({ + "page_id": h.page_id, + "slug": opt(&h.slug), + "skill": h.skill, + "page_type": h.page_type, + "anchor": opt(&h.anchor), + "snippet": h.snippet, + "score": h.score, + "frontmatter_excerpt": json_or_null(&h.frontmatter_excerpt_json), + }) + }) + .collect(); + Ok(json!({ "hits": hits, "granularity": resp.granularity })) +} + +async fn resolve(client: &Client, wikilink: String) -> Result { + let resp = client + .resolve(ResolveRequest { + wikilink, + ..Default::default() + }) + .await?; + Ok(json!({ + "exists": resp.exists, + "parsed": resp.parsed.map(|p| json!({ + "skill": opt(&p.skill), + "id": opt(&p.id), + "anchor": opt(&p.anchor), + "version": opt(&p.version), + "alias": opt(&p.alias), + })), + "page": resp.page.map(page_ref), + })) +} + +async fn list_skills(client: &Client) -> Result { + let resp = client.list_skills(ListSkillsRequest::default()).await?; + let skills: Vec = resp + .skills + .into_iter() + .map(|s| { + json!({ + "id": s.id, + "description": s.description, + "required_frontmatter": s.required_frontmatter, + "optional_frontmatter": s.optional_frontmatter, + "is_event_typed": s.is_event_typed, + }) + }) + .collect(); + Ok(json!({ "skills": skills })) +} + +async fn list_instances(client: &Client, a: InstanceListArgs) -> Result { + let resp = client + .list_instances(ListInstancesRequest { + skill: a.skill, + order_by_at: a.order_by_at, + limit: a.limit, + ..Default::default() + }) + .await?; + let instances: Vec = resp + .instances + .into_iter() + .map(|i| { + json!({ + "page_id": i.page_id, + "skill": i.skill, + "frontmatter": json_or_null(&i.frontmatter_json), + "at": opt(&i.at), + }) + }) + .collect(); + Ok(json!({ "instances": instances })) +} + +async fn expand(client: &Client, page_id: String) -> Result { + let resp = client + .expand(ExpandRequest { + page_id, + anchor: String::new(), + version: String::new(), + ..Default::default() + }) + .await?; + Ok(json!({ + "page": resp.page.map(page_ref), + "frontmatter": json_or_null(&resp.frontmatter_json), + "body": resp.body, + "blocks": resp.blocks.into_iter().map(|b| json!({ + "anchor": b.anchor, + "content": b.content, + })).collect::>(), + "wikilinks_out": resp.wikilinks_out.into_iter().map(|w| json!({ + "skill": opt(&w.skill), + "id": opt(&w.id), + "anchor": opt(&w.anchor), + "version": opt(&w.version), + "alias": opt(&w.alias), + })).collect::>(), + "snapshot_version": opt(&resp.snapshot_version), + })) +} + +async fn validate(client: &Client, page_id: String) -> Result { + let content = read_stdin("page body")?; + let resp = client + .validate(ValidateRequest { page_id, content }) + .await?; + Ok(json!({ + "ok": resp.ok, + "issues": resp.issues.into_iter().map(|i| json!({ + "code": i.code, + "message": i.message, + "anchor": opt(&i.anchor), + })).collect::>(), + })) +} + +async fn update_page(client: &Client, page_id: String) -> Result { + let content = read_stdin("page body")?; + let resp = client + .update_page(UpdatePageRequest { page_id, content }) + .await?; + Ok(json!({ + "ok": resp.ok, + "issues": resp.issues.into_iter().map(|i| json!({ + "code": i.code, + "message": i.message, + "anchor": opt(&i.anchor), + })).collect::>(), + "new_version": opt(&resp.new_version), + })) +} + +async fn neighbours(client: &Client, a: NeighboursArgs) -> Result { + let resp = client + .neighbours(NeighboursRequest { + page_id: a.page_id, + direction: a.direction, + link_skill: a.link_skill.unwrap_or_default(), + link_skill_in: Vec::new(), + order_by: String::new(), + limit: a.limit, + ..Default::default() + }) + .await?; + let edges: Vec = resp + .edges + .into_iter() + .map(|e| { + json!({ + "src_page": e.src_page, + "dst_page": e.dst_page, + "link_skill": e.link_skill, + "link_version": opt(&e.link_version), + "dst_anchor": opt(&e.dst_anchor), + }) + }) + .collect(); + Ok(json!({ "edges": edges })) +} + +async fn event_cmd(client: &Client, cmd: EventCmd) -> Result { + match cmd { + EventCmd::Capture(a) => { + let body = match a.body { + Some(b) => b, + None => read_stdin("event body")?, + }; + let stored = client + .capture_event(CaptureEventRequest { + event_id: a.event_id.unwrap_or_default(), + at: a.at.unwrap_or_default(), + source: a.source, + mime: a.mime, + label_skill: a.label_skill, + instance_page_id: a.instance.unwrap_or_default(), + title: a.title, + body, + provenance_json: a.provenance.unwrap_or_default(), + }) + .await?; + Ok(event(stored)) + } + EventCmd::Inbox { limit } => { + let resp = client.list_inbox(ListInboxRequest { limit }).await?; + Ok(json!({ "events": resp.events.into_iter().map(event).collect::>() })) + } + EventCmd::List { instance, limit } => { + let resp = client + .list_events(ListEventsRequest { + instance_page_id: instance, + limit, + }) + .await?; + Ok(json!({ "events": resp.events.into_iter().map(event).collect::>() })) + } + EventCmd::Assign { event, instance } => { + let ack = client + .assign_event(AssignEventRequest { + event_id: event, + instance_page_id: instance, + }) + .await?; + Ok(json!({ + "event_id": ack.event_id, + "instance_page_id": ack.instance_page_id, + })) + } + } +} + +async fn run_query(client: &Client, a: QueryRunArgs) -> Result { + let resp = client + .run_stored_query(RunStoredQueryRequest { + query_id: a.query_id, + params_json: a.params, + }) + .await?; + Ok(json!({ + "rows": json_or_null(&resp.rows_json), + "schema": resp.schema.into_iter().map(|c| json!({ + "name": c.name, + "type": c.type_name, + })).collect::>(), + })) +} + +async fn chat_append(client: &Client, a: ChatAppendArgs) -> Result { + let content = match a.content { + Some(c) => c, + None => read_stdin("message content")?, + }; + let resp = client + .append_message(AppendMessageRequest { + chat_group_id: a.group, + role: a.role, + content, + author: a.author.unwrap_or_default(), + ts: a.ts.unwrap_or_default(), + metadata_json: a.metadata.unwrap_or_default(), + msg_id: a.msg_id.unwrap_or_default(), + embed: !a.no_embed, + }) + .await?; + Ok(json!({ "msg_id": resp.msg_id, "ts": resp.ts })) +} + +async fn chat_list(client: &Client, a: ChatListArgs) -> Result { + let resp = client + .list_messages(ListMessagesRequest { + chat_group_id: a.group, + since: a.since.unwrap_or_default(), + until: a.until.unwrap_or_default(), + limit: a.limit, + cursor: a.cursor.unwrap_or_default(), + direction: a.direction, + }) + .await?; + Ok(json!({ + "messages": resp.messages.into_iter().map(|m| json!({ + "chat_group_id": m.chat_group_id, + "msg_id": m.msg_id, + "ts": m.ts, + "role": m.role, + "author": opt(&m.author), + "content": m.content, + "metadata": json_or_null(&m.metadata_json), + "embedded": m.embedded, + })).collect::>(), + "next_cursor": opt(&resp.next_cursor), + })) +} diff --git a/crates/escurel-cli/src/convert.rs b/crates/escurel-cli/src/convert.rs new file mode 100644 index 0000000..d504e98 --- /dev/null +++ b/crates/escurel-cli/src/convert.rs @@ -0,0 +1,52 @@ +//! Proto → JSON projection helpers shared by every command. +//! +//! The CLI commits to a stable JSON shape (the contract an LLM or a +//! script parses), so these mappers are the single place that shape is +//! defined. Empty proto strings become JSON `null` rather than `""` so +//! "absent" is unambiguous downstream. + +use escurel_client::{Event, PageRef}; +use serde_json::{Value, json}; + +/// Empty string → `null`, otherwise the string. +pub fn opt(s: &str) -> Value { + if s.is_empty() { + Value::Null + } else { + Value::String(s.to_owned()) + } +} + +/// Empty → `null`; otherwise parse as JSON, falling back to the raw +/// string if it isn't valid JSON. +pub fn json_or_null(s: &str) -> Value { + if s.is_empty() { + Value::Null + } else { + serde_json::from_str(s).unwrap_or_else(|_| Value::String(s.to_owned())) + } +} + +pub fn page_ref(p: PageRef) -> Value { + json!({ + "page_id": p.page_id, + "slug": opt(&p.slug), + "skill": p.skill, + "page_type": p.page_type, + }) +} + +pub fn event(e: Event) -> Value { + json!({ + "event_id": e.event_id, + "at": opt(&e.at), + "source": e.source, + "mime": e.mime, + "label_skill": e.label_skill, + "instance_page_id": opt(&e.instance_page_id), + "status": e.status, + "title": e.title, + "body": e.body, + "provenance": json_or_null(&e.provenance_json), + }) +} diff --git a/crates/escurel-cli/src/main.rs b/crates/escurel-cli/src/main.rs index 079cc02..70df63e 100644 --- a/crates/escurel-cli/src/main.rs +++ b/crates/escurel-cli/src/main.rs @@ -1,26 +1,37 @@ //! `escurel` — operator + agent-style CLI for the Escurel gateway. //! -//! Thin gRPC client. All subcommands talk to the `escurel.v1.Escurel` -//! service over the gRPC endpoint configured via `--server` / -//! `ESCUREL_SERVER`. Auth via `--token` / `ESCUREL_TOKEN`. +//! Pure presentation over [`escurel_client`]: every subcommand maps to +//! one typed RPC, renders the response as JSON (the default, stable +//! contract for scripts and LLM agents) or as a human table +//! (`--format table`). Errors are emitted as JSON on **stderr** with a +//! non-zero exit so a calling agent can branch on them. //! -//! Today the agent surface is wired (8 RPCs). The admin surface -//! (`escurel admin tenant …`, `escurel admin rebuild …`) lands -//! alongside `EscurelAdmin` in M3.5d / M4. - -use std::io::Read; - -use anyhow::{Context, Result, bail}; -use clap::{Args, Parser, Subcommand}; -use escurel_proto::v1::escurel_client::EscurelClient; -use escurel_proto::v1::{ - AppendMessageRequest, ExpandRequest, ListInstancesRequest, ListMessagesRequest, - ListSkillsRequest, NeighboursRequest, ResolveRequest, RunStoredQueryRequest, SearchRequest, - UpdatePageRequest, -}; -use serde_json::{Value, json}; -use tonic::metadata::MetadataValue; -use tonic::transport::Channel; +//! Commands are grouped gh/aws-style by resource noun: +//! escurel skill list +//! escurel instance list --skill customer +//! escurel page expand|validate|update +//! escurel link neighbours +//! escurel event capture|inbox|list|assign +//! escurel query run --params '{…}' +//! escurel chat append|list +//! escurel admin +//! with two natural top-level verbs (`search`, `resolve`). +//! +//! `--server` / `ESCUREL_SERVER` and `--token` / `ESCUREL_TOKEN` are +//! global. When no token is set the CLI sends an empty bearer; a server +//! without a verifier (dev mode) ignores it, a server with one rejects +//! it as `Unauthenticated`. + +mod admin; +mod agent; +mod convert; +mod output; + +use anyhow::Result; +use clap::{Parser, Subcommand}; +use escurel_client::{AdminClient, Client, SecretString}; +use output::Format; +use serde_json::json; #[derive(Parser, Debug)] #[command(name = "escurel", about = "CLI for the Escurel gateway", version)] @@ -32,481 +43,78 @@ struct Cli { /// unauthenticated (dev only). #[arg(long, env = "ESCUREL_TOKEN", hide_env_values = true)] token: Option, + /// Output format. + #[arg(long, value_enum, default_value_t = Format::Json, global = true)] + format: Format, #[command(subcommand)] cmd: Command, } #[derive(Subcommand, Debug)] enum Command { - /// Return the tenant's Tier-1 skill catalogue. - ListSkills, - /// Enumerate instances of a skill. - ListInstances(ListInstancesArgs), + /// Hybrid vector + FTS search. + Search(agent::SearchArgs), /// Parse a `[[wikilink]]` and look up its target page. Resolve { wikilink: String }, - /// Fetch a page's frontmatter + body + outbound wikilinks. - Expand { page_id: String }, + /// Tier-1 skill catalogue. + #[command(subcommand)] + Skill(agent::SkillCmd), + /// Instances of a skill. + #[command(subcommand)] + Instance(agent::InstanceCmd), + /// Page read / validate / write. + #[command(subcommand)] + Page(agent::PageCmd), /// Typed link-graph traversal. - Neighbours(NeighboursArgs), - /// Hybrid vector + FTS search. - Search(SearchArgs), - /// Execute a `[[query::]]` instance with named parameters. - RunStoredQuery(RunStoredQueryArgs), - /// Upsert a markdown page. Body is read from stdin. - UpdatePage { page_id: String }, - /// Per-chat-group conversation log (M-Chat, issue #63). #[command(subcommand)] - Chat(ChatCommand), -} - -#[derive(Subcommand, Debug)] -enum ChatCommand { - /// Append a message to a chat group. Content is read from - /// stdin unless `--content` is provided. - Append(ChatAppendArgs), - /// Read back a chat group's history. - List(ChatListArgs), -} - -#[derive(Args, Debug)] -struct ChatAppendArgs { - /// Opaque chat-group id (consumer-defined). - #[arg(long, short = 'g')] - group: String, - /// `user` | `assistant` | `system` | `tool`. - #[arg(long, default_value = "user")] - role: String, - /// Message content. If absent, read from stdin. - #[arg(long)] - content: Option, - /// Opaque author handle. - #[arg(long)] - author: Option, - /// Event time (RFC-3339 UTC). Server stamps `CURRENT_TIMESTAMP` - /// when absent. - #[arg(long)] - ts: Option, - /// Inline JSON metadata, e.g. `'{"thread":"t-42"}'`. - #[arg(long)] - metadata: Option, - /// Caller-supplied message id. Server generates a ULID when - /// absent. - #[arg(long)] - msg_id: Option, - /// Skip embedding (cheap insert for high-volume sources). - #[arg(long)] - no_embed: bool, -} - -#[derive(Args, Debug)] -struct ChatListArgs { - #[arg(long, short = 'g')] - group: String, - /// Inclusive lower bound (RFC-3339). - #[arg(long)] - since: Option, - /// Exclusive upper bound (RFC-3339). - #[arg(long)] - until: Option, - /// 0 → server default (100); hard cap 1000. - #[arg(long, default_value_t = 0)] - limit: u32, - /// Opaque cursor from a previous `next_cursor`. - #[arg(long)] - cursor: Option, - /// `asc` | `desc` (default `desc`). - #[arg(long, default_value = "desc")] - direction: String, -} - -#[derive(Args, Debug)] -struct ListInstancesArgs { - #[arg(long)] - skill: String, - /// "asc" | "desc"; empty for natural order. - #[arg(long, default_value = "")] - order_by_at: String, - /// 0 means no limit. - #[arg(long, default_value_t = 0)] - limit: u32, -} - -#[derive(Args, Debug)] -struct NeighboursArgs { - page_id: String, - /// "in" | "out" | "both" (default). - #[arg(long, default_value = "both")] - direction: String, - /// Filter to a specific link skill (e.g. "meeting"). - #[arg(long)] - link_skill: Option, - #[arg(long, default_value_t = 0)] - limit: u32, -} - -#[derive(Args, Debug)] -struct SearchArgs { - /// Free-text query. - q: String, - /// Top-k hits. 0 → server default of 10. - #[arg(long, default_value_t = 10)] - k: u32, - /// "skill" | "instance" | "any" (default). - #[arg(long, default_value = "any")] - page_type: String, - /// Restrict to one skill. - #[arg(long)] - skill: Option, -} - -#[derive(Args, Debug)] -struct RunStoredQueryArgs { - query_id: String, - /// JSON object of parameters, e.g. `{"skill":"customer"}`. - /// Defaults to `{}`. - #[arg(long, default_value = "{}")] - params: String, + Link(agent::LinkCmd), + /// Event-sourcing surface: inbox, history, capture, assign. + #[command(subcommand)] + Event(agent::EventCmd), + /// Stored queries. + #[command(subcommand)] + Query(agent::QueryCmd), + /// Per-chat-group conversation log. + #[command(subcommand)] + Chat(agent::ChatCmd), + /// Operator surface (admin-role token required, except `health`). + #[command(subcommand)] + Admin(admin::AdminCmd), } #[tokio::main] -async fn main() -> Result<()> { +async fn main() { let cli = Cli::parse(); - // The verifier on the server is optional (dev / on-host mode - // runs unauthenticated), so the CLI mirrors that: when no - // token is configured, send the RPC without an `authorization` - // metadata header and let the server enforce its own policy. - let bearer: Option> = match cli.token.as_deref() { - Some(t) if !t.is_empty() => Some( - format!("Bearer {t}") - .parse() - .context("token contains characters invalid in an HTTP header")?, - ), - _ => None, - }; - - let channel = Channel::from_shared(cli.server.clone()) - .with_context(|| format!("invalid --server URL `{}`", cli.server))? - .connect() - .await - .with_context(|| format!("failed to connect to {}", cli.server))?; - let mut client = EscurelClient::new(channel); - - let result: Value = match cli.cmd { - Command::ListSkills => { - let resp = client - .list_skills(authed(ListSkillsRequest::default(), &bearer)) - .await? - .into_inner(); - let skills: Vec = resp - .skills - .into_iter() - .map(|s| { - json!({ - "id": s.id, - "description": s.description, - "required_frontmatter": s.required_frontmatter, - "optional_frontmatter": s.optional_frontmatter, - "is_event_typed": s.is_event_typed, - }) - }) - .collect(); - json!({ "skills": skills }) - } - - Command::ListInstances(a) => { - let resp = client - .list_instances(authed( - ListInstancesRequest { - skill: a.skill, - order_by_at: a.order_by_at, - limit: a.limit, - ..Default::default() - }, - &bearer, - )) - .await? - .into_inner(); - let instances: Vec = resp - .instances - .into_iter() - .map(|i| { - json!({ - "page_id": i.page_id, - "skill": i.skill, - "frontmatter": json_or_null(&i.frontmatter_json), - "at": optional_string(&i.at), - }) - }) - .collect(); - json!({ "instances": instances }) - } - - Command::Resolve { wikilink } => { - let resp = client - .resolve(authed( - ResolveRequest { - wikilink, - ..Default::default() - }, - &bearer, - )) - .await? - .into_inner(); - json!({ - "exists": resp.exists, - "parsed": resp.parsed.map(|p| json!({ - "skill": optional_string(&p.skill), - "id": optional_string(&p.id), - "anchor": optional_string(&p.anchor), - "version": optional_string(&p.version), - "alias": optional_string(&p.alias), - })), - "page": resp.page.map(page_ref_to_json), - }) - } - - Command::Expand { page_id } => { - let resp = client - .expand(authed( - ExpandRequest { - page_id, - anchor: String::new(), - version: String::new(), - ..Default::default() - }, - &bearer, - )) - .await? - .into_inner(); - json!({ - "page": resp.page.map(page_ref_to_json), - "frontmatter": json_or_null(&resp.frontmatter_json), - "body": resp.body, - "blocks": resp.blocks.into_iter().map(|b| json!({ - "anchor": b.anchor, - "content": b.content, - })).collect::>(), - "wikilinks_out": resp.wikilinks_out.into_iter().map(|w| json!({ - "skill": optional_string(&w.skill), - "id": optional_string(&w.id), - "anchor": optional_string(&w.anchor), - "version": optional_string(&w.version), - "alias": optional_string(&w.alias), - })).collect::>(), - "snapshot_version": optional_string(&resp.snapshot_version), - }) - } - - Command::Neighbours(a) => { - let resp = client - .neighbours(authed( - NeighboursRequest { - page_id: a.page_id, - direction: a.direction, - link_skill: a.link_skill.unwrap_or_default(), - link_skill_in: Vec::new(), - order_by: String::new(), - limit: a.limit, - ..Default::default() - }, - &bearer, - )) - .await? - .into_inner(); - let edges: Vec = resp - .edges - .into_iter() - .map(|e| { - json!({ - "src_page": e.src_page, - "dst_page": e.dst_page, - "link_skill": e.link_skill, - "link_version": optional_string(&e.link_version), - "dst_anchor": optional_string(&e.dst_anchor), - }) - }) - .collect(); - json!({ "edges": edges }) - } - - Command::Search(a) => { - let resp = client - .search(authed( - SearchRequest { - q: a.q, - k: a.k, - granularity: String::new(), - page_type: a.page_type, - skill: a.skill.unwrap_or_default(), - filter_json: String::new(), - ..Default::default() - }, - &bearer, - )) - .await? - .into_inner(); - let hits: Vec = resp - .hits - .into_iter() - .map(|h| { - json!({ - "page_id": h.page_id, - "slug": optional_string(&h.slug), - "skill": h.skill, - "page_type": h.page_type, - "anchor": optional_string(&h.anchor), - "snippet": h.snippet, - "score": h.score, - "frontmatter_excerpt": json_or_null(&h.frontmatter_excerpt_json), - }) - }) - .collect(); - json!({ - "hits": hits, - "granularity": resp.granularity, - }) - } - - Command::RunStoredQuery(a) => { - let resp = client - .run_stored_query(authed( - RunStoredQueryRequest { - query_id: a.query_id, - params_json: a.params, - }, - &bearer, - )) - .await? - .into_inner(); - json!({ - "rows": json_or_null(&resp.rows_json), - "schema": resp.schema.into_iter().map(|c| json!({ - "name": c.name, - "type": c.type_name, - })).collect::>(), - }) - } - - Command::UpdatePage { page_id } => { - let mut content = String::new(); - std::io::stdin() - .read_to_string(&mut content) - .context("read page body from stdin")?; - if content.is_empty() { - bail!("page body is empty — pipe markdown into stdin"); - } - let resp = client - .update_page(authed(UpdatePageRequest { page_id, content }, &bearer)) - .await? - .into_inner(); - json!({ - "ok": resp.ok, - "issues": resp.issues.into_iter().map(|i| json!({ - "code": i.code, - "message": i.message, - "anchor": optional_string(&i.anchor), - })).collect::>(), - "new_version": optional_string(&resp.new_version), - }) + let fmt = cli.format; + if let Err(e) = run(cli).await { + // JSON-on-stderr error contract: an agent parses this; a human + // still reads it. Always non-zero exit. + let body = json!({ "error": e.to_string() }); + match fmt { + Format::Json => eprintln!("{}", serde_json::to_string_pretty(&body).unwrap()), + Format::Table => eprintln!("error: {e}"), } + std::process::exit(1); + } +} - Command::Chat(ChatCommand::Append(a)) => { - let content = match a.content { - Some(c) => c, - None => { - let mut buf = String::new(); - std::io::stdin() - .read_to_string(&mut buf) - .context("read message content from stdin")?; - if buf.is_empty() { - bail!("--content empty and stdin is empty"); - } - buf - } - }; - let resp = client - .append_message(authed( - AppendMessageRequest { - chat_group_id: a.group, - role: a.role, - content, - author: a.author.unwrap_or_default(), - ts: a.ts.unwrap_or_default(), - metadata_json: a.metadata.unwrap_or_default(), - msg_id: a.msg_id.unwrap_or_default(), - embed: !a.no_embed, - }, - &bearer, - )) - .await? - .into_inner(); - json!({ "msg_id": resp.msg_id, "ts": resp.ts }) +async fn run(cli: Cli) -> Result<()> { + let token = SecretString::from(cli.token.unwrap_or_default()); + let fmt = cli.format; + + // The admin group dials the admin service; everything else the + // agent service. Dial lazily so a bad URL surfaces the same way for + // both paths. + let value = match cli.cmd { + Command::Admin(cmd) => { + let client = AdminClient::connect(&cli.server, token).await?; + admin::run(&client, cmd).await? } - - Command::Chat(ChatCommand::List(a)) => { - let resp = client - .list_messages(authed( - ListMessagesRequest { - chat_group_id: a.group, - since: a.since.unwrap_or_default(), - until: a.until.unwrap_or_default(), - limit: a.limit, - cursor: a.cursor.unwrap_or_default(), - direction: a.direction, - }, - &bearer, - )) - .await? - .into_inner(); - json!({ - "messages": resp.messages.into_iter().map(|m| json!({ - "chat_group_id": m.chat_group_id, - "msg_id": m.msg_id, - "ts": m.ts, - "role": m.role, - "author": optional_string(&m.author), - "content": m.content, - "metadata": json_or_null(&m.metadata_json), - "embedded": m.embedded, - })).collect::>(), - "next_cursor": optional_string(&resp.next_cursor), - }) + other => { + let client = Client::connect(&cli.server, token).await?; + agent::run(&client, other).await? } }; - - println!("{}", serde_json::to_string_pretty(&result)?); + output::emit(&value, fmt)?; Ok(()) } - -fn authed(body: T, bearer: &Option>) -> tonic::Request { - let mut req = tonic::Request::new(body); - if let Some(b) = bearer { - req.metadata_mut().insert("authorization", b.clone()); - } - req -} - -fn page_ref_to_json(p: escurel_proto::v1::PageRef) -> Value { - json!({ - "page_id": p.page_id, - "slug": optional_string(&p.slug), - "skill": p.skill, - "page_type": p.page_type, - }) -} - -fn json_or_null(s: &str) -> Value { - if s.is_empty() { - Value::Null - } else { - serde_json::from_str(s).unwrap_or_else(|_| Value::String(s.to_owned())) - } -} - -fn optional_string(s: &str) -> Value { - if s.is_empty() { - Value::Null - } else { - Value::String(s.to_owned()) - } -} diff --git a/crates/escurel-cli/src/output.rs b/crates/escurel-cli/src/output.rs new file mode 100644 index 0000000..de23e3c --- /dev/null +++ b/crates/escurel-cli/src/output.rs @@ -0,0 +1,114 @@ +//! Output rendering: JSON (the default, script/LLM contract) and a +//! generic human-readable table mode. +//! +//! The table renderer is deliberately *generic* — it walks whatever +//! JSON a command produced rather than carrying per-command layout, so +//! adding a command never means touching this file. An object's array- +//! of-objects fields render as tables (column union, first-seen order); +//! scalar fields render as `key: value` lines. + +use serde_json::Value; + +#[derive(clap::ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Format { + /// Pretty-printed JSON (default). The stable, parseable contract. + #[default] + Json, + /// Human-readable tables + key/value lines. + Table, +} + +pub fn emit(v: &Value, fmt: Format) -> anyhow::Result<()> { + match fmt { + Format::Json => println!("{}", serde_json::to_string_pretty(v)?), + Format::Table => print!("{}", render(v)), + } + Ok(()) +} + +fn render(v: &Value) -> String { + let mut out = String::new(); + match v { + Value::Object(map) => { + for (k, val) in map { + match val { + Value::Array(items) + if !items.is_empty() && items.iter().all(Value::is_object) => + { + out.push_str(&format!("{k}:\n")); + out.push_str(&table(items)); + } + Value::Array(items) => { + let joined = items.iter().map(scalar).collect::>().join(", "); + out.push_str(&format!("{k}: {joined}\n")); + } + Value::Object(_) => { + out.push_str(&format!("{k}:\n")); + for line in render(val).lines() { + out.push_str(&format!(" {line}\n")); + } + } + _ => out.push_str(&format!("{k}: {}\n", scalar(val))), + } + } + } + _ => out.push_str(&format!("{}\n", scalar(v))), + } + out +} + +/// Render an array of objects as a padded table. +fn table(rows: &[Value]) -> String { + let mut cols: Vec = Vec::new(); + for r in rows { + if let Value::Object(m) = r { + for k in m.keys() { + if !cols.contains(k) { + cols.push(k.clone()); + } + } + } + } + let cells: Vec> = rows + .iter() + .map(|r| { + cols.iter() + .map(|c| r.get(c).map(scalar).unwrap_or_default()) + .collect() + }) + .collect(); + let mut widths: Vec = cols.iter().map(String::len).collect(); + for row in &cells { + for (i, c) in row.iter().enumerate() { + widths[i] = widths[i].max(c.len()); + } + } + let pad = |row: &[String]| -> String { + row.iter() + .enumerate() + .map(|(i, c)| format!("{c:width$}", width = widths[i])) + .collect::>() + .join(" ") + .trim_end() + .to_owned() + }; + let mut s = String::new(); + s.push_str(&format!(" {}\n", pad(&cols))); + let sep: Vec = widths.iter().map(|w| "-".repeat(*w)).collect(); + s.push_str(&format!(" {}\n", pad(&sep))); + for row in &cells { + s.push_str(&format!(" {}\n", pad(row))); + } + s +} + +/// One JSON value as a single-line cell. +fn scalar(v: &Value) -> String { + match v { + Value::Null => String::new(), + Value::String(s) => s.clone(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + other => other.to_string(), + } +} diff --git a/crates/escurel-cli/tests/cli_admin_e2e.rs b/crates/escurel-cli/tests/cli_admin_e2e.rs new file mode 100644 index 0000000..cd6d301 --- /dev/null +++ b/crates/escurel-cli/tests/cli_admin_e2e.rs @@ -0,0 +1,256 @@ +//! End-to-end tests for the `escurel admin` command group. +//! +//! Real gateway via `escurel-test-support` with a real tempdir-backed +//! `FsTenantStore` + quota wired, driven through the compiled binary. +//! Covers the unary admin commands and the streaming ones (rebuild, +//! tenant export/import). + +use std::path::PathBuf; +use std::sync::Arc; + +use assert_cmd::Command; +use escurel_admin::{FsTenantStore, TenantSpec, TenantStore}; +use escurel_quota::{QuotaConfig, QuotaManager}; +use escurel_test_support::{AuthMode, ConfigOverrides, EscurelProcess, FixtureBuilder, Opts, Role}; +use serde_json::Value; +use tempfile::TempDir; + +const TENANT: &str = "acme"; +const CUSTOMER_SKILL: &str = "---\ntype: skill\nid: customer\ndescription: x\n---\n# customer\n"; + +struct Harness { + process: EscurelProcess, + grpc_addr: String, + admin_token: String, + agent_token: String, + tenants_root: PathBuf, + _tenants_dir: TempDir, +} + +async fn start() -> Harness { + let tenants_dir = TempDir::new().unwrap(); + let tenants_root = tenants_dir.path().to_path_buf(); + let tenant_store: Arc = Arc::new(FsTenantStore::new(tenants_root.clone())); + // Provision the JWT tenant in the FsTenantStore so the export RPC + // (which walks `//markdown`) has a directory to tar, + // and mirror a markdown page into it so the tarball is non-trivial. + tenant_store + .create(&TenantSpec { + tenant_id: TENANT.to_owned(), + display_name: "Acme".to_owned(), + }) + .await + .unwrap(); + let md = tenants_root.join(TENANT).join("markdown").join("skills"); + std::fs::create_dir_all(&md).unwrap(); + std::fs::write(md.join("customer.md"), CUSTOMER_SKILL).unwrap(); + let quota = Arc::new(QuotaManager::new(QuotaConfig { + queries_per_minute: 100, + writes_per_minute: 50, + embeds_per_minute: 25, + concurrent_sessions: 4, + })); + let process = EscurelProcess::spawn(Opts { + auth: AuthMode::TestIssuer, + fixtures: Some( + FixtureBuilder::new() + .tenant(TENANT) + .skill("customer", CUSTOMER_SKILL) + .done(), + ), + config_overrides: ConfigOverrides { + gateway_version: Some("1.0.0-test".to_owned()), + tenant_store: Some(tenant_store), + quota: Some(quota), + ..Default::default() + }, + }) + .await; + let grpc_addr = process + .grpc_endpoint() + .expect("grpc endpoint") + .strip_prefix("http://") + .unwrap() + .to_owned(); + Harness { + admin_token: process.mint_token(TENANT, Role::Admin), + agent_token: process.mint_token(TENANT, Role::Agent), + process, + grpc_addr, + tenants_root, + _tenants_dir: tenants_dir, + } +} + +fn v(args: &[&str]) -> Vec { + args.iter().map(|s| s.to_string()).collect() +} + +async fn admin(h: &Harness, args: Vec) -> std::process::Output { + let addr = h.grpc_addr.clone(); + let token = h.admin_token.clone(); + tokio::task::spawn_blocking(move || { + Command::cargo_bin("escurel") + .unwrap() + .env("ESCUREL_SERVER", format!("http://{addr}")) + .env("ESCUREL_TOKEN", token) + .args(&args) + .assert() + .success() + .get_output() + .clone() + }) + .await + .unwrap() +} + +fn json(out: &std::process::Output) -> Value { + serde_json::from_slice(&out.stdout).expect("stdout is JSON") +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn admin_health_reports_version() { + let h = start().await; + let out = admin(&h, v(&["admin", "health"])).await; + assert_eq!(json(&out)["version"], "1.0.0-test"); + h.process.shutdown().await; +} + +/// Tenant lifecycle through the CLI: create → get → list → update → +/// delete, with on-disk provisioning verified. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn admin_tenant_crud_lifecycle() { + let h = start().await; + let created = admin( + &h, + v(&[ + "admin", + "tenant", + "create", + "--id", + "globex", + "--name", + "Globex Corp", + ]), + ) + .await; + assert_eq!(json(&created)["tenant"]["tenant_id"], "globex"); + assert!(h.tenants_root.join("globex").join("tenant.json").is_file()); + + let got = admin(&h, v(&["admin", "tenant", "get", "--id", "globex"])).await; + assert_eq!(json(&got)["tenant"]["display_name"], "Globex Corp"); + + let listed = admin(&h, v(&["admin", "tenant", "list"])).await; + assert!( + json(&listed)["tenants"] + .as_array() + .unwrap() + .iter() + .any(|t| t["tenant_id"] == "globex") + ); + + let updated = admin( + &h, + v(&[ + "admin", "tenant", "update", "--id", "globex", "--name", "Renamed", + ]), + ) + .await; + assert_eq!(json(&updated)["tenant"]["display_name"], "Renamed"); + + let deleted = admin(&h, v(&["admin", "tenant", "delete", "--id", "globex"])).await; + assert_eq!(json(&deleted)["deleted"], true); + assert!(!h.tenants_root.join("globex").exists()); + h.process.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn admin_audit_and_quota() { + let h = start().await; + let audit = admin(&h, v(&["admin", "audit", "--tenant", TENANT])).await; + let a = json(&audit); + assert!(a["markdown_not_in_duckdb"].as_array().unwrap().is_empty()); + + let quota = admin(&h, v(&["admin", "quota", "--tenant", TENANT])).await; + assert_eq!(json("a)["queries_remaining"], 100); + h.process.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn admin_rebuild_streams_progress() { + let h = start().await; + let out = admin(&h, v(&["admin", "rebuild", "--tenant", TENANT])).await; + let prog = json(&out); + let arr = prog["progress"].as_array().unwrap(); + assert!(!arr.is_empty(), "rebuild should stream progress chunks"); + let last = arr.last().unwrap(); + assert_eq!(last["done"], last["total"]); + h.process.shutdown().await; +} + +/// Export a tenant to a file, then import it back into a fresh tenant. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn admin_tenant_export_then_import() { + let h = start().await; + let tar = h.tenants_root.join("acme.tgz"); + let tar_str = tar.to_str().unwrap().to_owned(); + + let exp = admin( + &h, + v(&[ + "admin", "tenant", "export", "--id", TENANT, "--out", &tar_str, + ]), + ) + .await; + assert!(json(&exp)["bytes_exported"].as_u64().unwrap() > 0); + assert!(tar.is_file()); + + // Fresh destination tenant. + admin( + &h, + v(&[ + "admin", "tenant", "create", "--id", "globex", "--name", "Globex", + ]), + ) + .await; + + let imp = admin( + &h, + v(&[ + "admin", "tenant", "import", "--id", "globex", "--in", &tar_str, + ]), + ) + .await; + assert!(json(&imp)["bytes_imported"].as_u64().unwrap() > 0); + h.process.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn admin_rejects_agent_token() { + let h = start().await; + let addr = h.grpc_addr.clone(); + let token = h.agent_token.clone(); + let out = tokio::task::spawn_blocking(move || { + Command::cargo_bin("escurel") + .unwrap() + .env("ESCUREL_SERVER", format!("http://{addr}")) + .env("ESCUREL_TOKEN", token) + .args(["admin", "tenant", "list"]) + .assert() + .failure() + .get_output() + .clone() + }) + .await + .unwrap(); + let err: Value = serde_json::from_slice(&out.stderr).expect("stderr is JSON"); + assert!( + err["error"] + .as_str() + .unwrap() + .to_lowercase() + .contains("permission"), + "got: {err}" + ); + h.process.shutdown().await; +} diff --git a/crates/escurel-cli/tests/cli_e2e.rs b/crates/escurel-cli/tests/cli_e2e.rs index 183fba7..8b40a2f 100644 --- a/crates/escurel-cli/tests/cli_e2e.rs +++ b/crates/escurel-cli/tests/cli_e2e.rs @@ -1,10 +1,10 @@ -//! End-to-end test for the `escurel` CLI. +//! End-to-end tests for the `escurel` CLI (agent surface). //! -//! Spins up the real gateway via `escurel-test-support` and -//! exercises every CLI subcommand via the compiled binary -//! (`assert_cmd::cargo_bin`). No mocks at the CLI boundary; the -//! support crate's in-process JWKS issuer stands in for a real -//! OIDC realm. +//! Spins up the real gateway via `escurel-test-support` and drives the +//! compiled binary (`assert_cmd::cargo_bin`). No mocks at the CLI +//! boundary; the support crate's in-process JWKS issuer stands in for a +//! real OIDC realm. Every command + its common switches are exercised, +//! plus both `--format` modes and the auth on/off paths. use assert_cmd::Command; use escurel_test_support::{AuthMode, ConfigOverrides, EscurelProcess, FixtureBuilder, Opts, Role}; @@ -75,86 +75,128 @@ async fn start() -> Harness { } } -fn cli(h: &Harness) -> Command { - let mut c = Command::cargo_bin("escurel").expect("escurel binary built"); - c.env("ESCUREL_SERVER", format!("http://{}", h.grpc_addr)) - .env("ESCUREL_TOKEN", &h.bearer); - c +/// Build a CLI command pre-wired with server + token env, running the +/// given args on the blocking pool (assert_cmd is sync). +async fn run_args(h: &Harness, args: Vec) -> std::process::Output { + let addr = h.grpc_addr.clone(); + let bearer = h.bearer.clone(); + tokio::task::spawn_blocking(move || { + Command::cargo_bin("escurel") + .unwrap() + .env("ESCUREL_SERVER", format!("http://{addr}")) + .env("ESCUREL_TOKEN", bearer) + .args(&args) + .assert() + .success() + .get_output() + .clone() + }) + .await + .unwrap() } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn list_skills_emits_seeded_skill() { - let h = start().await; - let assert = tokio::task::spawn_blocking({ - let addr = h.grpc_addr.clone(); - let bearer = h.bearer.clone(); - move || { - Command::cargo_bin("escurel") - .unwrap() - .env("ESCUREL_SERVER", format!("http://{addr}")) - .env("ESCUREL_TOKEN", bearer) - .args(["list-skills"]) - .assert() - .success() - } +async fn run_stdin(h: &Harness, args: Vec, stdin: &str) -> std::process::Output { + let addr = h.grpc_addr.clone(); + let bearer = h.bearer.clone(); + let stdin = stdin.to_owned(); + tokio::task::spawn_blocking(move || { + Command::cargo_bin("escurel") + .unwrap() + .env("ESCUREL_SERVER", format!("http://{addr}")) + .env("ESCUREL_TOKEN", bearer) + .args(&args) + .write_stdin(stdin) + .assert() + .success() + .get_output() + .clone() }) .await - .unwrap(); - let out: Value = serde_json::from_slice(&assert.get_output().stdout).unwrap(); - let skills = out["skills"].as_array().unwrap(); - assert!(skills.iter().any(|s| s["id"] == "customer")); + .unwrap() +} + +fn json(out: &std::process::Output) -> Value { + serde_json::from_slice(&out.stdout).expect("stdout is JSON") +} + +fn v(args: &[&str]) -> Vec { + args.iter().map(|s| s.to_string()).collect() +} + +async fn acme_page_id(h: &Harness) -> String { + let out = run_args(h, v(&["instance", "list", "--skill", "customer"])).await; + let inst = json(&out); + inst["instances"] + .as_array() + .unwrap() + .iter() + .find(|i| i["page_id"].as_str().unwrap().contains("acme")) + .unwrap()["page_id"] + .as_str() + .unwrap() + .to_owned() +} + +// --- read / browse ------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn skill_list_emits_seeded_skill() { + let h = start().await; + let out = run_args(&h, v(&["skill", "list"])).await; + let val = json(&out); + assert!( + val["skills"] + .as_array() + .unwrap() + .iter() + .any(|s| s["id"] == "customer") + ); h.process.shutdown().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn resolve_emits_existing_page() { +async fn instance_list_honours_switches() { let h = start().await; - let assert = tokio::task::spawn_blocking({ - let mut c = cli(&h); - c.args(["resolve", "[[customer::acme]]"]); - move || c.assert().success() - }) - .await - .unwrap(); - let out: Value = serde_json::from_slice(&assert.get_output().stdout).unwrap(); - assert_eq!(out["exists"], true); - assert_eq!(out["page"]["skill"], "customer"); - assert_eq!(out["page"]["slug"], "acme"); + // --skill + --order-by-at + --limit together. + let out = run_args( + &h, + v(&[ + "instance", + "list", + "--skill", + "customer", + "--order-by-at", + "desc", + "--limit", + "1", + ]), + ) + .await; + let val = json(&out); + assert_eq!(val["instances"].as_array().unwrap().len(), 1); h.process.shutdown().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn expand_emits_body_and_wikilinks() { +async fn resolve_emits_existing_page() { let h = start().await; - // Use list-instances to find the page_id. - let inst_out = tokio::task::spawn_blocking({ - let mut c = cli(&h); - c.args(["list-instances", "--skill", "customer"]); - move || c.assert().success() - }) - .await - .unwrap(); - let inst: Value = serde_json::from_slice(&inst_out.get_output().stdout).unwrap(); - let acme = inst["instances"] - .as_array() - .unwrap() - .iter() - .find(|i| i["page_id"].as_str().unwrap().contains("acme")) - .unwrap() - .clone(); - let page_id = acme["page_id"].as_str().unwrap().to_owned(); + let out = run_args(&h, v(&["resolve", "[[customer::acme]]"])).await; + let val = json(&out); + assert_eq!(val["exists"], true); + assert_eq!(val["page"]["skill"], "customer"); + assert_eq!(val["page"]["slug"], "acme"); + h.process.shutdown().await; +} - let expand_out = tokio::task::spawn_blocking({ - let mut c = cli(&h); - c.args(["expand", page_id.as_str()]); - move || c.assert().success() - }) - .await - .unwrap(); - let out: Value = serde_json::from_slice(&expand_out.get_output().stdout).unwrap(); - assert!(out["body"].as_str().unwrap().contains("Acme Corp")); +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn page_expand_emits_body_and_wikilinks() { + let h = start().await; + let page_id = acme_page_id(&h).await; + let out = run_args(&h, v(&["page", "expand", &page_id])).await; + let val = json(&out); + assert!(val["body"].as_str().unwrap().contains("Acme Corp")); assert!( - out["wikilinks_out"] + val["wikilinks_out"] .as_array() .unwrap() .iter() @@ -164,24 +206,72 @@ async fn expand_emits_body_and_wikilinks() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn search_emits_hits() { +async fn link_neighbours_traverses() { let h = start().await; - let assert = tokio::task::spawn_blocking({ - let mut c = cli(&h); - c.args(["search", "Acme", "--k", "5"]); - move || c.assert().success() - }) - .await - .unwrap(); - let out: Value = serde_json::from_slice(&assert.get_output().stdout).unwrap(); - let hits = out["hits"].as_array().unwrap(); - assert!(!hits.is_empty()); - assert_eq!(out["granularity"], "block"); + let page_id = acme_page_id(&h).await; + // direction + limit switches. + let out = run_args( + &h, + v(&[ + "link", + "neighbours", + &page_id, + "--direction", + "out", + "--limit", + "10", + ]), + ) + .await; + let val = json(&out); + assert!(val["edges"].is_array()); h.process.shutdown().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn update_page_via_stdin_round_trips() { +async fn search_honours_switches_and_table_format() { + let h = start().await; + // --k + --page-type + --skill. + let out = run_args( + &h, + v(&[ + "search", + "Acme", + "--k", + "5", + "--page-type", + "any", + "--skill", + "customer", + ]), + ) + .await; + let val = json(&out); + assert!(!val["hits"].as_array().unwrap().is_empty()); + assert_eq!(val["granularity"], "block"); + + // Same query, table format: human output, non-JSON, mentions a hit. + let table = run_args(&h, v(&["--format", "table", "search", "Acme", "--k", "5"])).await; + let text = String::from_utf8_lossy(&table.stdout); + assert!(text.contains("hits:"), "table output should label hits"); + assert!(serde_json::from_slice::(&table.stdout).is_err()); + h.process.shutdown().await; +} + +// --- write --------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn page_validate_accepts_well_formed_body() { + let h = start().await; + let page_id = acme_page_id(&h).await; + let out = run_stdin(&h, v(&["page", "validate", &page_id]), ACME_INSTANCE).await; + let val = json(&out); + assert_eq!(val["ok"], true, "issues: {:?}", val["issues"]); + h.process.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn page_update_via_stdin_round_trips() { let h = start().await; let body = "---\n\ type: instance\n\ @@ -190,31 +280,105 @@ async fn update_page_via_stdin_round_trips() { name: Globex\n\ ---\n\ # Globex\n"; - let assert = tokio::task::spawn_blocking({ - let mut c = cli(&h); - c.args(["update-page", "markdown/instances/customer/globex.md"]); - c.write_stdin(body); - move || c.assert().success() - }) - .await - .unwrap(); - let out: Value = serde_json::from_slice(&assert.get_output().stdout).unwrap(); - assert_eq!(out["ok"], true); + let out = run_stdin( + &h, + v(&["page", "update", "markdown/instances/customer/globex.md"]), + body, + ) + .await; + assert_eq!(json(&out)["ok"], true); h.process.shutdown().await; } +// --- events (M7 CRM core) ------------------------------------------ + +/// The realistic CRM flow end to end through the CLI: +/// capture → inbox → assign → list on the instance. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn chat_append_then_list_round_trips() { +async fn event_capture_inbox_assign_list_flow() { let h = start().await; + let acme = acme_page_id(&h).await; + + // capture (body via --body), with common switches set. + let captured = run_args( + &h, + v(&[ + "event", + "capture", + "--title", + "Renewal call", + "--body", + "Acme wants to renew.", + "--source", + "manual", + "--label-skill", + "note", + ]), + ) + .await; + let event_id = json(&captured)["event_id"].as_str().unwrap().to_owned(); + assert!(!event_id.is_empty()); + + // inbox shows it (with --limit). + let inbox = run_args(&h, v(&["event", "inbox", "--limit", "50"])).await; + assert!( + json(&inbox)["events"] + .as_array() + .unwrap() + .iter() + .any(|e| e["event_id"] == event_id) + ); + + // assign it to acme. + let assigned = run_args( + &h, + v(&["event", "assign", "--event", &event_id, "--instance", &acme]), + ) + .await; + assert_eq!(json(&assigned)["instance_page_id"], acme); + + // list the instance's processed history (with --limit). + let hist = run_args( + &h, + v(&["event", "list", "--instance", &acme, "--limit", "10"]), + ) + .await; + let found = json(&hist)["events"] + .as_array() + .unwrap() + .iter() + .any(|e| e["event_id"] == event_id && e["status"] == "processed"); + assert!(found, "assigned event should be in processed history"); + h.process.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn event_capture_reads_body_from_stdin() { + let h = start().await; + let out = run_stdin( + &h, + v(&["event", "capture", "--title", "piped"]), + "piped event body", + ) + .await; + let val = json(&out); + assert_eq!(val["body"], "piped event body"); + assert_eq!(val["status"], "inbox"); + h.process.shutdown().await; +} + +// --- chat ---------------------------------------------------------- - // Append two messages via `chat append` with explicit timestamps. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn chat_append_then_list_round_trips() { + let h = start().await; for (ts, content) in [ ("2026-05-26T10:00:00Z", "hi"), ("2026-05-26T10:00:05Z", "there"), ] { - tokio::task::spawn_blocking({ - let mut c = cli(&h); - c.args([ + run_args( + &h, + v(&[ "chat", "append", "--group", @@ -225,70 +389,63 @@ async fn chat_append_then_list_round_trips() { content, "--ts", ts, - ]); - move || c.assert().success() - }) - .await - .unwrap(); + ]), + ) + .await; } - - // List them back. - let assert = tokio::task::spawn_blocking({ - let mut c = cli(&h); - c.args(["chat", "list", "--group", "room-1", "--direction", "asc"]); - move || c.assert().success() - }) - .await - .unwrap(); - let out: Value = serde_json::from_slice(&assert.get_output().stdout).unwrap(); - let bodies: Vec<&str> = out["messages"] + // list with --direction + --limit switches. + let out = run_args( + &h, + v(&[ + "chat", + "list", + "--group", + "room-1", + "--direction", + "asc", + "--limit", + "100", + ]), + ) + .await; + let bodies: Vec = json(&out)["messages"] .as_array() .unwrap() .iter() - .map(|m| m["content"].as_str().unwrap()) + .map(|m| m["content"].as_str().unwrap().to_owned()) .collect(); - assert_eq!(bodies, vec!["hi", "there"]); + assert_eq!(bodies, vec!["hi".to_owned(), "there".to_owned()]); h.process.shutdown().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn chat_append_reads_content_from_stdin_when_flag_omitted() { +async fn chat_append_reads_content_from_stdin() { let h = start().await; - let assert = tokio::task::spawn_blocking({ - let mut c = cli(&h); - c.args([ + let out = run_stdin( + &h, + v(&[ "chat", "append", "--group", - "room-1", - "--role", - "user", + "room-2", "--ts", "2026-05-26T10:00:00Z", - ]); - c.write_stdin("piped body"); - move || c.assert().success() - }) - .await - .unwrap(); - let out: Value = serde_json::from_slice(&assert.get_output().stdout).unwrap(); - assert!(out["msg_id"].as_str().unwrap().len() == 26, "server ULID"); - - let list = tokio::task::spawn_blocking({ - let mut c = cli(&h); - c.args(["chat", "list", "--group", "room-1", "--direction", "asc"]); - move || c.assert().success() - }) - .await - .unwrap(); - let list_out: Value = serde_json::from_slice(&list.get_output().stdout).unwrap(); - assert_eq!(list_out["messages"][0]["content"], "piped body"); + ]), + "piped body", + ) + .await; + assert_eq!(json(&out)["msg_id"].as_str().unwrap().len(), 26); + let list = run_args( + &h, + v(&["chat", "list", "--group", "room-2", "--direction", "asc"]), + ) + .await; + assert_eq!(json(&list)["messages"][0]["content"], "piped body"); h.process.shutdown().await; } -/// When the server is configured without an OidcVerifier (dev / -/// on-host mode), the CLI must still work without a token — -/// don't gate on `ESCUREL_TOKEN` before even attempting the call. +// --- auth modes ---------------------------------------------------- + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn unauthenticated_mode_works_without_token() { let process = EscurelProcess::spawn(Opts { @@ -297,8 +454,6 @@ async fn unauthenticated_mode_works_without_token() { FixtureBuilder::new() .tenant(TENANT) .skill("customer", CUSTOMER_SKILL) - .instance("customer", "acme", ACME_INSTANCE) - .instance("customer", "initech", INITECH_INSTANCE) .done(), ), config_overrides: ConfigOverrides { @@ -313,21 +468,21 @@ async fn unauthenticated_mode_works_without_token() { .strip_prefix("http://") .unwrap() .to_owned(); - - let assert = tokio::task::spawn_blocking(move || { + let out = tokio::task::spawn_blocking(move || { Command::cargo_bin("escurel") .unwrap() .env("ESCUREL_SERVER", format!("http://{grpc_addr}")) .env_remove("ESCUREL_TOKEN") - .args(["list-skills"]) + .args(["skill", "list"]) .assert() .success() + .get_output() + .clone() }) .await .unwrap(); - let out: Value = serde_json::from_slice(&assert.get_output().stdout).unwrap(); assert!( - out["skills"] + json(&out)["skills"] .as_array() .unwrap() .iter() @@ -337,28 +492,31 @@ async fn unauthenticated_mode_works_without_token() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn missing_token_against_authed_server_returns_unauthenticated() { +async fn missing_token_against_authed_server_emits_json_error() { let h = start().await; - let output = tokio::task::spawn_blocking({ - let addr = h.grpc_addr.clone(); - move || { - Command::cargo_bin("escurel") - .unwrap() - .env("ESCUREL_SERVER", format!("http://{addr}")) - .env_remove("ESCUREL_TOKEN") - .args(["list-skills"]) - .assert() - .failure() - } + let addr = h.grpc_addr.clone(); + let out = tokio::task::spawn_blocking(move || { + Command::cargo_bin("escurel") + .unwrap() + .env("ESCUREL_SERVER", format!("http://{addr}")) + .env_remove("ESCUREL_TOKEN") + .args(["skill", "list"]) + .assert() + .failure() + .get_output() + .clone() }) .await .unwrap(); - let stderr = String::from_utf8_lossy(&output.get_output().stderr).to_string(); + // JSON-on-stderr error contract: parseable object with `error`. + let err: Value = serde_json::from_slice(&out.stderr).expect("stderr is JSON"); assert!( - stderr.to_lowercase().contains("unauthenticated") - || stderr.to_lowercase().contains("missing") - || stderr.contains("ESCUREL_TOKEN"), - "expected an auth-related error in stderr, got: {stderr}" + err["error"] + .as_str() + .unwrap() + .to_lowercase() + .contains("unauthenticated"), + "got: {err}" ); h.process.shutdown().await; } From ce49b0341846833b2a223976f78b5121f1fe8387 Mon Sep 17 00:00:00 2001 From: Joachim Rosskopf Date: Sat, 30 May 2026 17:59:49 +0200 Subject: [PATCH 4/6] feat(tui): k9s-style terminal UI crate (escurel-tui) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary ------- New leaf crate `escurel-tui` providing a k9s-style interactive terminal UI over the Escurel gateway. Elm-structured so all navigation + render logic is terminal-free and testable without a TTY: - `App` (src/app.rs): the nav-stack state machine. Pure `render(&mut Frame)` (top breadcrumb bar, main list/detail area, bottom context key-hint bar, `?` help overlay) and `on_key(&mut self) -> Option`. Keys: arrows/j/k move, Enter drills in, Esc/h pops the stack, `/` filters, `q` quits, `?` toggles help. Drill paths: Skills->Instances, Instances/Search->Entity, Inbox event->its assigned-instance Entity. - `Screen` enum: Skills, Instances{skill}, Entity{page_id} (title + frontmatter + body + outgoing wikilinks + backlinks), Inbox, Events{instance}, Search{query}. `App::with_screen` launches straight into any screen (used by the inbox/events test). - `DataSource` (src/data.rs): wraps `escurel_client::Client`, turning a `DataRequest` into owned `ScreenData` DTOs via the real RPCs (list_skills / list_instances / expand+neighbours / list_inbox / list_events / search). DTO field names track the actual proto messages (Skill.id/description/is_event_typed, InstanceInfo page_id/skill/at, ExpandResponse page/body/wikilinks_out, Event title/status/instance_page_id, Edge backlinks). - `run(endpoint, token)` (src/lib.rs): thin async event loop with an RAII `TerminalGuard` that restores raw mode + the alternate screen on every exit path (Drop). Not unit-tested (needs a TTY); kept minimal. Pinned ratatui 0.29.0 + crossterm 0.28.1 (both resolved cleanly). Added the crate to the workspace `members`. Dependencies are spelled with explicit versions/path deps (the workspace root defines no `[workspace.dependencies]` table, so `{ workspace = true }` deps do not resolve here — matched the escurel-cli / escurel-client style instead). Not yet wired into the `escurel-cli` binary — that launch wiring is a deliberate follow-up. Scope note: the Search screen render + data path is implemented, but there is no key-driven drill-in that *creates* a Search screen from the current screen set (Search is launched via `App::with_screen`); this matches the spec's "at least the five CRM-core screens" allowance. Test plan --------- No-mock integration tests in `crates/escurel-tui/tests/tui_e2e.rs`, each spawning a real `EscurelProcess` (AuthMode::TestIssuer), seeding the customer/acme/initech fixture, minting an Agent token, driving `DataSource` + `App` directly (no TTY) and asserting the text rendered to a `ratatui::backend::TestBackend`: - `navigation_flow_renders_corpus` — Skills render contains "customer"; Enter->Instances render contains "acme"; Enter->Entity render contains the "Acme" title and the outgoing link "initech". - `inbox_and_events_render_captured_event` — capture_event onto the acme instance via the real client, then Events screen render contains "Renewal call"; Inbox screen render shows the "Inbox" panel. Both pass (2 passed; 0 failed). `cargo fmt --check -p escurel-tui`, `cargo clippy -p escurel-tui --all-targets -- -D warnings`, and `cargo test -p escurel-tui --all-targets` all green. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 178 ++++++++- Cargo.toml | 1 + crates/escurel-tui/Cargo.toml | 29 ++ crates/escurel-tui/src/app.rs | 537 ++++++++++++++++++++++++++++ crates/escurel-tui/src/data.rs | 321 +++++++++++++++++ crates/escurel-tui/src/lib.rs | 100 ++++++ crates/escurel-tui/tests/tui_e2e.rs | 207 +++++++++++ 7 files changed, 1364 insertions(+), 9 deletions(-) create mode 100644 crates/escurel-tui/Cargo.toml create mode 100644 crates/escurel-tui/src/app.rs create mode 100644 crates/escurel-tui/src/data.rs create mode 100644 crates/escurel-tui/src/lib.rs create mode 100644 crates/escurel-tui/tests/tui_e2e.rs diff --git a/Cargo.lock b/Cargo.lock index 0123323..fe22c49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -537,7 +537,7 @@ dependencies = [ "http 0.2.12", "http 1.4.0", "http-body 1.0.1", - "lru", + "lru 0.16.4", "percent-encoding", "regex-lite", "sha2 0.11.0", @@ -1328,6 +1328,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + [[package]] name = "cast" version = "0.3.0" @@ -1468,7 +1474,21 @@ checksum = "4a65ebfec4fb190b6f90e944a817d60499ee0744e582530e2c9900a22e591d9a" dependencies = [ "crossterm", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.0", +] + +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", ] [[package]] @@ -1495,7 +1515,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width", + "unicode-width 0.2.0", "windows-sys 0.59.0", ] @@ -1633,8 +1653,11 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags", "crossterm_winapi", + "mio", "parking_lot", "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", "winapi", ] @@ -1966,7 +1989,7 @@ dependencies = [ "libduckdb-sys", "num-integer", "rust_decimal", - "strum", + "strum 0.27.2", ] [[package]] @@ -2424,6 +2447,19 @@ dependencies = [ "wiremock", ] +[[package]] +name = "escurel-tui" +version = "1.0.0" +dependencies = [ + "anyhow", + "crossterm", + "escurel-client", + "escurel-test-support", + "ratatui", + "secrecy", + "tokio", +] + [[package]] name = "etcetera" version = "0.11.0" @@ -2959,6 +2995,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.1.5", ] @@ -3510,10 +3548,32 @@ dependencies = [ "console", "number_prefix", "portable-atomic", - "unicode-width", + "unicode-width 0.2.0", "web-time", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -3544,6 +3604,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -3898,6 +3967,15 @@ dependencies = [ "serde", ] +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru" version = "0.16.4" @@ -4030,6 +4108,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -5000,6 +5079,27 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str 0.8.2", + "crossterm", + "indoc", + "instability", + "itertools 0.13.0", + "lru 0.12.5", + "paste", + "strum 0.26.3", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "raw-cpuid" version = "11.6.0" @@ -5785,6 +5885,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -5952,13 +6073,35 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros 0.26.4", +] + [[package]] name = "strum" version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", ] [[package]] @@ -6254,7 +6397,7 @@ checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223" dependencies = [ "ahash 0.8.12", "aho-corasick", - "compact_str", + "compact_str 0.9.0", "dary_heap", "derive_builder", "esaxx-rs", @@ -6812,11 +6955,28 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "unicode-width" -version = "0.2.2" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "unicode-xid" diff --git a/Cargo.toml b/Cargo.toml index d00f6ca..c44be1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "crates/escurel-server", "crates/escurel-cli", "crates/escurel-client", + "crates/escurel-tui", "crates/escurel-demo-agent", "crates/escurel-crdt", "crates/escurel-test-support", diff --git a/crates/escurel-tui/Cargo.toml b/crates/escurel-tui/Cargo.toml new file mode 100644 index 0000000..0e9bfe8 --- /dev/null +++ b/crates/escurel-tui/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "escurel-tui" +description = "k9s-style interactive terminal UI for the Escurel gateway, built on escurel-client." +edition.workspace = true +rust-version.workspace = true +version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true + +[dependencies] +anyhow = "1" +# Single typed gateway abstraction — the TUI is pure presentation on top +# of it (no hand-rolled tonic, no direct escurel-proto dependency). +escurel-client = { path = "../escurel-client" } +ratatui = "0.29" +crossterm = "0.28" +secrecy = "0.10" +tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "macros"] } + +[dev-dependencies] +# No-mock e2e spawns a real gateway and seeds fixtures through the +# public write path. +escurel-test-support = { path = "../escurel-test-support" } +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } + +[lints] +workspace = true diff --git a/crates/escurel-tui/src/app.rs b/crates/escurel-tui/src/app.rs new file mode 100644 index 0000000..8163f0f --- /dev/null +++ b/crates/escurel-tui/src/app.rs @@ -0,0 +1,537 @@ +//! The Elm-style application state and pure render/update logic. +//! +//! Everything here is synchronous and terminal-free: [`App::render`] draws to +//! any [`ratatui::Frame`] (incl. a `TestBackend`), and [`App::on_key`] mutates +//! state and returns an optional [`DataRequest`] for the event loop to fetch. + +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}; + +use crate::data::{DataRequest, EntityView, ScreenData}; + +/// A view in the navigation stack. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Screen { + Skills, + Instances { skill: String }, + Entity { page_id: String }, + Inbox, + Events { instance: String }, + Search { query: String }, +} + +impl Screen { + /// The [`DataRequest`] that loads this screen's data. + pub fn request(&self) -> DataRequest { + match self { + Screen::Skills => DataRequest::Skills, + Screen::Instances { skill } => DataRequest::Instances(skill.clone()), + Screen::Entity { page_id } => DataRequest::Entity(page_id.clone()), + Screen::Inbox => DataRequest::Inbox, + Screen::Events { instance } => DataRequest::Events(instance.clone()), + Screen::Search { query } => DataRequest::Search(query.clone()), + } + } + + fn kind(&self) -> &'static str { + match self { + Screen::Skills => "Skills", + Screen::Instances { .. } => "Instances", + Screen::Entity { .. } => "Entity", + Screen::Inbox => "Inbox", + Screen::Events { .. } => "Events", + Screen::Search { .. } => "Search", + } + } +} + +/// The application state. +pub struct App { + /// Navigation stack; the last element is the focused screen. + stack: Vec, + /// Data loaded for the focused screen. + data: ScreenData, + /// Selected row index within the focused screen's list. + selected: usize, + /// Filter string (active while `filtering`). + filter: String, + /// Whether the filter input bar is active. + filtering: bool, + /// Whether the help overlay is shown. + help: bool, + /// Status / breadcrumb status message. + status: String, + /// Set once the user requests quit. + should_quit: bool, +} + +impl Default for App { + fn default() -> Self { + Self::new() + } +} + +impl App { + /// A fresh app focused on the Skills screen with no data yet loaded. + pub fn new() -> Self { + Self { + stack: vec![Screen::Skills], + data: ScreenData::Empty, + selected: 0, + filter: String::new(), + filtering: false, + help: false, + status: String::new(), + should_quit: false, + } + } + + /// Build an app whose initial focused screen is `screen` (e.g. to launch + /// straight into the inbox or an instance's events). The caller fetches + /// [`App::current_request`] to populate it. + pub fn with_screen(screen: Screen) -> Self { + let mut app = Self::new(); + app.stack = vec![screen]; + app + } + + /// The focused screen. + pub fn current(&self) -> &Screen { + self.stack.last().expect("stack is never empty") + } + + /// The [`DataRequest`] needed to populate the focused screen. + pub fn current_request(&self) -> DataRequest { + self.current().request() + } + + /// Whether the user asked to quit. + pub fn should_quit(&self) -> bool { + self.should_quit + } + + /// Store freshly fetched data, resetting the selection to the top. + pub fn set_data(&mut self, data: ScreenData) { + self.selected = 0; + self.data = data; + } + + /// Set the status / breadcrumb message. + pub fn set_status(&mut self, status: impl Into) { + self.status = status.into(); + } + + /// Push a new screen; the caller should then fetch its data. + fn push(&mut self, screen: Screen) { + self.stack.push(screen); + self.selected = 0; + self.filter.clear(); + self.data = ScreenData::Empty; + } + + /// Pop the focused screen; returns true if a screen was popped. + fn pop(&mut self) -> bool { + if self.stack.len() > 1 { + self.stack.pop(); + self.selected = 0; + self.filter.clear(); + self.data = ScreenData::Empty; + true + } else { + false + } + } + + /// Indices of rows passing the current filter, in display order. + fn filtered_indices(&self) -> Vec { + let needle = self.filter.to_lowercase(); + let matches = |hay: &str| needle.is_empty() || hay.to_lowercase().contains(&needle); + match &self.data { + ScreenData::Skills(v) => v + .iter() + .enumerate() + .filter(|(_, r)| matches(&r.id) || matches(&r.description)) + .map(|(i, _)| i) + .collect(), + ScreenData::Instances(v) => v + .iter() + .enumerate() + .filter(|(_, r)| matches(&r.page_id) || matches(&r.skill)) + .map(|(i, _)| i) + .collect(), + ScreenData::Inbox(v) => v + .iter() + .enumerate() + .filter(|(_, r)| matches(&r.title) || matches(&r.label_skill)) + .map(|(i, _)| i) + .collect(), + ScreenData::Events(v) => v + .iter() + .enumerate() + .filter(|(_, r)| matches(&r.title) || matches(&r.status)) + .map(|(i, _)| i) + .collect(), + ScreenData::Search(v) => v + .iter() + .enumerate() + .filter(|(_, r)| matches(&r.page_id) || matches(&r.snippet)) + .map(|(i, _)| i) + .collect(), + ScreenData::Entity(_) | ScreenData::Empty => Vec::new(), + } + } + + /// The screen produced by drilling into the selected row, if any. + fn drill_target(&self) -> Option { + let visible = self.filtered_indices(); + let &row = visible.get(self.selected)?; + match &self.data { + ScreenData::Skills(v) => v.get(row).map(|s| Screen::Instances { + skill: s.id.clone(), + }), + ScreenData::Instances(v) => v.get(row).map(|i| Screen::Entity { + page_id: i.page_id.clone(), + }), + // An inbox event drills into its assigned instance entity (if any). + ScreenData::Inbox(v) => v.get(row).and_then(|it| { + (!it.instance_page_id.is_empty()).then(|| Screen::Entity { + page_id: it.instance_page_id.clone(), + }) + }), + ScreenData::Search(v) => v.get(row).map(|r| Screen::Entity { + page_id: r.page_id.clone(), + }), + // Events rows are leaves; the entity detail has its own links. + ScreenData::Events(_) | ScreenData::Entity(_) | ScreenData::Empty => None, + } + } + + /// Handle a key event. Returns a [`DataRequest`] when the event loop must + /// fetch data (after a drill-in or pop). Pure: no I/O happens here. + pub fn on_key(&mut self, key: KeyEvent) -> Option { + // Help overlay swallows everything except its own dismissal. + if self.help { + if matches!( + key.code, + KeyCode::Esc | KeyCode::Char('?') | KeyCode::Char('q') + ) { + self.help = false; + } + return None; + } + + // Filter input mode. + if self.filtering { + match key.code { + KeyCode::Esc => { + self.filtering = false; + self.filter.clear(); + self.selected = 0; + } + KeyCode::Enter => { + self.filtering = false; + self.selected = 0; + } + KeyCode::Backspace => { + self.filter.pop(); + self.selected = 0; + } + KeyCode::Char(c) => { + self.filter.push(c); + self.selected = 0; + } + _ => {} + } + return None; + } + + match key.code { + KeyCode::Char('q') => { + self.should_quit = true; + None + } + KeyCode::Char('?') => { + self.help = true; + None + } + KeyCode::Char('/') => { + self.filtering = true; + self.filter.clear(); + None + } + KeyCode::Down | KeyCode::Char('j') => { + self.move_selection(1); + None + } + KeyCode::Up | KeyCode::Char('k') => { + self.move_selection(-1); + None + } + KeyCode::Enter => self + .drill_target() + .map(|screen| self.push_and_request(screen)), + KeyCode::Esc | KeyCode::Char('h') => { + if self.pop() { + Some(self.current_request()) + } else { + None + } + } + _ => None, + } + } + + fn push_and_request(&mut self, screen: Screen) -> DataRequest { + self.push(screen); + self.current_request() + } + + fn move_selection(&mut self, delta: i64) { + let n = self.filtered_indices().len(); + if n == 0 { + self.selected = 0; + return; + } + let cur = self.selected as i64; + let next = (cur + delta).clamp(0, n as i64 - 1); + self.selected = next as usize; + } + + // ---- rendering ------------------------------------------------------- + + /// Render the whole UI: breadcrumb bar, main area, key-hint bar. + pub fn render(&self, frame: &mut Frame) { + let area = frame.area(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // breadcrumb + Constraint::Min(1), // main + Constraint::Length(1), // key bar + ]) + .split(area); + + self.render_breadcrumb(frame, chunks[0]); + self.render_main(frame, chunks[1]); + self.render_keybar(frame, chunks[2]); + + if self.help { + self.render_help(frame, area); + } + } + + fn breadcrumb(&self) -> String { + let crumbs: Vec = self + .stack + .iter() + .map(|s| match s { + Screen::Skills => "skills".to_string(), + Screen::Instances { skill } => format!("skill:{skill}"), + Screen::Entity { page_id } => format!("entity:{page_id}"), + Screen::Inbox => "inbox".to_string(), + Screen::Events { instance } => format!("events:{instance}"), + Screen::Search { query } => format!("search:{query}"), + }) + .collect(); + crumbs.join(" > ") + } + + fn render_breadcrumb(&self, frame: &mut Frame, area: Rect) { + let mut line = format!(" {} ", self.breadcrumb()); + if !self.status.is_empty() { + line.push_str(&format!("- {} ", self.status)); + } + let p = Paragraph::new(line).style(Style::default().add_modifier(Modifier::BOLD)); + frame.render_widget(p, area); + } + + fn render_keybar(&self, frame: &mut Frame, area: Rect) { + let hints = if self.filtering { + "[type to filter] apply cancel" + } else { + match self.current() { + Screen::Entity { .. } => " back filter help quit", + _ => " move open back filter help quit", + } + }; + let p = Paragraph::new(format!(" {hints} ")) + .style(Style::default().add_modifier(Modifier::REVERSED)); + frame.render_widget(p, area); + } + + fn render_main(&self, frame: &mut Frame, area: Rect) { + match &self.data { + ScreenData::Entity(view) => self.render_entity(frame, area, view), + _ => self.render_list(frame, area), + } + } + + fn list_rows(&self) -> Vec { + let visible = self.filtered_indices(); + match &self.data { + ScreenData::Skills(v) => visible + .iter() + .filter_map(|&i| v.get(i)) + .map(|s| { + let tag = if s.event_typed { " [event]" } else { "" }; + format!("{}{tag} {}", s.id, s.description) + }) + .collect(), + ScreenData::Instances(v) => visible + .iter() + .filter_map(|&i| v.get(i)) + .map(|i| { + if i.at.is_empty() { + i.page_id.clone() + } else { + format!("{} ({})", i.page_id, i.at) + } + }) + .collect(), + ScreenData::Inbox(v) => visible + .iter() + .filter_map(|&i| v.get(i)) + .map(|it| format!("{} - {}", it.title, it.label_skill)) + .collect(), + ScreenData::Events(v) => visible + .iter() + .filter_map(|&i| v.get(i)) + .map(|e| format!("[{}] {}", e.status, e.title)) + .collect(), + ScreenData::Search(v) => visible + .iter() + .filter_map(|&i| v.get(i)) + .map(|r| format!("{} - {}", r.page_id, r.snippet)) + .collect(), + ScreenData::Entity(_) | ScreenData::Empty => Vec::new(), + } + } + + fn render_list(&self, frame: &mut Frame, area: Rect) { + let rows = self.list_rows(); + let title = format!(" {} ", self.current().kind()); + let items: Vec = if rows.is_empty() { + vec![ListItem::new("(no rows)")] + } else { + rows.iter() + .enumerate() + .map(|(i, r)| { + let marker = if i == self.selected { "> " } else { " " }; + let style = if i == self.selected { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }; + ListItem::new(format!("{marker}{r}")).style(style) + }) + .collect() + }; + let list = List::new(items).block(Block::default().borders(Borders::ALL).title(title)); + frame.render_widget(list, area); + + if self.filtering || !self.filter.is_empty() { + // Overlay the filter input on the top border line. + let filter_area = Rect { + x: area.x + 2, + y: area.y, + width: area.width.saturating_sub(4), + height: 1, + }; + let p = Paragraph::new(format!("/{}", self.filter)); + frame.render_widget(p, filter_area); + } + } + + fn render_entity(&self, frame: &mut Frame, area: Rect, view: &EntityView) { + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) + .split(area); + + // Left: title + frontmatter + body. + let mut left: Vec = vec![ + Line::from(Span::styled( + view.title.clone(), + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(format!("id: {}", view.page_id)), + ]; + if !view.frontmatter_json.is_empty() && view.frontmatter_json != "{}" { + left.push(Line::from("")); + left.push(Line::from(format!( + "frontmatter: {}", + view.frontmatter_json + ))); + } + left.push(Line::from("")); + for l in view.body.lines() { + left.push(Line::from(l.to_string())); + } + let body = Paragraph::new(left) + .wrap(Wrap { trim: false }) + .block(Block::default().borders(Borders::ALL).title(" Entity ")); + frame.render_widget(body, cols[0]); + + // Right: outgoing links + backlinks. + let mut right: Vec = vec![Line::from(Span::styled( + "Outgoing links", + Style::default().add_modifier(Modifier::BOLD), + ))]; + if view.outgoing_links.is_empty() { + right.push(Line::from(" (none)")); + } else { + for l in &view.outgoing_links { + right.push(Line::from(format!(" -> {}", l.target))); + } + } + right.push(Line::from("")); + right.push(Line::from(Span::styled( + "Backlinks", + Style::default().add_modifier(Modifier::BOLD), + ))); + if view.backlinks.is_empty() { + right.push(Line::from(" (none)")); + } else { + for b in &view.backlinks { + right.push(Line::from(format!( + " <- {} ({})", + b.src_page, b.link_skill + ))); + } + } + let links = Paragraph::new(right) + .wrap(Wrap { trim: false }) + .block(Block::default().borders(Borders::ALL).title(" Links ")); + frame.render_widget(links, cols[1]); + } + + fn render_help(&self, frame: &mut Frame, area: Rect) { + let text = vec![ + Line::from(Span::styled( + "escurel-tui - keys", + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(" up/k up down/j down"), + Line::from(" Enter open Esc/h back"), + Line::from(" / filter ? toggle help"), + Line::from(" q quit"), + ]; + // Centre a small box. + let w = 40.min(area.width); + let h = 9.min(area.height); + let rect = Rect { + x: area.x + (area.width.saturating_sub(w)) / 2, + y: area.y + (area.height.saturating_sub(h)) / 2, + width: w, + height: h, + }; + let p = Paragraph::new(text).block(Block::default().borders(Borders::ALL).title(" Help ")); + frame.render_widget(Clear, rect); + frame.render_widget(p, rect); + } +} diff --git a/crates/escurel-tui/src/data.rs b/crates/escurel-tui/src/data.rs new file mode 100644 index 0000000..a4ea301 --- /dev/null +++ b/crates/escurel-tui/src/data.rs @@ -0,0 +1,321 @@ +//! Data layer for the TUI: maps [`DataRequest`]s to gateway RPCs and returns +//! owned [`ScreenData`] DTOs the [`crate::app::App`] can store and render. +//! +//! Keeping all RPC plumbing here (and out of `App`) lets the navigation and +//! render logic be exercised against a [`ratatui::backend::TestBackend`] +//! without a terminal, while this layer is exercised against a real gateway. +//! +//! The DTOs are deliberately owned `String`s (not borrows of the proto +//! responses) so the `App` can hold them across event-loop turns without +//! lifetime gymnastics. + +use escurel_client::{ + Client, Edge, ExpandRequest, ListEventsRequest, ListInboxRequest, ListInstancesRequest, + ListSkillsRequest, NeighboursRequest, SearchRequest, WikilinkParsed, +}; + +/// A request the event loop should fulfil by talking to the gateway. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DataRequest { + /// Load the list of skills. + Skills, + /// Load the instances of `skill`. + Instances(String), + /// Expand a single page (frontmatter + body + outgoing links + backlinks). + Entity(String), + /// Load the global inbox. + Inbox, + /// Load the processed events for an instance page id. + Events(String), + /// Run a full-text search for `query`. + Search(String), +} + +/// One skill row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkillRow { + pub id: String, + pub description: String, + pub event_typed: bool, +} + +/// One instance row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstanceRow { + pub page_id: String, + pub skill: String, + pub at: String, +} + +/// An outgoing wikilink from an entity. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LinkRow { + /// Display target, e.g. `customer::initech`. + pub target: String, +} + +/// A backlink edge pointing at an entity. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BacklinkRow { + pub src_page: String, + pub link_skill: String, +} + +/// A fully expanded entity (frontmatter + body + links + backlinks). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EntityView { + pub page_id: String, + pub title: String, + pub frontmatter_json: String, + pub body: String, + pub outgoing_links: Vec, + pub backlinks: Vec, +} + +/// One inbox / event row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EventRow { + pub event_id: String, + pub title: String, + pub label_skill: String, + pub instance_page_id: String, + pub status: String, +} + +/// A search result row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SearchRow { + pub page_id: String, + pub skill: String, + pub snippet: String, +} + +/// The payload loaded for the focused screen. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ScreenData { + Skills(Vec), + Instances(Vec), + Entity(EntityView), + Inbox(Vec), + Events(Vec), + Search(Vec), + Empty, +} + +impl ScreenData { + /// Number of selectable rows (detail views have none). + pub fn len(&self) -> usize { + match self { + ScreenData::Skills(v) => v.len(), + ScreenData::Instances(v) => v.len(), + ScreenData::Inbox(v) => v.len(), + ScreenData::Events(v) => v.len(), + ScreenData::Search(v) => v.len(), + ScreenData::Entity(_) | ScreenData::Empty => 0, + } + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +/// Render a parsed wikilink back into its `skill::id` display form. Mirrors the +/// shape the body text used so a "link to initech" assertion can match. +fn wikilink_target(w: &WikilinkParsed) -> String { + let mut s = String::new(); + if !w.skill.is_empty() { + s.push_str(&w.skill); + s.push_str("::"); + } + s.push_str(&w.id); + if !w.anchor.is_empty() { + s.push('#'); + s.push_str(&w.anchor); + } + s +} + +/// Wraps the typed gateway [`Client`] and turns [`DataRequest`]s into +/// [`ScreenData`]. Entity expansion additionally pulls backlinks via +/// [`Client::neighbours`]. +pub struct DataSource { + client: Client, +} + +impl DataSource { + /// Build a data source over an existing connected client. + pub fn new(client: Client) -> Self { + Self { client } + } + + /// Connect to the gateway and build a data source. + pub async fn connect( + endpoint: &str, + token: escurel_client::SecretString, + ) -> anyhow::Result { + let client = Client::connect(endpoint, token).await?; + Ok(Self::new(client)) + } + + /// Borrow the underlying client (e.g. to capture events in tests). + pub fn client(&self) -> &Client { + &self.client + } + + /// Fulfil a [`DataRequest`] by calling the gateway. + pub async fn fetch(&self, req: &DataRequest) -> anyhow::Result { + match req { + DataRequest::Skills => self.skills().await, + DataRequest::Instances(skill) => self.instances(skill).await, + DataRequest::Entity(page_id) => self.entity(page_id).await, + DataRequest::Inbox => self.inbox().await, + DataRequest::Events(instance) => self.events(instance).await, + DataRequest::Search(query) => self.search(query).await, + } + } + + async fn skills(&self) -> anyhow::Result { + let resp = self + .client + .list_skills(ListSkillsRequest::default()) + .await?; + let rows = resp + .skills + .into_iter() + .map(|s| SkillRow { + id: s.id, + description: s.description, + event_typed: s.is_event_typed, + }) + .collect(); + Ok(ScreenData::Skills(rows)) + } + + async fn instances(&self, skill: &str) -> anyhow::Result { + let resp = self + .client + .list_instances(ListInstancesRequest { + skill: skill.to_string(), + ..Default::default() + }) + .await?; + let rows = resp + .instances + .into_iter() + .map(|i| InstanceRow { + page_id: i.page_id, + skill: i.skill, + at: i.at, + }) + .collect(); + Ok(ScreenData::Instances(rows)) + } + + async fn entity(&self, page_id: &str) -> anyhow::Result { + let resp = self + .client + .expand(ExpandRequest { + page_id: page_id.to_string(), + ..Default::default() + }) + .await?; + // PageRef has no human title field; use the slug as the label and fall + // back to the page id. The rendered body (`# Acme Corp`) carries the + // display name itself. + let (resolved_page_id, title) = match resp.page { + Some(p) if !p.slug.is_empty() => (p.page_id, p.slug), + Some(p) => { + let id = p.page_id.clone(); + (p.page_id, id) + } + None => (page_id.to_string(), page_id.to_string()), + }; + let outgoing_links = resp + .wikilinks_out + .iter() + .map(|w| LinkRow { + target: wikilink_target(w), + }) + .collect(); + // Backlinks come from the neighbours RPC (direction "in"); tolerate + // failure so the entity still renders if the graph lookup errors. + let backlinks = match self + .client + .neighbours(NeighboursRequest { + page_id: resolved_page_id.clone(), + direction: "in".to_string(), + ..Default::default() + }) + .await + { + Ok(n) => n + .edges + .into_iter() + .map(|e: Edge| BacklinkRow { + src_page: e.src_page, + link_skill: e.link_skill, + }) + .collect(), + Err(_) => Vec::new(), + }; + Ok(ScreenData::Entity(EntityView { + page_id: resolved_page_id, + title, + frontmatter_json: resp.frontmatter_json, + body: resp.body, + outgoing_links, + backlinks, + })) + } + + async fn inbox(&self) -> anyhow::Result { + let resp = self.client.list_inbox(ListInboxRequest::default()).await?; + Ok(ScreenData::Inbox( + resp.events.into_iter().map(event_row).collect(), + )) + } + + async fn events(&self, instance: &str) -> anyhow::Result { + let resp = self + .client + .list_events(ListEventsRequest { + instance_page_id: instance.to_string(), + ..Default::default() + }) + .await?; + Ok(ScreenData::Events( + resp.events.into_iter().map(event_row).collect(), + )) + } + + async fn search(&self, query: &str) -> anyhow::Result { + let resp = self + .client + .search(SearchRequest { + q: query.to_string(), + ..Default::default() + }) + .await?; + let rows = resp + .hits + .into_iter() + .map(|h| SearchRow { + page_id: h.page_id, + skill: h.skill, + snippet: h.snippet, + }) + .collect(); + Ok(ScreenData::Search(rows)) + } +} + +fn event_row(e: escurel_client::Event) -> EventRow { + EventRow { + event_id: e.event_id, + title: e.title, + label_skill: e.label_skill, + instance_page_id: e.instance_page_id, + status: e.status, + } +} diff --git a/crates/escurel-tui/src/lib.rs b/crates/escurel-tui/src/lib.rs new file mode 100644 index 0000000..d9f4908 --- /dev/null +++ b/crates/escurel-tui/src/lib.rs @@ -0,0 +1,100 @@ +//! `escurel-tui` — a k9s-style interactive terminal UI for the Escurel +//! knowledge-base gateway. +//! +//! The crate is split so that all navigation and render logic lives in pure, +//! terminal-free code ([`App`] in [`app`]) and all RPC plumbing lives in +//! [`DataSource`] ([`data`]). The only impure entry point is [`run`], a thin +//! event loop that wires crossterm + ratatui to those two pieces. +//! +//! Tests exercise [`App`] against a [`ratatui::backend::TestBackend`] and +//! [`DataSource`] against a real gateway — no mocks. + +mod app; +mod data; + +pub use app::{App, Screen}; +pub use data::{ + BacklinkRow, DataRequest, DataSource, EntityView, EventRow, InstanceRow, LinkRow, ScreenData, + SearchRow, SkillRow, +}; + +use std::io::{self, Stdout}; +use std::time::Duration; + +use crossterm::event::{self, Event}; +use crossterm::execute; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use escurel_client::SecretString; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; + +/// RAII guard that restores the terminal on drop, so even a panic or an early +/// `?` return leaves the user's terminal usable. +struct TerminalGuard; + +impl TerminalGuard { + fn enter() -> anyhow::Result { + enable_raw_mode()?; + let mut out = io::stdout(); + execute!(out, EnterAlternateScreen)?; + Ok(Self) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let mut out = io::stdout(); + let _ = execute!(out, LeaveAlternateScreen); + let _ = disable_raw_mode(); + } +} + +/// Run the interactive TUI against the gateway at `endpoint`. +/// +/// Sets up raw mode + the alternate screen, polls key events, dispatches the +/// resulting [`DataRequest`]s through a [`DataSource`], and re-renders after +/// each step. The terminal is always restored via [`TerminalGuard`]. +pub async fn run(endpoint: &str, token: SecretString) -> anyhow::Result<()> { + let source = DataSource::connect(endpoint, token).await?; + let mut app = App::new(); + + let _guard = TerminalGuard::enter()?; + let backend = CrosstermBackend::new(io::stdout()); + let mut terminal: Terminal> = Terminal::new(backend)?; + + // Initial load of the focused screen. + let initial = app.current_request(); + load(&source, &mut app, initial).await; + + loop { + terminal.draw(|f| app.render(f))?; + if app.should_quit() { + break; + } + + // Poll so we can re-render even without input (and stay responsive). + if !event::poll(Duration::from_millis(200))? { + continue; + } + if let Event::Key(key) = event::read()? + && let Some(req) = app.on_key(key) + { + load(&source, &mut app, req).await; + } + } + + Ok(()) +} + +/// Fetch `req` and store it (or surface the error in the status line). +async fn load(source: &DataSource, app: &mut App, req: DataRequest) { + match source.fetch(&req).await { + Ok(data) => { + app.set_data(data); + app.set_status(""); + } + Err(e) => app.set_status(format!("error: {e}")), + } +} diff --git a/crates/escurel-tui/tests/tui_e2e.rs b/crates/escurel-tui/tests/tui_e2e.rs new file mode 100644 index 0000000..155eb61 --- /dev/null +++ b/crates/escurel-tui/tests/tui_e2e.rs @@ -0,0 +1,207 @@ +//! End-to-end tests for `escurel-tui` against a real gateway. +//! +//! No mocks: each test spawns a real `EscurelProcess`, seeds the canonical +//! customer/acme/initech corpus, mints an Agent token, drives [`DataSource`] + +//! [`App`] state transitions directly (no TTY), renders to a [`TestBackend`] +//! after each step, and asserts the rendered text. Mirrors the spawn/seed +//! pattern in `crates/escurel-cli/tests/cli_e2e.rs`. + +use escurel_client::{AssignEventRequest, CaptureEventRequest, SecretString}; +use escurel_test_support::{AuthMode, ConfigOverrides, EscurelProcess, FixtureBuilder, Opts, Role}; +use escurel_tui::{App, DataRequest, DataSource, Screen}; +use ratatui::Terminal; +use ratatui::backend::TestBackend; + +const TENANT: &str = "acme"; + +const CUSTOMER_SKILL: &str = "---\n\ +type: skill\n\ +id: customer\n\ +description: A buying organisation.\n\ +required_frontmatter: [id, name]\n\ +optional_frontmatter: [tier]\n\ +---\n\ +# customer\n"; + +const ACME_INSTANCE: &str = "---\n\ +type: instance\n\ +skill: customer\n\ +id: acme\n\ +name: Acme Corp\n\ +tier: gold\n\ +---\n\ +# Acme Corp\n\nKey account. See [[customer::initech]].\n"; + +const INITECH_INSTANCE: &str = "---\n\ +type: instance\n\ +skill: customer\n\ +id: initech\n\ +name: Initech\n\ +---\n\ +# Initech\n"; + +const ACME_PAGE_ID: &str = "markdown/instances/customer/acme.md"; + +async fn spawn_seeded() -> EscurelProcess { + EscurelProcess::spawn(Opts { + auth: AuthMode::TestIssuer, + fixtures: Some( + FixtureBuilder::new() + .tenant(TENANT) + .skill("customer", CUSTOMER_SKILL) + .instance("customer", "acme", ACME_INSTANCE) + .instance("customer", "initech", INITECH_INSTANCE) + .done(), + ), + config_overrides: ConfigOverrides::default(), + }) + .await +} + +async fn connect(process: &EscurelProcess) -> DataSource { + let endpoint = process.grpc_endpoint().expect("grpc endpoint").to_owned(); + let token = SecretString::from(process.mint_token(TENANT, Role::Agent)); + DataSource::connect(&endpoint, token) + .await + .expect("connect data source") +} + +/// Render `app` to a 120x40 `TestBackend` and return the buffer as a string. +fn render_to_string(app: &App) -> String { + let backend = TestBackend::new(120, 40); + let mut terminal = Terminal::new(backend).expect("test terminal"); + terminal.draw(|f| app.render(f)).expect("draw"); + let buffer = terminal.backend().buffer().clone(); + let area = *buffer.area(); + let mut out = String::new(); + for y in 0..area.height { + for x in 0..area.width { + out.push_str(buffer[(x, y)].symbol()); + } + out.push('\n'); + } + out +} + +/// Load `req` through the data source into `app`, panicking on RPC error. +async fn load(source: &DataSource, app: &mut App, req: DataRequest) { + let data = source.fetch(&req).await.expect("fetch"); + app.set_data(data); +} + +fn enter() -> crossterm::event::KeyEvent { + crossterm::event::KeyEvent::from(crossterm::event::KeyCode::Enter) +} + +/// Render the screen `screen` of `source` after loading it, returning the text. +async fn render_screen(source: &DataSource, screen: Screen) -> String { + let mut app = App::with_screen(screen); + let req = app.current_request(); + load(source, &mut app, req).await; + render_to_string(&app) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn navigation_flow_renders_corpus() { + let process = spawn_seeded().await; + let source = connect(&process).await; + + let mut app = App::new(); + + // 1. Skills screen -> contains "customer". + let req = app.current_request(); + load(&source, &mut app, req).await; + let skills_view = render_to_string(&app); + assert!( + skills_view.contains("customer"), + "skills view should list the customer skill:\n{skills_view}" + ); + + // 2. Drill into the customer skill -> instances contains "acme". + assert_eq!(app.current(), &Screen::Skills); + let req = app + .on_key(enter()) + .expect("drill into customer should request instances"); + assert_eq!(req, DataRequest::Instances("customer".to_string())); + load(&source, &mut app, req).await; + let instances_view = render_to_string(&app); + assert!( + instances_view.contains("acme"), + "instances view should list acme:\n{instances_view}" + ); + + // 3. Drill into the acme instance -> entity shows "Acme" and the outgoing + // wikilink to initech (the body links [[customer::initech]]). + let req = app + .on_key(enter()) + .expect("drill into acme should request entity"); + assert!(matches!(req, DataRequest::Entity(_))); + load(&source, &mut app, req).await; + let entity_view = render_to_string(&app); + assert!( + entity_view.contains("Acme"), + "entity view should show the Acme title/body:\n{entity_view}" + ); + assert!( + entity_view.contains("initech"), + "entity view should show the outgoing link to initech:\n{entity_view}" + ); + + process.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn inbox_then_assign_renders_event_in_events() { + let process = spawn_seeded().await; + let source = connect(&process).await; + + // Capture an event via the real client. A captured event lands in the + // global inbox (status "inbox") until it is explicitly assigned. + let stored = source + .client() + .capture_event(CaptureEventRequest { + event_id: String::new(), + at: String::new(), + source: "manual".to_string(), + mime: "text/plain".to_string(), + label_skill: "note".to_string(), + instance_page_id: String::new(), + title: "Renewal call".to_string(), + body: "Discussed contract renewal.".to_string(), + provenance_json: String::new(), + }) + .await + .expect("capture event"); + + // Inbox screen render shows the captured event title. + let inbox_view = render_screen(&source, Screen::Inbox).await; + assert!( + inbox_view.contains("Renewal call"), + "inbox view should show the captured event title:\n{inbox_view}" + ); + + // Assign the event to the acme instance -> it becomes "processed" and + // surfaces in that instance's Events history. + source + .client() + .assign_event(AssignEventRequest { + event_id: stored.event_id.clone(), + instance_page_id: ACME_PAGE_ID.to_string(), + }) + .await + .expect("assign event"); + + let events_view = render_screen( + &source, + Screen::Events { + instance: ACME_PAGE_ID.to_string(), + }, + ) + .await; + assert!( + events_view.contains("Renewal call"), + "events view should show the assigned event title:\n{events_view}" + ); + + process.shutdown().await; +} From 7f7303a45b8466d707cb787aaa5cde6fd9f154bf Mon Sep 17 00:00:00 2001 From: Joachim Rosskopf Date: Sat, 30 May 2026 18:15:52 +0200 Subject: [PATCH 5/6] feat(tui): real event loop + escurel ui launcher + docs Summary ------- Finish the escurel-tui terminal-UI crate and wire it into the escurel CLI. - escurel-tui carries a complete crossterm event loop in src/run.rs: a panic-safe TerminalGuard restoring raw mode + the alternate screen on every exit path (Drop), an initial Skills load so the UI is non-empty on launch, and a poll/draw loop that renders the App, dispatches App::on_key -> Option through the DataSource, stores the result via App::set_data, and breaks on should_quit. No todo!/unimplemented! remain anywhere in the crate. - Wire the TUI into the escurel binary: add escurel-tui as a CLI dependency and a new `escurel ui` subcommand that connects with the same --server/--token and hands control to escurel_tui::run. It owns the terminal and emits no JSON, so it returns before the value/emit path. The exhaustive Command match in agent.rs gained an unreachable!("ui handled by escurel_tui::run") arm, mirroring the existing admin arm. - scripts/verify-tui.sh: exit-code-gated runner for the real-gateway TestBackend suite (the TUI analogue of scripts/verify-demo.sh). - Docs: README gains a "CLI & TUI" section (gh-style commands, --format json|table, escurel ui); CHANGELOG records the client admin+streaming surface, the rebuilt gh-style CLI, and escurel-tui. Test plan --------- - cargo fmt --check (escurel-tui, escurel-cli): clean. - cargo clippy -p escurel-tui -p escurel-cli --all-targets -- -D warnings: clean. - cargo test -p escurel-tui: 2 passed (tests/tui_e2e.rs::navigation_flow_renders_corpus and ::inbox_then_assign_renders_event_in_events, each against a real spawned gateway, drawn to a ratatui TestBackend; no mocks). - cargo build -p escurel-cli: builds; `escurel ui --help` and the top-level help list the new subcommand. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 32 ++++++++++++++++++++++++++++++++ Cargo.lock | 1 + README.md | 33 +++++++++++++++++++++++++++++++++ crates/escurel-cli/Cargo.toml | 3 +++ crates/escurel-cli/src/agent.rs | 3 ++- crates/escurel-cli/src/main.rs | 11 ++++++++--- scripts/verify-tui.sh | 17 +++++++++++++++++ 7 files changed, 96 insertions(+), 4 deletions(-) create mode 100755 scripts/verify-tui.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 5049b37..57643df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,38 @@ All notable changes to escurel are recorded here. The format is loosely [Keep a Changelog](https://keepachangelog.com/); escurel follows SemVer from v1.0.0 onward. +## Unreleased + +### Client + +- `escurel-client` admin + streaming surface: an `AdminClient` for the + unary `EscurelAdmin` RPCs (tenant CRUD, audit, quota, health, + `attach_external`, `embedding_reload`, `compact_lanes`) plus the + server-streaming (export / rebuild / compact) and client-streaming + (import) flows, and the agent event/validate RPCs (`capture_event` / + `list_inbox` / `list_events` / `assign_event`, `validate`). + +### CLI + +- Rebuilt the `escurel` CLI as a gh/aws-style noun-verb tree over + `escurel-client` (`skill`, `instance`, `page`, `link`, `event`, + `query`, `chat`, `admin`, plus top-level `search` / `resolve`), with + a global `--format json|table` flag and a JSON-on-stderr error + contract (non-zero exit) for agent consumption. +- New `escurel ui` subcommand launches the interactive terminal + browser against the same `--server` / `--token`. + +### TUI + +- New `escurel-tui` crate: a k9s-style interactive terminal UI + (ratatui + crossterm) over `escurel-client`. Elm-style `App` + (navigation stack skills → instances → entity, inbox + per-instance + event history, outgoing links + backlinks, `/` filter, `?` help) + with a panic-safe terminal guard and a real crossterm event loop. + Logic is terminal-free and exercised against a real gateway via a + ratatui `TestBackend` (no mocks); run it with + `scripts/verify-tui.sh`. + ## [1.0.0] — 2026-05-26 First stable release. The v1 cut-line in diff --git a/Cargo.lock b/Cargo.lock index fe22c49..1c732b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2210,6 +2210,7 @@ dependencies = [ "escurel-client", "escurel-quota", "escurel-test-support", + "escurel-tui", "futures", "serde_json", "tempfile", diff --git a/README.md b/README.md index 3c132dd..6852530 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,39 @@ repo alongside the spec. - Deployment binding to the DataZoo Hetzner substrate is in [`docs/deploy/`](docs/deploy/). +## CLI & TUI + +The `escurel` binary (crate `escurel-cli`) is a gh/aws-style client +over the gateway: one resource noun, one verb, one RPC. It speaks the +gRPC endpoint (`--server` / `ESCUREL_SERVER`, default +`http://127.0.0.1:8081`) with an OIDC bearer (`--token` / +`ESCUREL_TOKEN`). + +```sh +escurel skill list # Tier-1 skill catalogue +escurel instance list --skill customer # instances of a skill +escurel page expand markdown/instances/customer/acme.md +escurel link neighbours --direction in +escurel search "renewal" --k 5 +escurel resolve '[[customer::acme]]' +escurel event capture --title "Renewal call" --body "…" +escurel event inbox # unprocessed events +escurel event assign --event --instance +escurel query run --params '{"skill":"customer"}' +escurel chat append -g --content "hi" +escurel admin tenant list # operator surface +``` + +Every command emits stable JSON by default; pass `--format table` for +a human-readable view. Errors are emitted as JSON on stderr with a +non-zero exit, so an agent can branch on them. + +`escurel ui` launches an interactive **k9s-style terminal browser** +(crate `escurel-tui`) against the same `--server` / `--token`: drill +skills → instances → entity, inspect outgoing links + backlinks, +browse the event inbox and per-instance history, filter with `/`, `?` +for help, `q` to quit. + ## License Source-available under the [Business Source License 1.1](LICENSE), diff --git a/crates/escurel-cli/Cargo.toml b/crates/escurel-cli/Cargo.toml index 3d7ca86..8c59e0b 100644 --- a/crates/escurel-cli/Cargo.toml +++ b/crates/escurel-cli/Cargo.toml @@ -19,6 +19,9 @@ clap = { version = "4", features = ["derive", "env"] } # Single typed gateway abstraction. The CLI is pure presentation on top # of it — no hand-rolled tonic, no direct escurel-proto dependency. escurel-client = { path = "../escurel-client" } +# `escurel ui` launches the interactive k9s-style terminal browser, which +# is itself pure presentation over escurel-client. +escurel-tui = { path = "../escurel-tui" } # preserve_order keeps emitted JSON / table columns in the order the # command authored them, not alphabetised. serde_json = { version = "1", features = ["preserve_order"] } diff --git a/crates/escurel-cli/src/agent.rs b/crates/escurel-cli/src/agent.rs index f7e51a9..8eaa04b 100644 --- a/crates/escurel-cli/src/agent.rs +++ b/crates/escurel-cli/src/agent.rs @@ -232,8 +232,9 @@ pub async fn run(client: &Client, cmd: Command) -> Result { Command::Query(QueryCmd::Run(a)) => run_query(client, a).await, Command::Chat(ChatCmd::Append(a)) => chat_append(client, a).await, Command::Chat(ChatCmd::List(a)) => chat_list(client, a).await, - // Admin is dispatched in main before reaching here. + // Admin / Ui are dispatched in main before reaching here. Command::Admin(_) => unreachable!("admin handled by admin::run"), + Command::Ui => unreachable!("ui handled by escurel_tui::run"), } } diff --git a/crates/escurel-cli/src/main.rs b/crates/escurel-cli/src/main.rs index 70df63e..5dc9ea1 100644 --- a/crates/escurel-cli/src/main.rs +++ b/crates/escurel-cli/src/main.rs @@ -80,6 +80,8 @@ enum Command { /// Operator surface (admin-role token required, except `health`). #[command(subcommand)] Admin(admin::AdminCmd), + /// Launch the interactive k9s-style terminal browser. + Ui, } #[tokio::main] @@ -102,10 +104,13 @@ async fn run(cli: Cli) -> Result<()> { let token = SecretString::from(cli.token.unwrap_or_default()); let fmt = cli.format; - // The admin group dials the admin service; everything else the - // agent service. Dial lazily so a bad URL surfaces the same way for - // both paths. + // The admin group dials the admin service; the `ui` subcommand takes + // over the terminal (raw mode + alternate screen) and never emits + // JSON; everything else dials the agent service and renders a value. + // Dial lazily so a bad URL surfaces the same way for every path. let value = match cli.cmd { + // `ui` returns directly: it owns the terminal and produces no value. + Command::Ui => return escurel_tui::run(&cli.server, token).await, Command::Admin(cmd) => { let client = AdminClient::connect(&cli.server, token).await?; admin::run(&client, cmd).await? diff --git a/scripts/verify-tui.sh b/scripts/verify-tui.sh new file mode 100755 index 0000000..1ed7a12 --- /dev/null +++ b/scripts/verify-tui.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# verify-tui.sh — end-to-end smoke test for the escurel-tui terminal UI. +# +# The TUI's navigation + render logic is exercised against a real gateway +# (spawned by escurel-test-support, no mocks) and drawn to a ratatui +# `TestBackend`, so the whole surface is verifiable headlessly — there is +# no TTY to drive. This script just runs that suite and is exit-code +# gated, the CLI/TUI analogue of scripts/verify-demo.sh. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +echo "==> running escurel-tui end-to-end suite (real gateway, TestBackend)" +cargo test -p escurel-tui + +echo "==> escurel-tui verification passed" From 48bbf158ea5cef295e4b6ae9209e808cd3ab1f9a Mon Sep 17 00:00:00 2001 From: Joachim Rosskopf Date: Sat, 30 May 2026 18:28:09 +0200 Subject: [PATCH 6/6] fix(cli): make `admin tenant import` flag `--in` match its usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `#[arg(long, name = "in")]` renames the value placeholder, not the long flag — clap derived `--input` from the field name, so the tested `--in` was rejected. Use `#[arg(long = "in")]`. The cli_admin_e2e export→import round-trip now passes (6/6). Co-Authored-By: Claude Opus 4.8 --- crates/escurel-cli/src/admin.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/escurel-cli/src/admin.rs b/crates/escurel-cli/src/admin.rs index b27a8c7..3c6d350 100644 --- a/crates/escurel-cli/src/admin.rs +++ b/crates/escurel-cli/src/admin.rs @@ -97,7 +97,7 @@ pub enum TenantCmd { #[arg(long)] id: String, /// Input tarball file path. - #[arg(long, name = "in")] + #[arg(long = "in")] input: String, }, }