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`
371 /// - `"devin"` → `{source_dir}/.devin/wiki.json`
372 /// - `"lint"` → run V0 lint pass (warnings only)
373 /// - `"lint_only"` → run lint, skip file generation
374 ///
375 /// `config_path` — optional path to a TOML config file. When omitted,
376 /// the project root's `alc.toml` is auto-explored for `[hub.context7]`
377 /// and `[hub.devin]` sections. Core defaults apply when neither a
378 /// `config_path` nor `alc.toml` provides projection config. Passing a
379 /// `.lua` path is a typed error (retired). See
380 /// `docs/hub-gendoc-config.md` for the full schema.
381 ///
382 /// Projection names are validated strictly and unknown values are
383 /// rejected with `Err("gendoc: unknown projection ...")`.
384 ///
385 /// `lint_strict = true` upgrades lint errors to a hard failure
386 /// (equivalent to the `--strict` CLI flag).
387 ///
388 /// Returns a JSON string containing the collected stdout / stderr
389 /// plus the resolved `source_dir` / `out_dir` for observability.
390 async fn hub_gendoc(
391 &self,
392 source_dir: String,
393 out_dir: Option<String>,
394 projections: Option<Vec<String>>,
395 config_path: Option<String>,
396 lint_strict: Option<bool>,
397 ) -> Result<String, String>;
398
399 /// Run `hub_reindex` followed by `hub_gendoc` as a single facade.
400 ///
401 /// This is a convenience wrapper for downstream hub repositories that
402 /// want to regenerate the index and the public docs in one call. The
403 /// composed response is a JSON object:
404 ///
405 /// ```json
406 /// {
407 /// "reindex": <hub_reindex response>,
408 /// "gendoc": <hub_gendoc response>,
409 /// "preset_catalog_version": "...",
410 /// "preset": { "name": ..., "catalog_version": ..., "resolved": { ... } }
411 /// }
412 /// ```
413 ///
414 /// Error propagation:
415 ///
416 /// - If `hub_reindex` fails, `hub_dist` returns immediately with
417 /// `Err("dist: reindex failed: {inner}")` and does not invoke
418 /// `hub_gendoc`.
419 /// - If `hub_gendoc` fails, the error text includes the reindex JSON
420 /// that already succeeded:
421 /// `Err("dist: gendoc failed: {inner}\nreindex result (succeeded): {json}")`.
422 /// The reindex-side side effects (written `hub_index.json`) are not
423 /// rolled back.
424 ///
425 /// `output_path` is the `hub_index.json` destination (reindex arg).
426 /// Callers typically pass `{source_dir}/hub_index.json` so the
427 /// subsequent gendoc step can read it back.
428 ///
429 /// Presets (`preset`) are expanded inside `hub_dist` into primitive
430 /// `hub_gendoc` arguments (`projections` / `config_path` /
431 /// `lint_strict`). When `preset` is set, the successful JSON response
432 /// includes a `preset` object with `catalog_version` plus the fully
433 /// resolved knobs for observability.
434 ///
435 /// Merge order (strongest wins):
436 /// 1) explicit MCP arguments (`projections` / `config_path` / `lint_strict`)
437 /// 2) optional `alc.toml` overrides under `[hub.dist.presets.<name>]`
438 /// (keyed by `project_root`) — only fills **omitted** knobs
439 /// 3) builtin `Current` defaults for the selected preset
440 #[allow(clippy::too_many_arguments)]
441 async fn hub_dist(
442 &self,
443 source_dir: String,
444 output_path: Option<String>,
445 out_dir: Option<String>,
446 preset: Option<String>,
447 project_root: Option<String>,
448 projections: Option<Vec<String>>,
449 config_path: Option<String>,
450 lint_strict: Option<bool>,
451 ) -> Result<String, String>;
452
453 /// Show detailed information for a single package.
454 async fn hub_info(&self, pkg: String) -> Result<String, String>;
455
456 /// Search packages across remote index + local install state.
457 ///
458 /// This trait method mirrors the MCP `alc_hub_search` tool. Parameters
459 /// are deliberately individual JSON-primitive `Option<T>` values
460 /// (rather than an aggregate struct) so that the `algocline-core` crate
461 /// stays free of `algocline-app`-internal types (see plan.md §4.1).
462 /// The `algocline-app` side of the impl folds these into its
463 /// `pub(crate) ListOpts` struct.
464 ///
465 /// - `limit` is `Option<i32>` at this layer (MCP/JSON boundary). The
466 /// impl casts to `usize` internally.
467 /// - `filter` is a free-form JSON object; it is `Deserialize`d into
468 /// a `HashMap<String, Value>` inside the app layer.
469 /// - `fields` / `verbose` drive projection; `fields` wins when both
470 /// are supplied.
471 #[allow(clippy::too_many_arguments)]
472 async fn hub_search(
473 &self,
474 query: Option<String>,
475 category: Option<String>,
476 installed_only: Option<bool>,
477 limit: Option<i32>,
478 sort: Option<String>,
479 filter: Option<serde_json::Value>,
480 fields: Option<Vec<String>>,
481 verbose: Option<String>,
482 ) -> Result<String, String>;
483
484 // ─── Package scaffold ─────────────────────────────────────
485
486 /// Generate a minimal package skeleton at `<target_dir>/<name>/init.lua`.
487 ///
488 /// Writes an `M.meta` / `M.spec.entries.run` / `M.run` template with a
489 /// pre-filled `alc_shapes_compat` range derived from the embedded
490 /// alc_shapes version. Optional `category` / `description` are emitted
491 /// as uncommented fields in `M.meta` when provided.
492 ///
493 /// Returns `{ "status": "ok", "path": "...", "bytes_written": N }` on
494 /// success. Typed errors (`NameInvalid`, `AlreadyExists`, `IoError`) are
495 /// propagated via `Err(String)` to the MCP wire response.
496 async fn pkg_scaffold(
497 &self,
498 name: String,
499 target_dir: Option<String>,
500 category: Option<String>,
501 description: Option<String>,
502 ) -> Result<String, String>;
503
504 // ─── Diagnostics ─────────────────────────────────────────
505
506 /// Show server configuration and diagnostic info.
507 async fn info(&self) -> String;
508}