feat: route product commands through backend targets

This commit is contained in:
2026-08-22 19:16:14 +09:00
parent 1a1e3c286f
commit 30e4985f9a
19 changed files with 1650 additions and 180 deletions
+1
View File
@@ -15,6 +15,7 @@ client = { workspace = true }
memory = { workspace = true }
manifest = { workspace = true }
worker = { workspace = true }
workspace-api = { workspace = true }
session-store = { workspace = true }
session-analytics = { workspace = true }
ticket = { workspace = true }
+5 -5
View File
@@ -55,14 +55,14 @@ impl CliCommand {
CliCommand::DefaultTui
| CliCommand::Workers
| CliCommand::Resume
| CliCommand::Panel => CliConnectionRequirement::ConnectionAware,
| CliCommand::Panel
| CliCommand::Ticket
| CliCommand::Objective => CliConnectionRequirement::ConnectionAware,
CliCommand::Login => CliConnectionRequirement::BackendOnly,
CliCommand::Keys
| CliCommand::SetupModel
| CliCommand::WorkerRuntime
| CliCommand::WorkerCleanup
| CliCommand::Ticket
| CliCommand::Objective
| CliCommand::Plugin
| CliCommand::Mcp
| CliCommand::MemoryLint
@@ -259,6 +259,8 @@ mod tests {
CliCommand::Workers,
CliCommand::Resume,
CliCommand::Panel,
CliCommand::Ticket,
CliCommand::Objective,
] {
assert_eq!(
command.connection_requirement(),
@@ -277,8 +279,6 @@ mod tests {
CliCommand::SetupModel,
CliCommand::WorkerRuntime,
CliCommand::WorkerCleanup,
CliCommand::Ticket,
CliCommand::Objective,
CliCommand::Plugin,
CliCommand::Mcp,
CliCommand::MemoryLint,
+127 -34
View File
@@ -34,10 +34,16 @@ enum Mode {
MemoryLint(LintCliOptions),
Mcp(mcp_cli::McpCliCommand),
Plugin(plugin_cli::PluginCliCommand),
Objective(objective_cli::ObjectiveCli),
Objective {
cli: objective_cli::ObjectiveCli,
target: client::ResolvedTarget,
},
Session(session_cli::SessionCli),
WorkerCleanup(worker_cleanup_cli::WorkerCleanupCli),
Ticket(ticket_cli::TicketCli),
Ticket {
cli: ticket_cli::TicketCli,
target: client::ResolvedTarget,
},
Login {
backend_url: String,
no_wait: bool,
@@ -119,19 +125,25 @@ async fn main() -> ExitCode {
ExitCode::FAILURE
}
},
Mode::Objective(cli) => match objective_cli::run(cli) {
Ok(output) => {
print!("{}", output.stdout);
match output.status {
objective_cli::ObjectiveCliStatus::Success => ExitCode::SUCCESS,
objective_cli::ObjectiveCliStatus::Failure => ExitCode::FAILURE,
Mode::Objective { cli, target } => {
match tokio::task::spawn_blocking(move || objective_cli::run(cli, target)).await {
Ok(Ok(output)) => {
print!("{}", output.stdout);
match output.status {
objective_cli::ObjectiveCliStatus::Success => ExitCode::SUCCESS,
objective_cli::ObjectiveCliStatus::Failure => ExitCode::FAILURE,
}
}
Ok(Err(e)) => {
eprintln!("yoi objective: {e}");
ExitCode::FAILURE
}
Err(e) => {
eprintln!("yoi objective: execution task failed: {e}");
ExitCode::FAILURE
}
}
Err(e) => {
eprintln!("yoi objective: {e}");
ExitCode::FAILURE
}
},
}
Mode::Session(cli) => match session_cli::run(cli) {
Ok(output) => {
print!("{}", output.stdout);
@@ -158,19 +170,25 @@ async fn main() -> ExitCode {
ExitCode::FAILURE
}
},
Mode::Ticket(cli) => match ticket_cli::run(cli) {
Ok(output) => {
print!("{}", output.stdout);
match output.status {
ticket_cli::TicketCliStatus::Success => ExitCode::SUCCESS,
ticket_cli::TicketCliStatus::Failure => ExitCode::FAILURE,
Mode::Ticket { cli, target } => {
match tokio::task::spawn_blocking(move || ticket_cli::run(cli, target)).await {
Ok(Ok(output)) => {
print!("{}", output.stdout);
match output.status {
ticket_cli::TicketCliStatus::Success => ExitCode::SUCCESS,
ticket_cli::TicketCliStatus::Failure => ExitCode::FAILURE,
}
}
Ok(Err(e)) => {
eprintln!("yoi ticket: {e}");
ExitCode::FAILURE
}
Err(e) => {
eprintln!("yoi ticket: execution task failed: {e}");
ExitCode::FAILURE
}
}
Err(e) => {
eprintln!("yoi ticket: {e}");
ExitCode::FAILURE
}
},
}
Mode::WorkerRuntime(args) => worker::entrypoint::run_cli_from("yoi worker", args).await,
Mode::Keys => tui::keys::launch().await,
Mode::SetupModel => tui::setup_model::launch().await,
@@ -357,10 +375,18 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
return Ok(Mode::WorkerRuntime(args[1..].to_vec()));
}
"objective" => {
let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Objective)?;
let objective_cli = objective_cli::parse_objective_args(&args[1..])
let workspace_root = current_dir()?;
let target = resolve_tui_target(
connection_resolver,
CliCommand::Objective,
&target_selection,
&workspace_root,
)?
.resolve()
.map_err(|error| ParseError(error.to_string()))?;
let cli = objective_cli::parse_objective_args(&args[1..])
.map_err(|e| ParseError(e.to_string()))?;
return Ok(Mode::Objective(objective_cli));
return Ok(Mode::Objective { cli, target });
}
"session" => {
let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Session)?;
@@ -369,10 +395,18 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
return Ok(Mode::Session(session_cli));
}
"ticket" => {
let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Ticket)?;
let ticket_cli =
let workspace_root = current_dir()?;
let target = resolve_tui_target(
connection_resolver,
CliCommand::Ticket,
&target_selection,
&workspace_root,
)?
.resolve()
.map_err(|error| ParseError(error.to_string()))?;
let cli =
ticket_cli::parse_ticket_args(&args[1..]).map_err(|e| ParseError(e.to_string()))?;
return Ok(Mode::Ticket(ticket_cli));
return Ok(Mode::Ticket { cli, target });
}
"plugin" => {
let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Plugin)?;
@@ -1660,8 +1694,8 @@ Local commands:
worker [WORKER_OPTIONS] Run the local Worker runtime CLI
worker delete <NAME> Delete local Worker records
worker prune Prune old local Worker records
ticket <COMMAND> Manage Tickets through the local workspace authority
objective <COMMAND> Manage Objectives through the local workspace authority
ticket <COMMAND> Manage Tickets through the selected target
objective <COMMAND> Manage Objectives through the selected target
plugin <COMMAND> Build/check/list/show plugins
mcp <COMMAND> Inspect configured MCP servers
memory lint Lint local memory files
@@ -2105,11 +2139,67 @@ backend = "shared"
#[test]
fn parse_ticket_subcommand_uses_ticket_mode() {
match parse_args_from(["ticket", "doctor"]).unwrap() {
Mode::Ticket(ticket_cli::TicketCli::Command(ticket_cli::TicketCommand::Doctor)) => {}
Mode::Ticket {
cli: ticket_cli::TicketCli::Command(ticket_cli::TicketCommand::Doctor),
target: client::ResolvedTarget::Local,
} => {}
_ => panic!("expected Ticket doctor mode"),
}
}
#[test]
fn parse_backend_ticket_keeps_resolved_workspace_target() {
let resolver = FixedCliConnectionResolver {
backend_url: "http://fake-backend.example",
};
let args = vec![
"--backend".to_string(),
"http://ignored-by-fixed-resolver.example".to_string(),
"--workspace-id".to_string(),
"workspace-a".to_string(),
"ticket".to_string(),
"doctor".to_string(),
];
match parse_args_slice_with_connection_resolver(&args, &resolver).unwrap() {
Mode::Ticket {
cli: ticket_cli::TicketCli::Command(ticket_cli::TicketCommand::Doctor),
target:
client::ResolvedTarget::Backend {
base_url,
workspace_id,
},
} => {
assert_eq!(base_url, "http://fake-backend.example");
assert_eq!(workspace_id, "workspace-a");
}
other => panic!("expected Backend Ticket mode, got {other:?}"),
}
}
#[test]
fn parse_backend_objective_keeps_resolved_workspace_target() {
let resolver = FixedCliConnectionResolver {
backend_url: "http://fake-backend.example",
};
let args = vec![
"--backend".to_string(),
"http://ignored-by-fixed-resolver.example".to_string(),
"--workspace-id".to_string(),
"workspace-a".to_string(),
"objective".to_string(),
"doctor".to_string(),
];
match parse_args_slice_with_connection_resolver(&args, &resolver).unwrap() {
Mode::Objective {
cli: objective_cli::ObjectiveCli::Command(objective_cli::ObjectiveCommand::Doctor),
target: client::ResolvedTarget::Backend { workspace_id, .. },
} => assert_eq!(workspace_id, "workspace-a"),
other => panic!("expected Backend Objective mode, got {other:?}"),
}
}
#[test]
fn parse_session_analyze_uses_session_mode() {
match parse_args_from(["session", "analyze", "/tmp/session.jsonl", "--json"]).unwrap() {
@@ -2124,7 +2214,10 @@ backend = "shared"
#[test]
fn parse_ticket_help_uses_ticket_mode() {
match parse_args_from(["ticket", "--help"]).unwrap() {
Mode::Ticket(ticket_cli::TicketCli::Help) => {}
Mode::Ticket {
cli: ticket_cli::TicketCli::Help,
target: client::ResolvedTarget::Local,
} => {}
_ => panic!("expected Ticket help mode"),
}
}
+104 -8
View File
@@ -3,6 +3,7 @@ use std::fs;
use std::path::{Component, Path, PathBuf};
use chrono::Utc;
use client::{BackendWorkspaceProductClient, ResolvedTarget};
use project_record::{allocate_record_id, unix_epoch_millis_now, validate_record_id};
use serde::Deserialize;
use ticket::config::TicketConfig;
@@ -167,11 +168,100 @@ pub fn parse_objective_args(args: &[String]) -> Result<ObjectiveCli, ObjectiveCl
Ok(ObjectiveCli::Command(command))
}
pub fn run(cli: ObjectiveCli) -> Result<ObjectiveCliOutput, ObjectiveCliError> {
let workspace = std::env::current_dir().map_err(|error| {
ObjectiveCliError::new(format!("failed to resolve current directory: {error}"))
})?;
run_in_workspace(cli, &workspace)
pub fn run(
cli: ObjectiveCli,
target: ResolvedTarget,
) -> Result<ObjectiveCliOutput, ObjectiveCliError> {
match target {
ResolvedTarget::Local => {
let workspace = std::env::current_dir().map_err(|error| {
ObjectiveCliError::new(format!("failed to resolve current directory: {error}"))
})?;
run_in_workspace(cli, &workspace)
}
ResolvedTarget::Backend {
base_url,
workspace_id,
} => {
let backend = BackendWorkspaceProductClient::new(base_url, workspace_id)
.map_err(|error| ObjectiveCliError::new(error.to_string()))?;
run_with_backend(cli, &backend)
}
}
}
fn run_with_backend(
cli: ObjectiveCli,
backend: &BackendWorkspaceProductClient,
) -> Result<ObjectiveCliOutput, ObjectiveCliError> {
match cli {
ObjectiveCli::Help => Ok(success(help_text().to_string())),
ObjectiveCli::Command(ObjectiveCommand::Create(options)) => {
let title = options.title.trim();
if title.is_empty() {
return Err(ObjectiveCliError::new("create --title must not be empty"));
}
let objective = backend
.create_objective(&workspace_api::ObjectiveCreateRequest {
title: title.to_string(),
body_md: objective_body_template(),
state: "active".to_string(),
linked_tickets: options.linked_tickets,
})
.map_err(|error| ObjectiveCliError::new(error.to_string()))?;
Ok(success(format!("created\t{}\n", objective.id)))
}
ObjectiveCli::Command(ObjectiveCommand::List(options)) => {
let response = backend
.list_objectives(BackendWorkspaceProductClient::default_product_list_limit())
.map_err(|error| ObjectiveCliError::new(error.to_string()))?;
let mut stdout = String::from("state\tid\ttitle\tupdated_at\tlinked_tickets\n");
for objective in response.items {
let state = ObjectiveState::parse(&objective.state);
if !list_state_matches(options.state, state) {
continue;
}
stdout.push_str(&format!(
"{}\t{}\t{}\t{}\t{}\n",
objective.state,
objective.id,
objective.title,
objective.updated_at.unwrap_or_default(),
objective.linked_tickets.join(",")
));
}
Ok(success(stdout))
}
ObjectiveCli::Command(ObjectiveCommand::Show { id }) => {
let objective = backend
.show_objective(&id)
.map_err(|error| ObjectiveCliError::new(error.to_string()))?;
let mut stdout = String::new();
stdout.push_str(&format!("# {}\n\n", objective.title));
stdout.push_str(&format!("State: {}\n", objective.state));
stdout.push_str(&format!("ID: {}\n", objective.id));
stdout.push_str(&format!(
"Updated: {}\n\n## item.md\n\n",
objective.updated_at.unwrap_or_default()
));
stdout.push_str(&objective.body);
if !stdout.ends_with('\n') {
stdout.push('\n');
}
Ok(success(stdout))
}
ObjectiveCli::Command(ObjectiveCommand::Doctor) => {
let response = backend
.list_objectives(BackendWorkspaceProductClient::default_product_list_limit())
.map_err(|error| ObjectiveCliError::new(error.to_string()))?;
for objective in response.items {
backend
.show_objective(&objective.id)
.map_err(|error| ObjectiveCliError::new(error.to_string()))?;
}
Ok(success("doctor: ok\n".to_string()))
}
}
}
pub fn run_in_workspace(
@@ -453,15 +543,21 @@ fn list_state_matches(filter: ObjectiveListState, state: Option<ObjectiveState>)
}
}
fn objective_body_template() -> String {
"## Goal\n\nTBD\n\n## Motivation / background\n\nTBD\n\n## Strategy / design direction\n\nTBD\n\n## Success criteria / exit conditions\n\n- TBD\n\n## Decision context\n\n- TBD\n"
.to_string()
}
fn render_objective_item(title: &str, linked_tickets: &[String]) -> String {
let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
format!(
"---\ntitle: {}\nstate: {}\ncreated_at: {}\nupdated_at: {}\nlinked_tickets: {}\n---\n\n## Goal\n\nTBD\n\n## Motivation / background\n\nTBD\n\n## Strategy / design direction\n\nTBD\n\n## Success criteria / exit conditions\n\n- TBD\n\n## Decision context\n\n- TBD\n\n",
"---\ntitle: {}\nstate: {}\ncreated_at: {}\nupdated_at: {}\nlinked_tickets: {}\n---\n\n{}\n",
yaml_string(title),
yaml_string(ObjectiveState::Active.as_str()),
yaml_string(&now),
yaml_string(&now),
yaml_string_array(linked_tickets)
yaml_string_array(linked_tickets),
objective_body_template()
)
}
@@ -587,7 +683,7 @@ fn success(stdout: String) -> ObjectiveCliOutput {
}
fn help_text() -> &'static str {
"yoi objective\n\nUsage:\n yoi objective create --title <TITLE> [--ticket <TICKET_ID> ...]\n yoi objective list [--state active|paused|done|archived|all]\n yoi objective show <OBJECTIVE_ID>\n yoi objective doctor\n\nObjective records are lightweight project records stored as .yoi/objectives/<objective-id>/item.md. Linked Tickets must be canonical opaque Ticket IDs; Objective links are non-blocking context, not Ticket dependencies.\n"
"yoi objective\n\nUsage:\n yoi objective create --title <TITLE> [--ticket <TICKET_ID> ...]\n yoi objective list [--state active|paused|done|archived|all]\n yoi objective show <OBJECTIVE_ID>\n yoi objective doctor\n\nBackend targets use the Workspace-scoped Objective API selected by the shared client Target. Explicit local targets preserve the repository-file Objective backend. Linked Tickets must be canonical opaque Ticket IDs; Objective links are non-blocking context, not Ticket dependencies.\n"
}
#[cfg(test)]
+48 -19
View File
@@ -5,6 +5,7 @@ use std::io::Write;
use std::path::{Path, PathBuf};
use chrono::{SecondsFormat, Utc};
use client::{BackendWorkspaceProductClient, ResolvedTarget};
use ticket::config::{
TICKET_CONFIG_RELATIVE_PATH, TicketConfig, WORKSPACE_SETTINGS_RELATIVE_PATH,
ticket_config_scaffold,
@@ -205,11 +206,32 @@ pub fn parse_ticket_args(args: &[String]) -> Result<TicketCli, TicketCliError> {
Ok(TicketCli::Command(command))
}
pub fn run(cli: TicketCli) -> Result<TicketCliOutput, TicketCliError> {
let workspace = std::env::current_dir().map_err(|error| {
TicketCliError::new(format!("failed to resolve current directory: {error}"))
})?;
run_in_workspace(cli, &workspace)
pub fn run(cli: TicketCli, target: ResolvedTarget) -> Result<TicketCliOutput, TicketCliError> {
match target {
ResolvedTarget::Local => {
let workspace = std::env::current_dir().map_err(|error| {
TicketCliError::new(format!("failed to resolve current directory: {error}"))
})?;
run_in_workspace(cli, &workspace)
}
ResolvedTarget::Backend {
base_url,
workspace_id,
} => match cli {
TicketCli::Help => Ok(TicketCliOutput {
status: TicketCliStatus::Success,
stdout: help_text().to_string(),
}),
TicketCli::Command(TicketCommand::Init | TicketCommand::ImportLocal) => Err(
TicketCliError::new("ticket init/import-local require an explicit local target"),
),
TicketCli::Command(command) => {
let backend = BackendWorkspaceProductClient::new(base_url, workspace_id)
.map_err(|error| TicketCliError::new(error.to_string()))?;
run_backend_command(command, &backend)
}
},
}
}
pub fn run_in_workspace(
@@ -234,23 +256,30 @@ fn run_command(
TicketCommand::ImportLocal => import_local(workspace),
command => {
let backend = backend_for_workspace(workspace)?;
match command {
TicketCommand::Create(options) => create(backend.as_ref(), options),
TicketCommand::List(options) => list(backend.as_ref(), options),
TicketCommand::Show { query } => show(backend.as_ref(), query),
TicketCommand::Comment(options) => comment(backend.as_ref(), options),
TicketCommand::State(options) => state(backend.as_ref(), options),
TicketCommand::Close(options) => close(backend.as_ref(), options),
TicketCommand::Relation(options) => relation(backend.as_ref(), options),
TicketCommand::Doctor => doctor(backend.as_ref()),
TicketCommand::Init | TicketCommand::ImportLocal => {
unreachable!("handled before backend setup")
}
}
run_backend_command(command, backend.as_ref())
}
}
}
fn run_backend_command(
command: TicketCommand,
backend: &dyn TicketBackend,
) -> Result<TicketCliOutput, TicketCliError> {
match command {
TicketCommand::Create(options) => create(backend, options),
TicketCommand::List(options) => list(backend, options),
TicketCommand::Show { query } => show(backend, query),
TicketCommand::Comment(options) => comment(backend, options),
TicketCommand::State(options) => state(backend, options),
TicketCommand::Close(options) => close(backend, options),
TicketCommand::Relation(options) => relation(backend, options),
TicketCommand::Doctor => doctor(backend),
TicketCommand::Init | TicketCommand::ImportLocal => Err(TicketCliError::new(
"ticket init/import-local require an explicit local target",
)),
}
}
fn init(workspace: &Path) -> Result<TicketCliOutput, TicketCliError> {
let legacy_config_path = workspace.join(TICKET_CONFIG_RELATIVE_PATH);
if legacy_config_path.exists() {
@@ -1153,7 +1182,7 @@ fn default_author() -> String {
}
fn help_text() -> &'static str {
"yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket import-local\n yoi ticket create --title <title>\n yoi ticket list [--state active|all|planning|ready|queued|inprogress|done|closed[,..]] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n Tickets are stored in the workspace SQLite DB under the Yoi data directory.\n `yoi ticket import-local` imports the legacy .yoi/tickets backend root configured in .yoi/workspace.toml.\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml, but does not create .yoi/tickets.\n"
"yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket import-local\n yoi ticket create --title <title>\n yoi ticket list [--state active|all|planning|ready|queued|inprogress|done|closed[,..]] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nTargets:\n Backend targets use the Workspace-scoped Ticket API selected by the shared client Target.\n Explicit local targets use the workspace SQLite backend. `init` and `import-local` are local-only.\n `yoi ticket import-local` imports the legacy .yoi/tickets backend root configured in .yoi/workspace.toml.\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml, but does not create .yoi/tickets.\n"
}
#[cfg(test)]