huskies: merge 606_story_extract_project_service

This commit is contained in:
dave
2026-04-24 14:56:54 +00:00
parent d9e883c21d
commit 1910365321
7 changed files with 376 additions and 13 deletions
+24 -10
View File
@@ -1,6 +1,7 @@
//! HTTP project endpoints — REST API for project initialization and context management.
use crate::http::context::{AppContext, OpenApiResult, bad_request};
use crate::io::fs;
//! HTTP project endpoints — thin adapters over `service::project`.
use crate::http::context::{AppContext, OpenApiResult, bad_request, not_found};
use crate::service::project::{self as svc, Error as ProjectError};
use poem::http::StatusCode;
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
use serde::Deserialize;
use std::sync::Arc;
@@ -15,6 +16,17 @@ struct PathPayload {
path: String,
}
/// Map a typed [`ProjectError`] to a `poem::Error` with the appropriate HTTP status.
fn map_project_error(e: ProjectError) -> poem::Error {
match e {
ProjectError::PathNotFound(msg) => not_found(msg),
ProjectError::NotADirectory(msg) => bad_request(msg),
ProjectError::Internal(msg) => {
poem::Error::from_string(msg, StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
pub struct ProjectApi {
pub ctx: Arc<AppContext>,
}
@@ -26,8 +38,8 @@ impl ProjectApi {
/// Returns null when no project is open.
#[oai(path = "/project", method = "get")]
async fn get_current_project(&self) -> OpenApiResult<Json<Option<String>>> {
let result = fs::get_current_project(&self.ctx.state, self.ctx.store.as_ref())
.map_err(bad_request)?;
let result = svc::get_current_project(&self.ctx.state, self.ctx.store.as_ref())
.map_err(map_project_error)?;
Ok(Json(result))
}
@@ -36,14 +48,14 @@ impl ProjectApi {
/// Persists the selected path for later sessions.
#[oai(path = "/project", method = "post")]
async fn open_project(&self, payload: Json<PathPayload>) -> OpenApiResult<Json<String>> {
let confirmed = fs::open_project(
let confirmed = svc::open_project(
payload.0.path,
&self.ctx.state,
self.ctx.store.as_ref(),
self.ctx.agents.port(),
)
.await
.map_err(bad_request)?;
.map_err(map_project_error)?;
Ok(Json(confirmed))
}
@@ -55,21 +67,23 @@ impl ProjectApi {
"[MERGE-DEBUG] DELETE /project called! \
Backtrace: this is the only code path that clears project_root."
);
fs::close_project(&self.ctx.state, self.ctx.store.as_ref()).map_err(bad_request)?;
svc::close_project(&self.ctx.state, self.ctx.store.as_ref()).map_err(map_project_error)?;
Ok(Json(true))
}
/// List known projects from the store.
#[oai(path = "/projects", method = "get")]
async fn list_known_projects(&self) -> OpenApiResult<Json<Vec<String>>> {
let projects = fs::get_known_projects(self.ctx.store.as_ref()).map_err(bad_request)?;
let projects =
svc::get_known_projects(self.ctx.store.as_ref()).map_err(map_project_error)?;
Ok(Json(projects))
}
/// Forget a known project path.
#[oai(path = "/projects/forget", method = "post")]
async fn forget_known_project(&self, payload: Json<PathPayload>) -> OpenApiResult<Json<bool>> {
fs::forget_known_project(payload.0.path, self.ctx.store.as_ref()).map_err(bad_request)?;
svc::forget_known_project(payload.0.path, self.ctx.store.as_ref())
.map_err(map_project_error)?;
Ok(Json(true))
}
}
+1 -3
View File
@@ -11,6 +11,4 @@ pub use files::{
};
pub use paths::{find_story_kit_root, get_home_directory, resolve_cli_path};
pub use preferences::{get_model_preference, set_model_preference};
pub use project::{
close_project, forget_known_project, get_current_project, get_known_projects, open_project,
};
pub use project::open_project;
+4
View File
@@ -84,6 +84,7 @@ pub async fn open_project(
Ok(path)
}
#[allow(dead_code)]
pub fn close_project(state: &SessionState, store: &dyn StoreOps) -> Result<(), String> {
{
// TRACE:MERGE-DEBUG — remove once root cause is found
@@ -98,6 +99,7 @@ pub fn close_project(state: &SessionState, store: &dyn StoreOps) -> Result<(), S
Ok(())
}
#[allow(dead_code)]
pub fn get_current_project(
state: &SessionState,
store: &dyn StoreOps,
@@ -131,6 +133,7 @@ pub fn get_current_project(
Ok(None)
}
#[allow(dead_code)]
pub fn get_known_projects(store: &dyn StoreOps) -> Result<Vec<String>, String> {
let projects = store
.get(KEY_KNOWN_PROJECTS)
@@ -143,6 +146,7 @@ pub fn get_known_projects(store: &dyn StoreOps) -> Result<Vec<String>, String> {
Ok(projects)
}
#[allow(dead_code)]
pub fn forget_known_project(path: String, store: &dyn StoreOps) -> Result<(), String> {
let mut known_projects = get_known_projects(store)?;
let original_len = known_projects.len();
+1
View File
@@ -8,4 +8,5 @@
pub mod agents;
pub mod events;
pub mod health;
pub mod project;
pub mod ws;
+144
View File
@@ -0,0 +1,144 @@
//! Project I/O — the ONLY place in `service/project/` that may perform
//! filesystem reads, state mutations, or store operations.
//!
//! Every function here is a thin adapter that converts lower-level errors
//! into the typed [`super::Error`] for this domain. No business logic lives
//! here; branching belongs in `selection.rs` or `mod.rs`.
use crate::state::SessionState;
use crate::store::StoreOps;
use std::path::{Path, PathBuf};
use super::Error;
const KEY_LAST_PROJECT: &str = "last_project_path";
const KEY_KNOWN_PROJECTS: &str = "known_projects";
/// Validate that `path` exists and is a directory, returning a typed error.
pub(super) async fn validate_path(path: &Path) -> Result<(), Error> {
let p = path.to_path_buf();
tokio::task::spawn_blocking(move || {
if !p.exists() {
return Err(Error::PathNotFound(format!(
"Path does not exist: {}",
p.display()
)));
}
if !p.is_dir() {
return Err(Error::NotADirectory(format!(
"Path is not a directory: {}",
p.display()
)));
}
Ok(())
})
.await
.map_err(|e| Error::Internal(format!("Task failed: {e}")))?
}
/// Ensure the project directory has a `.huskies/` scaffold and an `.mcp.json`.
///
/// Creates the directory if it does not exist. If `.huskies/` is absent,
/// writes the full scaffold. Always rewrites `.mcp.json` with `port`.
pub(super) async fn ensure_scaffold(path: PathBuf, port: u16) -> Result<(), Error> {
crate::io::fs::project::ensure_project_root_with_story_kit(path, port)
.await
.map_err(Error::Internal)
}
/// Set (or clear) the active project root in session state.
pub(super) fn set_project_root(state: &SessionState, path: Option<PathBuf>) -> Result<(), Error> {
// TRACE:MERGE-DEBUG — remove once root cause is found
match &path {
Some(p) => crate::slog!(
"[MERGE-DEBUG] open_project: setting project_root to {:?}",
p
),
None => crate::slog!("[MERGE-DEBUG] close_project: setting project_root to None"),
}
let mut root = state
.project_root
.lock()
.map_err(|e| Error::Internal(format!("Lock poisoned: {e}")))?;
*root = path;
Ok(())
}
/// Read the active project root from session state.
pub(super) fn get_project_root_from_state(state: &SessionState) -> Result<Option<PathBuf>, Error> {
let root = state
.project_root
.lock()
.map_err(|e| Error::Internal(format!("Lock poisoned: {e}")))?;
Ok(root.clone())
}
/// Persist the last-used project path and known-projects list to the store.
///
/// Sets both keys and flushes in a single `save()` call to minimise writes.
pub(super) fn persist_open_project(
path: &str,
known: &[String],
store: &dyn StoreOps,
) -> Result<(), Error> {
store.set(KEY_LAST_PROJECT, serde_json::json!(path));
store.set(KEY_KNOWN_PROJECTS, serde_json::json!(known));
store.save().map_err(Error::Internal)
}
/// Remove the persisted project path from the store and flush.
pub(super) fn clear_project(store: &dyn StoreOps) -> Result<(), Error> {
store.delete(KEY_LAST_PROJECT);
store.save().map_err(Error::Internal)
}
/// Read the known-projects list from the store.
pub(super) fn read_known_projects(store: &dyn StoreOps) -> Vec<String> {
store
.get(KEY_KNOWN_PROJECTS)
.and_then(|val| val.as_array().cloned())
.unwrap_or_default()
.into_iter()
.filter_map(|val| val.as_str().map(|s| s.to_string()))
.collect()
}
/// Persist the known-projects list to the store and flush.
pub(super) fn save_known_projects(projects: &[String], store: &dyn StoreOps) -> Result<(), Error> {
store.set(KEY_KNOWN_PROJECTS, serde_json::json!(projects));
store.save().map_err(Error::Internal)
}
/// Try to restore the project root from the persisted store path.
///
/// If the stored path still exists and is a directory, updates session state
/// and returns the path string. Returns `Ok(None)` when no valid stored path
/// is found.
pub(super) fn restore_from_store(
state: &SessionState,
store: &dyn StoreOps,
) -> Result<Option<String>, Error> {
let last = store
.get(KEY_LAST_PROJECT)
.and_then(|val| val.as_str().map(|s| s.to_string()));
if let Some(path_str) = last {
let p = PathBuf::from(&path_str);
if p.exists() && p.is_dir() {
// TRACE:MERGE-DEBUG — remove once root cause is found
crate::slog!(
"[MERGE-DEBUG] get_current_project: project_root was None, \
restoring from store to {:?}",
p
);
let mut root = state
.project_root
.lock()
.map_err(|e| Error::Internal(format!("Lock poisoned: {e}")))?;
*root = Some(p);
return Ok(Some(path_str));
}
}
Ok(None)
}
+102
View File
@@ -0,0 +1,102 @@
//! Project service — public API for the project domain.
//!
//! Exposes functions to open, close, query, and manage known projects.
//! HTTP handlers call these functions instead of touching `io::fs` or session
//! state directly.
//!
//! Conventions: `docs/architecture/service-modules.md`
pub(super) mod io;
pub mod selection;
use crate::state::SessionState;
use crate::store::StoreOps;
use std::path::PathBuf;
// ── Error type ────────────────────────────────────────────────────────────────
/// Typed errors returned by `service::project` functions.
///
/// HTTP handlers map these to specific status codes:
/// - [`Error::PathNotFound`] → 404 Not Found
/// - [`Error::NotADirectory`] → 400 Bad Request
/// - [`Error::Internal`] → 500 Internal Server Error
#[derive(Debug)]
pub enum Error {
/// The given path does not exist on the filesystem.
PathNotFound(String),
/// The given path exists but is not a directory.
NotADirectory(String),
/// An internal error occurred (lock poisoned, store I/O failure, task panic).
Internal(String),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::PathNotFound(msg) => write!(f, "Project not found: {msg}"),
Self::NotADirectory(msg) => write!(f, "Invalid project path: {msg}"),
Self::Internal(msg) => write!(f, "Internal error: {msg}"),
}
}
}
// ── Public API ────────────────────────────────────────────────────────────────
/// Open a project, scaffolding it when needed, and persist the selection.
///
/// Validates that `path` exists and is a directory. On success, returns the
/// canonical path string. The path is promoted to the front of the known list.
pub async fn open_project(
path: String,
state: &SessionState,
store: &dyn StoreOps,
port: u16,
) -> Result<String, Error> {
let p = PathBuf::from(&path);
io::ensure_scaffold(p.clone(), port).await?;
io::validate_path(&p).await?;
io::set_project_root(state, Some(p))?;
let known = io::read_known_projects(store);
let updated = selection::promote_to_front(known, &path);
io::persist_open_project(&path, &updated, store)?;
Ok(path)
}
/// Close the current project and remove it from the persisted selection.
pub fn close_project(state: &SessionState, store: &dyn StoreOps) -> Result<(), Error> {
io::set_project_root(state, None)?;
io::clear_project(store)
}
/// Return the currently open project path, if any.
///
/// Checks in-memory state first, then falls back to the store.
/// If the store has a valid path, restores it into state for future calls.
pub fn get_current_project(
state: &SessionState,
store: &dyn StoreOps,
) -> Result<Option<String>, Error> {
if let Some(path) = io::get_project_root_from_state(state)? {
return Ok(Some(path.to_string_lossy().to_string()));
}
io::restore_from_store(state, store)
}
/// Return all known (previously opened) project paths from the store.
pub fn get_known_projects(store: &dyn StoreOps) -> Result<Vec<String>, Error> {
Ok(io::read_known_projects(store))
}
/// Remove a path from the known-projects list.
///
/// Returns `Ok(())` whether or not the path was present (idempotent).
pub fn forget_known_project(path: String, store: &dyn StoreOps) -> Result<(), Error> {
let known = io::read_known_projects(store);
let before = known.len();
let updated: Vec<String> = known.into_iter().filter(|p| p != &path).collect();
if updated.len() == before {
return Ok(());
}
io::save_known_projects(&updated, store)
}
+100
View File
@@ -0,0 +1,100 @@
//! Pure project-selection logic — no I/O, no async, no side effects.
//!
//! All functions here are deterministic and depend only on their arguments.
/// Promote a project path to the front of the known-projects list.
///
/// Removes any existing occurrence of `path` and inserts it at position 0,
/// so the most-recently-opened project is always first.
pub fn promote_to_front(mut projects: Vec<String>, path: &str) -> Vec<String> {
projects.retain(|p| p != path);
projects.insert(0, path.to_string());
projects
}
#[allow(dead_code)]
/// Extract the display name for a project from its filesystem path.
///
/// Returns the last non-empty path component, or `None` for root or empty input.
pub fn project_name_from_path(path: &str) -> Option<&str> {
path.trim_end_matches('/')
.rsplit('/')
.find(|s| !s.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn promote_to_front_inserts_new_path_at_position_zero() {
let result = promote_to_front(vec!["/a".to_string(), "/b".to_string()], "/c");
assert_eq!(result, vec!["/c", "/a", "/b"]);
}
#[test]
fn promote_to_front_moves_existing_entry_to_front() {
let result = promote_to_front(
vec!["/a".to_string(), "/b".to_string(), "/c".to_string()],
"/b",
);
assert_eq!(result, vec!["/b", "/a", "/c"]);
}
#[test]
fn promote_to_front_is_idempotent_when_already_first() {
let result = promote_to_front(vec!["/a".to_string(), "/b".to_string()], "/a");
assert_eq!(result, vec!["/a", "/b"]);
}
#[test]
fn promote_to_front_handles_empty_list() {
let result = promote_to_front(vec![], "/new");
assert_eq!(result, vec!["/new"]);
}
#[test]
fn promote_to_front_deduplicates_single_entry() {
let result = promote_to_front(vec!["/a".to_string()], "/a");
assert_eq!(result, vec!["/a"]);
}
#[test]
fn project_name_from_path_extracts_last_component() {
assert_eq!(
project_name_from_path("/home/user/myproject"),
Some("myproject")
);
}
#[test]
fn project_name_from_path_handles_trailing_slash() {
assert_eq!(
project_name_from_path("/home/user/myproject/"),
Some("myproject")
);
}
#[test]
fn project_name_from_path_returns_none_for_root() {
assert_eq!(project_name_from_path("/"), None);
}
#[test]
fn project_name_from_path_returns_none_for_empty() {
assert_eq!(project_name_from_path(""), None);
}
#[test]
fn project_name_from_path_handles_single_component() {
assert_eq!(project_name_from_path("myproject"), Some("myproject"));
}
#[test]
fn project_name_from_path_handles_deep_path() {
assert_eq!(
project_name_from_path("/a/b/c/d/project-name"),
Some("project-name")
);
}
}