diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index bdd7a6dc..6483cdb7 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -17,8 +17,8 @@ const DETAIL_BODY_LIMIT: usize = 64 * 1024; /// Workspace-scoped runtime authority for project resources. /// /// Normal Backend API handlers should depend on this authority abstraction, not -/// on legacy filesystem layouts. Filesystem readers belong in explicit import -/// / migration helpers such as `objective_import`. +/// on legacy filesystem layouts. Filesystem readers belong in explicitly +/// temporary migration/repair tools and tests, not normal runtime authority paths. pub trait WorkspaceAuthority: ObjectiveAuthority + TicketAuthority + MemoryAuthorityMarker {} impl WorkspaceAuthority for T where @@ -216,7 +216,7 @@ mod tests { use std::path::Path; use super::*; - use crate::store::WorkspaceRecord; + use crate::store::{ObjectiveRecord, ObjectiveTicketLinkRecord, WorkspaceRecord}; #[tokio::test] async fn sqlite_workspace_authority_reads_sqlite_records_without_filesystem_authority() { @@ -228,7 +228,6 @@ mod tests { dir.path().join(".yoi/tickets"), )) .unwrap(); - write_objective(dir.path(), "00000000001J3", "Control plane", "active"); let store = SqliteWorkspaceStore::open(&db_path).unwrap(); store .upsert_workspace(&WorkspaceRecord { @@ -241,12 +240,30 @@ mod tests { }) .await .unwrap(); - crate::objective_import::import_legacy_objectives_and_memory_staging( - dir.path(), - "workspace-test", - &store, - ) - .unwrap(); + store + .upsert_objective(&ObjectiveRecord { + workspace_id: "workspace-test".to_string(), + objective_id: "00000000001J3".to_string(), + title: "Control plane".to_string(), + state: "active".to_string(), + body_md: "Objective body.\n".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-02T00:00:00Z".to_string(), + }) + .unwrap(); + store + .replace_objective_ticket_links( + "workspace-test", + "00000000001J3", + &[ObjectiveTicketLinkRecord { + workspace_id: "workspace-test".to_string(), + objective_id: "00000000001J3".to_string(), + ticket_id: "00000000001J2".to_string(), + kind: "linked".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + }], + ) + .unwrap(); write_objective( dir.path(), "00000000001J4", diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 87d58f10..95985766 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -11,7 +11,6 @@ pub mod config; pub mod hosts; pub mod identity; pub mod memory_staging; -pub mod objective_import; pub mod observation; pub mod profile_settings; pub mod records; diff --git a/crates/workspace-server/src/main.rs b/crates/workspace-server/src/main.rs index 51a14b80..2202d5f7 100644 --- a/crates/workspace-server/src/main.rs +++ b/crates/workspace-server/src/main.rs @@ -17,7 +17,6 @@ enum Command { Init(InitOptions), ConfigDefault, ConfigDiff(WorkspacePathOptions), - ImportObjectives(ImportObjectivesOptions), Skills(SkillsCommand), Help, } @@ -37,12 +36,6 @@ struct WorkspacePathOptions { workspace: PathBuf, } -#[derive(Debug)] -struct ImportObjectivesOptions { - workspace: PathBuf, - database: Option, -} - #[derive(Debug)] enum SkillsCommand { List(WorkspacePathOptions), @@ -79,7 +72,6 @@ async fn run() -> Result<(), Box> { Command::Init(options) => run_init(options).await, Command::ConfigDefault => run_config_default(), Command::ConfigDiff(options) => run_config_diff(options), - Command::ImportObjectives(options) => run_import_objectives(options).await, Command::Skills(command) => run_skills(command), Command::Help => Ok(()), } @@ -100,15 +92,6 @@ fn parse_command(args: &[String]) -> Result { Ok(Command::Init(parse_init_options(rest)?)) } "config" => parse_config_command(rest), - "import-objectives" => { - if rest.iter().any(|arg| arg == "--help" || arg == "-h") { - print_import_objectives_help(); - return Ok(Command::Help); - } - Ok(Command::ImportObjectives(parse_import_objectives_options( - rest, - )?)) - } "skills" => parse_skills_command(rest), "serve" => { if rest.iter().any(|arg| arg == "--help" || arg == "-h") { @@ -122,7 +105,7 @@ fn parse_command(args: &[String]) -> Result { Ok(Command::Help) } other => Err(CliError(format!( - "unknown command `{other}`; expected `init`, `config`, `import-objectives`, `skills`, or `serve`" + "unknown command `{other}`; expected `init`, `config`, `skills`, or `serve`" ))), } } @@ -186,45 +169,6 @@ fn run_config_diff(options: WorkspacePathOptions) -> Result<(), Box Result<(), Box> { - let identity = WorkspaceIdentity::load_required(&options.workspace)?; - let database_path = options - .database - .unwrap_or_else(ServerConfig::default_server_database_path); - let store = SqliteWorkspaceStore::open(&database_path)?; - store - .upsert_workspace(&WorkspaceRecord { - workspace_id: identity.workspace_id.clone(), - owner_account_id: None, - display_name: identity.display_name.clone(), - state: "active".to_string(), - created_at: identity.created_at.clone(), - updated_at: chrono::Utc::now().to_rfc3339(), - }) - .await?; - let report = - yoi_workspace_server::objective_import::import_legacy_objectives_and_memory_staging( - &options.workspace, - &identity.workspace_id, - &store, - )?; - println!( - "imported objectives={} resources={} ticket_links={} skipped_ticket_links={} memory_staging={} invalid_records={}", - report.objectives_imported, - report.objective_resources_imported, - report.objective_ticket_links_imported, - report.objective_ticket_links_skipped, - report.memory_staging_records_imported, - report.invalid_records.len(), - ); - for invalid in report.invalid_records { - eprintln!("invalid objective record: {invalid}"); - } - Ok(()) -} - fn run_skills(command: SkillsCommand) -> Result<(), Box> { match command { SkillsCommand::List(options) => { @@ -433,47 +377,6 @@ fn parse_workspace_path_options(args: &[String]) -> Result Result { - let mut workspace = std::env::current_dir() - .map_err(|error| CliError(format!("failed to read current dir: {error}")))?; - let mut database = None; - let mut iter = args.iter(); - while let Some(arg) = iter.next() { - match arg.as_str() { - "--workspace" => { - let value = iter - .next() - .ok_or_else(|| CliError("--workspace requires a path".to_string()))?; - workspace = PathBuf::from(value); - } - value if value.starts_with("--workspace=") => { - workspace = PathBuf::from(value_after_equals(arg, "--workspace")?); - } - "--database" => { - let value = iter - .next() - .ok_or_else(|| CliError("--database requires a path".to_string()))?; - database = Some(PathBuf::from(value)); - } - value if value.starts_with("--database=") => { - database = Some(PathBuf::from(value_after_equals(arg, "--database")?)); - } - other => { - return Err(CliError(format!( - "unknown import-objectives option `{other}`" - ))); - } - } - } - let workspace = workspace - .canonicalize() - .map_err(|error| CliError(format!("failed to canonicalize workspace: {error}")))?; - Ok(ImportObjectivesOptions { - workspace, - database, - }) -} - fn parse_init_options(args: &[String]) -> Result { let mut workspace = std::env::current_dir() .map_err(|error| CliError(format!("failed to read current dir: {error}")))?; @@ -550,7 +453,7 @@ fn parse_listen(value: &str) -> Result { fn print_help() { println!( - "yoi-workspace-server\n\nUsage:\n yoi-workspace-server init [OPTIONS]\n yoi-workspace-server config [OPTIONS]\n yoi-workspace-server import-objectives [OPTIONS]\n yoi-workspace-server skills [OPTIONS]\n yoi-workspace-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help" + "yoi-workspace-server\n\nUsage:\n yoi-workspace-server init [OPTIONS]\n yoi-workspace-server config [OPTIONS]\n yoi-workspace-server skills [OPTIONS]\n yoi-workspace-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help" ); } @@ -566,12 +469,6 @@ fn print_config_help() { ); } -fn print_import_objectives_help() { - println!( - "yoi-workspace-server import-objectives\n\nUsage:\n yoi-workspace-server import-objectives [OPTIONS]\n\nDescription:\n Imports legacy .yoi/objectives records and .yoi/memory/_staging JSON files into the Workspace SQLite authority.\n\nOptions:\n --workspace Workspace root (defaults to cwd)\n --database Server SQLite DB path (defaults to canonical server DB)\n -h, --help Print help" - ); -} - fn print_skills_help() { println!( "yoi-workspace-server skills\n\nUsage:\n yoi-workspace-server skills list [OPTIONS]\n yoi-workspace-server skills lint [OPTIONS]\n yoi-workspace-server skills show [OPTIONS]\n\nDescription:\n Uses the Workspace backend Skill catalog/lint/detail authority. Catalog output is lightweight and omits full SKILL.md bodies; detail output includes the body. allowed-tools and scripts are diagnostics only.\n\nOptions:\n --workspace Workspace root (defaults to cwd)\n -h, --help Print help" diff --git a/crates/workspace-server/src/objective_import.rs b/crates/workspace-server/src/objective_import.rs deleted file mode 100644 index 4d9a1e07..00000000 --- a/crates/workspace-server/src/objective_import.rs +++ /dev/null @@ -1,260 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; - -use chrono::Utc; -use project_record::validate_record_id; -use serde::Deserialize; - -use crate::store::{ - ControlPlaneStore, MemoryStagingRecord, ObjectiveRecord, ObjectiveResourceRecord, - ObjectiveTicketLinkRecord, -}; -use crate::{Error, Result}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ObjectiveImportReport { - pub objectives_imported: usize, - pub objective_resources_imported: usize, - pub objective_ticket_links_imported: usize, - pub objective_ticket_links_skipped: usize, - pub memory_staging_records_imported: usize, - pub invalid_records: Vec, -} - -#[derive(Debug, Deserialize)] -struct ObjectiveFrontmatter { - title: String, - state: String, - #[serde(default)] - created_at: Option, - #[serde(default)] - updated_at: Option, - #[serde(default)] - linked_tickets: Vec, -} - -pub fn import_legacy_objectives_and_memory_staging( - workspace_root: &Path, - workspace_id: &str, - store: &S, -) -> Result { - let mut report = ObjectiveImportReport { - objectives_imported: 0, - objective_resources_imported: 0, - objective_ticket_links_imported: 0, - objective_ticket_links_skipped: 0, - memory_staging_records_imported: 0, - invalid_records: Vec::new(), - }; - - import_objectives(workspace_root, workspace_id, store, &mut report)?; - import_memory_staging(workspace_root, workspace_id, store, &mut report)?; - Ok(report) -} - -fn import_objectives( - workspace_root: &Path, - workspace_id: &str, - store: &S, - report: &mut ObjectiveImportReport, -) -> Result<()> { - let root = workspace_root.join(".yoi/objectives"); - if !root.exists() { - return Ok(()); - } - for entry in fs::read_dir(root)? { - let entry = entry?; - let path = entry.path(); - if !path.is_dir() { - continue; - } - let objective_id = entry.file_name().to_string_lossy().to_string(); - if let Err(err) = validate_record_id(&objective_id) { - report - .invalid_records - .push(format!("{objective_id}: invalid objective id: {err}")); - continue; - } - match import_one_objective(&path, workspace_id, &objective_id, store) { - Ok(item) => { - report.objectives_imported += 1; - report.objective_resources_imported += item.resources; - report.objective_ticket_links_imported += item.links; - report.objective_ticket_links_skipped += item.skipped_links; - } - Err(err) => report - .invalid_records - .push(format!("{objective_id}: {err}")), - } - } - Ok(()) -} - -struct ImportedObjectiveCounts { - resources: usize, - links: usize, - skipped_links: usize, -} - -fn import_one_objective( - objective_dir: &Path, - workspace_id: &str, - objective_id: &str, - store: &S, -) -> Result { - let item_path = objective_dir.join("item.md"); - let raw = fs::read_to_string(&item_path)?; - let (frontmatter, body) = split_frontmatter(&raw, objective_id)?; - let meta: ObjectiveFrontmatter = serde_yaml::from_str(frontmatter)?; - let now = Utc::now().to_rfc3339(); - let created_at = meta.created_at.clone().unwrap_or_else(|| now.clone()); - let updated_at = meta.updated_at.clone().unwrap_or_else(|| now.clone()); - - store.upsert_objective(&ObjectiveRecord { - workspace_id: workspace_id.to_string(), - objective_id: objective_id.to_string(), - title: meta.title, - state: meta.state, - body_md: body.trim_start().to_string(), - created_at: created_at.clone(), - updated_at: updated_at.clone(), - })?; - - let mut links = Vec::new(); - let mut skipped_links = 0; - for ticket_id in meta.linked_tickets { - if validate_record_id(&ticket_id).is_ok() { - links.push(ObjectiveTicketLinkRecord { - workspace_id: workspace_id.to_string(), - objective_id: objective_id.to_string(), - ticket_id, - kind: "linked".to_string(), - created_at: now.clone(), - }); - } else { - skipped_links += 1; - } - } - let imported_links = links.len(); - let links_imported = if let Err(err) = - store.replace_objective_ticket_links(workspace_id, objective_id, &links) - { - // The legacy filesystem allowed links to tickets that may no longer - // exist. Preserve the Objective itself and make the skipped count clear - // rather than failing the whole import on a foreign-key mismatch. - skipped_links += imported_links; - eprintln!( - "warning: skipped {imported_links} ticket link(s) for objective {objective_id}: {err}" - ); - 0 - } else { - imported_links - }; - - let mut resources = 0; - for file in walk_files(objective_dir)? { - let relative = file - .strip_prefix(objective_dir) - .map_err(|err| Error::Store(err.to_string()))? - .to_string_lossy() - .replace('\\', "/"); - if relative == "item.md" || relative.starts_with("_staging/") { - continue; - } - let Ok(body) = fs::read_to_string(&file) else { - continue; - }; - store.upsert_objective_resource(&ObjectiveResourceRecord { - workspace_id: workspace_id.to_string(), - objective_id: objective_id.to_string(), - resource_path: relative, - body, - media_type: Some("text/markdown".to_string()), - created_at: created_at.clone(), - updated_at: updated_at.clone(), - })?; - resources += 1; - } - - Ok(ImportedObjectiveCounts { - resources, - links: links_imported, - skipped_links, - }) -} - -fn import_memory_staging( - workspace_root: &Path, - workspace_id: &str, - store: &S, - report: &mut ObjectiveImportReport, -) -> Result<()> { - let root = workspace_root.join(".yoi/memory/_staging"); - if !root.exists() { - return Ok(()); - } - let now = Utc::now().to_rfc3339(); - for entry in fs::read_dir(root)? { - let entry = entry?; - let path = entry.path(); - if path.extension().and_then(|ext| ext.to_str()) != Some("json") { - continue; - } - let raw_json = fs::read_to_string(&path)?; - let candidate_id = candidate_id_from_json(&raw_json) - .or_else(|| { - path.file_stem() - .map(|stem| stem.to_string_lossy().to_string()) - }) - .ok_or_else(|| Error::Store(format!("missing candidate id: {}", path.display())))?; - store.upsert_memory_staging_record(&MemoryStagingRecord { - workspace_id: workspace_id.to_string(), - candidate_id, - raw_json, - source_path: Some(path.to_string_lossy().to_string()), - imported_at: now.clone(), - })?; - report.memory_staging_records_imported += 1; - } - Ok(()) -} - -fn candidate_id_from_json(raw_json: &str) -> Option { - serde_json::from_str::(raw_json) - .ok() - .and_then(|value| { - value - .get("id") - .and_then(|id| id.as_str()) - .map(str::to_string) - }) -} - -fn split_frontmatter<'a>(raw: &'a str, label: &str) -> Result<(&'a str, &'a str)> { - let rest = raw - .strip_prefix("---\n") - .ok_or_else(|| Error::MissingFrontmatter(label.to_string()))?; - let Some((frontmatter, body)) = rest.split_once("\n---\n") else { - return Err(Error::MissingFrontmatter(label.to_string())); - }; - Ok((frontmatter, body)) -} - -fn walk_files(root: &Path) -> Result> { - let mut files = Vec::new(); - walk_files_inner(root, &mut files)?; - Ok(files) -} - -fn walk_files_inner(path: &Path, files: &mut Vec) -> Result<()> { - for entry in fs::read_dir(path)? { - let entry = entry?; - let path = entry.path(); - if path.is_dir() { - walk_files_inner(&path, files)?; - } else if path.is_file() { - files.push(path); - } - } - Ok(()) -} diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index bd31ec83..3b32e895 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -6607,7 +6607,10 @@ mod tests { TicketWorkerRole, WorkerInputKind, WorkerOperationState, WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, }; - use crate::store::SqliteWorkspaceStore; + use crate::store::{ + MemoryStagingRecord, ObjectiveRecord, ObjectiveResourceRecord, ObjectiveTicketLinkRecord, + SqliteWorkspaceStore, + }; const TEST_WORKSPACE_ID: &str = "0192f0e8-4d84-7d6e-a000-000000000001"; const TEST_REPOSITORY_ID: &str = "main"; @@ -8309,8 +8312,6 @@ mod tests { #[tokio::test] async fn serves_bounded_read_apis_and_static_spa_separately() { let dir = tempfile::tempdir().unwrap(); - write_objective(dir.path(), "00000000001J3", "API Objective", "active"); - write_memory_staging(dir.path(), "00000000001J4"); let static_dir = dir.path().join("static"); std::fs::create_dir_all(static_dir.join("assets")).unwrap(); std::fs::write(static_dir.join("index.html"), "
Yoi Workspace
").unwrap(); @@ -8336,12 +8337,50 @@ mod tests { }) .await .unwrap(); - crate::objective_import::import_legacy_objectives_and_memory_staging( - dir.path(), - TEST_WORKSPACE_ID, - &sqlite_store, - ) - .unwrap(); + sqlite_store + .upsert_objective(&ObjectiveRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + objective_id: "00000000001J3".to_string(), + title: "API Objective".to_string(), + state: "active".to_string(), + body_md: "Objective body.\n".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-02T00:00:00Z".to_string(), + }) + .unwrap(); + sqlite_store + .replace_objective_ticket_links( + TEST_WORKSPACE_ID, + "00000000001J3", + &[ObjectiveTicketLinkRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + objective_id: "00000000001J3".to_string(), + ticket_id: "00000000001J2".to_string(), + kind: "linked".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + }], + ) + .unwrap(); + sqlite_store + .upsert_objective_resource(&ObjectiveResourceRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + objective_id: "00000000001J3".to_string(), + resource_path: "memory-architecture-overview.md".to_string(), + body: "# Memory architecture\n\nResource body.\n".to_string(), + media_type: Some("text/markdown".to_string()), + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-02T00:00:00Z".to_string(), + }) + .unwrap(); + sqlite_store + .upsert_memory_staging_record(&MemoryStagingRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + candidate_id: "00000000001J4".to_string(), + raw_json: r#"{"id":"00000000001J4","kind":"working_assumption"}"#.to_string(), + source_path: None, + imported_at: "2026-01-01T00:00:00Z".to_string(), + }) + .unwrap(); assert_eq!( sqlite_store .count_memory_staging_records(TEST_WORKSPACE_ID) @@ -9363,25 +9402,6 @@ mod tests { backend.create(input).unwrap(); } - fn write_memory_staging(root: &Path, candidate_id: &str) { - let staging_dir = root.join(".yoi/memory/_staging"); - std::fs::create_dir_all(&staging_dir).unwrap(); - std::fs::write( - staging_dir.join(format!("{candidate_id}.json")), - format!( - r#"{{ - "schema_version": 2, - "id": "{candidate_id}", - "kind": "working_assumption", - "claim": "Objective staging import test", - "why_useful": "Verifies SQLite push" -}} -"#, - ), - ) - .unwrap(); - } - fn write_objective(root: &Path, id: &str, title: &str, state: &str) { let objective_dir = root.join(".yoi/objectives").join(id); std::fs::create_dir_all(&objective_dir).unwrap(); diff --git a/scripts/import-objectives-and-staging-to-sqlite-20260726.sh b/scripts/import-objectives-and-staging-to-sqlite-20260726.sh deleted file mode 100755 index 976c2a2a..00000000 --- a/scripts/import-objectives-and-staging-to-sqlite-20260726.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -usage() { - cat <<'EOF' -Usage: scripts/import-objectives-and-staging-to-sqlite-20260726.sh [--workspace PATH] [--database PATH] [--apply] - -Imports legacy .yoi/objectives records and .yoi/memory/_staging JSON files into -Workspace SQLite authority by invoking yoi-workspace-server import-objectives. - -By default this is a dry-run of the command line only. Pass --apply to execute. -EOF -} - -workspace="$(pwd)" -database="${YOI_SERVER_DB:-$HOME/.local/share/yoi/server/server.db}" -apply=0 - -while [[ $# -gt 0 ]]; do - case "$1" in - --workspace) - workspace="$2" - shift 2 - ;; - --workspace=*) - workspace="${1#--workspace=}" - shift - ;; - --database) - database="$2" - shift 2 - ;; - --database=*) - database="${1#--database=}" - shift - ;; - --apply) - apply=1 - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "unknown argument: $1" >&2 - usage >&2 - exit 2 - ;; - esac -done - -cmd=(cargo run -q -p yoi-workspace-server -- import-objectives --workspace "$workspace" --database "$database") - -printf 'Workspace: %s\n' "$workspace" -printf 'Database: %s\n' "$database" -printf 'Command: ' -printf '%q ' "${cmd[@]}" -printf '\n' - -if [[ "$apply" -ne 1 ]]; then - echo 'Dry run only. Re-run with --apply to import.' - exit 0 -fi - -"${cmd[@]}"