Skip to main content

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