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 /// Run mlua-lspec tests for a package, a single file, or inline code.
244 ///
245 /// Exactly one of `pkg`, `code_file`, or `code` must be provided; providing
246 /// zero or more than one returns a typed `Err`.
247 ///
248 /// # Arguments
249 ///
250 /// * `pkg` — installed package name; spec files are discovered under
251 /// `<pkg_root>/<spec_dir>/*_spec.lua` (default `spec_dir = "spec"`).
252 /// * `code_file` — absolute path to a single `.lua` test file.
253 /// * `code` — inline Lua source code containing lspec tests.
254 /// * `spec_dir` — subdirectory within the package root that holds spec files
255 /// (default `"spec"`). Only used when `pkg` is provided.
256 /// * `filter` — substring filter applied to spec file stems (only when `pkg`
257 /// is provided).
258 /// * `search_paths` — additional directories appended to `package.path`
259 /// inside the Lua VM, after auto-resolved paths.
260 /// * `project_root` — optional project root for variant-scope package
261 /// resolution (`alc.local.toml`). Falls back to ancestor walk from cwd.
262 /// * `auto_search_paths` — when `true` (default) or `None`, auto-prepends
263 /// parent dirs of all linked/installed packages (installed
264 /// `~/.algocline/packages/`, `alc.toml` path entries, `alc.local.toml`
265 /// path entries) to `package.path`. When `false`, no auto-resolve is
266 /// performed and zero paths are injected. Resolved mapping is returned in
267 /// the JSON response `resolved_search_paths` field.
268 ///
269 /// # Returns
270 ///
271 /// On success: JSON string `{passed, failed, pending, total, duration_ms,
272 /// spec_files: [{path, passed, failed, total, duration_ms, tests: [{suite,
273 /// name, passed, pending, error}]}], resolved_search_paths: [{name,
274 /// search_dir, source}], search_path_warnings?: [...]}`.
275 ///
276 /// # Errors
277 ///
278 /// * Zero or multiple input sources provided → `"pkg_test: provide exactly
279 /// one of pkg, code_file, code"`.
280 /// * `pkg` not found → `"pkg_test: package '<name>' not found …"`.
281 /// * No spec files found → `"pkg_test: no spec files found in <path> …"`.
282 /// * mlua VM init failure, I/O errors, or `spawn_blocking` panic → typed
283 /// `Err` string.
284 // 9 parameters are justified by the MCP wire shape: 3 mutually exclusive
285 // input sources (pkg / code_file / code) plus filtering/path/auto-resolve
286 // options.
287 #[allow(clippy::too_many_arguments)]
288 async fn pkg_test(
289 &self,
290 pkg: Option<String>,
291 code_file: Option<String>,
292 code: Option<String>,
293 spec_dir: Option<String>,
294 filter: Option<String>,
295 search_paths: Option<Vec<String>>,
296 project_root: Option<String>,
297 auto_search_paths: Option<bool>,
298 ) -> Result<String, String>;
299
300 // ─── Logging ─────────────────────────────────────────────
301
302 /// Append a note to a session's log file.
303 async fn add_note(
304 &self,
305 session_id: &str,
306 content: &str,
307 title: Option<&str>,
308 ) -> Result<String, String>;
309
310 /// View session logs.
311 async fn log_view(
312 &self,
313 session_id: Option<&str>,
314 limit: Option<usize>,
315 max_chars: Option<usize>,
316 ) -> Result<String, String>;
317
318 /// Aggregate stats across all logged sessions.
319 async fn stats(
320 &self,
321 strategy_filter: Option<&str>,
322 days: Option<u64>,
323 ) -> Result<String, String>;
324
325 // ─── Project lifecycle ────────────────────────────────────
326
327 /// Initialize `alc.toml` in the given project root.
328 ///
329 /// Creates a minimal `alc.toml` (`[packages]` section only).
330 /// Fails if `alc.toml` already exists (no overwrite).
331 async fn init(&self, project_root: Option<String>) -> Result<String, String>;
332
333 /// Re-resolve all `alc.toml` entries and rewrite `alc.lock`.
334 ///
335 /// Requires an `alc.toml` to be present. Returns resolved count and errors.
336 async fn update(&self, project_root: Option<String>) -> Result<String, String>;
337
338 /// Migrate a legacy `alc.lock` to `alc.toml` + new `alc.lock` format.
339 ///
340 /// Detects legacy format via `linked_at` / `local_dir` fields.
341 /// Backs up the old lock file as `alc.lock.bak`.
342 async fn migrate(&self, project_root: Option<String>) -> Result<String, String>;
343
344 // ─── Package narrative (issue #1778221491-39903) ─────────
345
346 /// Render the narrative markdown for a package on-the-fly.
347 ///
348 /// Extracts the init.lua docstring H2/H3 sections via the embedded
349 /// gendoc pipeline (`extract.split_sections` + `projections.narrative_md`)
350 /// and returns the rendered markdown string.
351 ///
352 /// Returns `Ok(Some(markdown))` when the pkg is found and its init.lua
353 /// is loadable. Returns `Ok(None)` when the pkg is not installed.
354 /// Returns `Err(...)` when the pkg is found but the gendoc pipeline fails
355 /// (e.g. malformed init.lua).
356 async fn pkg_get_narrative_md(&self, name: &str) -> Result<Option<String>, String>;
357
358 // ─── Session activation (issue #1776627475) ──────────────
359
360 /// Activate a session pin for the current MCP connection.
361 ///
362 /// `project_root` is resolved at activation time using the
363 /// existing fallback chain (P > E > W) and cached on the
364 /// `AppService`. Subsequent tool calls without an explicit
365 /// `project_root` argument resolve via P > **S** > E > W,
366 /// where S is this pin (issue #1776627475 §6).
367 ///
368 /// `mode` accepts `"default"` (or `None`) and `"test"`. Unknown
369 /// values return a typed error rather than silent fallback.
370 /// Mode is exposed back to callers so downstream tools can
371 /// adapt behaviour (e.g. scenario test isolation).
372 ///
373 /// Returns a JSON string with `session_id`, `project_root`
374 /// (resolved or `null`), and `mode`.
375 async fn session_new(
376 &self,
377 project_root: Option<String>,
378 mode: Option<String>,
379 ) -> Result<String, String>;
380
381 // ─── Cards ───────────────────────────────────────────────
382
383 /// List Card summaries, optionally filtered by pkg.
384 async fn card_list(&self, pkg: Option<String>) -> Result<String, String>;
385
386 /// Fetch a full Card by id.
387 async fn card_get(&self, card_id: &str) -> Result<String, String>;
388
389 /// Filter/sort Cards using the Prisma-style `where` DSL.
390 ///
391 /// - `pkg`: restricts filesystem scan to a single pkg subdir (I/O hint).
392 /// - `where_`: nested-object predicate (see `card::parse_where`).
393 /// - `order_by`: array of dotted-path sort keys; `-` prefix = desc.
394 /// - `limit` / `offset`: pagination.
395 async fn card_find(
396 &self,
397 pkg: Option<String>,
398 where_: Option<serde_json::Value>,
399 order_by: Option<serde_json::Value>,
400 limit: Option<usize>,
401 offset: Option<usize>,
402 ) -> Result<String, String>;
403
404 /// List aliases, optionally filtered by pkg.
405 async fn card_alias_list(&self, pkg: Option<String>) -> Result<String, String>;
406
407 /// Resolve an alias name to its bound Card and return the full Card JSON.
408 async fn card_get_by_alias(&self, name: &str) -> Result<String, String>;
409
410 /// Bind (or rebind) an alias to a Card.
411 async fn card_alias_set(
412 &self,
413 name: &str,
414 card_id: &str,
415 pkg: Option<String>,
416 note: Option<String>,
417 ) -> Result<String, String>;
418
419 /// Append new top-level fields to an existing Card (additive-only).
420 async fn card_append(&self, card_id: &str, fields: serde_json::Value)
421 -> Result<String, String>;
422
423 /// Install Cards from a Card Collection repo (Git URL or local path).
424 async fn card_install(&self, url: String) -> Result<String, String>;
425
426 /// Read per-case samples from a Card's sidecar JSONL file.
427 ///
428 /// `where_` applies the same Prisma-style DSL used by `card_find`
429 /// to each sample row; offset/limit page the post-filter stream.
430 async fn card_samples(
431 &self,
432 card_id: &str,
433 offset: Option<usize>,
434 limit: Option<usize>,
435 where_: Option<serde_json::Value>,
436 ) -> Result<String, String>;
437
438 /// Walk a Card's lineage tree via `metadata.prior_card_id`.
439 ///
440 /// - `direction`: `"up"` | `"down"` | `"both"` (default `"up"`).
441 /// - `depth`: max traversal depth (default 10).
442 /// - `include_stats`: include each node's `[stats]` section.
443 /// - `relation_filter`: optional list of accepted `prior_relation` values.
444 async fn card_lineage(
445 &self,
446 card_id: &str,
447 direction: Option<String>,
448 depth: Option<usize>,
449 include_stats: Option<bool>,
450 relation_filter: Option<Vec<String>>,
451 ) -> Result<String, String>;
452
453 /// Backfill one subscriber (`sink` URI) with all cards from the
454 /// primary store. Drift-safe: cards already present on the sink are
455 /// skipped (never overwritten). Returns a `SinkBackfillReport`
456 /// serialized as a JSON string.
457 async fn card_sink_backfill(&self, _sink: String, _dry_run: bool) -> Result<String, String> {
458 Err("card_sink_backfill: not implemented by this EngineApi impl".into())
459 }
460
461 /// Run a Card analyzer package over a single Card.
462 ///
463 /// The host loads the Card body + samples sidecar, builds a Lua ctx
464 /// (`{ card, samples, card_id }`), and dispatches to the named pkg
465 /// via `require(pkg).run(ctx)`. The default pkg name is
466 /// `"card_analysis"` — backed by the constant
467 /// `DEFAULT_CARD_ANALYZE_PKG` defined in `algocline_app::service::card`.
468 /// This is an IF promise, not a bundled hard dependency.
469 /// If the pkg is missing the call returns an error.
470 ///
471 /// Sister tool to `advice`: `advice` runs a generic strategy over
472 /// a free-form task, while `card_analyze` runs an analyzer over a
473 /// Card and its samples. The Card domain is owned by the host
474 /// (Card schema parsing + samples sidecar load), not the pkg.
475 async fn card_analyze(&self, _card_id: &str, _pkg: Option<String>) -> Result<String, String> {
476 Err("card_analyze: not implemented by this EngineApi impl".into())
477 }
478
479 /// Publish a Card to a hub repository.
480 ///
481 /// Runs: git clone target_repo to staging → copy card files → git add →
482 /// git commit → git push → hub_reindex.
483 ///
484 /// Credential prerequisite: SSH key or `gh auth login` must be
485 /// configured on the host; returns a typed `CardPublishError::MissingCredentials`
486 /// with actionable guidance if push fails due to authentication.
487 ///
488 /// Push success and reindex failure are surfaced as independent fields:
489 /// a successful push is never rolled back when only reindex fails.
490 async fn card_publish(
491 &self,
492 _card_id: &str,
493 _target_repo: &str,
494 _commit_message: Option<&str>,
495 ) -> Result<String, String> {
496 Err("card_publish: not implemented by this EngineApi impl".into())
497 }
498
499 // ─── Hub ─────────────────────────────────────────────────
500
501 /// Rebuild hub index from a packages directory.
502 ///
503 /// When `source_dir` is provided, scans that directory directly
504 /// (pure metadata, no manifest). When omitted, scans `~/.algocline/packages/`.
505 async fn hub_reindex(
506 &self,
507 output_path: Option<String>,
508 source_dir: Option<String>,
509 ) -> Result<String, String>;
510
511 /// Generate human-readable documentation artifacts from a hub index.
512 ///
513 /// Runs the embedded Lua `gen_docs` pipeline (originally shipped
514 /// with `algocline-bundled-packages`) against `source_dir`, which
515 /// must contain a fresh `hub_index.json`. Emits
516 /// `narrative/{pkg}.md`, `llms.txt`, `llms-full.txt` under
517 /// `out_dir` (defaults to `{source_dir}/docs`), plus optional
518 /// projections depending on `projections`:
519 ///
520 /// - `"hub"` → `{out_dir}/hub/{pkg}.json`
521 /// - `"context7"` → `{source_dir}/context7.json`
522 /// - `"devin"` → `{source_dir}/.devin/wiki.json`
523 /// - `"lint"` → run V0 lint pass (warnings only)
524 /// - `"lint_only"` → run lint, skip file generation
525 ///
526 /// `config_path` — optional path to a TOML config file. When omitted,
527 /// the project root's `alc.toml` is auto-explored for `[hub.context7]`
528 /// and `[hub.devin]` sections. Core defaults apply when neither a
529 /// `config_path` nor `alc.toml` provides projection config. Passing a
530 /// `.lua` path is a typed error (retired). See
531 /// `docs/hub-gendoc-config.md` for the full schema.
532 ///
533 /// Projection names are validated strictly and unknown values are
534 /// rejected with `Err("gendoc: unknown projection ...")`.
535 ///
536 /// `lint_strict = true` upgrades lint errors to a hard failure
537 /// (equivalent to the `--strict` CLI flag).
538 ///
539 /// Returns a JSON string containing the collected stdout / stderr
540 /// plus the resolved `source_dir` / `out_dir` for observability.
541 async fn hub_gendoc(
542 &self,
543 source_dir: String,
544 out_dir: Option<String>,
545 projections: Option<Vec<String>>,
546 config_path: Option<String>,
547 lint_strict: Option<bool>,
548 ) -> Result<String, String>;
549
550 /// Run `hub_reindex` followed by `hub_gendoc` as a single facade.
551 ///
552 /// This is a convenience wrapper for downstream hub repositories that
553 /// want to regenerate the index and the public docs in one call. The
554 /// composed response is a JSON object:
555 ///
556 /// ```json
557 /// {
558 /// "reindex": <hub_reindex response>,
559 /// "gendoc": <hub_gendoc response>,
560 /// "preset_catalog_version": "...",
561 /// "preset": { "name": ..., "catalog_version": ..., "resolved": { ... } }
562 /// }
563 /// ```
564 ///
565 /// Error propagation:
566 ///
567 /// - If `hub_reindex` fails, `hub_dist` returns immediately with
568 /// `Err("dist: reindex failed: {inner}")` and does not invoke
569 /// `hub_gendoc`.
570 /// - If `hub_gendoc` fails, the error text includes the reindex JSON
571 /// that already succeeded:
572 /// `Err("dist: gendoc failed: {inner}\nreindex result (succeeded): {json}")`.
573 /// The reindex-side side effects (written `hub_index.json`) are not
574 /// rolled back.
575 ///
576 /// `output_path` is the `hub_index.json` destination (reindex arg).
577 /// Callers typically pass `{source_dir}/hub_index.json` so the
578 /// subsequent gendoc step can read it back.
579 ///
580 /// Presets (`preset`) are expanded inside `hub_dist` into primitive
581 /// `hub_gendoc` arguments (`projections` / `config_path` /
582 /// `lint_strict`). When `preset` is set, the successful JSON response
583 /// includes a `preset` object with `catalog_version` plus the fully
584 /// resolved knobs for observability.
585 ///
586 /// Merge order (strongest wins):
587 /// 1) explicit MCP arguments (`projections` / `config_path` / `lint_strict`)
588 /// 2) optional `alc.toml` overrides under `[hub.dist.presets.<name>]`
589 /// (keyed by `project_root`) — only fills **omitted** knobs
590 /// 3) builtin `Current` defaults for the selected preset
591 #[allow(clippy::too_many_arguments)]
592 async fn hub_dist(
593 &self,
594 source_dir: String,
595 output_path: Option<String>,
596 out_dir: Option<String>,
597 preset: Option<String>,
598 project_root: Option<String>,
599 projections: Option<Vec<String>>,
600 config_path: Option<String>,
601 lint_strict: Option<bool>,
602 ) -> Result<String, String>;
603
604 /// Show detailed information for a single package.
605 async fn hub_info(&self, pkg: String) -> Result<String, String>;
606
607 /// Search packages across remote index + local install state.
608 ///
609 /// This trait method mirrors the MCP `alc_hub_search` tool. Parameters
610 /// are deliberately individual JSON-primitive `Option<T>` values
611 /// (rather than an aggregate struct) so that the `algocline-core` crate
612 /// stays free of `algocline-app`-internal types (see plan.md §4.1).
613 /// The `algocline-app` side of the impl folds these into its
614 /// `pub(crate) ListOpts` struct.
615 ///
616 /// - `limit` is `Option<i32>` at this layer (MCP/JSON boundary). The
617 /// impl casts to `usize` internally.
618 /// - `filter` is a free-form JSON object; it is `Deserialize`d into
619 /// a `HashMap<String, Value>` inside the app layer.
620 /// - `fields` / `verbose` drive projection; `fields` wins when both
621 /// are supplied.
622 #[allow(clippy::too_many_arguments)]
623 async fn hub_search(
624 &self,
625 query: Option<String>,
626 category: Option<String>,
627 installed_only: Option<bool>,
628 limit: Option<i32>,
629 sort: Option<String>,
630 filter: Option<serde_json::Value>,
631 fields: Option<Vec<String>>,
632 verbose: Option<String>,
633 local_indices: Option<Vec<String>>,
634 ) -> Result<String, String>;
635
636 // ─── Package scaffold ─────────────────────────────────────
637
638 /// Generate a minimal package skeleton at `<target_dir>/<name>/init.lua`.
639 ///
640 /// Writes an `M.meta` / `M.spec.entries.run` / `M.run` template with a
641 /// pre-filled `alc_shapes_compat` range derived from the embedded
642 /// alc_shapes version. Optional `category` / `description` are emitted
643 /// as uncommented fields in `M.meta` when provided.
644 ///
645 /// Returns `{ "status": "ok", "path": "...", "bytes_written": N }` on
646 /// success. Typed errors (`NameInvalid`, `AlreadyExists`, `IoError`) are
647 /// propagated via `Err(String)` to the MCP wire response.
648 async fn pkg_scaffold(
649 &self,
650 name: String,
651 target_dir: Option<String>,
652 category: Option<String>,
653 description: Option<String>,
654 ) -> Result<String, String>;
655
656 /// Read the `init.lua` source of an installed package.
657 ///
658 /// Searches global (`~/.algocline/packages/`) and variant
659 /// (`alc.local.toml`) scope in priority order (variant wins).
660 /// Returns the raw Lua source on success, or an `Err(String)` describing
661 /// why the package was not found or could not be read.
662 async fn pkg_read_init_lua(&self, name: &str) -> Result<String, String>;
663
664 /// Read metadata for a single installed package.
665 ///
666 /// Returns the JSON object string for one package entry (the same shape
667 /// `pkg_list` returns inside `packages[*]`). `Err("pkg not found: ...")`
668 /// when the package is unknown.
669 async fn pkg_meta(&self, name: &str) -> Result<String, String>;
670
671 // ─── Settings ────────────────────────────────────────────
672
673 /// Resolve `[setting.<target>]` config across env, project (`alc.toml`/`alc.local.toml`),
674 /// and global (`~/.algocline/config.toml`) layers.
675 ///
676 /// Field-level merge: each field independently selects the highest-priority layer
677 /// that defines it (env > project > global). The returned JSON contains both the
678 /// resolved values and a per-field `sources` map identifying the winning layer.
679 ///
680 /// When `target` is `None`, all `[setting.*]` tables across all layers are returned.
681 /// When `target` is `Some(t)`, only the specified target (snake_case) is returned.
682 ///
683 /// Returns JSON string with shape:
684 /// ```json
685 /// {
686 /// "resolved": { "journal": { "path": "...", "pkg": true } },
687 /// "sources": { "journal": { "path": "env", "pkg": "global" } }
688 /// }
689 /// ```
690 async fn setting_resolve(&self, target: Option<String>) -> Result<String, String>;
691
692 // ─── State management ────────────────────────────────────
693
694 /// List all state keys within a namespace.
695 ///
696 /// Returns a JSON array of key strings. Returns an empty array when the
697 /// namespace directory does not exist (not an error).
698 ///
699 /// # Arguments
700 /// - `namespace` — state namespace to list (e.g. `"orch"`).
701 ///
702 /// # Returns
703 /// `Ok(JSON-array-string)` on success.
704 ///
705 /// # Errors
706 /// Structured JSON error string: `{"error":"<CODE>",...}` where CODE is one of
707 /// `UNSAFE_SEGMENT`, `IO_READ`.
708 async fn state_list(&self, namespace: String) -> Result<String, String>;
709
710 /// Show the full JSON content of a state file.
711 ///
712 /// # Arguments
713 /// - `namespace` — state namespace (e.g. `"orch"`).
714 /// - `key` — state key / task id.
715 ///
716 /// # Returns
717 /// `Ok(JSON-value-string)` on success.
718 ///
719 /// # Errors
720 /// Structured JSON error string: `{"error":"<CODE>",...}` where CODE is one of
721 /// `NOT_FOUND`, `UNSAFE_SEGMENT`, `IO_READ`, `SERDE`.
722 async fn state_show(&self, namespace: String, key: String) -> Result<String, String>;
723
724 /// Reset (partially delete) fields or completed steps from a state file.
725 ///
726 /// # Arguments
727 /// - `namespace` — state namespace (e.g. `"orch"`).
728 /// - `key` — state key / task id.
729 /// - `steps` — step names to remove from `data.completed_steps`. `None` means no steps removed.
730 /// - `fields` — top-level field names to remove from `data`. `None` means no fields removed.
731 ///
732 /// # Returns
733 /// `Ok(JSON-object-string)` with shape
734 /// `{"ok":true,"backup_path":"...","steps_removed":<usize>,"steps_input":[...],"fields_removed":<usize>,"fields_input":[...]}`.
735 ///
736 /// # Errors
737 /// Structured JSON error string: `{"error":"<CODE>",...}` where CODE is one of
738 /// `NOT_FOUND`, `UNSAFE_SEGMENT`, `IO_BACKUP`, `IO_READ`, `IO_WRITE`, `SERDE`, `SHAPE_INVALID`.
739 async fn state_reset(
740 &self,
741 namespace: String,
742 key: String,
743 steps: Option<Vec<String>>,
744 fields: Option<Vec<String>>,
745 ) -> Result<String, String>;
746
747 /// Write or overwrite a value in the dispatched-layout state store.
748 ///
749 /// The namespace and key must be path-safe segments (ASCII alphanumeric,
750 /// `_`, `-`, `.`; no `..` or empty). On overwrite, a `.bak` file is
751 /// created atomically before the mutation (Crux §3).
752 ///
753 /// # Arguments
754 /// * `namespace` — subdirectory (state namespace), e.g. `"orch"`
755 /// * `key` — file stem, e.g. `"my-task-id"`
756 /// * `value` — JSON value to store
757 ///
758 /// # Returns
759 /// JSON string `{"ok":true}` on success.
760 ///
761 /// # Errors
762 /// Structured JSON error string: `{"error":"<CODE>",...}` where CODE is one of
763 /// `UNSAFE_SEGMENT`, `IO_BACKUP`, `IO_WRITE`.
764 async fn state_set(
765 &self,
766 namespace: String,
767 key: String,
768 value: serde_json::Value,
769 ) -> Result<String, String>;
770
771 /// Delete a value from the dispatched-layout state store.
772 ///
773 /// Returns `{"ok":true,"existed":false}` if the key was absent (idempotent
774 /// no-op). Returns `{"ok":true,"existed":true}` after copying a `.bak` and
775 /// removing the file (Crux §3 atomicity contract).
776 ///
777 /// # Arguments
778 /// * `namespace` — subdirectory (state namespace), e.g. `"orch"`
779 /// * `key` — file stem, e.g. `"my-task-id"`
780 ///
781 /// # Returns
782 /// JSON string `{"ok":true,"existed":<bool>}` on success.
783 ///
784 /// # Errors
785 /// Structured JSON error string: `{"error":"<CODE>",...}` where CODE is one of
786 /// `UNSAFE_SEGMENT`, `IO_BACKUP`, `IO_WRITE`.
787 async fn state_delete(&self, namespace: String, key: String) -> Result<String, String>;
788
789 // ─── Diagnostics ─────────────────────────────────────────
790
791 /// Show server configuration and diagnostic info.
792 async fn info(&self) -> String;
793
794 // ─── Hub resources ───────────────────────────────────────
795
796 /// Return the aggregated hub index across all registered sources as a JSON string.
797 ///
798 /// Merges the cached `hub_index.json` from every discovered source URL.
799 /// Sources that fail to load produce warnings that are embedded in the
800 /// returned JSON under a `"warnings"` field so the MCP caller can observe
801 /// partial failures.
802 ///
803 /// Returns `Ok(json_string)` where the JSON has shape:
804 /// ```json
805 /// { "schema_version": "hub_index/v0", "packages": [...], "warnings": [...] }
806 /// ```
807 /// Returns `Err(message)` only when the hub registries file itself is
808 /// corrupt (hard I/O failure), making further index discovery impossible.
809 async fn hub_index_aggregate(&self) -> Result<String, String>;
810
811 // ─── Pool management ─────────────────────────────────────────
812
813 /// Ensure pool workers are alive; GC stale entries. Idempotent.
814 ///
815 /// Returns JSON `{"sessions": [...], "pool_version": "..."}`.
816 async fn pool_ensure(&self) -> Result<String, String>;
817
818 /// Return pool worker status (registry.json + live state).
819 ///
820 /// When `sid` is provided, restricts to a single worker.
821 /// Returns JSON `{"sessions": [...], "pool_version": "..."}`.
822 async fn pool_status(&self, sid: Option<String>) -> Result<String, String>;
823
824 /// Send SIGTERM to all workers (`sid=None`) or a single worker.
825 ///
826 /// Returns JSON `{"stopped": [...], "errors": [...]}`.
827 async fn pool_stop(&self, sid: Option<String>) -> Result<String, String>;
828}