algocline_core/engine_api.rs
1use async_trait::async_trait;
2
3// ─── Parameter types (transport-independent) ─────────────────────
4
5/// A single query response in a batch feed.
6#[derive(Debug)]
7pub struct QueryResponse {
8 /// Query ID (e.g. "q-0", "q-1").
9 pub query_id: String,
10 /// The host LLM's response for this query.
11 pub response: String,
12 /// Token usage reported by the host for this query.
13 pub usage: Option<crate::TokenUsage>,
14}
15
16// ─── Engine API trait ────────────────────────────────────────────
17
18/// Transport-independent API for the algocline engine.
19///
20/// Abstracts the full public surface of AppService so that callers
21/// (MCP handler, future daemon client, etc.) can operate through
22/// `Arc<dyn EngineApi>` without depending on the concrete implementation.
23///
24/// All methods are async to support both local (in-process) and remote
25/// (socket/HTTP) implementations uniformly.
26#[async_trait]
27pub trait EngineApi: Send + Sync {
28 // ─── Core execution ──────────────────────────────────────
29
30 /// Execute Lua code with optional JSON context.
31 ///
32 /// When `host_mode: Some(true)` is passed, the call is proxied via
33 /// `PoolClient` to a long-lived worker subprocess over a Unix domain socket.
34 /// When `host_mode` is `None` or `Some(false)` the existing in-process
35 /// `Executor::start_session` path is used unchanged.
36 async fn run(
37 &self,
38 code: Option<String>,
39 code_file: Option<String>,
40 ctx: Option<serde_json::Value>,
41 project_root: Option<String>,
42 host_mode: Option<bool>,
43 ) -> Result<String, String>;
44
45 /// Apply an installed strategy package. Task is optional.
46 async fn advice(
47 &self,
48 strategy: &str,
49 task: Option<String>,
50 opts: Option<serde_json::Value>,
51 project_root: Option<String>,
52 ) -> Result<String, String>;
53
54 /// Continue a paused execution — single response (with optional query_id).
55 async fn continue_single(
56 &self,
57 session_id: &str,
58 response: String,
59 query_id: Option<&str>,
60 usage: Option<crate::TokenUsage>,
61 ) -> Result<String, String>;
62
63 /// Continue a paused execution — batch feed.
64 async fn continue_batch(
65 &self,
66 session_id: &str,
67 responses: Vec<QueryResponse>,
68 ) -> Result<String, String>;
69
70 // ─── Session status ──────────────────────────────────────
71
72 /// Query active session status.
73 ///
74 /// `pending_filter` is a free-form JSON value forwarded from MCP
75 /// callers, decoded inside the app layer into either a preset name
76 /// (`"meta"` / `"preview"` / `"full"`) or a custom field-filter
77 /// object. `None` keeps the legacy count-only snapshot.
78 ///
79 /// `include_history`: when `true`, each session snapshot includes
80 /// `conversation_history` (capped at 10 entries). Default `false`
81 /// preserves the lightweight snapshot contract for high-frequency pollers.
82 async fn status(
83 &self,
84 session_id: Option<&str>,
85 pending_filter: Option<serde_json::Value>,
86 include_history: bool,
87 ) -> Result<String, String>;
88
89 // ─── Evaluation ──────────────────────────────────────────
90
91 /// Run an evalframe evaluation suite.
92 ///
93 /// `auto_card`: when true, emit an immutable Card
94 /// (`~/.algocline/cards/{strategy}/{card_id}.toml`) summarizing the run.
95 async fn eval(
96 &self,
97 scenario: Option<String>,
98 scenario_file: Option<String>,
99 scenario_name: Option<String>,
100 strategy: &str,
101 strategy_opts: Option<serde_json::Value>,
102 auto_card: bool,
103 ) -> Result<String, String>;
104
105 /// List eval history, optionally filtered by strategy.
106 async fn eval_history(&self, strategy: Option<&str>, limit: usize) -> Result<String, String>;
107
108 /// View a specific eval result by ID.
109 async fn eval_detail(&self, eval_id: &str) -> Result<String, String>;
110
111 /// Compare two eval results with statistical significance testing.
112 async fn eval_compare(&self, eval_id_a: &str, eval_id_b: &str) -> Result<String, String>;
113
114 // ─── Scenarios ───────────────────────────────────────────
115
116 /// List available scenarios.
117 async fn scenario_list(&self) -> Result<String, String>;
118
119 /// Show the content of a named scenario.
120 async fn scenario_show(&self, name: &str) -> Result<String, String>;
121
122 /// Install scenarios from a Git URL or local path.
123 async fn scenario_install(&self, url: String) -> Result<String, String>;
124
125 // ─── Packages ────────────────────────────────────────────
126
127 /// Link a local directory as a project-local package (symlink to cache).
128 ///
129 /// Scope selection:
130 /// - `scope = None` or `Some("global")` — symlink into `~/.algocline/packages/`
131 /// (visible to all projects).
132 /// - `scope = Some("variant")` — record the path in `alc.local.toml`
133 /// at the project root (worktree-scoped override, git-ignored). No
134 /// symlink is created.
135 /// - Any other value → `Err("invalid scope: ...")`.
136 ///
137 /// `project_root` is only consulted when `scope = Some("variant")`.
138 /// If `None`, falls back to `ALC_PROJECT_ROOT` env or ancestor walk
139 /// from cwd.
140 async fn pkg_link(
141 &self,
142 path: String,
143 name: Option<String>,
144 force: Option<bool>,
145 scope: Option<String>,
146 project_root: Option<String>,
147 ) -> Result<String, String>;
148
149 /// List installed packages with metadata.
150 ///
151 /// When `project_root` is provided, project-local packages from `alc.toml`/`alc.lock`
152 /// are included with `scope: "project"`. Global packages carry `scope: "global"`.
153 ///
154 /// Mirrors the list-tool knob contract used by [`Self::hub_search`]
155 /// (plan.md §4.1). Parameters are individual JSON-primitive
156 /// `Option<T>` values so the `algocline-core` crate stays free of
157 /// `algocline-app`-internal types; the impl folds them into its
158 /// `pub(crate) ListOpts` struct.
159 ///
160 /// - `limit` is `Option<i32>` at this layer (MCP/JSON boundary).
161 /// The impl clamps negative values to 0 and casts to `usize`.
162 /// `Some(0)` (and thus clamped negatives) means **no limit**
163 /// (return all entries — empty-means-all idiom); `None` falls
164 /// back to the tool's default cap.
165 /// - `filter` is a free-form JSON object; it is `Deserialize`d into
166 /// a `HashMap<String, Value>` inside the app layer. Non-object
167 /// values are logged via `tracing::warn` and treated as no filter.
168 /// - `fields` / `verbose` drive projection on each entry of the
169 /// `packages` array; `fields` wins when both are supplied.
170 /// - Top-level keys (`packages`, `search_paths`, `project_root`,
171 /// `lockfile_path`) are never projected away.
172 #[allow(clippy::too_many_arguments)]
173 async fn pkg_list(
174 &self,
175 project_root: Option<String>,
176 limit: Option<i32>,
177 sort: Option<String>,
178 filter: Option<serde_json::Value>,
179 fields: Option<Vec<String>>,
180 verbose: Option<String>,
181 ) -> Result<String, String>;
182
183 /// Install a package from a Git URL or local path.
184 async fn pkg_install(&self, url: String, name: Option<String>) -> Result<String, String>;
185
186 /// Remove a symlinked package from `~/.algocline/packages/`.
187 ///
188 /// Only removes symlinks; for installed (copied) packages, use `pkg_remove`.
189 async fn pkg_unlink(&self, name: String) -> Result<String, String>;
190
191 /// Remove a package entry, scoped by `scope` (`"project"` /
192 /// `"global"` / `"all"`, default `"project"`).
193 ///
194 /// - `"project"`: remove from `alc.toml` + `alc.lock`. Requires an
195 /// `alc.toml` via `project_root` or ancestor walk.
196 /// - `"global"`: remove from `~/.algocline/installed.json` only.
197 /// `project_root` is ignored.
198 /// - `"all"`: remove from both; succeeds if either scope had the entry.
199 ///
200 /// Physical files in `~/.algocline/packages/{name}/` are never deleted.
201 async fn pkg_remove(
202 &self,
203 name: &str,
204 project_root: Option<String>,
205 version: Option<String>,
206 scope: Option<String>,
207 ) -> Result<String, String>;
208
209 /// Heal broken package state by reinstalling entries whose installed
210 /// directory is missing. Other broken kinds (dangling symlink,
211 /// declared-path missing) are surfaced as `unrepairable` with a
212 /// suggested remediation.
213 async fn pkg_repair(
214 &self,
215 name: Option<String>,
216 project_root: Option<String>,
217 ) -> Result<String, String>;
218
219 /// Diagnose package state without side effects.
220 ///
221 /// Read-only counterpart of [`Self::pkg_repair`]. Classifies packages
222 /// into four buckets — `healthy`, `installed_missing`, `symlink_dangling`,
223 /// `path_missing` — and returns the result as a JSON string. No
224 /// filesystem writes, no `pkg_install` calls.
225 ///
226 /// `name` restricts the report to a single package; `None` inspects
227 /// every known package. `project_root` is used for the `alc.toml` /
228 /// `alc.local.toml` pass (falls back to ancestor walk from cwd).
229 async fn pkg_doctor(
230 &self,
231 name: Option<String>,
232 project_root: Option<String>,
233 ) -> Result<String, String>;
234
235 // ─── Logging ─────────────────────────────────────────────
236
237 /// Append a note to a session's log file.
238 async fn add_note(
239 &self,
240 session_id: &str,
241 content: &str,
242 title: Option<&str>,
243 ) -> Result<String, String>;
244
245 /// View session logs.
246 async fn log_view(
247 &self,
248 session_id: Option<&str>,
249 limit: Option<usize>,
250 max_chars: Option<usize>,
251 ) -> Result<String, String>;
252
253 /// Aggregate stats across all logged sessions.
254 async fn stats(
255 &self,
256 strategy_filter: Option<&str>,
257 days: Option<u64>,
258 ) -> Result<String, String>;
259
260 // ─── Project lifecycle ────────────────────────────────────
261
262 /// Initialize `alc.toml` in the given project root.
263 ///
264 /// Creates a minimal `alc.toml` (`[packages]` section only).
265 /// Fails if `alc.toml` already exists (no overwrite).
266 async fn init(&self, project_root: Option<String>) -> Result<String, String>;
267
268 /// Re-resolve all `alc.toml` entries and rewrite `alc.lock`.
269 ///
270 /// Requires an `alc.toml` to be present. Returns resolved count and errors.
271 async fn update(&self, project_root: Option<String>) -> Result<String, String>;
272
273 /// Migrate a legacy `alc.lock` to `alc.toml` + new `alc.lock` format.
274 ///
275 /// Detects legacy format via `linked_at` / `local_dir` fields.
276 /// Backs up the old lock file as `alc.lock.bak`.
277 async fn migrate(&self, project_root: Option<String>) -> Result<String, String>;
278
279 // ─── Cards ───────────────────────────────────────────────
280
281 /// List Card summaries, optionally filtered by pkg.
282 async fn card_list(&self, pkg: Option<String>) -> Result<String, String>;
283
284 /// Fetch a full Card by id.
285 async fn card_get(&self, card_id: &str) -> Result<String, String>;
286
287 /// Filter/sort Cards using the Prisma-style `where` DSL.
288 ///
289 /// - `pkg`: restricts filesystem scan to a single pkg subdir (I/O hint).
290 /// - `where_`: nested-object predicate (see `card::parse_where`).
291 /// - `order_by`: array of dotted-path sort keys; `-` prefix = desc.
292 /// - `limit` / `offset`: pagination.
293 async fn card_find(
294 &self,
295 pkg: Option<String>,
296 where_: Option<serde_json::Value>,
297 order_by: Option<serde_json::Value>,
298 limit: Option<usize>,
299 offset: Option<usize>,
300 ) -> Result<String, String>;
301
302 /// List aliases, optionally filtered by pkg.
303 async fn card_alias_list(&self, pkg: Option<String>) -> Result<String, String>;
304
305 /// Resolve an alias name to its bound Card and return the full Card JSON.
306 async fn card_get_by_alias(&self, name: &str) -> Result<String, String>;
307
308 /// Bind (or rebind) an alias to a Card.
309 async fn card_alias_set(
310 &self,
311 name: &str,
312 card_id: &str,
313 pkg: Option<String>,
314 note: Option<String>,
315 ) -> Result<String, String>;
316
317 /// Append new top-level fields to an existing Card (additive-only).
318 async fn card_append(&self, card_id: &str, fields: serde_json::Value)
319 -> Result<String, String>;
320
321 /// Install Cards from a Card Collection repo (Git URL or local path).
322 async fn card_install(&self, url: String) -> Result<String, String>;
323
324 /// Read per-case samples from a Card's sidecar JSONL file.
325 ///
326 /// `where_` applies the same Prisma-style DSL used by `card_find`
327 /// to each sample row; offset/limit page the post-filter stream.
328 async fn card_samples(
329 &self,
330 card_id: &str,
331 offset: Option<usize>,
332 limit: Option<usize>,
333 where_: Option<serde_json::Value>,
334 ) -> Result<String, String>;
335
336 /// Walk a Card's lineage tree via `metadata.prior_card_id`.
337 ///
338 /// - `direction`: `"up"` | `"down"` | `"both"` (default `"up"`).
339 /// - `depth`: max traversal depth (default 10).
340 /// - `include_stats`: include each node's `[stats]` section.
341 /// - `relation_filter`: optional list of accepted `prior_relation` values.
342 async fn card_lineage(
343 &self,
344 card_id: &str,
345 direction: Option<String>,
346 depth: Option<usize>,
347 include_stats: Option<bool>,
348 relation_filter: Option<Vec<String>>,
349 ) -> Result<String, String>;
350
351 /// Backfill one subscriber (`sink` URI) with all cards from the
352 /// primary store. Drift-safe: cards already present on the sink are
353 /// skipped (never overwritten). Returns a `SinkBackfillReport`
354 /// serialized as a JSON string.
355 async fn card_sink_backfill(&self, _sink: String, _dry_run: bool) -> Result<String, String> {
356 Err("card_sink_backfill: not implemented by this EngineApi impl".into())
357 }
358
359 // ─── Hub ─────────────────────────────────────────────────
360
361 /// Rebuild hub index from a packages directory.
362 ///
363 /// When `source_dir` is provided, scans that directory directly
364 /// (pure metadata, no manifest). When omitted, scans `~/.algocline/packages/`.
365 async fn hub_reindex(
366 &self,
367 output_path: Option<String>,
368 source_dir: Option<String>,
369 ) -> Result<String, String>;
370
371 /// Generate human-readable documentation artifacts from a hub index.
372 ///
373 /// Runs the embedded Lua `gen_docs` pipeline (originally shipped
374 /// with `algocline-bundled-packages`) against `source_dir`, which
375 /// must contain a fresh `hub_index.json`. Emits
376 /// `narrative/{pkg}.md`, `llms.txt`, `llms-full.txt` under
377 /// `out_dir` (defaults to `{source_dir}/docs`), plus optional
378 /// projections depending on `projections`:
379 ///
380 /// - `"hub"` → `{out_dir}/hub/{pkg}.json`
381 /// - `"context7"` → `{source_dir}/context7.json`
382 /// - `"devin"` → `{source_dir}/.devin/wiki.json`
383 /// - `"lint"` → run V0 lint pass (warnings only)
384 /// - `"lint_only"` → run lint, skip file generation
385 ///
386 /// `config_path` — optional path to a TOML config file. When omitted,
387 /// the project root's `alc.toml` is auto-explored for `[hub.context7]`
388 /// and `[hub.devin]` sections. Core defaults apply when neither a
389 /// `config_path` nor `alc.toml` provides projection config. Passing a
390 /// `.lua` path is a typed error (retired). See
391 /// `docs/hub-gendoc-config.md` for the full schema.
392 ///
393 /// Projection names are validated strictly and unknown values are
394 /// rejected with `Err("gendoc: unknown projection ...")`.
395 ///
396 /// `lint_strict = true` upgrades lint errors to a hard failure
397 /// (equivalent to the `--strict` CLI flag).
398 ///
399 /// Returns a JSON string containing the collected stdout / stderr
400 /// plus the resolved `source_dir` / `out_dir` for observability.
401 async fn hub_gendoc(
402 &self,
403 source_dir: String,
404 out_dir: Option<String>,
405 projections: Option<Vec<String>>,
406 config_path: Option<String>,
407 lint_strict: Option<bool>,
408 ) -> Result<String, String>;
409
410 /// Run `hub_reindex` followed by `hub_gendoc` as a single facade.
411 ///
412 /// This is a convenience wrapper for downstream hub repositories that
413 /// want to regenerate the index and the public docs in one call. The
414 /// composed response is a JSON object:
415 ///
416 /// ```json
417 /// {
418 /// "reindex": <hub_reindex response>,
419 /// "gendoc": <hub_gendoc response>,
420 /// "preset_catalog_version": "...",
421 /// "preset": { "name": ..., "catalog_version": ..., "resolved": { ... } }
422 /// }
423 /// ```
424 ///
425 /// Error propagation:
426 ///
427 /// - If `hub_reindex` fails, `hub_dist` returns immediately with
428 /// `Err("dist: reindex failed: {inner}")` and does not invoke
429 /// `hub_gendoc`.
430 /// - If `hub_gendoc` fails, the error text includes the reindex JSON
431 /// that already succeeded:
432 /// `Err("dist: gendoc failed: {inner}\nreindex result (succeeded): {json}")`.
433 /// The reindex-side side effects (written `hub_index.json`) are not
434 /// rolled back.
435 ///
436 /// `output_path` is the `hub_index.json` destination (reindex arg).
437 /// Callers typically pass `{source_dir}/hub_index.json` so the
438 /// subsequent gendoc step can read it back.
439 ///
440 /// Presets (`preset`) are expanded inside `hub_dist` into primitive
441 /// `hub_gendoc` arguments (`projections` / `config_path` /
442 /// `lint_strict`). When `preset` is set, the successful JSON response
443 /// includes a `preset` object with `catalog_version` plus the fully
444 /// resolved knobs for observability.
445 ///
446 /// Merge order (strongest wins):
447 /// 1) explicit MCP arguments (`projections` / `config_path` / `lint_strict`)
448 /// 2) optional `alc.toml` overrides under `[hub.dist.presets.<name>]`
449 /// (keyed by `project_root`) — only fills **omitted** knobs
450 /// 3) builtin `Current` defaults for the selected preset
451 #[allow(clippy::too_many_arguments)]
452 async fn hub_dist(
453 &self,
454 source_dir: String,
455 output_path: Option<String>,
456 out_dir: Option<String>,
457 preset: Option<String>,
458 project_root: Option<String>,
459 projections: Option<Vec<String>>,
460 config_path: Option<String>,
461 lint_strict: Option<bool>,
462 ) -> Result<String, String>;
463
464 /// Show detailed information for a single package.
465 async fn hub_info(&self, pkg: String) -> Result<String, String>;
466
467 /// Search packages across remote index + local install state.
468 ///
469 /// This trait method mirrors the MCP `alc_hub_search` tool. Parameters
470 /// are deliberately individual JSON-primitive `Option<T>` values
471 /// (rather than an aggregate struct) so that the `algocline-core` crate
472 /// stays free of `algocline-app`-internal types (see plan.md §4.1).
473 /// The `algocline-app` side of the impl folds these into its
474 /// `pub(crate) ListOpts` struct.
475 ///
476 /// - `limit` is `Option<i32>` at this layer (MCP/JSON boundary). The
477 /// impl casts to `usize` internally.
478 /// - `filter` is a free-form JSON object; it is `Deserialize`d into
479 /// a `HashMap<String, Value>` inside the app layer.
480 /// - `fields` / `verbose` drive projection; `fields` wins when both
481 /// are supplied.
482 #[allow(clippy::too_many_arguments)]
483 async fn hub_search(
484 &self,
485 query: Option<String>,
486 category: Option<String>,
487 installed_only: Option<bool>,
488 limit: Option<i32>,
489 sort: Option<String>,
490 filter: Option<serde_json::Value>,
491 fields: Option<Vec<String>>,
492 verbose: Option<String>,
493 ) -> Result<String, String>;
494
495 // ─── Package scaffold ─────────────────────────────────────
496
497 /// Generate a minimal package skeleton at `<target_dir>/<name>/init.lua`.
498 ///
499 /// Writes an `M.meta` / `M.spec.entries.run` / `M.run` template with a
500 /// pre-filled `alc_shapes_compat` range derived from the embedded
501 /// alc_shapes version. Optional `category` / `description` are emitted
502 /// as uncommented fields in `M.meta` when provided.
503 ///
504 /// Returns `{ "status": "ok", "path": "...", "bytes_written": N }` on
505 /// success. Typed errors (`NameInvalid`, `AlreadyExists`, `IoError`) are
506 /// propagated via `Err(String)` to the MCP wire response.
507 async fn pkg_scaffold(
508 &self,
509 name: String,
510 target_dir: Option<String>,
511 category: Option<String>,
512 description: Option<String>,
513 ) -> Result<String, String>;
514
515 /// Read the `init.lua` source of an installed package.
516 ///
517 /// Searches global (`~/.algocline/packages/`) and variant
518 /// (`alc.local.toml`) scope in priority order (variant wins).
519 /// Returns the raw Lua source on success, or an `Err(String)` describing
520 /// why the package was not found or could not be read.
521 async fn pkg_read_init_lua(&self, name: &str) -> Result<String, String>;
522
523 /// Read metadata for a single installed package.
524 ///
525 /// Returns the JSON object string for one package entry (the same shape
526 /// `pkg_list` returns inside `packages[*]`). `Err("pkg not found: ...")`
527 /// when the package is unknown.
528 async fn pkg_meta(&self, name: &str) -> Result<String, String>;
529
530 // ─── Diagnostics ─────────────────────────────────────────
531
532 /// Show server configuration and diagnostic info.
533 async fn info(&self) -> String;
534
535 // ─── Hub resources ───────────────────────────────────────
536
537 /// Return the aggregated hub index across all registered sources as a JSON string.
538 ///
539 /// Merges the cached `hub_index.json` from every discovered source URL.
540 /// Sources that fail to load produce warnings that are embedded in the
541 /// returned JSON under a `"warnings"` field so the MCP caller can observe
542 /// partial failures.
543 ///
544 /// Returns `Ok(json_string)` where the JSON has shape:
545 /// ```json
546 /// { "schema_version": "hub_index/v0", "packages": [...], "warnings": [...] }
547 /// ```
548 /// Returns `Err(message)` only when the hub registries file itself is
549 /// corrupt (hard I/O failure), making further index discovery impossible.
550 async fn hub_index_aggregate(&self) -> Result<String, String>;
551
552 // ─── Pool management ─────────────────────────────────────────
553
554 /// Ensure pool workers are alive; GC stale entries. Idempotent.
555 ///
556 /// Returns JSON `{"sessions": [...], "pool_version": "..."}`.
557 async fn pool_ensure(&self) -> Result<String, String>;
558
559 /// Return pool worker status (registry.json + live state).
560 ///
561 /// When `sid` is provided, restricts to a single worker.
562 /// Returns JSON `{"sessions": [...], "pool_version": "..."}`.
563 async fn pool_status(&self, sid: Option<String>) -> Result<String, String>;
564
565 /// Send SIGTERM to all workers (`sid=None`) or a single worker.
566 ///
567 /// Returns JSON `{"stopped": [...], "errors": [...]}`.
568 async fn pool_stop(&self, sid: Option<String>) -> Result<String, String>;
569}