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