objective: move records to sqlite authority
This commit is contained in:
parent
18b5cead7a
commit
5b1b0688bc
|
|
@ -1,8 +1,8 @@
|
|||
//! Local workspace web control plane backend bootstrap.
|
||||
//!
|
||||
//! This crate deliberately provides backend building blocks and an HTTP router;
|
||||
//! it is not the product CLI facade. Existing `.yoi` Ticket and Objective files
|
||||
//! remain the canonical project records and are read through bounded bridge APIs.
|
||||
//! it is not the product CLI facade. Tickets and Objectives are served through
|
||||
//! Backend authority surfaces rather than Worker-local filesystem access.
|
||||
|
||||
pub mod auth;
|
||||
pub mod companion;
|
||||
|
|
@ -10,6 +10,7 @@ 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;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ enum Command {
|
|||
Init(InitOptions),
|
||||
ConfigDefault,
|
||||
ConfigDiff(WorkspacePathOptions),
|
||||
ImportObjectives(ImportObjectivesOptions),
|
||||
Skills(SkillsCommand),
|
||||
Help,
|
||||
}
|
||||
|
|
@ -36,6 +37,12 @@ struct WorkspacePathOptions {
|
|||
workspace: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ImportObjectivesOptions {
|
||||
workspace: PathBuf,
|
||||
database: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum SkillsCommand {
|
||||
List(WorkspacePathOptions),
|
||||
|
|
@ -72,6 +79,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||
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(()),
|
||||
}
|
||||
|
|
@ -92,6 +100,15 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
|
|||
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") {
|
||||
|
|
@ -105,7 +122,7 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
|
|||
Ok(Command::Help)
|
||||
}
|
||||
other => Err(CliError(format!(
|
||||
"unknown command `{other}`; expected `init`, `config`, `skills`, or `serve`"
|
||||
"unknown command `{other}`; expected `init`, `config`, `import-objectives`, `skills`, or `serve`"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
|
@ -169,6 +186,45 @@ fn run_config_diff(options: WorkspacePathOptions) -> Result<(), Box<dyn std::err
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_import_objectives(
|
||||
options: ImportObjectivesOptions,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
match command {
|
||||
SkillsCommand::List(options) => {
|
||||
|
|
@ -377,6 +433,47 @@ fn parse_workspace_path_options(args: &[String]) -> Result<WorkspacePathOptions,
|
|||
Ok(WorkspacePathOptions { workspace })
|
||||
}
|
||||
|
||||
fn parse_import_objectives_options(args: &[String]) -> Result<ImportObjectivesOptions, CliError> {
|
||||
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<InitOptions, CliError> {
|
||||
let mut workspace = std::env::current_dir()
|
||||
.map_err(|error| CliError(format!("failed to read current dir: {error}")))?;
|
||||
|
|
@ -453,7 +550,7 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
|
|||
|
||||
fn print_help() {
|
||||
println!(
|
||||
"yoi-workspace-server\n\nUsage:\n yoi-workspace-server init [OPTIONS]\n yoi-workspace-server config <COMMAND> [OPTIONS]\n yoi-workspace-server skills <COMMAND> [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 <COMMAND> [OPTIONS]\n yoi-workspace-server import-objectives [OPTIONS]\n yoi-workspace-server skills <COMMAND> [OPTIONS]\n yoi-workspace-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -469,6 +566,12 @@ 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 <PATH> Workspace root (defaults to cwd)\n --database <PATH> 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 <NAME> [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 <PATH> Workspace root (defaults to cwd)\n -h, --help Print help"
|
||||
|
|
|
|||
260
crates/workspace-server/src/objective_import.rs
Normal file
260
crates/workspace-server/src/objective_import.rs
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ObjectiveFrontmatter {
|
||||
title: String,
|
||||
state: String,
|
||||
#[serde(default)]
|
||||
created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
updated_at: Option<String>,
|
||||
#[serde(default)]
|
||||
linked_tickets: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn import_legacy_objectives_and_memory_staging<S: ControlPlaneStore>(
|
||||
workspace_root: &Path,
|
||||
workspace_id: &str,
|
||||
store: &S,
|
||||
) -> Result<ObjectiveImportReport> {
|
||||
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<S: ControlPlaneStore>(
|
||||
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<S: ControlPlaneStore>(
|
||||
objective_dir: &Path,
|
||||
workspace_id: &str,
|
||||
objective_id: &str,
|
||||
store: &S,
|
||||
) -> Result<ImportedObjectiveCounts> {
|
||||
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<S: ControlPlaneStore>(
|
||||
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<String> {
|
||||
serde_json::from_str::<serde_json::Value>(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<Vec<PathBuf>> {
|
||||
let mut files = Vec::new();
|
||||
walk_files_inner(root, &mut files)?;
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn walk_files_inner(path: &Path, files: &mut Vec<PathBuf>) -> 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(())
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use project_record::validate_record_id;
|
||||
|
|
@ -8,14 +7,17 @@ use ticket::{
|
|||
TicketWorkspaceActionPriority, project_ticket_workspace_item,
|
||||
};
|
||||
|
||||
use crate::store::{ControlPlaneStore, SqliteWorkspaceStore};
|
||||
use crate::{Error, Result};
|
||||
|
||||
const DETAIL_BODY_LIMIT: usize = 64 * 1024;
|
||||
const SUMMARY_BODY_LIMIT: usize = 240;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct LocalProjectRecordReader {
|
||||
workspace_id: String,
|
||||
workspace_root: PathBuf,
|
||||
store: SqliteWorkspaceStore,
|
||||
ticket_backend: SqliteTicketBackend,
|
||||
}
|
||||
|
||||
|
|
@ -26,8 +28,12 @@ impl LocalProjectRecordReader {
|
|||
workspace_id: impl Into<String>,
|
||||
) -> Result<Self> {
|
||||
let workspace_root = workspace_root.into();
|
||||
let database_path = database_path.into();
|
||||
let workspace_id = workspace_id.into();
|
||||
Ok(Self {
|
||||
workspace_id: workspace_id.clone(),
|
||||
workspace_root,
|
||||
store: SqliteWorkspaceStore::open(&database_path)?,
|
||||
ticket_backend: SqliteTicketBackend::new(database_path, workspace_id),
|
||||
})
|
||||
}
|
||||
|
|
@ -96,61 +102,65 @@ impl LocalProjectRecordReader {
|
|||
|
||||
pub fn list_objectives(&self, limit: usize) -> Result<ProjectRecordList<ObjectiveSummary>> {
|
||||
let mut items = Vec::new();
|
||||
let mut invalid_records = Vec::new();
|
||||
let root = self.workspace_root.join(".yoi/objectives");
|
||||
if !root.exists() {
|
||||
return Ok(ProjectRecordList {
|
||||
items,
|
||||
invalid_records,
|
||||
record_authority: "local_yoi_project_records".to_string(),
|
||||
for record in self.store.list_objectives(&self.workspace_id, limit)? {
|
||||
let linked_tickets = self
|
||||
.store
|
||||
.list_objective_ticket_links(&self.workspace_id, &record.objective_id)?
|
||||
.into_iter()
|
||||
.map(|link| link.ticket_id)
|
||||
.collect::<Vec<_>>();
|
||||
items.push(ObjectiveSummary {
|
||||
id: record.objective_id,
|
||||
title: record.title,
|
||||
state: record.state,
|
||||
updated_at: Some(record.updated_at),
|
||||
summary: summarize_body(&record.body_md),
|
||||
linked_tickets,
|
||||
record_source: "workspace-sqlite".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
for entry in fs::read_dir(&root)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let id = entry.file_name().to_string_lossy().to_string();
|
||||
match read_objective_summary(&path, &id) {
|
||||
Ok(item) => items.push(item),
|
||||
Err(error) => invalid_records.push(InvalidProjectRecord {
|
||||
label: id,
|
||||
reason: error.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
items.sort_by(|a, b| {
|
||||
b.updated_at
|
||||
.cmp(&a.updated_at)
|
||||
.then_with(|| a.id.cmp(&b.id))
|
||||
});
|
||||
items.truncate(limit.min(200));
|
||||
Ok(ProjectRecordList {
|
||||
items,
|
||||
invalid_records,
|
||||
record_authority: "local_yoi_project_records".to_string(),
|
||||
invalid_records: Vec::new(),
|
||||
record_authority: "workspace-sqlite".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn objective(&self, id: &str) -> Result<ObjectiveDetail> {
|
||||
validate_project_id(id)?;
|
||||
let path = self.workspace_root.join(".yoi/objectives").join(id);
|
||||
let raw = fs::read_to_string(path.join("item.md"))?;
|
||||
let (frontmatter, body) = split_frontmatter(&raw, id)?;
|
||||
let meta: ObjectiveFrontmatter = serde_yaml::from_str(frontmatter)?;
|
||||
let (body, body_truncated) = truncate_body(body, DETAIL_BODY_LIMIT);
|
||||
let record = self
|
||||
.store
|
||||
.get_objective(&self.workspace_id, id)?
|
||||
.ok_or_else(|| Error::Store(format!("unknown objective `{id}`")))?;
|
||||
let linked_tickets = self
|
||||
.store
|
||||
.list_objective_ticket_links(&self.workspace_id, &record.objective_id)?
|
||||
.into_iter()
|
||||
.map(|link| link.ticket_id)
|
||||
.collect::<Vec<_>>();
|
||||
let resources = self
|
||||
.store
|
||||
.list_objective_resources(&self.workspace_id, &record.objective_id)?
|
||||
.into_iter()
|
||||
.map(|resource| ObjectiveResourceSummary {
|
||||
path: resource.resource_path,
|
||||
media_type: resource.media_type,
|
||||
bytes: resource.body.len(),
|
||||
updated_at: resource.updated_at,
|
||||
})
|
||||
.collect();
|
||||
let (body, body_truncated) = truncate_body(&record.body_md, DETAIL_BODY_LIMIT);
|
||||
Ok(ObjectiveDetail {
|
||||
id: id.to_string(),
|
||||
title: meta.title,
|
||||
state: meta.state,
|
||||
created_at: meta.created_at,
|
||||
updated_at: meta.updated_at,
|
||||
linked_tickets: meta.linked_tickets,
|
||||
id: record.objective_id,
|
||||
title: record.title,
|
||||
state: record.state,
|
||||
created_at: Some(record.created_at),
|
||||
updated_at: Some(record.updated_at),
|
||||
linked_tickets,
|
||||
resources,
|
||||
body,
|
||||
body_truncated,
|
||||
record_source: "local_yoi_objective".to_string(),
|
||||
record_source: "workspace-sqlite".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -226,47 +236,18 @@ pub struct ObjectiveDetail {
|
|||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
pub linked_tickets: Vec<String>,
|
||||
pub resources: Vec<ObjectiveResourceSummary>,
|
||||
pub body: String,
|
||||
pub body_truncated: bool,
|
||||
pub record_source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ObjectiveFrontmatter {
|
||||
title: String,
|
||||
state: String,
|
||||
#[serde(default)]
|
||||
created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
updated_at: Option<String>,
|
||||
#[serde(default)]
|
||||
linked_tickets: Vec<String>,
|
||||
}
|
||||
|
||||
fn read_objective_summary(path: &Path, id: &str) -> Result<ObjectiveSummary> {
|
||||
validate_project_id(id)?;
|
||||
let raw = fs::read_to_string(path.join("item.md"))?;
|
||||
let (frontmatter, body) = split_frontmatter(&raw, id)?;
|
||||
let meta: ObjectiveFrontmatter = serde_yaml::from_str(frontmatter)?;
|
||||
Ok(ObjectiveSummary {
|
||||
id: id.to_string(),
|
||||
title: meta.title,
|
||||
state: meta.state,
|
||||
updated_at: meta.updated_at,
|
||||
summary: summarize_body(body),
|
||||
linked_tickets: meta.linked_tickets,
|
||||
record_source: "local_yoi_objective".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))
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ObjectiveResourceSummary {
|
||||
pub path: String,
|
||||
pub media_type: Option<String>,
|
||||
pub bytes: usize,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
fn validate_project_id(id: &str) -> Result<()> {
|
||||
|
|
@ -300,10 +281,13 @@ fn truncate_body(body: &str, limit: usize) -> (String, bool) {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn reads_sqlite_yoi_ticket_and_objective_records_without_migration() {
|
||||
use super::*;
|
||||
use crate::store::WorkspaceRecord;
|
||||
|
||||
#[tokio::test]
|
||||
async fn reads_sqlite_yoi_ticket_and_objective_records_without_filesystem_authority() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready");
|
||||
let db_path = dir.path().join("workspace.db");
|
||||
|
|
@ -313,6 +297,24 @@ mod tests {
|
|||
))
|
||||
.unwrap();
|
||||
write_objective(dir.path(), "00000000001J3", "Control plane", "active");
|
||||
let store = SqliteWorkspaceStore::open(&db_path).unwrap();
|
||||
store
|
||||
.upsert_workspace(&WorkspaceRecord {
|
||||
workspace_id: "workspace-test".to_string(),
|
||||
owner_account_id: None,
|
||||
display_name: "Workspace Test".to_string(),
|
||||
state: "active".to_string(),
|
||||
created_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
updated_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
crate::objective_import::import_legacy_objectives_and_memory_staging(
|
||||
dir.path(),
|
||||
"workspace-test",
|
||||
&store,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let reader = LocalProjectRecordReader::new(dir.path(), &db_path, "workspace-test").unwrap();
|
||||
let tickets = reader.list_tickets(20).unwrap();
|
||||
|
|
|
|||
|
|
@ -8312,6 +8312,7 @@ mod tests {
|
|||
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"), "<main>Yoi Workspace</main>").unwrap();
|
||||
|
|
@ -8325,6 +8326,36 @@ mod tests {
|
|||
"API Ticket",
|
||||
ticket::TicketWorkflowState::Ready,
|
||||
);
|
||||
let sqlite_store = SqliteWorkspaceStore::open(&config.database_path).unwrap();
|
||||
sqlite_store
|
||||
.upsert_workspace(&WorkspaceRecord {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
owner_account_id: None,
|
||||
display_name: "Test Workspace".to_string(),
|
||||
state: "active".to_string(),
|
||||
created_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
updated_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
crate::objective_import::import_legacy_objectives_and_memory_staging(
|
||||
dir.path(),
|
||||
TEST_WORKSPACE_ID,
|
||||
&sqlite_store,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
sqlite_store
|
||||
.count_memory_staging_records(TEST_WORKSPACE_ID)
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
write_objective(
|
||||
dir.path(),
|
||||
"00000000001J5",
|
||||
"Filesystem Only Objective",
|
||||
"active",
|
||||
);
|
||||
config.static_assets_dir = Some(static_dir);
|
||||
let api = WorkspaceApi::new_with_execution_backend(
|
||||
config,
|
||||
|
|
@ -8412,8 +8443,10 @@ mod tests {
|
|||
assert_eq!(tickets["items"][0]["state"], "ready");
|
||||
|
||||
let objectives = get_json(app.clone(), "/api/objectives").await;
|
||||
assert_eq!(objectives["items"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(objectives["items"][0]["id"], "00000000001J3");
|
||||
assert_eq!(objectives["items"][0]["summary"], "Objective body.");
|
||||
assert_eq!(objectives["record_authority"], "workspace-sqlite");
|
||||
let scoped_objectives = get_json(
|
||||
app.clone(),
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/objectives"),
|
||||
|
|
@ -8432,6 +8465,11 @@ mod tests {
|
|||
)
|
||||
.await;
|
||||
assert_eq!(scoped_objective["id"], "00000000001J3");
|
||||
assert_eq!(scoped_objective["record_source"], "workspace-sqlite");
|
||||
assert_eq!(
|
||||
scoped_objective["resources"][0]["path"],
|
||||
"memory-architecture-overview.md"
|
||||
);
|
||||
|
||||
let repositories = get_json(app.clone(), "/api/repositories").await;
|
||||
assert_eq!(repositories["items"][0]["id"], TEST_REPOSITORY_ID);
|
||||
|
|
@ -9327,6 +9365,25 @@ 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();
|
||||
|
|
@ -9346,5 +9403,10 @@ Objective body.
|
|||
),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
objective_dir.join("memory-architecture-overview.md"),
|
||||
"# Memory architecture\n\nResource body.\n",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,11 @@ const MIGRATIONS: &[Migration] = &[
|
|||
name: "webauthn challenge state",
|
||||
apply: add_webauthn_challenge_state,
|
||||
},
|
||||
Migration {
|
||||
version: 10,
|
||||
name: "sqlite objective authority and memory staging import",
|
||||
apply: create_objective_sqlite_authority_tables,
|
||||
},
|
||||
];
|
||||
|
||||
struct Migration {
|
||||
|
|
@ -221,6 +226,46 @@ pub struct WorkerWorkdirLinkRecord {
|
|||
pub unlinked_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ObjectiveRecord {
|
||||
pub workspace_id: String,
|
||||
pub objective_id: String,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
pub body_md: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ObjectiveTicketLinkRecord {
|
||||
pub workspace_id: String,
|
||||
pub objective_id: String,
|
||||
pub ticket_id: String,
|
||||
pub kind: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ObjectiveResourceRecord {
|
||||
pub workspace_id: String,
|
||||
pub objective_id: String,
|
||||
pub resource_path: String,
|
||||
pub body: String,
|
||||
pub media_type: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct MemoryStagingRecord {
|
||||
pub workspace_id: String,
|
||||
pub candidate_id: String,
|
||||
pub raw_json: String,
|
||||
pub source_path: Option<String>,
|
||||
pub imported_at: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ControlPlaneStore: Send + Sync {
|
||||
async fn schema_version(&self) -> Result<i64>;
|
||||
|
|
@ -230,6 +275,33 @@ pub trait ControlPlaneStore: Send + Sync {
|
|||
fn upsert_repository(&self, record: &RepositoryRecord) -> Result<()>;
|
||||
fn list_repositories(&self, workspace_id: &str) -> Result<Vec<RepositoryRecord>>;
|
||||
|
||||
fn upsert_objective(&self, record: &ObjectiveRecord) -> Result<()>;
|
||||
fn list_objectives(&self, workspace_id: &str, limit: usize) -> Result<Vec<ObjectiveRecord>>;
|
||||
fn get_objective(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
objective_id: &str,
|
||||
) -> Result<Option<ObjectiveRecord>>;
|
||||
fn replace_objective_ticket_links(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
objective_id: &str,
|
||||
links: &[ObjectiveTicketLinkRecord],
|
||||
) -> Result<()>;
|
||||
fn list_objective_ticket_links(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
objective_id: &str,
|
||||
) -> Result<Vec<ObjectiveTicketLinkRecord>>;
|
||||
fn upsert_objective_resource(&self, record: &ObjectiveResourceRecord) -> Result<()>;
|
||||
fn list_objective_resources(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
objective_id: &str,
|
||||
) -> Result<Vec<ObjectiveResourceRecord>>;
|
||||
fn upsert_memory_staging_record(&self, record: &MemoryStagingRecord) -> Result<()>;
|
||||
fn count_memory_staging_records(&self, workspace_id: &str) -> Result<usize>;
|
||||
|
||||
fn upsert_account(&self, record: &AccountRecord) -> Result<()>;
|
||||
fn get_account(&self, account_id: &str) -> Result<Option<AccountRecord>>;
|
||||
fn get_account_by_handle(&self, kind: &str, handle: &str) -> Result<Option<AccountRecord>>;
|
||||
|
|
@ -480,6 +552,200 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||
})
|
||||
}
|
||||
|
||||
fn upsert_objective(&self, record: &ObjectiveRecord) -> Result<()> {
|
||||
self.with_conn(|conn| {
|
||||
conn.execute(
|
||||
r#"INSERT INTO objectives (
|
||||
workspace_id, objective_id, title, state, body_md, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
ON CONFLICT(objective_id) DO UPDATE SET
|
||||
workspace_id = excluded.workspace_id,
|
||||
title = excluded.title,
|
||||
state = excluded.state,
|
||||
body_md = excluded.body_md,
|
||||
created_at = excluded.created_at,
|
||||
updated_at = excluded.updated_at"#,
|
||||
params![
|
||||
record.workspace_id,
|
||||
record.objective_id,
|
||||
record.title,
|
||||
record.state,
|
||||
record.body_md,
|
||||
record.created_at,
|
||||
record.updated_at,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn list_objectives(&self, workspace_id: &str, limit: usize) -> Result<Vec<ObjectiveRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
r#"SELECT workspace_id, objective_id, title, state, body_md, created_at, updated_at
|
||||
FROM objectives
|
||||
WHERE workspace_id = ?1
|
||||
ORDER BY updated_at DESC, objective_id ASC
|
||||
LIMIT ?2"#,
|
||||
)?;
|
||||
let rows =
|
||||
stmt.query_map(params![workspace_id, limit as i64], read_objective_record)?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(Error::from)
|
||||
})
|
||||
}
|
||||
|
||||
fn get_objective(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
objective_id: &str,
|
||||
) -> Result<Option<ObjectiveRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
conn.query_row(
|
||||
r#"SELECT workspace_id, objective_id, title, state, body_md, created_at, updated_at
|
||||
FROM objectives
|
||||
WHERE workspace_id = ?1 AND objective_id = ?2"#,
|
||||
params![workspace_id, objective_id],
|
||||
read_objective_record,
|
||||
)
|
||||
.optional()
|
||||
.map_err(Error::from)
|
||||
})
|
||||
}
|
||||
|
||||
fn replace_objective_ticket_links(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
objective_id: &str,
|
||||
links: &[ObjectiveTicketLinkRecord],
|
||||
) -> Result<()> {
|
||||
self.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"DELETE FROM objective_ticket_links WHERE workspace_id = ?1 AND objective_id = ?2",
|
||||
params![workspace_id, objective_id],
|
||||
)?;
|
||||
for link in links {
|
||||
conn.execute(
|
||||
r#"INSERT INTO objective_ticket_links (
|
||||
workspace_id, objective_id, ticket_id, kind, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(objective_id, ticket_id, kind) DO UPDATE SET
|
||||
workspace_id = excluded.workspace_id,
|
||||
created_at = excluded.created_at"#,
|
||||
params![
|
||||
link.workspace_id,
|
||||
link.objective_id,
|
||||
link.ticket_id,
|
||||
link.kind,
|
||||
link.created_at,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn list_objective_ticket_links(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
objective_id: &str,
|
||||
) -> Result<Vec<ObjectiveTicketLinkRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
r#"SELECT workspace_id, objective_id, ticket_id, kind, created_at
|
||||
FROM objective_ticket_links
|
||||
WHERE workspace_id = ?1 AND objective_id = ?2
|
||||
ORDER BY ticket_id ASC, kind ASC"#,
|
||||
)?;
|
||||
let rows = stmt.query_map(
|
||||
params![workspace_id, objective_id],
|
||||
read_objective_ticket_link_record,
|
||||
)?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(Error::from)
|
||||
})
|
||||
}
|
||||
|
||||
fn upsert_objective_resource(&self, record: &ObjectiveResourceRecord) -> Result<()> {
|
||||
self.with_conn(|conn| {
|
||||
conn.execute(
|
||||
r#"INSERT INTO objective_resources (
|
||||
workspace_id, objective_id, resource_path, body, media_type, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
ON CONFLICT(objective_id, resource_path) DO UPDATE SET
|
||||
workspace_id = excluded.workspace_id,
|
||||
body = excluded.body,
|
||||
media_type = excluded.media_type,
|
||||
updated_at = excluded.updated_at"#,
|
||||
params![
|
||||
record.workspace_id,
|
||||
record.objective_id,
|
||||
record.resource_path,
|
||||
record.body,
|
||||
record.media_type,
|
||||
record.created_at,
|
||||
record.updated_at,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn list_objective_resources(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
objective_id: &str,
|
||||
) -> Result<Vec<ObjectiveResourceRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
r#"SELECT workspace_id, objective_id, resource_path, body, media_type, created_at, updated_at
|
||||
FROM objective_resources
|
||||
WHERE workspace_id = ?1 AND objective_id = ?2
|
||||
ORDER BY resource_path ASC"#,
|
||||
)?;
|
||||
let rows = stmt.query_map(
|
||||
params![workspace_id, objective_id],
|
||||
read_objective_resource_record,
|
||||
)?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(Error::from)
|
||||
})
|
||||
}
|
||||
|
||||
fn upsert_memory_staging_record(&self, record: &MemoryStagingRecord) -> Result<()> {
|
||||
self.with_conn(|conn| {
|
||||
conn.execute(
|
||||
r#"INSERT INTO memory_staging_records (
|
||||
workspace_id, candidate_id, raw_json, source_path, imported_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(workspace_id, candidate_id) DO UPDATE SET
|
||||
raw_json = excluded.raw_json,
|
||||
source_path = excluded.source_path,
|
||||
imported_at = excluded.imported_at"#,
|
||||
params![
|
||||
record.workspace_id,
|
||||
record.candidate_id,
|
||||
record.raw_json,
|
||||
record.source_path,
|
||||
record.imported_at,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn count_memory_staging_records(&self, workspace_id: &str) -> Result<usize> {
|
||||
self.with_conn(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM memory_staging_records WHERE workspace_id = ?1",
|
||||
params![workspace_id],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.map(|count| count as usize)
|
||||
.map_err(Error::from)
|
||||
})
|
||||
}
|
||||
|
||||
fn upsert_account(&self, record: &AccountRecord) -> Result<()> {
|
||||
self.with_conn(|conn| {
|
||||
conn.execute(
|
||||
|
|
@ -1265,6 +1531,44 @@ fn read_worker_workdir_link_record(
|
|||
})
|
||||
}
|
||||
|
||||
fn read_objective_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<ObjectiveRecord> {
|
||||
Ok(ObjectiveRecord {
|
||||
workspace_id: row.get(0)?,
|
||||
objective_id: row.get(1)?,
|
||||
title: row.get(2)?,
|
||||
state: row.get(3)?,
|
||||
body_md: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
updated_at: row.get(6)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_objective_ticket_link_record(
|
||||
row: &rusqlite::Row<'_>,
|
||||
) -> rusqlite::Result<ObjectiveTicketLinkRecord> {
|
||||
Ok(ObjectiveTicketLinkRecord {
|
||||
workspace_id: row.get(0)?,
|
||||
objective_id: row.get(1)?,
|
||||
ticket_id: row.get(2)?,
|
||||
kind: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_objective_resource_record(
|
||||
row: &rusqlite::Row<'_>,
|
||||
) -> rusqlite::Result<ObjectiveResourceRecord> {
|
||||
Ok(ObjectiveResourceRecord {
|
||||
workspace_id: row.get(0)?,
|
||||
objective_id: row.get(1)?,
|
||||
resource_path: row.get(2)?,
|
||||
body: row.get(3)?,
|
||||
media_type: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
updated_at: row.get(6)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn worker_registry_select_sql(where_clause: &str) -> String {
|
||||
format!(
|
||||
"SELECT workspace_id, runtime_id, runtime_worker_id, display_name, profile, \
|
||||
|
|
@ -1381,6 +1685,53 @@ fn add_webauthn_challenge_state(conn: &Connection) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn create_objective_sqlite_authority_tables(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
PRAGMA foreign_keys = OFF;
|
||||
ALTER TABLE objective_ticket_links RENAME TO objective_ticket_links_old;
|
||||
CREATE TABLE objective_ticket_links (
|
||||
workspace_id TEXT NOT NULL,
|
||||
objective_id TEXT NOT NULL,
|
||||
ticket_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (objective_id, ticket_id, kind),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (objective_id) REFERENCES objectives(objective_id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT OR IGNORE INTO objective_ticket_links (workspace_id, objective_id, ticket_id, kind, created_at)
|
||||
SELECT workspace_id, objective_id, ticket_id, kind, created_at FROM objective_ticket_links_old;
|
||||
DROP TABLE objective_ticket_links_old;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS objective_resources (
|
||||
workspace_id TEXT NOT NULL,
|
||||
objective_id TEXT NOT NULL,
|
||||
resource_path TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
media_type TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (objective_id, resource_path),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (objective_id) REFERENCES objectives(objective_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS memory_staging_records (
|
||||
workspace_id TEXT NOT NULL,
|
||||
candidate_id TEXT NOT NULL,
|
||||
raw_json TEXT NOT NULL,
|
||||
source_path TEXT,
|
||||
imported_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, candidate_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
"#,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_account_identity_tables(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
|
|
@ -1916,12 +2267,32 @@ CREATE TABLE IF NOT EXISTS objectives (
|
|||
CREATE TABLE IF NOT EXISTS objective_ticket_links (
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
objective_id TEXT NOT NULL REFERENCES objectives(objective_id) ON DELETE CASCADE,
|
||||
ticket_id TEXT NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE,
|
||||
ticket_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (objective_id, ticket_id, kind)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS objective_resources (
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
objective_id TEXT NOT NULL REFERENCES objectives(objective_id) ON DELETE CASCADE,
|
||||
resource_path TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
media_type TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (objective_id, resource_path)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS memory_staging_records (
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
candidate_id TEXT NOT NULL,
|
||||
raw_json TEXT NOT NULL,
|
||||
source_path TEXT,
|
||||
imported_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, candidate_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS repositories (
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
repository_id TEXT PRIMARY KEY,
|
||||
|
|
@ -2053,7 +2424,7 @@ mod tests {
|
|||
let db = dir.path().join("control-plane.sqlite");
|
||||
let store = SqliteWorkspaceStore::open(&db).unwrap();
|
||||
|
||||
assert_eq!(store.schema_version().await.unwrap(), 9);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 10);
|
||||
|
||||
let record = WorkspaceRecord {
|
||||
workspace_id: "local-dev".to_string(),
|
||||
|
|
@ -2066,7 +2437,7 @@ mod tests {
|
|||
store.upsert_workspace(&record).await.unwrap();
|
||||
|
||||
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
|
||||
assert_eq!(reopened.schema_version().await.unwrap(), 9);
|
||||
assert_eq!(reopened.schema_version().await.unwrap(), 10);
|
||||
assert_eq!(
|
||||
reopened.get_workspace("local-dev").await.unwrap(),
|
||||
Some(record)
|
||||
|
|
@ -2283,7 +2654,7 @@ mod tests {
|
|||
.unwrap();
|
||||
|
||||
let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
|
||||
assert_eq!(store.schema_version().await.unwrap(), 9);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 10);
|
||||
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
|
|
@ -2374,7 +2745,7 @@ mod tests {
|
|||
#[tokio::test]
|
||||
async fn repository_records_round_trip() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
assert_eq!(store.schema_version().await.unwrap(), 9);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 10);
|
||||
let workspace = WorkspaceRecord {
|
||||
workspace_id: "local-dev".to_string(),
|
||||
owner_account_id: None,
|
||||
|
|
@ -2511,7 +2882,7 @@ mod tests {
|
|||
#[tokio::test]
|
||||
async fn account_and_login_records_round_trip() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
assert_eq!(store.schema_version().await.unwrap(), 9);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 10);
|
||||
let now = "2026-07-22T00:00:00Z".to_string();
|
||||
let account = AccountRecord {
|
||||
account_id: "acct-user-alice".to_string(),
|
||||
|
|
|
|||
66
scripts/import-objectives-and-staging-to-sqlite-20260726.sh
Executable file
66
scripts/import-objectives-and-staging-to-sqlite-20260726.sh
Executable file
|
|
@ -0,0 +1,66 @@
|
|||
#!/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[@]}"
|
||||
Loading…
Reference in New Issue
Block a user