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    ///
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    ///
185    /// `force` (optional, default `false`): Collection mode only — overwrite existing
186    /// packages at dest. Single mode rejects pre-existing dest with an error regardless.
187    async fn pkg_install(
188        &self,
189        url: String,
190        name: Option<String>,
191        force: Option<bool>,
192    ) -> Result<String, String>;
193
194    /// Remove a symlinked package from `~/.algocline/packages/`.
195    ///
196    /// Only removes symlinks; for installed (copied) packages, use `pkg_remove`.
197    async fn pkg_unlink(&self, name: String) -> Result<String, String>;
198
199    /// Remove a package entry, scoped by `scope` (`"project"` /
200    /// `"global"` / `"all"`, default `"project"`).
201    ///
202    /// - `"project"`: remove from `alc.toml` + `alc.lock`. Requires an
203    ///   `alc.toml` via `project_root` or ancestor walk.
204    /// - `"global"`: remove from `~/.algocline/installed.json` only.
205    ///   `project_root` is ignored.
206    /// - `"all"`: remove from both; succeeds if either scope had the entry.
207    ///
208    /// Physical files in `~/.algocline/packages/{name}/` are never deleted.
209    async fn pkg_remove(
210        &self,
211        name: &str,
212        project_root: Option<String>,
213        version: Option<String>,
214        scope: Option<String>,
215    ) -> Result<String, String>;
216
217    /// Heal broken package state by reinstalling entries whose installed
218    /// directory is missing. Other broken kinds (dangling symlink,
219    /// declared-path missing) are surfaced as `unrepairable` with a
220    /// suggested remediation.
221    async fn pkg_repair(
222        &self,
223        name: Option<String>,
224        project_root: Option<String>,
225    ) -> Result<String, String>;
226
227    /// Diagnose package state without side effects.
228    ///
229    /// Read-only counterpart of [`Self::pkg_repair`]. Classifies packages
230    /// into four buckets — `healthy`, `installed_missing`, `symlink_dangling`,
231    /// `path_missing` — and returns the result as a JSON string. No
232    /// filesystem writes, no `pkg_install` calls.
233    ///
234    /// `name` restricts the report to a single package; `None` inspects
235    /// every known package. `project_root` is used for the `alc.toml` /
236    /// `alc.local.toml` pass (falls back to ancestor walk from cwd).
237    async fn pkg_doctor(
238        &self,
239        name: Option<String>,
240        project_root: Option<String>,
241    ) -> Result<String, String>;
242
243    // ─── Logging ─────────────────────────────────────────────
244
245    /// Append a note to a session's log file.
246    async fn add_note(
247        &self,
248        session_id: &str,
249        content: &str,
250        title: Option<&str>,
251    ) -> Result<String, String>;
252
253    /// View session logs.
254    async fn log_view(
255        &self,
256        session_id: Option<&str>,
257        limit: Option<usize>,
258        max_chars: Option<usize>,
259    ) -> Result<String, String>;
260
261    /// Aggregate stats across all logged sessions.
262    async fn stats(
263        &self,
264        strategy_filter: Option<&str>,
265        days: Option<u64>,
266    ) -> Result<String, String>;
267
268    // ─── Project lifecycle ────────────────────────────────────
269
270    /// Initialize `alc.toml` in the given project root.
271    ///
272    /// Creates a minimal `alc.toml` (`[packages]` section only).
273    /// Fails if `alc.toml` already exists (no overwrite).
274    async fn init(&self, project_root: Option<String>) -> Result<String, String>;
275
276    /// Re-resolve all `alc.toml` entries and rewrite `alc.lock`.
277    ///
278    /// Requires an `alc.toml` to be present. Returns resolved count and errors.
279    async fn update(&self, project_root: Option<String>) -> Result<String, String>;
280
281    /// Migrate a legacy `alc.lock` to `alc.toml` + new `alc.lock` format.
282    ///
283    /// Detects legacy format via `linked_at` / `local_dir` fields.
284    /// Backs up the old lock file as `alc.lock.bak`.
285    async fn migrate(&self, project_root: Option<String>) -> Result<String, String>;
286
287    // ─── Cards ───────────────────────────────────────────────
288
289    /// List Card summaries, optionally filtered by pkg.
290    async fn card_list(&self, pkg: Option<String>) -> Result<String, String>;
291
292    /// Fetch a full Card by id.
293    async fn card_get(&self, card_id: &str) -> Result<String, String>;
294
295    /// Filter/sort Cards using the Prisma-style `where` DSL.
296    ///
297    /// - `pkg`: restricts filesystem scan to a single pkg subdir (I/O hint).
298    /// - `where_`: nested-object predicate (see `card::parse_where`).
299    /// - `order_by`: array of dotted-path sort keys; `-` prefix = desc.
300    /// - `limit` / `offset`: pagination.
301    async fn card_find(
302        &self,
303        pkg: Option<String>,
304        where_: Option<serde_json::Value>,
305        order_by: Option<serde_json::Value>,
306        limit: Option<usize>,
307        offset: Option<usize>,
308    ) -> Result<String, String>;
309
310    /// List aliases, optionally filtered by pkg.
311    async fn card_alias_list(&self, pkg: Option<String>) -> Result<String, String>;
312
313    /// Resolve an alias name to its bound Card and return the full Card JSON.
314    async fn card_get_by_alias(&self, name: &str) -> Result<String, String>;
315
316    /// Bind (or rebind) an alias to a Card.
317    async fn card_alias_set(
318        &self,
319        name: &str,
320        card_id: &str,
321        pkg: Option<String>,
322        note: Option<String>,
323    ) -> Result<String, String>;
324
325    /// Append new top-level fields to an existing Card (additive-only).
326    async fn card_append(&self, card_id: &str, fields: serde_json::Value)
327        -> Result<String, String>;
328
329    /// Install Cards from a Card Collection repo (Git URL or local path).
330    async fn card_install(&self, url: String) -> Result<String, String>;
331
332    /// Read per-case samples from a Card's sidecar JSONL file.
333    ///
334    /// `where_` applies the same Prisma-style DSL used by `card_find`
335    /// to each sample row; offset/limit page the post-filter stream.
336    async fn card_samples(
337        &self,
338        card_id: &str,
339        offset: Option<usize>,
340        limit: Option<usize>,
341        where_: Option<serde_json::Value>,
342    ) -> Result<String, String>;
343
344    /// Walk a Card's lineage tree via `metadata.prior_card_id`.
345    ///
346    /// - `direction`: `"up"` | `"down"` | `"both"` (default `"up"`).
347    /// - `depth`: max traversal depth (default 10).
348    /// - `include_stats`: include each node's `[stats]` section.
349    /// - `relation_filter`: optional list of accepted `prior_relation` values.
350    async fn card_lineage(
351        &self,
352        card_id: &str,
353        direction: Option<String>,
354        depth: Option<usize>,
355        include_stats: Option<bool>,
356        relation_filter: Option<Vec<String>>,
357    ) -> Result<String, String>;
358
359    /// Backfill one subscriber (`sink` URI) with all cards from the
360    /// primary store. Drift-safe: cards already present on the sink are
361    /// skipped (never overwritten). Returns a `SinkBackfillReport`
362    /// serialized as a JSON string.
363    async fn card_sink_backfill(&self, _sink: String, _dry_run: bool) -> Result<String, String> {
364        Err("card_sink_backfill: not implemented by this EngineApi impl".into())
365    }
366
367    // ─── Hub ─────────────────────────────────────────────────
368
369    /// Rebuild hub index from a packages directory.
370    ///
371    /// When `source_dir` is provided, scans that directory directly
372    /// (pure metadata, no manifest).  When omitted, scans `~/.algocline/packages/`.
373    async fn hub_reindex(
374        &self,
375        output_path: Option<String>,
376        source_dir: Option<String>,
377    ) -> Result<String, String>;
378
379    /// Generate human-readable documentation artifacts from a hub index.
380    ///
381    /// Runs the embedded Lua `gen_docs` pipeline (originally shipped
382    /// with `algocline-bundled-packages`) against `source_dir`, which
383    /// must contain a fresh `hub_index.json`. Emits
384    /// `narrative/{pkg}.md`, `llms.txt`, `llms-full.txt` under
385    /// `out_dir` (defaults to `{source_dir}/docs`), plus optional
386    /// projections depending on `projections`:
387    ///
388    /// - `"hub"`       → `{out_dir}/hub/{pkg}.json`
389    /// - `"context7"`  → `{source_dir}/context7.json`
390    /// - `"devin"`     → `{source_dir}/.devin/wiki.json`
391    /// - `"lint"`      → run V0 lint pass (warnings only)
392    /// - `"lint_only"` → run lint, skip file generation
393    ///
394    /// `config_path` — optional path to a TOML config file. When omitted,
395    /// the project root's `alc.toml` is auto-explored for `[hub.context7]`
396    /// and `[hub.devin]` sections. Core defaults apply when neither a
397    /// `config_path` nor `alc.toml` provides projection config. Passing a
398    /// `.lua` path is a typed error (retired). See
399    /// `docs/hub-gendoc-config.md` for the full schema.
400    ///
401    /// Projection names are validated strictly and unknown values are
402    /// rejected with `Err("gendoc: unknown projection ...")`.
403    ///
404    /// `lint_strict = true` upgrades lint errors to a hard failure
405    /// (equivalent to the `--strict` CLI flag).
406    ///
407    /// Returns a JSON string containing the collected stdout / stderr
408    /// plus the resolved `source_dir` / `out_dir` for observability.
409    async fn hub_gendoc(
410        &self,
411        source_dir: String,
412        out_dir: Option<String>,
413        projections: Option<Vec<String>>,
414        config_path: Option<String>,
415        lint_strict: Option<bool>,
416    ) -> Result<String, String>;
417
418    /// Run `hub_reindex` followed by `hub_gendoc` as a single facade.
419    ///
420    /// This is a convenience wrapper for downstream hub repositories that
421    /// want to regenerate the index and the public docs in one call. The
422    /// composed response is a JSON object:
423    ///
424    /// ```json
425    /// {
426    ///   "reindex": <hub_reindex response>,
427    ///   "gendoc": <hub_gendoc response>,
428    ///   "preset_catalog_version": "...",
429    ///   "preset": { "name": ..., "catalog_version": ..., "resolved": { ... } }
430    /// }
431    /// ```
432    ///
433    /// Error propagation:
434    ///
435    /// - If `hub_reindex` fails, `hub_dist` returns immediately with
436    ///   `Err("dist: reindex failed: {inner}")` and does not invoke
437    ///   `hub_gendoc`.
438    /// - If `hub_gendoc` fails, the error text includes the reindex JSON
439    ///   that already succeeded:
440    ///   `Err("dist: gendoc failed: {inner}\nreindex result (succeeded): {json}")`.
441    ///   The reindex-side side effects (written `hub_index.json`) are not
442    ///   rolled back.
443    ///
444    /// `output_path` is the `hub_index.json` destination (reindex arg).
445    /// Callers typically pass `{source_dir}/hub_index.json` so the
446    /// subsequent gendoc step can read it back.
447    ///
448    /// Presets (`preset`) are expanded inside `hub_dist` into primitive
449    /// `hub_gendoc` arguments (`projections` / `config_path` /
450    /// `lint_strict`). When `preset` is set, the successful JSON response
451    /// includes a `preset` object with `catalog_version` plus the fully
452    /// resolved knobs for observability.
453    ///
454    /// Merge order (strongest wins):
455    /// 1) explicit MCP arguments (`projections` / `config_path` / `lint_strict`)
456    /// 2) optional `alc.toml` overrides under `[hub.dist.presets.<name>]`
457    ///    (keyed by `project_root`) — only fills **omitted** knobs
458    /// 3) builtin `Current` defaults for the selected preset
459    #[allow(clippy::too_many_arguments)]
460    async fn hub_dist(
461        &self,
462        source_dir: String,
463        output_path: Option<String>,
464        out_dir: Option<String>,
465        preset: Option<String>,
466        project_root: Option<String>,
467        projections: Option<Vec<String>>,
468        config_path: Option<String>,
469        lint_strict: Option<bool>,
470    ) -> Result<String, String>;
471
472    /// Show detailed information for a single package.
473    async fn hub_info(&self, pkg: String) -> Result<String, String>;
474
475    /// Search packages across remote index + local install state.
476    ///
477    /// This trait method mirrors the MCP `alc_hub_search` tool. Parameters
478    /// are deliberately individual JSON-primitive `Option<T>` values
479    /// (rather than an aggregate struct) so that the `algocline-core` crate
480    /// stays free of `algocline-app`-internal types (see plan.md §4.1).
481    /// The `algocline-app` side of the impl folds these into its
482    /// `pub(crate) ListOpts` struct.
483    ///
484    /// - `limit` is `Option<i32>` at this layer (MCP/JSON boundary). The
485    ///   impl casts to `usize` internally.
486    /// - `filter` is a free-form JSON object; it is `Deserialize`d into
487    ///   a `HashMap<String, Value>` inside the app layer.
488    /// - `fields` / `verbose` drive projection; `fields` wins when both
489    ///   are supplied.
490    #[allow(clippy::too_many_arguments)]
491    async fn hub_search(
492        &self,
493        query: Option<String>,
494        category: Option<String>,
495        installed_only: Option<bool>,
496        limit: Option<i32>,
497        sort: Option<String>,
498        filter: Option<serde_json::Value>,
499        fields: Option<Vec<String>>,
500        verbose: Option<String>,
501    ) -> Result<String, String>;
502
503    // ─── Package scaffold ─────────────────────────────────────
504
505    /// Generate a minimal package skeleton at `<target_dir>/<name>/init.lua`.
506    ///
507    /// Writes an `M.meta` / `M.spec.entries.run` / `M.run` template with a
508    /// pre-filled `alc_shapes_compat` range derived from the embedded
509    /// alc_shapes version.  Optional `category` / `description` are emitted
510    /// as uncommented fields in `M.meta` when provided.
511    ///
512    /// Returns `{ "status": "ok", "path": "...", "bytes_written": N }` on
513    /// success. Typed errors (`NameInvalid`, `AlreadyExists`, `IoError`) are
514    /// propagated via `Err(String)` to the MCP wire response.
515    async fn pkg_scaffold(
516        &self,
517        name: String,
518        target_dir: Option<String>,
519        category: Option<String>,
520        description: Option<String>,
521    ) -> Result<String, String>;
522
523    /// Read the `init.lua` source of an installed package.
524    ///
525    /// Searches global (`~/.algocline/packages/`) and variant
526    /// (`alc.local.toml`) scope in priority order (variant wins).
527    /// Returns the raw Lua source on success, or an `Err(String)` describing
528    /// why the package was not found or could not be read.
529    async fn pkg_read_init_lua(&self, name: &str) -> Result<String, String>;
530
531    /// Read metadata for a single installed package.
532    ///
533    /// Returns the JSON object string for one package entry (the same shape
534    /// `pkg_list` returns inside `packages[*]`). `Err("pkg not found: ...")`
535    /// when the package is unknown.
536    async fn pkg_meta(&self, name: &str) -> Result<String, String>;
537
538    // ─── Diagnostics ─────────────────────────────────────────
539
540    /// Show server configuration and diagnostic info.
541    async fn info(&self) -> String;
542
543    // ─── Hub resources ───────────────────────────────────────
544
545    /// Return the aggregated hub index across all registered sources as a JSON string.
546    ///
547    /// Merges the cached `hub_index.json` from every discovered source URL.
548    /// Sources that fail to load produce warnings that are embedded in the
549    /// returned JSON under a `"warnings"` field so the MCP caller can observe
550    /// partial failures.
551    ///
552    /// Returns `Ok(json_string)` where the JSON has shape:
553    /// ```json
554    /// { "schema_version": "hub_index/v0", "packages": [...], "warnings": [...] }
555    /// ```
556    /// Returns `Err(message)` only when the hub registries file itself is
557    /// corrupt (hard I/O failure), making further index discovery impossible.
558    async fn hub_index_aggregate(&self) -> Result<String, String>;
559
560    // ─── Pool management ─────────────────────────────────────────
561
562    /// Ensure pool workers are alive; GC stale entries. Idempotent.
563    ///
564    /// Returns JSON `{"sessions": [...], "pool_version": "..."}`.
565    async fn pool_ensure(&self) -> Result<String, String>;
566
567    /// Return pool worker status (registry.json + live state).
568    ///
569    /// When `sid` is provided, restricts to a single worker.
570    /// Returns JSON `{"sessions": [...], "pool_version": "..."}`.
571    async fn pool_status(&self, sid: Option<String>) -> Result<String, String>;
572
573    /// Send SIGTERM to all workers (`sid=None`) or a single worker.
574    ///
575    /// Returns JSON `{"stopped": [...], "errors": [...]}`.
576    async fn pool_stop(&self, sid: Option<String>) -> Result<String, String>;
577}