Compare commits

..

22 Commits

Author SHA1 Message Date
b9067a8110
web: align ticket table with panel order 2026-07-24 12:46:17 +09:00
7a28395d75
web: theme ticket table 2026-07-24 11:20:23 +09:00
d3d2b28adc
runtime: simplify worker lifecycle 2026-07-24 11:07:04 +09:00
e0b092dbc8
workdir: project occupancy from links 2026-07-24 09:29:25 +09:00
b1570aba63
web: add sortable ticket table 2026-07-24 08:56:36 +09:00
4ff599c011
server: use single XDG server database 2026-07-24 08:45:51 +09:00
19d5396897
migrate: move workspace data to XDG SQLite stores 2026-07-24 08:45:42 +09:00
e975c648c2
web: move sidebar fold into frame 2026-07-23 21:59:47 +09:00
60460b23dc
web: make sidebar fold button visible 2026-07-23 21:19:06 +09:00
62c1e4303b
web: move sidebar chrome css out of app 2026-07-23 21:09:44 +09:00
97397a1c4d
web: keep sidebar fold button visible 2026-07-23 20:57:02 +09:00
6e53df0303
web: rename sidebar collapse to fold 2026-07-23 20:50:30 +09:00
f848a96343
web: keep sidebar collapse local 2026-07-23 20:45:58 +09:00
fb4d3c3f1f
web: register sidebar snippets from layouts 2026-07-23 20:01:54 +09:00
b3854004a1
web: restore root sidebar chrome 2026-07-23 19:47:11 +09:00
308e1ffdbc
web: add global sidebar fallback 2026-07-23 19:23:02 +09:00
2bd69f9403
web: scope sidebar to workspace routes 2026-07-23 19:12:50 +09:00
61b1735292
web: simplify header chrome 2026-07-23 18:49:36 +09:00
11663830e3
web: move account nav to header 2026-07-23 18:46:43 +09:00
2285277060
fix: align account UI actor shape 2026-07-23 18:35:04 +09:00
cd6468bb92
fix: send browser origin for passkey options 2026-07-23 18:03:22 +09:00
28304f13c3
fix: honor browser origin for passkey ceremonies 2026-07-23 17:54:51 +09:00
53 changed files with 3627 additions and 1134 deletions

View File

@ -1,22 +0,0 @@
[backend]
provider = "builtin:yoi_local"
root = ".yoi/tickets"
[ticket]
language = "Japanese"
[roles.intake]
profile = "builtin:intake"
workflow = "ticket-intake-workflow"
[roles.orchestrator]
profile = "builtin:orchestrator"
workflow = "ticket-orchestrator-routing"
[roles.coder]
profile = "builtin:coder"
workflow = "multi-agent-workflow"
[roles.reviewer]
profile = "builtin:reviewer"
workflow = "multi-agent-workflow"

View File

@ -1,17 +0,0 @@
[server]
[data]
[limits]
[[repositories]]
id = "main"
provider = "git"
uri = "."
display_name = "Yoi"
default_selector = "HEAD"
[[runtimes.remote]]
id = "arc"
endpoint = "http://127.0.0.1:38800"
display_name = "arc"

1
Cargo.lock generated
View File

@ -4294,6 +4294,7 @@ dependencies = [
"fs4", "fs4",
"llm-engine", "llm-engine",
"project-record", "project-record",
"rusqlite",
"schemars", "schemars",
"serde", "serde",
"serde_json", "serde_json",

View File

@ -105,6 +105,15 @@ pub struct BackendWorkingDirectoryCleanupTarget {
pub repository_id: String, pub repository_id: String,
} }
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct BackendWorkingDirectoryOccupancy {
pub runtime_id: String,
pub runtime_worker_id: u64,
pub worker_id: String,
pub display_name: String,
pub linked_at: String,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct BackendWorkingDirectorySummary { pub struct BackendWorkingDirectorySummary {
pub working_directory_id: String, pub working_directory_id: String,
@ -122,7 +131,9 @@ pub struct BackendWorkingDirectorySummary {
#[serde(default)] #[serde(default)]
pub cleanliness: Option<String>, pub cleanliness: Option<String>,
#[serde(default)] #[serde(default)]
pub primary_worker_id: Option<String>, pub primary_worker_id: Option<u64>,
#[serde(default)]
pub occupied_by: Option<BackendWorkingDirectoryOccupancy>,
} }
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Deserialize, PartialEq, Eq)]

View File

@ -13,7 +13,7 @@
//! | base | 1. `YOI_<KIND>_DIR` | 2. `YOI_HOME` | 3. `XDG_*` | 4. 既定 | //! | base | 1. `YOI_<KIND>_DIR` | 2. `YOI_HOME` | 3. `XDG_*` | 4. 既定 |
//! |---|---|---|---|---| //! |---|---|---|---|---|
//! | config | `YOI_CONFIG_DIR` | `$YOI_HOME/config` | `$XDG_CONFIG_HOME/yoi` | `$HOME/.config/yoi` | //! | config | `YOI_CONFIG_DIR` | `$YOI_HOME/config` | `$XDG_CONFIG_HOME/yoi` | `$HOME/.config/yoi` |
//! | data | `YOI_DATA_DIR` | `$YOI_HOME` | — | `$HOME/.yoi` | //! | data | `YOI_DATA_DIR` | `$YOI_HOME` | `$XDG_DATA_HOME/yoi` | `$HOME/.local/share/yoi` |
//! | runtime | `YOI_RUNTIME_DIR` | `$YOI_HOME/run` | `$XDG_RUNTIME_DIR/yoi` | `$HOME/.yoi/run` | //! | runtime | `YOI_RUNTIME_DIR` | `$YOI_HOME/run` | `$XDG_RUNTIME_DIR/yoi` | `$HOME/.yoi/run` |
//! //!
//! `YOI_HOME=$X` のとき config は `$X/config`、data は `$X` 直下、 //! `YOI_HOME=$X` のとき config は `$X/config`、data は `$X` 直下、
@ -42,6 +42,7 @@ pub fn data_dir() -> Option<PathBuf> {
resolve_data_dir_from_parts( resolve_data_dir_from_parts(
env_path("YOI_DATA_DIR"), env_path("YOI_DATA_DIR"),
env_path("YOI_HOME"), env_path("YOI_HOME"),
env_path("XDG_DATA_HOME"),
env_path("HOME"), env_path("HOME"),
) )
} }
@ -131,6 +132,7 @@ fn resolve_config_dir_from_parts(
fn resolve_data_dir_from_parts( fn resolve_data_dir_from_parts(
yoi_data_dir: Option<PathBuf>, yoi_data_dir: Option<PathBuf>,
yoi_home: Option<PathBuf>, yoi_home: Option<PathBuf>,
xdg_data_home: Option<PathBuf>,
home: Option<PathBuf>, home: Option<PathBuf>,
) -> Option<PathBuf> { ) -> Option<PathBuf> {
if let Some(p) = yoi_data_dir { if let Some(p) = yoi_data_dir {
@ -139,7 +141,10 @@ fn resolve_data_dir_from_parts(
if let Some(p) = yoi_home { if let Some(p) = yoi_home {
return Some(p); return Some(p);
} }
Some(home?.join(".yoi")) if let Some(p) = xdg_data_home {
return Some(p.join("yoi"));
}
Some(home?.join(".local").join("share").join("yoi"))
} }
fn resolve_runtime_dir_from_parts( fn resolve_runtime_dir_from_parts(
@ -268,20 +273,35 @@ mod tests {
} }
#[test] #[test]
fn data_dir_default_is_dot_yoi() { fn data_dir_falls_back_to_home_local_share() {
assert_eq!( assert_eq!(
resolve_data_dir_from_parts(None, None, Some(PathBuf::from("/h"))).unwrap(), resolve_data_dir_from_parts(None, None, None, Some(PathBuf::from("/h"))).unwrap(),
PathBuf::from("/h/.yoi") PathBuf::from("/h/.local/share/yoi")
); );
} }
#[test] #[test]
fn data_dir_yoi_home_is_data_dir_itself() { fn data_dir_uses_xdg_when_set() {
assert_eq!(
resolve_data_dir_from_parts(
None,
None,
Some(PathBuf::from("/xdg-data")),
Some(PathBuf::from("/h")),
)
.unwrap(),
PathBuf::from("/xdg-data/yoi")
);
}
#[test]
fn data_dir_yoi_home_outranks_xdg() {
assert_eq!( assert_eq!(
resolve_data_dir_from_parts( resolve_data_dir_from_parts(
None, None,
Some(PathBuf::from("/sand")), Some(PathBuf::from("/sand")),
Some(PathBuf::from("/h")) Some(PathBuf::from("/xdg-data")),
Some(PathBuf::from("/h")),
) )
.unwrap(), .unwrap(),
PathBuf::from("/sand") PathBuf::from("/sand")
@ -294,6 +314,7 @@ mod tests {
resolve_data_dir_from_parts( resolve_data_dir_from_parts(
Some(PathBuf::from("/explicit-data")), Some(PathBuf::from("/explicit-data")),
Some(PathBuf::from("/sand")), Some(PathBuf::from("/sand")),
Some(PathBuf::from("/xdg-data")),
Some(PathBuf::from("/h")), Some(PathBuf::from("/h")),
) )
.unwrap(), .unwrap(),
@ -365,7 +386,7 @@ mod tests {
#[test] #[test]
fn returns_none_when_nothing_set() { fn returns_none_when_nothing_set() {
assert!(resolve_config_dir_from_parts(None, None, None, None).is_none()); assert!(resolve_config_dir_from_parts(None, None, None, None).is_none());
assert!(resolve_data_dir_from_parts(None, None, None).is_none()); assert!(resolve_data_dir_from_parts(None, None, None, None).is_none());
assert!(resolve_runtime_dir_from_parts(None, None, None, None).is_none()); assert!(resolve_runtime_dir_from_parts(None, None, None, None).is_none());
} }

View File

@ -14,7 +14,9 @@ schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
serde_yaml = "0.9.34" serde_yaml = "0.9.34"
rusqlite.workspace = true
thiserror.workspace = true thiserror.workspace = true
tempfile.workspace = true
toml = { workspace = true } toml = { workspace = true }
[dev-dependencies] [dev-dependencies]

File diff suppressed because it is too large Load Diff

View File

@ -219,7 +219,7 @@ fn default_store_dir() -> Result<PathBuf, PickerError> {
PickerError::Io(io::Error::new( PickerError::Io(io::Error::new(
io::ErrorKind::NotFound, io::ErrorKind::NotFound,
"could not resolve sessions directory \ "could not resolve sessions directory \
(set YOI_HOME, YOI_DATA_DIR, or HOME)", (set YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME)",
)) ))
}) })
} }
@ -231,7 +231,7 @@ fn default_worker_metadata_dir() -> Result<PathBuf, PickerError> {
PickerError::Io(io::Error::new( PickerError::Io(io::Error::new(
io::ErrorKind::NotFound, io::ErrorKind::NotFound,
"could not resolve worker state directory \ "could not resolve worker state directory \
(set YOI_HOME, YOI_DATA_DIR, or HOME)", (set YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME)",
)) ))
}) })
} }

View File

@ -138,6 +138,15 @@ pub struct WorkingDirectoryCleanupTarget {
pub repository_id: String, pub repository_id: String,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkingDirectoryOccupancy {
pub runtime_id: String,
pub runtime_worker_id: u64,
pub worker_id: String,
pub display_name: String,
pub linked_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkingDirectorySummary { pub struct WorkingDirectorySummary {
pub working_directory_id: String, pub working_directory_id: String,
@ -156,6 +165,8 @@ pub struct WorkingDirectorySummary {
pub cleanliness: Option<String>, pub cleanliness: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub primary_worker_id: Option<WorkerId>, pub primary_worker_id: Option<WorkerId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub occupied_by: Option<WorkingDirectoryOccupancy>,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]

View File

@ -1,4 +1,4 @@
use crate::catalog::{CreateWorkerRequest, WorkingDirectoryRequest, WorkingDirectoryStatus}; use crate::catalog::{WorkingDirectoryRequest, WorkingDirectoryStatus};
use crate::config_bundle::ConfigBundle; use crate::config_bundle::ConfigBundle;
use crate::error::RuntimeError; use crate::error::RuntimeError;
use crate::identity::WorkerRef; use crate::identity::WorkerRef;
@ -11,24 +11,29 @@ use serde::{Deserialize, Serialize};
use std::fmt; use std::fmt;
use std::sync::Arc; use std::sync::Arc;
/// Coarse execution attachment visible through Worker catalog/detail responses. /// Persisted execution lifecycle visible through Worker catalog/detail responses.
/// ///
/// This deliberately does not expose backend handles, process paths, sockets, /// This is intentionally a worker lifecycle projection, not a transport/backend
/// credentials, session files, or manifest paths. It only says whether Runtime /// handle state. Runtime restart boundaries invalidate live handles, so a
/// has an execution backend attached for the Worker. /// persisted `alive` worker is restored on startup; restore deferral keeps the
/// worker `stopped`, while structural restore failure marks it `corrupted`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum WorkerExecutionBackendKind { pub enum WorkerExecutionBackendKind {
/// Restoreable persisted state exists, but no live execution handle is attached.
#[default] #[default]
Unconnected, #[serde(alias = "unconnected", alias = "stale")]
/// A durable execution binding was restored, but no live handle was recovered. Stopped,
Stale, /// A live execution handle is currently attached. Legacy `connected` maps here.
Connected, #[serde(alias = "connected")]
Alive,
/// Persisted execution state is structurally invalid and cannot be restored.
Corrupted,
} }
/// Durable, non-authority execution binding projection. /// Durable, non-authority execution binding projection.
/// ///
/// This records only enough identity to diagnose stale mappings after restore. /// This records only enough identity to restore through the same backend kind.
/// It is not a live handle and must not contain sockets, paths, credentials, or /// It is not a live handle and must not contain sockets, paths, credentials, or
/// provider-private authority. /// provider-private authority.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@ -49,12 +54,11 @@ impl WorkerExecutionBindingIdentity {
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum WorkerExecutionRunState { pub enum WorkerExecutionRunState {
#[default] #[default]
Unconnected, Stopped,
Idle, Idle,
Busy, Busy,
Rejected, Rejected,
Errored, Errored,
Stopped,
} }
/// Execution operation that produced a result. /// Execution operation that produced a result.
@ -69,7 +73,18 @@ pub enum WorkerExecutionOperation {
Cancel, Cancel,
} }
/// Typed execution result class. /// Typed execution result class. Results are transient operation outcomes and
/// are not persisted as Worker lifecycle authority.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerExecutionResult {
pub operation: WorkerExecutionOperation,
pub outcome: WorkerExecutionOutcome,
pub run_state: WorkerExecutionRunState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
/// Backend result class for a Worker execution operation.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum WorkerExecutionOutcome { pub enum WorkerExecutionOutcome {
@ -80,16 +95,6 @@ pub enum WorkerExecutionOutcome {
Unsupported, Unsupported,
} }
/// Backend result for a Worker execution operation.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerExecutionResult {
pub operation: WorkerExecutionOperation,
pub outcome: WorkerExecutionOutcome,
pub run_state: WorkerExecutionRunState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
impl WorkerExecutionResult { impl WorkerExecutionResult {
pub fn accepted( pub fn accepted(
operation: WorkerExecutionOperation, operation: WorkerExecutionOperation,
@ -116,7 +121,7 @@ impl WorkerExecutionResult {
Self { Self {
operation, operation,
outcome: WorkerExecutionOutcome::Rejected, outcome: WorkerExecutionOutcome::Rejected,
run_state: WorkerExecutionRunState::Rejected, run_state: WorkerExecutionRunState::Stopped,
message: Some(message.into()), message: Some(message.into()),
} }
} }
@ -134,7 +139,7 @@ impl WorkerExecutionResult {
Self { Self {
operation, operation,
outcome: WorkerExecutionOutcome::Unsupported, outcome: WorkerExecutionOutcome::Unsupported,
run_state: WorkerExecutionRunState::Rejected, run_state: WorkerExecutionRunState::Stopped,
message: Some(message.into()), message: Some(message.into()),
} }
} }
@ -159,28 +164,31 @@ pub struct WorkerExecutionStatus {
pub binding: Option<WorkerExecutionBindingIdentity>, pub binding: Option<WorkerExecutionBindingIdentity>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory: Option<WorkingDirectoryStatus>, pub working_directory: Option<WorkingDirectoryStatus>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_result: Option<WorkerExecutionResult>,
} }
impl WorkerExecutionStatus { impl WorkerExecutionStatus {
pub fn unconnected() -> Self { pub fn stopped() -> Self {
Self::default() Self::default()
} }
pub fn connected(run_state: WorkerExecutionRunState) -> Self { pub fn alive(run_state: WorkerExecutionRunState) -> Self {
Self { Self {
backend: WorkerExecutionBackendKind::Connected, backend: WorkerExecutionBackendKind::Alive,
run_state, run_state,
binding: None, binding: None,
working_directory: None, working_directory: None,
last_result: None,
} }
} }
pub fn stale(mut previous: Self) -> Self { pub fn stopped_from(mut previous: Self) -> Self {
previous.backend = WorkerExecutionBackendKind::Stale; previous.backend = WorkerExecutionBackendKind::Stopped;
previous.run_state = WorkerExecutionRunState::Unconnected; previous.run_state = WorkerExecutionRunState::Stopped;
previous
}
pub fn corrupted(mut previous: Self) -> Self {
previous.backend = WorkerExecutionBackendKind::Corrupted;
previous.run_state = WorkerExecutionRunState::Errored;
previous previous
} }
@ -196,7 +204,6 @@ impl WorkerExecutionStatus {
pub fn with_result(mut self, result: WorkerExecutionResult) -> Self { pub fn with_result(mut self, result: WorkerExecutionResult) -> Self {
self.run_state = result.run_state; self.run_state = result.run_state;
self.last_result = Some(result);
self self
} }
} }
@ -266,13 +273,20 @@ impl WorkerExecutionContext {
&self.worker_ref &self.worker_ref
} }
/// Publish a protocol event into the Runtime observation bus. #[cfg(feature = "ws-server")]
pub fn publish_observation(
&self,
payload: protocol::Event,
) -> Result<WorkerObservationEvent, RuntimeError> {
(self.observation_publisher)(self.worker_ref.clone(), payload)
}
#[cfg(feature = "ws-server")] #[cfg(feature = "ws-server")]
pub fn publish_protocol_event( pub fn publish_protocol_event(
&self, &self,
payload: protocol::Event, payload: protocol::Event,
) -> Result<WorkerObservationEvent, RuntimeError> { ) -> Result<WorkerObservationEvent, RuntimeError> {
(self.observation_publisher)(self.worker_ref.clone(), payload) self.publish_observation(payload)
} }
} }
@ -284,32 +298,29 @@ impl fmt::Debug for WorkerExecutionContext {
} }
} }
/// Spawn/initialization request passed to an execution backend. /// Request passed to a [`WorkerExecutionBackend`] when spawning a Worker.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct WorkerExecutionSpawnRequest { pub struct WorkerExecutionSpawnRequest {
pub worker_ref: WorkerRef, pub worker_ref: WorkerRef,
pub request: CreateWorkerRequest, pub request: crate::catalog::CreateWorkerRequest,
pub context: WorkerExecutionContext, pub context: WorkerExecutionContext,
pub working_directory: Option<WorkingDirectoryBinding>, pub working_directory: Option<WorkingDirectoryBinding>,
pub config_bundle: Option<ConfigBundle>, pub config_bundle: Option<ConfigBundle>,
} }
/// Restore request passed to an execution backend for a persisted Runtime Worker. /// Request passed to a [`WorkerExecutionBackend`] when restoring a persisted Worker.
///
/// The persisted execution status is a restore hint, not a live handle. Backends
/// must create a fresh controller/handle before returning `Connected`.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct WorkerExecutionRestoreRequest { pub struct WorkerExecutionRestoreRequest {
pub worker_ref: WorkerRef, pub worker_ref: WorkerRef,
pub request: CreateWorkerRequest, pub request: crate::catalog::CreateWorkerRequest,
pub context: WorkerExecutionContext, pub context: WorkerExecutionContext,
pub previous_execution: WorkerExecutionStatus, pub previous_execution: WorkerExecutionStatus,
pub working_directory: Option<WorkingDirectoryBinding>, pub working_directory: Option<WorkingDirectoryBinding>,
pub config_bundle: Option<ConfigBundle>, pub config_bundle: Option<ConfigBundle>,
} }
/// Result of backend Worker spawn/initialization. /// Backend outcome for Worker spawn/restore operations.
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug)]
pub enum WorkerExecutionSpawnResult { pub enum WorkerExecutionSpawnResult {
Connected { Connected {
handle: WorkerExecutionHandle, handle: WorkerExecutionHandle,
@ -320,11 +331,20 @@ pub enum WorkerExecutionSpawnResult {
Errored(WorkerExecutionResult), Errored(WorkerExecutionResult),
} }
/// Backend boundary for Worker execution. impl WorkerExecutionSpawnResult {
/// pub fn connected(
/// Runtime owns Worker catalog, protocol observation, and lifecycle state. A handle: WorkerExecutionHandle,
/// backend owns concrete execution. The default Runtime has no backend, so input run_state: WorkerExecutionRunState,
/// to those Workers is rejected instead of producing providerless responses. working_directory: Option<WorkingDirectoryStatus>,
) -> Self {
Self::Connected {
handle,
run_state,
working_directory,
}
}
}
pub trait WorkerExecutionBackend: Send + Sync + 'static { pub trait WorkerExecutionBackend: Send + Sync + 'static {
fn backend_id(&self) -> &str; fn backend_id(&self) -> &str;
@ -393,6 +413,15 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
) )
} }
fn worker_completions(
&self,
_handle: &WorkerExecutionHandle,
_kind: protocol::CompletionKind,
_prefix: &str,
) -> Vec<protocol::CompletionEntry> {
Vec::new()
}
fn stop_worker(&self, _handle: &WorkerExecutionHandle) -> WorkerExecutionResult { fn stop_worker(&self, _handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
WorkerExecutionResult::unsupported( WorkerExecutionResult::unsupported(
WorkerExecutionOperation::Stop, WorkerExecutionOperation::Stop,
@ -411,32 +440,30 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
fn worker_snapshot(&self, _handle: &WorkerExecutionHandle) -> Option<protocol::Event> { fn worker_snapshot(&self, _handle: &WorkerExecutionHandle) -> Option<protocol::Event> {
None None
} }
fn worker_completions(
&self,
_handle: &WorkerExecutionHandle,
_kind: protocol::CompletionKind,
_prefix: &str,
) -> Vec<protocol::CompletionEntry> {
Vec::new()
}
} }
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct WorkerExecutionBackendRef { pub(crate) struct WorkerExecutionBackendRef {
id: String, backend_id: String,
backend: Arc<dyn WorkerExecutionBackend>, backend: Arc<dyn WorkerExecutionBackend>,
} }
impl WorkerExecutionBackendRef { impl WorkerExecutionBackendRef {
pub(crate) fn new(backend: Arc<dyn WorkerExecutionBackend>) -> Result<Self, RuntimeError> { pub(crate) fn new(backend: Arc<dyn WorkerExecutionBackend>) -> Result<Self, RuntimeError> {
let id = backend.backend_id().trim().to_string(); let backend_id = backend.backend_id().to_string();
if id.is_empty() { if backend_id.trim().is_empty() {
return Err(RuntimeError::InvalidRequest( return Err(RuntimeError::InvalidRequest(
"execution backend id must not be empty".to_string(), "execution backend id must not be empty".to_string(),
)); ));
} }
Ok(Self { id, backend }) Ok(Self {
backend_id,
backend,
})
}
pub(crate) fn backend_id(&self) -> &str {
&self.backend_id
} }
pub(crate) fn spawn_worker( pub(crate) fn spawn_worker(
@ -446,12 +473,6 @@ impl WorkerExecutionBackendRef {
self.backend.spawn_worker(request) self.backend.spawn_worker(request)
} }
#[cfg(feature = "fs-store")]
pub(crate) fn backend_id(&self) -> &str {
&self.id
}
#[cfg(feature = "fs-store")]
pub(crate) fn restore_worker( pub(crate) fn restore_worker(
&self, &self,
request: WorkerExecutionRestoreRequest, request: WorkerExecutionRestoreRequest,
@ -500,14 +521,6 @@ impl WorkerExecutionBackendRef {
self.backend.dispatch_method(handle, method) self.backend.dispatch_method(handle, method)
} }
pub(crate) fn stop_worker(&self, handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
self.backend.stop_worker(handle)
}
pub(crate) fn cancel_worker(&self, handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
self.backend.cancel_worker(handle)
}
#[cfg(feature = "ws-server")] #[cfg(feature = "ws-server")]
pub(crate) fn worker_snapshot( pub(crate) fn worker_snapshot(
&self, &self,
@ -524,12 +537,62 @@ impl WorkerExecutionBackendRef {
) -> Vec<protocol::CompletionEntry> { ) -> Vec<protocol::CompletionEntry> {
self.backend.worker_completions(handle, kind, prefix) self.backend.worker_completions(handle, kind, prefix)
} }
pub(crate) fn stop_worker(&self, handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
self.backend.stop_worker(handle)
}
pub(crate) fn cancel_worker(&self, handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
self.backend.cancel_worker(handle)
}
} }
impl fmt::Debug for WorkerExecutionBackendRef { impl fmt::Debug for WorkerExecutionBackendRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("WorkerExecutionBackendRef") f.debug_struct("WorkerExecutionBackendRef")
.field("id", &self.id) .field("backend_id", &self.backend_id)
.finish_non_exhaustive() .finish_non_exhaustive()
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn execution_backend_kind_accepts_legacy_values() {
let connected: WorkerExecutionStatus = serde_json::from_value(json!({
"backend": "connected",
"run_state": "idle",
"binding": { "backend_id": "worker-crate" }
}))
.unwrap();
assert_eq!(connected.backend, WorkerExecutionBackendKind::Alive);
let stale: WorkerExecutionStatus = serde_json::from_value(json!({
"backend": "stale",
"run_state": "stopped",
"binding": { "backend_id": "worker-crate" }
}))
.unwrap();
assert_eq!(stale.backend, WorkerExecutionBackendKind::Stopped);
let unconnected: WorkerExecutionStatus = serde_json::from_value(json!({
"backend": "unconnected",
"run_state": "stopped"
}))
.unwrap();
assert_eq!(unconnected.backend, WorkerExecutionBackendKind::Stopped);
}
#[test]
fn execution_status_serializes_without_last_result() {
let status = WorkerExecutionStatus::alive(WorkerExecutionRunState::Idle).with_result(
WorkerExecutionResult::rejected(WorkerExecutionOperation::Input, "transient"),
);
let serialized = serde_json::to_value(status).unwrap();
assert_eq!(serialized["backend"], "alive");
assert!(serialized.get("last_result").is_none());
}
}

View File

@ -473,7 +473,7 @@ struct WorkerSnapshot {
worker_id: WorkerId, worker_id: WorkerId,
status: WorkerStatus, status: WorkerStatus,
request: CreateWorkerRequest, request: CreateWorkerRequest,
#[serde(default = "WorkerExecutionStatus::unconnected")] #[serde(default = "WorkerExecutionStatus::stopped")]
execution: WorkerExecutionStatus, execution: WorkerExecutionStatus,
last_event_id: u64, last_event_id: u64,
} }

View File

@ -357,7 +357,7 @@ impl Runtime {
worker_id: worker_id.clone(), worker_id: worker_id.clone(),
status: WorkerStatus::Running, status: WorkerStatus::Running,
request: request.clone(), request: request.clone(),
execution: WorkerExecutionStatus::unconnected(), execution: WorkerExecutionStatus::stopped(),
execution_handle: None, execution_handle: None,
last_event_id: event_id, last_event_id: event_id,
}; };
@ -471,14 +471,13 @@ impl Runtime {
match (backend, handle) { match (backend, handle) {
(Some(backend), Some(handle)) => (backend, handle), (Some(backend), Some(handle)) => (backend, handle),
_ => { _ => {
let result = WorkerExecutionResult::rejected(
WorkerExecutionOperation::Input,
"worker has no execution backend",
);
let worker = state.worker_mut(worker_ref)?; let worker = state.worker_mut(worker_ref)?;
let mut execution = WorkerExecutionStatus::unconnected().with_result(result); worker.execution =
execution.binding = worker.execution.binding.clone(); if worker.execution.backend == WorkerExecutionBackendKind::Corrupted {
worker.execution = execution; worker.execution.clone()
} else {
WorkerExecutionStatus::stopped_from(worker.execution.clone())
};
state.persist_worker(&worker_ref.worker_id)?; state.persist_worker(&worker_ref.worker_id)?;
return Err(RuntimeError::WorkerExecutionUnavailable { return Err(RuntimeError::WorkerExecutionUnavailable {
worker_id: worker_ref.worker_id.clone(), worker_id: worker_ref.worker_id.clone(),
@ -510,11 +509,10 @@ impl Runtime {
let worker = state.worker_mut(worker_ref)?; let worker = state.worker_mut(worker_ref)?;
worker.last_event_id = event_id; worker.last_event_id = event_id;
worker.execution = WorkerExecutionStatus { worker.execution = WorkerExecutionStatus {
backend: WorkerExecutionBackendKind::Connected, backend: WorkerExecutionBackendKind::Alive,
run_state: dispatch_result.run_state, run_state: dispatch_result.run_state,
binding: worker.execution.binding.clone(), binding: worker.execution.binding.clone(),
working_directory: worker.execution.working_directory.clone(), working_directory: worker.execution.working_directory.clone(),
last_result: Some(dispatch_result),
}; };
let status = worker.status; let status = worker.status;
@ -592,14 +590,13 @@ impl Runtime {
match (backend, handle) { match (backend, handle) {
(Some(backend), Some(handle)) => (backend, handle), (Some(backend), Some(handle)) => (backend, handle),
_ => { _ => {
let result = WorkerExecutionResult::rejected(
WorkerExecutionOperation::ProtocolMethod,
"worker has no execution backend",
);
let worker = state.worker_mut(worker_ref)?; let worker = state.worker_mut(worker_ref)?;
let mut execution = WorkerExecutionStatus::unconnected().with_result(result); worker.execution =
execution.binding = worker.execution.binding.clone(); if worker.execution.backend == WorkerExecutionBackendKind::Corrupted {
worker.execution = execution; worker.execution.clone()
} else {
WorkerExecutionStatus::stopped_from(worker.execution.clone())
};
state.persist_worker(&worker_ref.worker_id)?; state.persist_worker(&worker_ref.worker_id)?;
return Err(RuntimeError::WorkerExecutionUnavailable { return Err(RuntimeError::WorkerExecutionUnavailable {
worker_id: worker_ref.worker_id.clone(), worker_id: worker_ref.worker_id.clone(),
@ -638,7 +635,7 @@ impl Runtime {
let binding = WorkerExecutionBindingIdentity::from_handle(&handle); let binding = WorkerExecutionBindingIdentity::from_handle(&handle);
let worker = state.worker_mut(worker_ref)?; let worker = state.worker_mut(worker_ref)?;
worker.execution_handle = Some(handle); worker.execution_handle = Some(handle);
let mut execution = WorkerExecutionStatus::connected(run_state).with_binding(binding); let mut execution = WorkerExecutionStatus::alive(run_state).with_binding(binding);
if let Some(status) = working_directory { if let Some(status) = working_directory {
execution = execution.with_working_directory(status); execution = execution.with_working_directory(status);
} }
@ -669,11 +666,10 @@ impl Runtime {
let mut state = self.lock()?; let mut state = self.lock()?;
let worker = state.worker_mut(worker_ref)?; let worker = state.worker_mut(worker_ref)?;
worker.execution = WorkerExecutionStatus { worker.execution = WorkerExecutionStatus {
backend: WorkerExecutionBackendKind::Connected, backend: WorkerExecutionBackendKind::Alive,
run_state: result.run_state, run_state: result.run_state,
binding: worker.execution.binding.clone(), binding: worker.execution.binding.clone(),
working_directory: worker.execution.working_directory.clone(), working_directory: worker.execution.working_directory.clone(),
last_result: Some(result),
}; };
state.persist_worker(&worker_ref.worker_id)?; state.persist_worker(&worker_ref.worker_id)?;
Ok(()) Ok(())
@ -1048,6 +1044,24 @@ impl Runtime {
let candidates = { let candidates = {
let mut state = self.lock()?; let mut state = self.lock()?;
let Some(backend) = state.execution_backend.clone() else { let Some(backend) = state.execution_backend.clone() else {
let worker_ids: Vec<_> = state.workers.keys().cloned().collect();
for worker_id in worker_ids {
let Some(worker) = state.workers.get(&worker_id) else {
continue;
};
if worker.execution.backend != WorkerExecutionBackendKind::Alive {
continue;
}
let worker_ref = worker.worker_ref.clone();
let worker = state.worker_mut(&worker_ref)?;
worker.execution =
if worker.execution.backend == WorkerExecutionBackendKind::Corrupted {
worker.execution.clone()
} else {
WorkerExecutionStatus::stopped_from(worker.execution.clone())
};
state.persist_worker(&worker_ref.worker_id)?;
}
return Ok(()); return Ok(());
}; };
let backend_id = backend.backend_id().to_string(); let backend_id = backend.backend_id().to_string();
@ -1059,7 +1073,10 @@ impl Runtime {
}; };
if !worker.status.is_active() if !worker.status.is_active()
|| worker.execution_handle.is_some() || worker.execution_handle.is_some()
|| worker.execution.backend != WorkerExecutionBackendKind::Stale || !matches!(
worker.execution.backend,
WorkerExecutionBackendKind::Alive | WorkerExecutionBackendKind::Stopped
)
|| worker || worker
.execution .execution
.binding .binding
@ -1152,7 +1169,7 @@ impl Runtime {
{ {
let worker = state.worker_mut(worker_ref)?; let worker = state.worker_mut(worker_ref)?;
worker.execution_handle = Some(handle.clone()); worker.execution_handle = Some(handle.clone());
let mut execution = WorkerExecutionStatus::connected(run_state) let mut execution = WorkerExecutionStatus::alive(run_state)
.with_binding(WorkerExecutionBindingIdentity::from_handle(&handle)); .with_binding(WorkerExecutionBindingIdentity::from_handle(&handle));
if let Some(status) = working_directory { if let Some(status) = working_directory {
execution = execution.with_working_directory(status); execution = execution.with_working_directory(status);
@ -1269,22 +1286,21 @@ impl RuntimeState {
let mut diagnostics = persisted.diagnostics; let mut diagnostics = persisted.diagnostics;
let mut next_diagnostic_id = persisted.next_diagnostic_id; let mut next_diagnostic_id = persisted.next_diagnostic_id;
for (worker_id, worker) in persisted.workers { for (worker_id, worker) in persisted.workers {
let execution = if worker.execution.binding.is_some() let execution = if worker.execution.backend == WorkerExecutionBackendKind::Alive
&& worker.execution.backend == WorkerExecutionBackendKind::Connected && worker.execution.binding.is_none()
{ {
let stale = WorkerExecutionStatus::stale(worker.execution);
diagnostics.push(RuntimeDiagnostic { diagnostics.push(RuntimeDiagnostic {
id: next_diagnostic_id, id: next_diagnostic_id,
severity: DiagnosticSeverity::Warning, severity: DiagnosticSeverity::Error,
code: "worker_execution_mapping_stale".to_string(), code: "worker_execution_binding_missing".to_string(),
message: format!( message: format!(
"worker {} has persisted execution binding identity but no live execution handle was restored", "worker {} was persisted as alive but has no execution binding identity",
worker.worker_id worker.worker_id
), ),
worker_ref: Some(worker.worker_ref.clone()), worker_ref: Some(worker.worker_ref.clone()),
}); });
next_diagnostic_id += 1; next_diagnostic_id += 1;
stale WorkerExecutionStatus::corrupted(worker.execution)
} else { } else {
worker.execution worker.execution
}; };
@ -1596,9 +1612,7 @@ impl RuntimeState {
}); });
let worker = self.worker_mut(worker_ref)?; let worker = self.worker_mut(worker_ref)?;
worker.execution_handle = None; worker.execution_handle = None;
let mut execution = WorkerExecutionStatus::stale(worker.execution.clone()); worker.execution = WorkerExecutionStatus::corrupted(worker.execution.clone());
execution.last_result = Some(result);
worker.execution = execution;
self.persist_runtime_snapshot()?; self.persist_runtime_snapshot()?;
self.persist_worker(&worker_ref.worker_id)?; self.persist_worker(&worker_ref.worker_id)?;
Ok(()) Ok(())
@ -2623,7 +2637,7 @@ mod tests {
assert_eq!(restored_worker.status, WorkerStatus::Stopped); assert_eq!(restored_worker.status, WorkerStatus::Stopped);
assert_eq!( assert_eq!(
restored_worker.execution.backend, restored_worker.execution.backend,
WorkerExecutionBackendKind::Stale WorkerExecutionBackendKind::Stopped
); );
assert_eq!( assert_eq!(
restored_worker restored_worker
@ -2633,16 +2647,6 @@ mod tests {
.map(|binding| binding.backend_id.as_str()), .map(|binding| binding.backend_id.as_str()),
Some("test-execution-backend") Some("test-execution-backend")
); );
assert!(
restored
.diagnostics()
.unwrap()
.iter()
.any(
|diagnostic| diagnostic.code == "worker_execution_mapping_stale"
&& diagnostic.worker_ref.as_ref() == Some(&worker.worker_ref)
)
);
#[cfg(feature = "ws-server")] #[cfg(feature = "ws-server")]
{ {
let observations = restored let observations = restored
@ -2708,6 +2712,19 @@ mod tests {
.unwrap(); .unwrap();
drop(runtime); drop(runtime);
let backendless = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions {
root: root.clone(),
display_name: None,
limits: RuntimeLimits::default(),
})
.unwrap();
let stopped_worker = backendless.worker_detail(&worker.worker_ref).unwrap();
assert_eq!(
stopped_worker.execution.backend,
WorkerExecutionBackendKind::Stopped
);
drop(backendless);
let restoring_backend = Arc::new(TestExecutionBackend::default()); let restoring_backend = Arc::new(TestExecutionBackend::default());
let restored = Runtime::with_fs_store_and_execution_backend( let restored = Runtime::with_fs_store_and_execution_backend(
crate::fs_store::FsRuntimeStoreOptions { crate::fs_store::FsRuntimeStoreOptions {
@ -2724,7 +2741,7 @@ mod tests {
assert_eq!(restored_worker.status, WorkerStatus::Running); assert_eq!(restored_worker.status, WorkerStatus::Running);
assert_eq!( assert_eq!(
restored_worker.execution.backend, restored_worker.execution.backend,
WorkerExecutionBackendKind::Connected WorkerExecutionBackendKind::Alive
); );
assert!(restored_worker.execution.binding.is_some()); assert!(restored_worker.execution.binding.is_some());
restored restored
@ -2748,7 +2765,7 @@ mod tests {
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
#[test] #[test]
fn fs_store_keeps_worker_stale_when_execution_restore_fails() { fn fs_store_marks_worker_corrupted_when_execution_restore_fails() {
let root = fs_store_root("execution-restore-failed"); let root = fs_store_root("execution-restore-failed");
let runtime = Runtime::with_fs_store_and_execution_backend( let runtime = Runtime::with_fs_store_and_execution_backend(
crate::fs_store::FsRuntimeStoreOptions { crate::fs_store::FsRuntimeStoreOptions {
@ -2785,7 +2802,7 @@ mod tests {
assert_eq!(restored_worker.status, WorkerStatus::Running); assert_eq!(restored_worker.status, WorkerStatus::Running);
assert_eq!( assert_eq!(
restored_worker.execution.backend, restored_worker.execution.backend,
WorkerExecutionBackendKind::Stale WorkerExecutionBackendKind::Corrupted
); );
assert!( assert!(
restored restored

View File

@ -121,7 +121,7 @@ impl ProfileRuntimeWorkerFactory {
.clone() .clone()
.or_else(paths::sessions_dir) .or_else(paths::sessions_dir)
.ok_or_else(|| { .ok_or_else(|| {
"could not resolve sessions directory (set YOI_HOME, YOI_DATA_DIR, or HOME)" "could not resolve sessions directory (set YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME)"
.to_string() .to_string()
}) })
} }

View File

@ -48,6 +48,7 @@ impl WorkingDirectory {
status: self.status.clone(), status: self.status.clone(),
cleanliness: None, cleanliness: None,
primary_worker_id: None, primary_worker_id: None,
occupied_by: None,
} }
} }
} }
@ -221,6 +222,7 @@ impl LocalGitWorktreeMaterializer {
status: WorkingDirectoryStatusKind::Corrupted, status: WorkingDirectoryStatusKind::Corrupted,
cleanliness: Some("unknown".to_string()), cleanliness: Some("unknown".to_string()),
primary_worker_id: None, primary_worker_id: None,
occupied_by: None,
}, },
} }
} }

View File

@ -449,8 +449,8 @@ async fn run_cli_inner(cli: Cli) -> ExitCode {
} }
}; };
// Initialize persistent store. `paths::sessions_dir()` only // Initialize persistent store. `paths::sessions_dir()` only
// returns None when none of YOI_HOME / YOI_DATA_DIR / // returns None when none of YOI_DATA_DIR / YOI_HOME /
// HOME is set — surface that as a hard error to match the // XDG_DATA_HOME / HOME is set — surface that as a hard error to match the
// runtime-dir resolution below, rather than silently writing to a // runtime-dir resolution below, rather than silently writing to a
// relative path under cwd. // relative path under cwd.
let store_dir = match cli.store.clone() { let store_dir = match cli.store.clone() {
@ -460,7 +460,7 @@ async fn run_cli_inner(cli: Cli) -> ExitCode {
None => { None => {
eprintln!( eprintln!(
"error: could not resolve sessions directory \ "error: could not resolve sessions directory \
(set --store, YOI_HOME, YOI_DATA_DIR, or HOME)" (set --store, YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME)"
); );
return ExitCode::FAILURE; return ExitCode::FAILURE;
} }

View File

@ -11,6 +11,7 @@ use crate::server::{AuthConfig, ServerConfig};
use crate::{Error, Result}; use crate::{Error, Result};
pub const WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH: &str = ".yoi/workspace-backend.local.toml"; pub const WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH: &str = ".yoi/workspace-backend.local.toml";
pub const BACKEND_RUNTIMES_CONFIG_FILE_NAME: &str = "runtimes.toml";
pub const WORKSPACE_BACKEND_CONFIG_TEMPLATE: &str = pub const WORKSPACE_BACKEND_CONFIG_TEMPLATE: &str =
include_str!("../../../resources/workspace-backend.default.toml"); include_str!("../../../resources/workspace-backend.default.toml");
const DEFAULT_LISTEN: &str = "127.0.0.1:8787"; const DEFAULT_LISTEN: &str = "127.0.0.1:8787";
@ -49,6 +50,11 @@ pub struct WorkspaceBackendConfigFile {
pub auth: WorkspaceBackendAuthConfig, pub auth: WorkspaceBackendAuthConfig,
#[serde(default)] #[serde(default)]
pub repositories: Vec<WorkspaceRepositoryConfigFile>, pub repositories: Vec<WorkspaceRepositoryConfigFile>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct BackendRuntimesConfigFile {
#[serde(default)] #[serde(default)]
pub runtimes: WorkspaceBackendRuntimesConfig, pub runtimes: WorkspaceBackendRuntimesConfig,
} }
@ -198,6 +204,74 @@ pub struct ResolvedWorkspaceBackendConfig {
pub database_path: PathBuf, pub database_path: PathBuf,
} }
impl BackendRuntimesConfigFile {
pub fn path_for_config_dir(config_dir: impl AsRef<Path>) -> PathBuf {
config_dir.as_ref().join(BACKEND_RUNTIMES_CONFIG_FILE_NAME)
}
pub fn default_path() -> Option<PathBuf> {
manifest::paths::config_dir().map(Self::path_for_config_dir)
}
pub fn load_default() -> Result<Self> {
match Self::default_path() {
Some(path) => Self::load_from_path(path),
None => Ok(Self::default()),
}
}
pub fn load_from_config_dir(config_dir: impl AsRef<Path>) -> Result<Self> {
Self::load_from_path(Self::path_for_config_dir(config_dir))
}
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
match fs::read_to_string(path) {
Ok(raw) => Self::parse_str(&raw, path),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
Err(error) => Err(Error::Io(error)),
}
}
pub fn write_default(&self) -> Result<PathBuf> {
let path = Self::default_path().ok_or_else(|| {
Error::Config(
"YOI_CONFIG_DIR, YOI_HOME, XDG_CONFIG_HOME, or HOME is required to write Backend runtimes config"
.to_string(),
)
})?;
self.write_to_path(&path)?;
Ok(path)
}
pub fn write_to_config_dir(&self, config_dir: impl AsRef<Path>) -> Result<()> {
self.write_to_path(Self::path_for_config_dir(config_dir))
}
pub fn write_to_path(&self, path: impl AsRef<Path>) -> Result<()> {
let path = path.as_ref();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let raw = toml::to_string_pretty(self).map_err(|error| {
Error::Config(format!(
"failed to serialize Backend runtimes config: {error}"
))
})?;
fs::write(path, raw)?;
Ok(())
}
pub fn parse_str(raw: &str, path: impl AsRef<Path>) -> Result<Self> {
toml::from_str(raw).map_err(|error| {
Error::Config(format!(
"failed to parse Backend runtimes config `{}`: {error}",
path.as_ref().display()
))
})
}
}
impl WorkspaceBackendConfigFile { impl WorkspaceBackendConfigFile {
pub fn path_for_workspace(workspace_root: impl AsRef<Path>) -> PathBuf { pub fn path_for_workspace(workspace_root: impl AsRef<Path>) -> PathBuf {
workspace_root workspace_root
@ -276,6 +350,19 @@ impl WorkspaceBackendConfigFile {
&self, &self,
workspace_root: impl AsRef<Path>, workspace_root: impl AsRef<Path>,
identity: WorkspaceIdentity, identity: WorkspaceIdentity,
) -> Result<ResolvedWorkspaceBackendConfig> {
self.resolve_with_runtime_config(
workspace_root,
identity,
&BackendRuntimesConfigFile::default(),
)
}
pub fn resolve_with_runtime_config(
&self,
workspace_root: impl AsRef<Path>,
identity: WorkspaceIdentity,
runtime_config: &BackendRuntimesConfigFile,
) -> Result<ResolvedWorkspaceBackendConfig> { ) -> Result<ResolvedWorkspaceBackendConfig> {
let workspace_root = workspace_root.as_ref(); let workspace_root = workspace_root.as_ref();
let data_root = self let data_root = self
@ -291,7 +378,7 @@ impl WorkspaceBackendConfigFile {
.workspace_database_path .workspace_database_path
.as_ref() .as_ref()
.map(|path| resolve_workspace_path(workspace_root, path)) .map(|path| resolve_workspace_path(workspace_root, path))
.unwrap_or_else(|| data_root.join("workspace.db")); .unwrap_or_else(ServerConfig::default_server_database_path);
let embedded_runtime_store_root = self let embedded_runtime_store_root = self
.data .data
.embedded_runtime_store_root .embedded_runtime_store_root
@ -312,6 +399,7 @@ impl WorkspaceBackendConfigFile {
})?; })?;
let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), identity); let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), identity);
server.database_path = database_path.clone();
server.frontend_url = self server.frontend_url = self
.server .server
.frontend_url .frontend_url
@ -329,7 +417,7 @@ impl WorkspaceBackendConfigFile {
.iter() .iter()
.map(|repository| resolve_repository(workspace_root, repository)) .map(|repository| resolve_repository(workspace_root, repository))
.collect::<Result<Vec<_>>>()?; .collect::<Result<Vec<_>>>()?;
server.remote_runtime_sources = self server.remote_runtime_sources = runtime_config
.runtimes .runtimes
.remote .remote
.iter() .iter()
@ -352,7 +440,9 @@ impl WorkspaceBackendConfigFile {
impl ResolvedWorkspaceBackendConfig { impl ResolvedWorkspaceBackendConfig {
pub fn with_database_path(mut self, path: impl Into<PathBuf>) -> Self { pub fn with_database_path(mut self, path: impl Into<PathBuf>) -> Self {
self.database_path = path.into(); let path = path.into();
self.database_path = path.clone();
self.server.database_path = path;
self self
} }
@ -480,7 +570,7 @@ mod tests {
assert_eq!(resolved.listen, "127.0.0.1:8787".parse().unwrap()); assert_eq!(resolved.listen, "127.0.0.1:8787".parse().unwrap());
assert_eq!(resolved.server.frontend_url, DEFAULT_FRONTEND_URL); assert_eq!(resolved.server.frontend_url, DEFAULT_FRONTEND_URL);
assert_eq!(resolved.server.max_records, DEFAULT_MAX_RECORDS); assert_eq!(resolved.server.max_records, DEFAULT_MAX_RECORDS);
assert!(resolved.database_path.ends_with("workspace.db")); assert!(resolved.database_path.ends_with("server.db"));
assert!( assert!(
resolved resolved
.server .server
@ -553,7 +643,7 @@ embedded_runtime_store_root = "/tmp/yoi-runtime"
} }
#[test] #[test]
fn data_root_derives_database_and_runtime_store_paths() { fn data_root_derives_runtime_store_path_only() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str( let config = WorkspaceBackendConfigFile::parse_str(
r#" r#"
@ -565,10 +655,7 @@ root = ".local-data"
.unwrap(); .unwrap();
let resolved = config.resolve(dir.path(), identity()).unwrap(); let resolved = config.resolve(dir.path(), identity()).unwrap();
assert_eq!( assert!(resolved.database_path.ends_with("server.db"));
resolved.database_path,
dir.path().join(".local-data/workspace.db")
);
assert_eq!( assert_eq!(
resolved.server.embedded_runtime_store_root, resolved.server.embedded_runtime_store_root,
dir.path().join(".local-data/embedded-runtime") dir.path().join(".local-data/embedded-runtime")
@ -663,15 +750,80 @@ uri = "https://example.com/org/repo.git"
} }
#[test] #[test]
fn token_value_field_is_not_in_schema() { fn backend_runtimes_config_loads_from_config_dir() {
let dir = tempfile::tempdir().unwrap();
let config = BackendRuntimesConfigFile {
runtimes: WorkspaceBackendRuntimesConfig {
remote: vec![RemoteRuntimeConfigFile {
id: "arc".to_string(),
endpoint: "http://127.0.0.1:38800".to_string(),
display_name: Some("arc".to_string()),
token_ref: None,
}],
},
};
config.write_to_config_dir(dir.path()).unwrap();
let loaded = BackendRuntimesConfigFile::load_from_config_dir(dir.path()).unwrap();
assert_eq!(loaded, config);
assert_eq!(
BackendRuntimesConfigFile::path_for_config_dir(dir.path()),
dir.path().join("runtimes.toml")
);
}
#[test]
fn workspace_backend_config_rejects_runtime_entries() {
let error = WorkspaceBackendConfigFile::parse_str( let error = WorkspaceBackendConfigFile::parse_str(
r#" r#"
[[runtimes.remote]] [[runtimes.remote]]
id = "arc"
endpoint = "http://legacy.example.test"
display_name = "legacy arc"
"#,
"test",
)
.unwrap_err();
assert!(
error.to_string().contains("unknown field `runtimes`"),
"unexpected error: {error}"
);
}
#[test]
fn backend_runtimes_config_is_the_only_runtime_source() {
let dir = tempfile::tempdir().unwrap();
let workspace_config = WorkspaceBackendConfigFile::parse_str("", "test").unwrap();
let runtime_config = BackendRuntimesConfigFile::parse_str(
r#"
[[runtimes.remote]]
id = "arc"
endpoint = "http://xdg.example.test"
display_name = "xdg arc"
"#,
"runtimes.toml",
)
.unwrap();
let resolved = workspace_config
.resolve_with_runtime_config(dir.path(), identity(), &runtime_config)
.unwrap();
assert_eq!(resolved.server.remote_runtime_sources.len(), 1);
assert_eq!(resolved.server.remote_runtime_sources[0].runtime_id, "arc");
assert_eq!(
resolved.server.remote_runtime_sources[0].base_url.as_str(),
"http://xdg.example.test"
);
}
#[test]
fn token_value_field_is_not_in_runtime_schema() {
let error = BackendRuntimesConfigFile::parse_str(
r#"
[[runtimes.remote]]
id = "remote" id = "remote"
endpoint = "http://127.0.0.1:8790" endpoint = "http://127.0.0.1:8790"
token = "secret" token = "secret"
"#, "#,
"test", "runtimes.toml",
) )
.unwrap_err(); .unwrap_err();
assert!( assert!(
@ -683,17 +835,22 @@ token = "secret"
#[test] #[test]
fn token_ref_fails_closed_until_secret_resolution_exists() { fn token_ref_fails_closed_until_secret_resolution_exists() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str( let workspace_config = WorkspaceBackendConfigFile::parse_str("", "test").unwrap();
let runtime_config = BackendRuntimesConfigFile::parse_str(
r#" r#"
[[runtimes.remote]] [[runtimes.remote]]
id = "remote" id = "remote"
endpoint = "http://127.0.0.1:8790" endpoint = "http://127.0.0.1:8790"
token_ref = "local:remote-token" token_ref = "local:remote-token"
"#, "#,
"test", "runtimes.toml",
) )
.unwrap(); .unwrap();
let error = match config.resolve(dir.path(), identity()) { let error = match workspace_config.resolve_with_runtime_config(
dir.path(),
identity(),
&runtime_config,
) {
Ok(_) => panic!("token_ref should fail closed until secret resolution exists"), Ok(_) => panic!("token_ref should fail closed until secret resolution exists"),
Err(error) => error, Err(error) => error,
}; };

View File

@ -1629,38 +1629,24 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
}), }),
}; };
match self.runtime.create_worker(create_request) { match self.runtime.create_worker(create_request) {
Ok(detail) => { Ok(detail) => WorkerSpawnResult {
let execution_failure =
embedded_spawn_execution_failure_diagnostic(&detail.execution);
if let Some(diagnostic) = execution_failure {
diagnostics.push(diagnostic);
WorkerSpawnResult {
state: WorkerOperationState::Rejected,
worker: Some(self.map_worker_detail(detail)),
acceptance_evidence: Vec::new(),
diagnostics,
}
} else {
WorkerSpawnResult {
state: WorkerOperationState::Accepted, state: WorkerOperationState::Accepted,
worker: Some(self.map_worker_detail(detail)), worker: Some(self.map_worker_detail(detail)),
acceptance_evidence: vec![ acceptance_evidence: vec![
WorkerSpawnAcceptanceEvidence { WorkerSpawnAcceptanceEvidence {
kind: "embedded_runtime_worker_created".to_string(), kind: "embedded_runtime_worker_created".to_string(),
detail: "worker-runtime catalog accepted a backend-internal tools-less Worker" detail:
"worker-runtime catalog accepted a backend-internal tools-less Worker"
.to_string(), .to_string(),
}, },
WorkerSpawnAcceptanceEvidence { WorkerSpawnAcceptanceEvidence {
kind: "embedded_runtime_backend_internal_projection".to_string(), kind: "embedded_runtime_backend_internal_projection".to_string(),
detail: detail: "only runtime_id plus worker_id backend projections were exposed"
"only runtime_id plus worker_id backend projections were exposed"
.to_string(), .to_string(),
}, },
], ],
diagnostics, diagnostics,
} },
}
}
Err(err) => { Err(err) => {
diagnostics.push(embedded_runtime_diagnostic(&err)); diagnostics.push(embedded_runtime_diagnostic(&err));
WorkerSpawnResult { WorkerSpawnResult {
@ -2710,38 +2696,6 @@ fn embedded_runtime_status_label(status: RuntimeStatus) -> &'static str {
} }
} }
fn embedded_spawn_execution_failure_diagnostic(
execution: &worker_runtime::execution::WorkerExecutionStatus,
) -> Option<RuntimeDiagnostic> {
let result = execution.last_result.as_ref()?;
let severity = match result.outcome {
worker_runtime::execution::WorkerExecutionOutcome::Accepted => return None,
worker_runtime::execution::WorkerExecutionOutcome::Rejected
| worker_runtime::execution::WorkerExecutionOutcome::Busy
| worker_runtime::execution::WorkerExecutionOutcome::Unsupported => {
DiagnosticSeverity::Warning
}
worker_runtime::execution::WorkerExecutionOutcome::Errored => DiagnosticSeverity::Error,
};
let status = match result.outcome {
worker_runtime::execution::WorkerExecutionOutcome::Accepted => "accepted",
worker_runtime::execution::WorkerExecutionOutcome::Rejected => "rejected",
worker_runtime::execution::WorkerExecutionOutcome::Busy => "busy",
worker_runtime::execution::WorkerExecutionOutcome::Unsupported => "unsupported",
worker_runtime::execution::WorkerExecutionOutcome::Errored => "errored",
};
let detail = result
.message
.as_deref()
.map(|message| format!(": {message}"))
.unwrap_or_default();
Some(diagnostic(
format!("embedded_worker_execution_spawn_{status}"),
severity,
format!("Embedded Worker execution spawn was {status} during setup{detail}"),
))
}
fn runtime_worker_can_stop( fn runtime_worker_can_stop(
execution_enabled: bool, execution_enabled: bool,
status: EmbeddedWorkerStatus, status: EmbeddedWorkerStatus,
@ -2749,26 +2703,11 @@ fn runtime_worker_can_stop(
) -> bool { ) -> bool {
execution_enabled execution_enabled
&& status == EmbeddedWorkerStatus::Running && status == EmbeddedWorkerStatus::Running
&& execution.backend == worker_runtime::execution::WorkerExecutionBackendKind::Connected && execution.backend == worker_runtime::execution::WorkerExecutionBackendKind::Alive
&& !matches!( && matches!(
execution.run_state, execution.run_state,
WorkerExecutionRunState::Stopped WorkerExecutionRunState::Idle | WorkerExecutionRunState::Busy
| WorkerExecutionRunState::Rejected
| WorkerExecutionRunState::Errored
| WorkerExecutionRunState::Unconnected
) )
&& !execution_last_result_blocks_control(execution)
}
fn execution_last_result_blocks_control(execution: &WorkerExecutionStatus) -> bool {
execution.last_result.as_ref().is_some_and(|result| {
matches!(
result.outcome,
worker_runtime::execution::WorkerExecutionOutcome::Rejected
| worker_runtime::execution::WorkerExecutionOutcome::Errored
| worker_runtime::execution::WorkerExecutionOutcome::Unsupported
)
})
} }
fn embedded_worker_status_label(status: EmbeddedWorkerStatus) -> &'static str { fn embedded_worker_status_label(status: EmbeddedWorkerStatus) -> &'static str {
@ -2788,28 +2727,27 @@ fn embedded_worker_projection_diagnostics(
"Worker identity is projected only as runtime_id plus worker_id; embedded runtime internals remain backend-private".to_string(), "Worker identity is projected only as runtime_id plus worker_id; embedded runtime internals remain backend-private".to_string(),
)]; )];
if execution.backend == WorkerExecutionBackendKind::Stale { match execution.backend {
diagnostics.push(diagnostic( WorkerExecutionBackendKind::Stopped => diagnostics.push(diagnostic(
"embedded_worker_execution_stale", "embedded_worker_execution_stopped",
DiagnosticSeverity::Warning, DiagnosticSeverity::Info,
"Worker execution handle is not connected in this server process; persisted execution binding was marked stale".to_string(), "Worker execution is stopped; the runtime will restore it before dispatching input or protocol methods".to_string(),
)); )),
} else if execution.backend == WorkerExecutionBackendKind::Unconnected WorkerExecutionBackendKind::Corrupted => diagnostics.push(diagnostic(
|| execution.run_state == WorkerExecutionRunState::Unconnected "embedded_worker_execution_corrupted",
{
diagnostics.push(diagnostic(
"embedded_worker_execution_unconnected",
DiagnosticSeverity::Warning,
"Worker execution handle is not connected in this server process".to_string(),
));
} else if execution.run_state == WorkerExecutionRunState::Rejected {
diagnostics.push(diagnostic(
"embedded_worker_execution_spawn_rejected",
DiagnosticSeverity::Error, DiagnosticSeverity::Error,
"Worker execution spawn was rejected; backend-private details are not exposed" "Worker execution state is corrupted and cannot be restored without repair".to_string(),
.to_string(), )),
WorkerExecutionBackendKind::Alive => {
if execution.run_state == WorkerExecutionRunState::Rejected {
diagnostics.push(diagnostic(
"embedded_worker_execution_rejected",
DiagnosticSeverity::Warning,
"Worker execution rejected the last transient operation; retry or inspect runtime logs".to_string(),
)); ));
} }
}
}
diagnostics diagnostics
} }
@ -2821,19 +2759,19 @@ fn embedded_worker_execution_status_label(
match status { match status {
EmbeddedWorkerStatus::Stopped => "stopped", EmbeddedWorkerStatus::Stopped => "stopped",
EmbeddedWorkerStatus::Cancelled => "cancelled", EmbeddedWorkerStatus::Cancelled => "cancelled",
EmbeddedWorkerStatus::Running => { EmbeddedWorkerStatus::Running => match execution.backend {
if execution.backend == worker_runtime::execution::WorkerExecutionBackendKind::Stale { worker_runtime::execution::WorkerExecutionBackendKind::Stopped => "stopped",
return "stale"; worker_runtime::execution::WorkerExecutionBackendKind::Corrupted => "corrupted",
} worker_runtime::execution::WorkerExecutionBackendKind::Alive => {
match execution.run_state { match execution.run_state {
WorkerExecutionRunState::Idle => "idle", WorkerExecutionRunState::Idle => "idle",
WorkerExecutionRunState::Busy => "running", WorkerExecutionRunState::Busy => "running",
WorkerExecutionRunState::Stopped => "stopped", WorkerExecutionRunState::Stopped => "stopped",
WorkerExecutionRunState::Rejected => "rejected", WorkerExecutionRunState::Rejected => "rejected",
WorkerExecutionRunState::Errored => "errored", WorkerExecutionRunState::Errored => "errored",
WorkerExecutionRunState::Unconnected => "unconnected",
} }
} }
},
} }
} }
@ -4331,7 +4269,7 @@ mod tests {
} }
#[test] #[test]
fn remote_runtime_projection_blocks_stale_and_unconnected_execution_input() { fn remote_runtime_projection_blocks_stopped_and_corrupted_execution_stop() {
let (base_url, server) = serve_mock_http(vec![ let (base_url, server) = serve_mock_http(vec![
mock_response( mock_response(
"GET", "GET",
@ -4343,30 +4281,26 @@ mod tests {
worker_json_with_execution( worker_json_with_execution(
"remote:primary", "remote:primary",
"1", "1",
"stale", "stopped",
"unconnected", "stopped",
None,
), ),
worker_json_with_execution( worker_json_with_execution(
"remote:primary", "remote:primary",
"2", "2",
"unconnected", "corrupted",
"unconnected", "errored",
None,
), ),
worker_json_with_execution( worker_json_with_execution(
"remote:primary", "remote:primary",
"3", "3",
"connected", "alive",
"rejected", "rejected",
Some("rejected"),
), ),
worker_json_with_execution( worker_json_with_execution(
"remote:primary", "remote:primary",
"4", "4",
"connected", "alive",
"errored", "errored",
Some("errored"),
) )
] ]
}) })
@ -4381,9 +4315,8 @@ mod tests {
"worker": worker_json_with_execution( "worker": worker_json_with_execution(
"remote:primary", "remote:primary",
"1", "1",
"stale", "stopped",
"unconnected", "stopped",
None,
)}) )})
.to_string(), .to_string(),
), ),
@ -4411,14 +4344,14 @@ mod tests {
worker.worker_id worker.worker_id
); );
} }
assert_eq!(workers.items[0].state, "stale"); assert_eq!(workers.items[0].state, "stopped");
assert_eq!(workers.items[1].state, "unconnected"); assert_eq!(workers.items[1].state, "corrupted");
assert_eq!(workers.items[2].state, "rejected"); assert_eq!(workers.items[2].state, "rejected");
assert_eq!(workers.items[3].state, "errored"); assert_eq!(workers.items[3].state, "errored");
let stale_detail = registry.worker("remote:primary", "1").unwrap(); let stopped_detail = registry.worker("remote:primary", "1").unwrap();
assert!(!stale_detail.capabilities.can_stop); assert!(!stopped_detail.capabilities.can_stop);
assert_eq!(stale_detail.state, "stale"); assert_eq!(stopped_detail.state, "stopped");
server.join().expect("mock remote server finished"); server.join().expect("mock remote server finished");
} }
@ -4599,7 +4532,7 @@ mod tests {
} }
fn worker_json(runtime_id: &str, worker_id: &str) -> serde_json::Value { fn worker_json(runtime_id: &str, worker_id: &str) -> serde_json::Value {
worker_json_with_execution(runtime_id, worker_id, "connected", "idle", None) worker_json_with_execution(runtime_id, worker_id, "alive", "idle")
} }
fn worker_json_with_execution( fn worker_json_with_execution(
@ -4607,23 +4540,14 @@ mod tests {
worker_id: &str, worker_id: &str,
backend: &str, backend: &str,
run_state: &str, run_state: &str,
last_outcome: Option<&str>,
) -> serde_json::Value { ) -> serde_json::Value {
let last_result = last_outcome.map(|outcome| {
json!({
"operation": "input",
"outcome": outcome,
"run_state": run_state,
"message": format!("{outcome} result")
})
});
let worker_id = worker_id.parse::<u64>().unwrap(); let worker_id = worker_id.parse::<u64>().unwrap();
json!({ json!({
"worker_ref": { "runtime_id": runtime_id, "worker_id": worker_id }, "worker_ref": { "runtime_id": runtime_id, "worker_id": worker_id },
"runtime_id": runtime_id, "runtime_id": runtime_id,
"worker_id": worker_id, "worker_id": worker_id,
"status": "running", "status": "running",
"execution": { "backend": backend, "run_state": run_state, "last_result": last_result }, "execution": { "backend": backend, "run_state": run_state },
"intent": { "kind": "role", "role": "coder", "purpose": "remote test" }, "intent": { "kind": "role", "role": "coder", "purpose": "remote test" },
"profile": { "kind": "builtin", "value": "coder" }, "profile": { "kind": "builtin", "value": "coder" },
"profile_source": { "profile_source": {

View File

@ -19,8 +19,9 @@ pub mod skills;
pub mod store; pub mod store;
pub use config::{ pub use config::{
ConfigDiff, ResolvedWorkspaceBackendConfig, WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, BackendRuntimesConfigFile, ConfigDiff, ResolvedWorkspaceBackendConfig,
WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile, WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, WORKSPACE_BACKEND_CONFIG_TEMPLATE,
WorkspaceBackendConfigFile,
}; };
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity}; pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
pub use records::{ pub use records::{

View File

@ -4,9 +4,11 @@ use std::process::ExitCode;
use std::sync::Arc; use std::sync::Arc;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use yoi_workspace_server::store::RepositoryRecord;
use yoi_workspace_server::{ use yoi_workspace_server::{
SqliteWorkspaceStore, WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile, BackendRuntimesConfigFile, ControlPlaneStore, ServerConfig, SqliteWorkspaceStore,
WorkspaceIdentity, serve, WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile, WorkspaceIdentity,
WorkspaceRecord, serve,
}; };
#[derive(Debug)] #[derive(Debug)]
@ -21,9 +23,6 @@ enum Command {
#[derive(Debug)] #[derive(Debug)]
struct ServeOptions { struct ServeOptions {
workspace: PathBuf,
db: Option<PathBuf>,
frontend: Option<PathBuf>,
listen: Option<SocketAddr>, listen: Option<SocketAddr>,
} }
@ -70,7 +69,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
let args = std::env::args().skip(1).collect::<Vec<_>>(); let args = std::env::args().skip(1).collect::<Vec<_>>();
match parse_command(&args)? { match parse_command(&args)? {
Command::Serve(options) => run_serve(options).await, Command::Serve(options) => run_serve(options).await,
Command::Init(options) => run_init(options), Command::Init(options) => run_init(options).await,
Command::ConfigDefault => run_config_default(), Command::ConfigDefault => run_config_default(),
Command::ConfigDiff(options) => run_config_diff(options), Command::ConfigDiff(options) => run_config_diff(options),
Command::Skills(command) => run_skills(command), Command::Skills(command) => run_skills(command),
@ -111,13 +110,50 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
} }
} }
fn run_init(options: InitOptions) -> Result<(), Box<dyn std::error::Error>> { async fn run_init(options: InitOptions) -> Result<(), Box<dyn std::error::Error>> {
run_init_with_database_path(options, ServerConfig::default_server_database_path()).await
}
async fn run_init_with_database_path(
options: InitOptions,
database_path: PathBuf,
) -> Result<(), Box<dyn std::error::Error>> {
let identity = WorkspaceIdentity::load_or_init(&options.workspace)?; let identity = WorkspaceIdentity::load_or_init(&options.workspace)?;
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(&options.workspace)?; WorkspaceBackendConfigFile::ensure_local_config_for_workspace(&options.workspace)?;
if let Some(parent) = database_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
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: identity.created_at.clone(),
})
.await?;
store.upsert_repository(&RepositoryRecord {
workspace_id: identity.workspace_id.clone(),
repository_id: "main".to_string(),
name: "Main repository".to_string(),
kind: "git".to_string(),
provider: Some("git".to_string()),
uri: options.workspace.display().to_string(),
default_ref: Some("HEAD".to_string()),
auth_ref_kind: None,
auth_ref_key: None,
created_at: identity.created_at.clone(),
updated_at: identity.created_at.clone(),
})?;
eprintln!( eprintln!(
"yoi-workspace-server: initialized workspace `{}` ({})", "yoi-workspace-server: initialized workspace `{}` ({}) in server DB `{}`",
options.workspace.display(), options.workspace.display(),
identity.workspace_id identity.workspace_id,
database_path.display()
); );
Ok(()) Ok(())
} }
@ -167,34 +203,90 @@ fn run_skills(command: SkillsCommand) -> Result<(), Box<dyn std::error::Error>>
} }
async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Error>> { async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Error>> {
let identity = WorkspaceIdentity::load_required(&options.workspace)?; let database_path = ServerConfig::default_server_database_path();
let config_file = WorkspaceBackendConfigFile::load_for_workspace(&options.workspace)?; if let Some(parent) = database_path.parent() {
let mut resolved = config_file.resolve(&options.workspace, identity)?; tokio::fs::create_dir_all(parent).await?;
if let Some(db) = options.db {
resolved = resolved.with_database_path(db);
}
if let Some(frontend) = options.frontend {
resolved = resolved.with_static_assets_dir(Some(frontend));
} }
let store = Arc::new(SqliteWorkspaceStore::open(&database_path)?);
let workspace = select_serve_workspace(store.as_ref())?;
let workspace_root = infer_workspace_root_from_repositories(store.as_ref(), &workspace)?;
let identity = WorkspaceIdentity {
workspace_id: workspace.workspace_id.clone(),
created_at: workspace.created_at.clone(),
display_name: workspace.display_name.clone(),
};
let runtime_config = BackendRuntimesConfigFile::load_default()?;
let mut resolved = WorkspaceBackendConfigFile::default().resolve_with_runtime_config(
&workspace_root,
identity,
&runtime_config,
)?;
resolved.database_path = database_path.clone();
resolved.server.database_path = database_path.clone();
if let Some(listen) = options.listen { if let Some(listen) = options.listen {
resolved = resolved.with_listen(listen); resolved = resolved.with_listen(listen);
} }
if let Some(parent) = resolved.database_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let store = Arc::new(SqliteWorkspaceStore::open(&resolved.database_path)?);
let listener = TcpListener::bind(resolved.listen).await?; let listener = TcpListener::bind(resolved.listen).await?;
eprintln!( eprintln!(
"yoi-workspace-server: serving workspace `{}` on http://{}", "yoi-workspace-server: serving workspace `{}` from server DB `{}` on http://{}",
options.workspace.display(), workspace.workspace_id,
database_path.display(),
listener.local_addr()? listener.local_addr()?
); );
serve(resolved.server, store, listener).await?; serve(resolved.server, store, listener).await?;
Ok(()) Ok(())
} }
fn select_serve_workspace(store: &SqliteWorkspaceStore) -> Result<WorkspaceRecord, CliError> {
let workspaces = store
.list_workspaces()
.map_err(|error| CliError(format!("failed to list workspaces from server DB: {error}")))?;
match workspaces.as_slice() {
[] => Err(CliError(
"server DB has no workspace records; run `yoi-workspace-server init --workspace <PATH>`".to_string(),
)),
[workspace] => Ok(workspace.clone()),
_ => Err(CliError(format!(
"server DB contains {} workspaces; serve workspace selection is not implemented yet",
workspaces.len()
))),
}
}
fn infer_workspace_root_from_repositories(
store: &SqliteWorkspaceStore,
workspace: &WorkspaceRecord,
) -> Result<PathBuf, CliError> {
let repositories = store
.list_repositories(&workspace.workspace_id)
.map_err(|error| {
CliError(format!(
"failed to list repositories from server DB: {error}"
))
})?;
let Some(repository) = repositories
.iter()
.find(|repository| repository.repository_id == "main")
.or_else(|| repositories.first())
else {
return Err(CliError(format!(
"workspace `{}` has no repository records; cannot derive a workspace root",
workspace.workspace_id
)));
};
let repository_path = PathBuf::from(&repository.uri);
if !repository_path.is_absolute() {
return Err(CliError(format!(
"repository `{}` has relative URI `{}`; repository records used by serve must be absolute paths",
repository.repository_id, repository.uri
)));
}
Ok(repository_path)
}
fn parse_config_command(args: &[String]) -> Result<Command, CliError> { fn parse_config_command(args: &[String]) -> Result<Command, CliError> {
let Some((subcommand, rest)) = args.split_first() else { let Some((subcommand, rest)) = args.split_first() else {
print_config_help(); print_config_help();
@ -311,37 +403,12 @@ fn parse_init_options(args: &[String]) -> Result<InitOptions, CliError> {
} }
fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> { fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
let mut workspace = std::env::current_dir()
.map_err(|error| CliError(format!("failed to resolve current directory: {error}")))?;
let mut db = None;
let mut frontend = None;
let mut listen = None; let mut listen = None;
let mut index = 0; let mut index = 0;
while index < args.len() { while index < args.len() {
let arg = &args[index]; let arg = &args[index];
match arg.as_str() { match arg.as_str() {
"--workspace" => {
index += 1;
let value = args
.get(index)
.ok_or_else(|| CliError("--workspace requires a value".to_string()))?;
workspace = PathBuf::from(value);
}
"--db" => {
index += 1;
let value = args
.get(index)
.ok_or_else(|| CliError("--db requires a value".to_string()))?;
db = Some(PathBuf::from(value));
}
"--frontend" => {
index += 1;
let value = args
.get(index)
.ok_or_else(|| CliError("--frontend requires a value".to_string()))?;
frontend = Some(PathBuf::from(value));
}
"--listen" => { "--listen" => {
index += 1; index += 1;
let value = args let value = args
@ -349,15 +416,6 @@ fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
.ok_or_else(|| CliError("--listen requires a value".to_string()))?; .ok_or_else(|| CliError("--listen requires a value".to_string()))?;
listen = Some(parse_listen(value)?); listen = Some(parse_listen(value)?);
} }
_ if arg.starts_with("--workspace=") => {
workspace = PathBuf::from(value_after_equals(arg, "--workspace")?);
}
_ if arg.starts_with("--db=") => {
db = Some(PathBuf::from(value_after_equals(arg, "--db")?));
}
_ if arg.starts_with("--frontend=") => {
frontend = Some(PathBuf::from(value_after_equals(arg, "--frontend")?));
}
_ if arg.starts_with("--listen=") => { _ if arg.starts_with("--listen=") => {
listen = Some(parse_listen(value_after_equals(arg, "--listen")?)?); listen = Some(parse_listen(value_after_equals(arg, "--listen")?)?);
} }
@ -366,26 +424,14 @@ fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
} }
_ => { _ => {
return Err(CliError(format!( return Err(CliError(format!(
"unexpected positional argument `{arg}`; use --workspace <PATH>" "unexpected positional argument `{arg}`; serve reads the workspace from the server DB"
))); )));
} }
} }
index += 1; index += 1;
} }
let workspace = workspace.canonicalize().map_err(|error| { Ok(ServeOptions { listen })
CliError(format!(
"failed to canonicalize workspace `{}`: {error}",
workspace.display()
))
})?;
Ok(ServeOptions {
workspace,
db,
frontend,
listen,
})
} }
fn value_after_equals<'a>(arg: &'a str, flag: &str) -> Result<&'a str, CliError> { fn value_after_equals<'a>(arg: &'a str, flag: &str) -> Result<&'a str, CliError> {
@ -413,7 +459,7 @@ fn print_help() {
fn print_init_help() { fn print_init_help() {
println!( println!(
"yoi-workspace-server init\n\nUsage:\n yoi-workspace-server init [OPTIONS]\n\nDescription:\n Initializes a Workspace identity and copies the packaged Backend config template to .yoi/workspace-backend.local.toml. Does not create Backend data stores.\n\nOptions:\n --workspace <PATH> Workspace root to initialize (defaults to cwd)\n -h, --help Print help" "yoi-workspace-server init\n\nUsage:\n yoi-workspace-server init [OPTIONS]\n\nDescription:\n Initializes a Workspace identity, copies the packaged Backend config template to .yoi/workspace-backend.local.toml, and registers the Workspace in the Yoi server DB.\n\nOptions:\n --workspace <PATH> Workspace root to initialize (defaults to cwd)\n -h, --help Print help"
); );
} }
@ -431,7 +477,7 @@ fn print_skills_help() {
fn print_serve_help() { fn print_serve_help() {
println!( println!(
"yoi-workspace-server serve\n\nUsage:\n yoi-workspace-server serve [OPTIONS]\n\nDescription:\n Serves an already initialized Workspace. Run `yoi workspace init` first.\n\nOptions:\n --workspace <PATH> Workspace root containing .yoi project records (defaults to cwd)\n --db <PATH> SQLite database path (legacy dev override)\n --frontend <PATH> Static SPA build directory to serve (legacy dev override)\n --listen <ADDR> Listen address (legacy dev override; default 127.0.0.1:8787)\n -h, --help Print help" "yoi-workspace-server serve\n\nUsage:\n yoi-workspace-server serve [OPTIONS]\n\nDescription:\n Serves the Workspace recorded in the Yoi server DB. Workspace records are stored in the XDG/Yoi data directory, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n -h, --help Print help"
); );
} }
@ -452,11 +498,42 @@ mod tests {
} }
#[test] #[test]
fn init_creates_identity_and_local_config_only() { fn parse_serve_accepts_listen_only() {
let args = vec!["--listen".to_string(), "127.0.0.1:0".to_string()];
let options = parse_serve_options(&args).unwrap();
assert_eq!(options.listen.unwrap(), "127.0.0.1:0".parse().unwrap());
}
#[test]
fn parse_serve_rejects_legacy_workspace_flag() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
run_init(InitOptions { let args = vec!["--workspace".to_string(), temp.path().display().to_string()];
let error = parse_serve_options(&args).unwrap_err();
assert_eq!(error.to_string(), "unknown serve option `--workspace`");
}
#[test]
fn parse_serve_rejects_legacy_db_and_frontend_flags() {
let error = parse_serve_options(&["--db=/tmp/yoi.db".to_string()]).unwrap_err();
assert_eq!(error.to_string(), "unknown serve option `--db=/tmp/yoi.db`");
let error = parse_serve_options(&["--frontend=/tmp/web".to_string()]).unwrap_err();
assert_eq!(
error.to_string(),
"unknown serve option `--frontend=/tmp/web`"
);
}
#[tokio::test]
async fn init_creates_identity_local_config_and_server_records() {
let temp = tempfile::tempdir().unwrap();
let database_path = temp.path().join("data").join("server").join("server.db");
run_init_with_database_path(
InitOptions {
workspace: temp.path().canonicalize().unwrap(), workspace: temp.path().canonicalize().unwrap(),
}) },
database_path.clone(),
)
.await
.unwrap(); .unwrap();
assert!(temp.path().join(WORKSPACE_IDENTITY_RELATIVE_PATH).exists()); assert!(temp.path().join(WORKSPACE_IDENTITY_RELATIVE_PATH).exists());
@ -474,5 +551,19 @@ mod tests {
); );
assert!(!temp.path().join(".yoi/workspace.db").exists()); assert!(!temp.path().join(".yoi/workspace.db").exists());
assert!(!temp.path().join(".yoi/embedded-runtime").exists()); assert!(!temp.path().join(".yoi/embedded-runtime").exists());
assert!(database_path.exists());
let store = SqliteWorkspaceStore::open(&database_path).unwrap();
let workspaces = store.list_workspaces().unwrap();
assert_eq!(workspaces.len(), 1);
let repositories = store
.list_repositories(&workspaces[0].workspace_id)
.unwrap();
assert_eq!(repositories.len(), 1);
assert_eq!(repositories[0].repository_id, "main");
assert_eq!(
repositories[0].uri,
temp.path().canonicalize().unwrap().display().to_string()
);
} }
} }

View File

@ -3,8 +3,10 @@ use std::path::{Path, PathBuf};
use project_record::validate_record_id; use project_record::validate_record_id;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use ticket::config::TicketConfig; use ticket::{
use ticket::{LocalTicketBackend, TicketIdOrSlug, TicketListQuery}; SqliteTicketBackend, TicketBackend, TicketIdOrSlug, TicketListQuery,
TicketWorkspaceActionPriority, project_ticket_workspace_item,
};
use crate::{Error, Result}; use crate::{Error, Result};
@ -14,19 +16,19 @@ const SUMMARY_BODY_LIMIT: usize = 240;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct LocalProjectRecordReader { pub struct LocalProjectRecordReader {
workspace_root: PathBuf, workspace_root: PathBuf,
ticket_backend: LocalTicketBackend, ticket_backend: SqliteTicketBackend,
} }
impl LocalProjectRecordReader { impl LocalProjectRecordReader {
pub fn new(workspace_root: impl Into<PathBuf>) -> Result<Self> { pub fn new(
workspace_root: impl Into<PathBuf>,
database_path: impl Into<PathBuf>,
workspace_id: impl Into<String>,
) -> Result<Self> {
let workspace_root = workspace_root.into(); let workspace_root = workspace_root.into();
let ticket_config = TicketConfig::load_workspace(&workspace_root)
.map_err(|error| Error::Config(format!("load Ticket workspace settings: {error}")))?;
let ticket_backend = LocalTicketBackend::new(ticket_config.backend_root().to_path_buf())
.with_record_language(ticket_config.ticket_record_language());
Ok(Self { Ok(Self {
workspace_root, workspace_root,
ticket_backend, ticket_backend: SqliteTicketBackend::new(database_path, workspace_id),
}) })
} }
@ -35,11 +37,13 @@ impl LocalProjectRecordReader {
} }
pub fn list_tickets(&self, limit: usize) -> Result<ProjectRecordList<TicketSummary>> { pub fn list_tickets(&self, limit: usize) -> Result<ProjectRecordList<TicketSummary>> {
let partial = self.ticket_backend.list_partial(TicketListQuery::all())?; let mut items = Vec::new();
let mut items = partial for item in self.ticket_backend.list(TicketListQuery::all())? {
.tickets let ticket = self
.into_iter() .ticket_backend
.map(|item| TicketSummary { .show(TicketIdOrSlug::Id(item.id.clone()))?;
let projection = project_ticket_workspace_item(&item, &ticket.relations.blockers, None);
items.push(TicketSummary {
id: item.id, id: item.id,
title: item.title, title: item.title,
state: item.workflow_state.as_str().to_string(), state: item.workflow_state.as_str().to_string(),
@ -47,35 +51,29 @@ impl LocalProjectRecordReader {
updated_at: item.updated_at, updated_at: item.updated_at,
queued_by: item.queued_by, queued_by: item.queued_by,
queued_at: item.queued_at, queued_at: item.queued_at,
record_source: "local_yoi_ticket".to_string(), workspace_action_priority: workspace_action_priority_name(projection.priority)
}) .to_string(),
.collect::<Vec<_>>(); record_source: "sqlite_yoi_ticket".to_string(),
});
}
items.sort_by(|a, b| { items.sort_by(|a, b| {
b.updated_at b.updated_at
.cmp(&a.updated_at) .cmp(&a.updated_at)
.then_with(|| a.id.cmp(&b.id)) .then_with(|| a.id.cmp(&b.id))
}); });
items.truncate(limit.min(200)); items.truncate(limit);
Ok(ProjectRecordList { Ok(ProjectRecordList {
items, items,
invalid_records: partial invalid_records: Vec::new(),
.invalid_records
.into_iter()
.map(|record| InvalidProjectRecord {
label: record.label,
reason: record.reason,
})
.collect(),
record_authority: "local_yoi_project_records".to_string(), record_authority: "local_yoi_project_records".to_string(),
}) })
} }
pub fn ticket(&self, id: &str) -> Result<TicketDetail> { pub fn ticket(&self, id: &str) -> Result<TicketDetail> {
validate_project_id(id)?; validate_project_id(id)?;
let partial = self let ticket = self
.ticket_backend .ticket_backend
.show_partial(TicketIdOrSlug::Id(id.to_string()))?; .show(TicketIdOrSlug::Id(id.to_string()))?;
let ticket = partial.ticket;
let (body, body_truncated) = let (body, body_truncated) =
truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT); truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT);
Ok(TicketDetail { Ok(TicketDetail {
@ -92,7 +90,7 @@ impl LocalProjectRecordReader {
body_truncated, body_truncated,
event_count: ticket.events.len(), event_count: ticket.events.len(),
artifact_count: ticket.artifacts.len(), artifact_count: ticket.artifacts.len(),
record_source: "local_yoi_ticket".to_string(), record_source: "sqlite_yoi_ticket".to_string(),
}) })
} }
@ -157,6 +155,14 @@ impl LocalProjectRecordReader {
} }
} }
fn workspace_action_priority_name(priority: TicketWorkspaceActionPriority) -> &'static str {
match priority {
TicketWorkspaceActionPriority::ReadyForQueue => "ready_for_queue",
TicketWorkspaceActionPriority::ActiveWork => "active_work",
TicketWorkspaceActionPriority::Background => "background",
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProjectRecordList<T> { pub struct ProjectRecordList<T> {
pub items: Vec<T>, pub items: Vec<T>,
@ -179,6 +185,7 @@ pub struct TicketSummary {
pub updated_at: Option<String>, pub updated_at: Option<String>,
pub queued_by: Option<String>, pub queued_by: Option<String>,
pub queued_at: Option<String>, pub queued_at: Option<String>,
pub workspace_action_priority: String,
pub record_source: String, pub record_source: String,
} }
@ -296,16 +303,27 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn reads_local_yoi_ticket_and_objective_records_without_migration() { fn reads_sqlite_yoi_ticket_and_objective_records_without_migration() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready"); write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready");
let db_path = dir.path().join("workspace.db");
SqliteTicketBackend::new(&db_path, "workspace-test")
.import_from_local_backend(&ticket::LocalTicketBackend::new(
dir.path().join(".yoi/tickets"),
))
.unwrap();
write_objective(dir.path(), "00000000001J3", "Control plane", "active"); write_objective(dir.path(), "00000000001J3", "Control plane", "active");
let reader = LocalProjectRecordReader::new(dir.path()).unwrap(); let reader = LocalProjectRecordReader::new(dir.path(), &db_path, "workspace-test").unwrap();
let tickets = reader.list_tickets(20).unwrap(); let tickets = reader.list_tickets(20).unwrap();
assert_eq!(tickets.record_authority, "local_yoi_project_records"); assert_eq!(tickets.record_authority, "local_yoi_project_records");
assert_eq!(tickets.items[0].record_source, "sqlite_yoi_ticket");
assert_eq!(tickets.items[0].id, "00000000001J2"); assert_eq!(tickets.items[0].id, "00000000001J2");
assert_eq!(tickets.items[0].state, "ready"); assert_eq!(tickets.items[0].state, "ready");
assert_eq!(
tickets.items[0].workspace_action_priority,
"ready_for_queue"
);
let ticket = reader.ticket("00000000001J2").unwrap(); let ticket = reader.ticket("00000000001J2").unwrap();
assert!(ticket.body.contains("Ticket body")); assert!(ticket.body.contains("Ticket body"));
@ -318,34 +336,16 @@ mod tests {
assert!(objective.body.contains("Objective body")); assert!(objective.body.contains("Objective body"));
} }
#[test] #[test]
fn reads_tickets_from_workspace_settings_backend_root() { fn does_not_read_legacy_ticket_files_without_sqlite_import() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join(".yoi")).unwrap(); write_ticket(dir.path(), "00000000001J5", "Legacy file", "ready");
fs::write( let db_path = dir.path().join("workspace.db");
dir.path().join(".yoi/workspace.toml"),
r#"
[ticket]
language = "Japanese"
[ticket.backend] let reader = LocalProjectRecordReader::new(dir.path(), &db_path, "workspace-test").unwrap();
provider = "builtin:yoi_local"
root = "project-records/tickets"
"#,
)
.unwrap();
write_ticket_at(
&dir.path().join("project-records/tickets"),
"00000000001J4",
"Configured root",
"ready",
);
write_ticket(dir.path(), "00000000001J5", "Default root", "ready");
let reader = LocalProjectRecordReader::new(dir.path()).unwrap();
let tickets = reader.list_tickets(20).unwrap(); let tickets = reader.list_tickets(20).unwrap();
assert_eq!(tickets.items.len(), 1); assert!(tickets.items.is_empty());
assert_eq!(tickets.items[0].id, "00000000001J4");
} }
fn write_ticket(root: &Path, id: &str, title: &str, state: &str) { fn write_ticket(root: &Path, id: &str, title: &str, state: &str) {
write_ticket_at(&root.join(".yoi/tickets"), id, title, state); write_ticket_at(&root.join(".yoi/tickets"), id, title, state);
} }

View File

@ -209,7 +209,7 @@ impl RepositoryRegistryReader {
kind: repository.provider.clone(), kind: repository.provider.clone(),
provider: repository.provider.clone(), provider: repository.provider.clone(),
default_selector: repository.default_selector.clone(), default_selector: repository.default_selector.clone(),
record_authority: "workspace-backend-config".to_string(), record_authority: "workspace-control-plane".to_string(),
git, git,
diagnostics, diagnostics,
} }

File diff suppressed because it is too large Load Diff

View File

@ -82,6 +82,21 @@ pub struct WorkspaceRecord {
pub updated_at: String, pub updated_at: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RepositoryRecord {
pub workspace_id: String,
pub repository_id: String,
pub name: String,
pub kind: String,
pub provider: Option<String>,
pub uri: String,
pub default_ref: Option<String>,
pub auth_ref_kind: Option<String>,
pub auth_ref_key: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AccountRecord { pub struct AccountRecord {
pub account_id: String, pub account_id: String,
@ -211,6 +226,9 @@ pub trait ControlPlaneStore: Send + Sync {
async fn schema_version(&self) -> Result<i64>; async fn schema_version(&self) -> Result<i64>;
async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()>; async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()>;
async fn get_workspace(&self, workspace_id: &str) -> Result<Option<WorkspaceRecord>>; async fn get_workspace(&self, workspace_id: &str) -> Result<Option<WorkspaceRecord>>;
fn list_workspaces(&self) -> Result<Vec<WorkspaceRecord>>;
fn upsert_repository(&self, record: &RepositoryRecord) -> Result<()>;
fn list_repositories(&self, workspace_id: &str) -> Result<Vec<RepositoryRecord>>;
fn upsert_account(&self, record: &AccountRecord) -> Result<()>; fn upsert_account(&self, record: &AccountRecord) -> Result<()>;
fn get_account(&self, account_id: &str) -> Result<Option<AccountRecord>>; fn get_account(&self, account_id: &str) -> Result<Option<AccountRecord>>;
@ -399,6 +417,69 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}) })
} }
fn list_workspaces(&self) -> Result<Vec<WorkspaceRecord>> {
self.with_conn(|conn| {
let mut stmt = conn.prepare(
r#"SELECT workspace_id, owner_account_id, display_name, state, created_at, updated_at
FROM workspaces
ORDER BY workspace_id ASC"#,
)?;
let rows = stmt.query_map([], read_workspace_record)?;
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Error::from)
})
}
fn upsert_repository(&self, record: &RepositoryRecord) -> Result<()> {
self.with_conn(|conn| {
conn.execute(
r#"INSERT INTO repositories (
workspace_id, repository_id, name, kind, provider, uri, default_ref,
auth_ref_kind, auth_ref_key, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
ON CONFLICT(repository_id) DO UPDATE SET
workspace_id = excluded.workspace_id,
name = excluded.name,
kind = excluded.kind,
provider = excluded.provider,
uri = excluded.uri,
default_ref = excluded.default_ref,
auth_ref_kind = excluded.auth_ref_kind,
auth_ref_key = excluded.auth_ref_key,
updated_at = excluded.updated_at"#,
params![
record.workspace_id,
record.repository_id,
record.name,
record.kind,
record.provider,
record.uri,
record.default_ref,
record.auth_ref_kind,
record.auth_ref_key,
record.created_at,
record.updated_at,
],
)?;
Ok(())
})
}
fn list_repositories(&self, workspace_id: &str) -> Result<Vec<RepositoryRecord>> {
self.with_conn(|conn| {
let mut stmt = conn.prepare(
r#"SELECT workspace_id, repository_id, name, kind, provider, uri, default_ref,
auth_ref_kind, auth_ref_key, created_at, updated_at
FROM repositories
WHERE workspace_id = ?1
ORDER BY repository_id ASC"#,
)?;
let rows = stmt.query_map(params![workspace_id], read_repository_record)?;
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Error::from)
})
}
fn upsert_account(&self, record: &AccountRecord) -> Result<()> { fn upsert_account(&self, record: &AccountRecord) -> Result<()> {
self.with_conn(|conn| { self.with_conn(|conn| {
conn.execute( conn.execute(
@ -1019,6 +1100,22 @@ fn read_workspace_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<WorkspaceR
}) })
} }
fn read_repository_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<RepositoryRecord> {
Ok(RepositoryRecord {
workspace_id: row.get(0)?,
repository_id: row.get(1)?,
name: row.get(2)?,
kind: row.get(3)?,
provider: row.get(4)?,
uri: row.get(5)?,
default_ref: row.get(6)?,
auth_ref_kind: row.get(7)?,
auth_ref_key: row.get(8)?,
created_at: row.get(9)?,
updated_at: row.get(10)?,
})
}
fn account_select_sql(where_clause: &str) -> String { fn account_select_sql(where_clause: &str) -> String {
format!( format!(
"SELECT account_id, kind, handle, display_name, created_at, updated_at FROM accounts {where_clause}" "SELECT account_id, kind, handle, display_name, created_at, updated_at FROM accounts {where_clause}"
@ -2274,6 +2371,44 @@ mod tests {
); );
} }
#[tokio::test]
async fn repository_records_round_trip() {
let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 9);
let workspace = WorkspaceRecord {
workspace_id: "local-dev".to_string(),
owner_account_id: None,
display_name: "Local Dev".to_string(),
state: "active".to_string(),
created_at: "1".to_string(),
updated_at: "1".to_string(),
};
store.upsert_workspace(&workspace).await.unwrap();
let repository = RepositoryRecord {
workspace_id: "local-dev".to_string(),
repository_id: "main".to_string(),
name: "Yoi".to_string(),
kind: "git".to_string(),
provider: Some("git".to_string()),
uri: ".".to_string(),
default_ref: Some("HEAD".to_string()),
auth_ref_kind: None,
auth_ref_key: None,
created_at: "2".to_string(),
updated_at: "2".to_string(),
};
store.upsert_repository(&repository).unwrap();
assert_eq!(
store.list_repositories("local-dev").unwrap(),
vec![repository]
);
assert_eq!(
store.list_repositories("other-workspace").unwrap(),
Vec::new()
);
}
#[tokio::test] #[tokio::test]
async fn worker_workdir_registry_round_trips_and_preserves_pinned_retention() { async fn worker_workdir_registry_round_trips_and_preserves_pinned_retention() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();

View File

@ -1448,7 +1448,7 @@ fn print_help() {
fn print_workspace_help() { fn print_workspace_help() {
println!( println!(
"yoi workspace\n\nUsage:\n yoi workspace init [OPTIONS]\n yoi workspace config <COMMAND> [OPTIONS]\n yoi workspace serve [OPTIONS]\n\nDescription:\n Launches the separate yoi-workspace-server executable. The yoi binary does not link the workspace server crate.\n\nSubcommands:\n init Initialize .yoi/workspace.toml and .yoi/workspace-backend.local.toml\n config default Print the latest packaged Backend config template\n config diff Compare workspace local config with the packaged template\n serve Serve an already initialized Workspace\n\nOptions forwarded to init/config diff/serve:\n --workspace <PATH> Workspace root (defaults to cwd)\n\nLegacy dev options forwarded to serve:\n --db <PATH> SQLite database path override\n --frontend <PATH> Static SPA build directory to serve\n --listen <ADDR> Listen address override\n -h, --help Print help\n\nEnvironment:\n YOI_WORKSPACE_SERVER_COMMAND Path to yoi-workspace-server executable override\n" "yoi workspace\n\nUsage:\n yoi workspace init [OPTIONS]\n yoi workspace config <COMMAND> [OPTIONS]\n yoi workspace serve [OPTIONS]\n\nDescription:\n Launches the separate yoi-workspace-server executable. The yoi binary does not link the workspace server crate. `serve` reads Workspace records from the Yoi server DB.\n\nSubcommands:\n init Initialize .yoi/workspace.toml, .yoi/workspace-backend.local.toml, and a server DB Workspace record\n config default Print the latest packaged Backend config template\n config diff Compare workspace local config with the packaged template\n serve Serve the Workspace recorded in the server DB\n\nOptions forwarded to init/config diff:\n --workspace <PATH> Workspace root (defaults to cwd)\n\nOptions forwarded to serve:\n --listen <ADDR> Listen address override\n -h, --help Print help\n\nEnvironment:\n YOI_WORKSPACE_SERVER_COMMAND Path to yoi-workspace-server executable override\n"
); );
} }
@ -1698,6 +1698,17 @@ mod tests {
} }
} }
#[test]
fn parse_workspace_serve_leaves_legacy_flags_to_server() {
match parse_args_from(["workspace", "serve", "--workspace", "/tmp/ws"]).unwrap() {
Mode::WorkspaceServer { subcommand, args } => {
assert_eq!(subcommand, "serve");
assert_eq!(args, vec!["--workspace", "/tmp/ws"]);
}
other => panic!("unexpected mode: {other:?}"),
}
}
#[test] #[test]
fn parse_workspace_init_passthrough() { fn parse_workspace_init_passthrough() {
match parse_args_from(["workspace", "init", "--workspace", "/tmp/ws"]).unwrap() { match parse_args_from(["workspace", "init", "--workspace", "/tmp/ws"]).unwrap() {

View File

@ -6,14 +6,14 @@ use std::path::{Path, PathBuf};
use chrono::{SecondsFormat, Utc}; use chrono::{SecondsFormat, Utc};
use ticket::config::{ use ticket::config::{
DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TICKET_CONFIG_RELATIVE_PATH, TicketConfig, TICKET_CONFIG_RELATIVE_PATH, TicketConfig, WORKSPACE_SETTINGS_RELATIVE_PATH,
WORKSPACE_SETTINGS_RELATIVE_PATH, ticket_config_scaffold, ticket_config_scaffold,
}; };
use ticket::{ use ticket::{
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend, LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation,
TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary, TicketListQuery, SqliteTicketBackend, TicketBackend, TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug,
TicketListState, TicketRelationKind, TicketReview, TicketReviewResult, TicketSummary, TicketIntakeSummary, TicketListQuery, TicketListState, TicketRelationKind, TicketReview,
TicketWorkflowState, TicketReviewResult, TicketSummary, TicketWorkflowState,
}; };
const DEFAULT_LIST_LIMIT: usize = 50; const DEFAULT_LIST_LIMIT: usize = 50;
@ -30,6 +30,7 @@ pub enum TicketCli {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum TicketCommand { pub enum TicketCommand {
Init, Init,
ImportLocal,
Create(CreateOptions), Create(CreateOptions),
List(ListOptions), List(ListOptions),
Show { query: String }, Show { query: String },
@ -179,6 +180,14 @@ pub fn parse_ticket_args(args: &[String]) -> Result<TicketCli, TicketCliError> {
} }
TicketCommand::Init TicketCommand::Init
} }
"import-local" => {
if args.len() != 1 {
return Err(TicketCliError::new(
"ticket import-local takes no arguments",
));
}
TicketCommand::ImportLocal
}
"create" => TicketCommand::Create(parse_create(&args[1..])?), "create" => TicketCommand::Create(parse_create(&args[1..])?),
"list" => TicketCommand::List(parse_list(&args[1..])?), "list" => TicketCommand::List(parse_list(&args[1..])?),
"show" => TicketCommand::Show { "show" => TicketCommand::Show {
@ -232,19 +241,22 @@ fn run_command(
) -> Result<TicketCliOutput, TicketCliError> { ) -> Result<TicketCliOutput, TicketCliError> {
match command { match command {
TicketCommand::Init => init(workspace), TicketCommand::Init => init(workspace),
TicketCommand::ImportLocal => import_local(workspace),
command => { command => {
let backend = backend_for_workspace(workspace)?; let backend = backend_for_workspace(workspace)?;
match command { match command {
TicketCommand::Create(options) => create(&backend, options), TicketCommand::Create(options) => create(backend.as_ref(), options),
TicketCommand::List(options) => list(&backend, options), TicketCommand::List(options) => list(backend.as_ref(), options),
TicketCommand::Show { query } => show(&backend, query), TicketCommand::Show { query } => show(backend.as_ref(), query),
TicketCommand::Comment(options) => comment(&backend, options), TicketCommand::Comment(options) => comment(backend.as_ref(), options),
TicketCommand::Review(options) => review(&backend, options), TicketCommand::Review(options) => review(backend.as_ref(), options),
TicketCommand::State(options) => state(&backend, options), TicketCommand::State(options) => state(backend.as_ref(), options),
TicketCommand::Close(options) => close(&backend, options), TicketCommand::Close(options) => close(backend.as_ref(), options),
TicketCommand::Relation(options) => relation(&backend, options), TicketCommand::Relation(options) => relation(backend.as_ref(), options),
TicketCommand::Doctor => doctor(&backend), TicketCommand::Doctor => doctor(backend.as_ref()),
TicketCommand::Init => unreachable!("init handled before backend setup"), TicketCommand::Init | TicketCommand::ImportLocal => {
unreachable!("handled before backend setup")
}
} }
} }
} }
@ -262,9 +274,6 @@ fn init(workspace: &Path) -> Result<TicketCliOutput, TicketCliError> {
let yoi_dir = workspace.join(".yoi"); let yoi_dir = workspace.join(".yoi");
fs::create_dir_all(&yoi_dir)?; fs::create_dir_all(&yoi_dir)?;
let tickets_dir = workspace.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH);
fs::create_dir_all(&tickets_dir)?;
fs::write(tickets_dir.join(".gitkeep"), b"")?;
let settings_path = workspace.join(WORKSPACE_SETTINGS_RELATIVE_PATH); let settings_path = workspace.join(WORKSPACE_SETTINGS_RELATIVE_PATH);
let scaffold = ticket_config_scaffold(); let scaffold = ticket_config_scaffold();
@ -311,8 +320,8 @@ fn init(workspace: &Path) -> Result<TicketCliOutput, TicketCliError> {
"updated" "updated"
}; };
Ok(success(format!( Ok(success(format!(
"{verb}\t{}\nensured\t{}\n", "{verb}\t{}\nbackend\tworkspace-sqlite\n",
WORKSPACE_SETTINGS_RELATIVE_PATH, DEFAULT_TICKET_BACKEND_RELATIVE_PATH WORKSPACE_SETTINGS_RELATIVE_PATH
))) )))
} }
@ -356,14 +365,83 @@ fn workspace_settings_uuid_v7(workspace: &Path) -> String {
) )
} }
fn backend_for_workspace(workspace: &Path) -> Result<LocalTicketBackend, TicketCliError> { fn backend_for_workspace(workspace: &Path) -> Result<Box<dyn TicketBackend>, TicketCliError> {
let config = TicketConfig::load_workspace(workspace)?; let config = TicketConfig::load_workspace(workspace)?;
Ok(LocalTicketBackend::new(config.backend_root().to_path_buf()) let workspace_id = workspace_id_for_workspace(workspace)?;
.with_record_language(config.ticket_record_language())) let db_path = server_database_path(workspace)?;
Ok(Box::new(
SqliteTicketBackend::new(db_path, workspace_id)
.with_record_language(config.ticket_record_language()),
))
}
fn import_local(workspace: &Path) -> Result<TicketCliOutput, TicketCliError> {
let config = TicketConfig::load_workspace(workspace)?;
let local = LocalTicketBackend::new(config.backend_root().to_path_buf())
.with_record_language(config.ticket_record_language());
let workspace_id = workspace_id_for_workspace(workspace)?;
let db_path = server_database_path(workspace)?;
let sqlite = SqliteTicketBackend::new(db_path.clone(), workspace_id)
.with_record_language(config.ticket_record_language());
sqlite.import_from_local_backend(&local)?;
Ok(success(format!(
"imported\t{}\nbackend\t{}\n",
config.backend_root().display(),
db_path.display()
)))
}
fn server_database_path(workspace: &Path) -> Result<PathBuf, TicketCliError> {
#[cfg(test)]
{
return Ok(workspace
.join(".test-yoi-data")
.join("server")
.join("server.db"));
}
#[cfg(not(test))]
{
let _ = workspace;
let data_dir = manifest::paths::data_dir().ok_or_else(|| {
TicketCliError::new(
"could not resolve Yoi data directory for SQLite Ticket backend (set YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME)",
)
})?;
Ok(data_dir.join("server").join("server.db"))
}
}
fn workspace_id_for_workspace(workspace: &Path) -> Result<String, TicketCliError> {
let settings_path = workspace.join(WORKSPACE_SETTINGS_RELATIVE_PATH);
let raw = fs::read_to_string(&settings_path).map_err(|error| {
TicketCliError::new(format!(
"failed to read workspace settings {}: {error}",
settings_path.display()
))
})?;
let value: toml::Value = toml::from_str(&raw).map_err(|error| {
TicketCliError::new(format!(
"failed to parse workspace settings {}: {error}",
settings_path.display()
))
})?;
value
.get("workspace_id")
.and_then(toml::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.ok_or_else(|| {
TicketCliError::new(format!(
"workspace settings {} must contain workspace_id for SQLite Ticket backend",
settings_path.display()
))
})
} }
fn create( fn create(
backend: &LocalTicketBackend, backend: &dyn TicketBackend,
options: CreateOptions, options: CreateOptions,
) -> Result<TicketCliOutput, TicketCliError> { ) -> Result<TicketCliOutput, TicketCliError> {
let mut input = NewTicket::new(options.title); let mut input = NewTicket::new(options.title);
@ -374,7 +452,7 @@ fn create(
} }
fn list( fn list(
backend: &LocalTicketBackend, backend: &dyn TicketBackend,
options: ListOptions, options: ListOptions,
) -> Result<TicketCliOutput, TicketCliError> { ) -> Result<TicketCliOutput, TicketCliError> {
let filter = match options.state { let filter = match options.state {
@ -410,7 +488,7 @@ fn list(
Ok(success(stdout)) Ok(success(stdout))
} }
fn show(backend: &LocalTicketBackend, query: String) -> Result<TicketCliOutput, TicketCliError> { fn show(backend: &dyn TicketBackend, query: String) -> Result<TicketCliOutput, TicketCliError> {
let ticket = backend.show(TicketIdOrSlug::Query(query))?; let ticket = backend.show(TicketIdOrSlug::Query(query))?;
let mut stdout = String::new(); let mut stdout = String::new();
stdout.push_str(&format!("# {}\n\n", ticket.meta.title)); stdout.push_str(&format!("# {}\n\n", ticket.meta.title));
@ -545,7 +623,7 @@ fn is_obsolete_ticket_frontmatter_key(key: &str) -> bool {
} }
fn comment( fn comment(
backend: &LocalTicketBackend, backend: &dyn TicketBackend,
options: CommentOptions, options: CommentOptions,
) -> Result<TicketCliOutput, TicketCliError> { ) -> Result<TicketCliOutput, TicketCliError> {
let role = options.role.as_str().to_string(); let role = options.role.as_str().to_string();
@ -556,7 +634,7 @@ fn comment(
} }
fn review( fn review(
backend: &LocalTicketBackend, backend: &dyn TicketBackend,
options: ReviewOptions, options: ReviewOptions,
) -> Result<TicketCliOutput, TicketCliError> { ) -> Result<TicketCliOutput, TicketCliError> {
let result = options.result.as_str().to_string(); let result = options.result.as_str().to_string();
@ -573,7 +651,7 @@ fn review(
} }
fn state( fn state(
backend: &LocalTicketBackend, backend: &dyn TicketBackend,
options: StateOptions, options: StateOptions,
) -> Result<TicketCliOutput, TicketCliError> { ) -> Result<TicketCliOutput, TicketCliError> {
let id = TicketIdOrSlug::Query(options.query.clone()); let id = TicketIdOrSlug::Query(options.query.clone());
@ -626,7 +704,7 @@ fn state(
} }
fn close( fn close(
backend: &LocalTicketBackend, backend: &dyn TicketBackend,
options: CloseOptions, options: CloseOptions,
) -> Result<TicketCliOutput, TicketCliError> { ) -> Result<TicketCliOutput, TicketCliError> {
backend.close( backend.close(
@ -637,7 +715,7 @@ fn close(
} }
fn relation( fn relation(
backend: &LocalTicketBackend, backend: &dyn TicketBackend,
options: RelationOptions, options: RelationOptions,
) -> Result<TicketCliOutput, TicketCliError> { ) -> Result<TicketCliOutput, TicketCliError> {
match options.action { match options.action {
@ -683,7 +761,7 @@ fn relation(
} }
} }
fn doctor(backend: &LocalTicketBackend) -> Result<TicketCliOutput, TicketCliError> { fn doctor(backend: &dyn TicketBackend) -> Result<TicketCliOutput, TicketCliError> {
let report = backend.doctor()?; let report = backend.doctor()?;
let mut stdout = String::new(); let mut stdout = String::new();
if report.is_ok() { if report.is_ok() {
@ -1166,14 +1244,13 @@ fn default_author() -> String {
} }
fn help_text() -> &'static str { fn help_text() -> &'static str {
"yoi ticket\n\nUsage:\n yoi ticket init\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 review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|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 `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml.\n Uses workspace Ticket settings from .yoi/workspace.toml [ticket] when present; .yoi/ticket.config.toml is a read-only migration fallback only.\n Supported provider: builtin:yoi_local.\n Without configured Ticket settings, the local backend root is <cwd>/.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 review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|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"
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use tempfile::TempDir; use tempfile::TempDir;
use ticket::TicketEventKind;
use ticket::config::TicketRole; use ticket::config::TicketRole;
fn args(items: &[&str]) -> Vec<String> { fn args(items: &[&str]) -> Vec<String> {
@ -1181,6 +1258,13 @@ mod tests {
} }
fn run(temp: &TempDir, items: &[&str]) -> TicketCliOutput { fn run(temp: &TempDir, items: &[&str]) -> TicketCliOutput {
if items.first().copied() != Some("init")
&& !temp.path().join(WORKSPACE_SETTINGS_RELATIVE_PATH).exists()
{
let init_cli = parse_ticket_args(&args(&["init"])).unwrap();
let init = run_in_workspace(init_cli, temp.path()).unwrap();
assert_eq!(init.status, TicketCliStatus::Success);
}
let cli = parse_ticket_args(&args(items)).unwrap(); let cli = parse_ticket_args(&args(items)).unwrap();
run_in_workspace(cli, temp.path()).unwrap() run_in_workspace(cli, temp.path()).unwrap()
} }
@ -1201,9 +1285,8 @@ mod tests {
let initialized = run(&temp, &["init"]); let initialized = run(&temp, &["init"]);
assert_eq!(initialized.status, TicketCliStatus::Success); assert_eq!(initialized.status, TicketCliStatus::Success);
assert!(initialized.stdout.contains("created\t.yoi/workspace.toml")); assert!(initialized.stdout.contains("created\t.yoi/workspace.toml"));
assert!(initialized.stdout.contains("ensured\t.yoi/tickets")); assert!(initialized.stdout.contains("backend\tworkspace-sqlite"));
assert!(temp.path().join(".yoi/tickets").exists()); assert!(!temp.path().join(".yoi/tickets").exists());
assert!(temp.path().join(".yoi/tickets/.gitkeep").exists());
let config = fs::read_to_string(temp.path().join(".yoi/workspace.toml")).unwrap(); let config = fs::read_to_string(temp.path().join(".yoi/workspace.toml")).unwrap();
assert!(config.contains("workspace_id = \"")); assert!(config.contains("workspace_id = \""));
@ -1299,21 +1382,8 @@ mod tests {
assert_eq!(created.status, TicketCliStatus::Success); assert_eq!(created.status, TicketCliStatus::Success);
assert!(created.stdout.contains("created\t")); assert!(created.stdout.contains("created\t"));
let ticket_id = created_id(&created); let ticket_id = created_id(&created);
assert!(temp.path().join(".yoi/tickets").join(&ticket_id).exists()); assert!(!temp.path().join(".yoi/tickets").exists());
assert!(!temp.path().join("work-items").exists()); assert!(!temp.path().join("work-items").exists());
let created_item = fs::read_to_string(
temp.path()
.join(".yoi/tickets")
.join(&ticket_id)
.join("item.md"),
)
.unwrap();
assert!(created_item.contains("state:"));
assert!(created_item.contains("planning"));
assert!(!created_item.contains("legacy_ticket:"));
assert!(!created_item.contains("needs_preflight:"));
assert!(!created_item.contains("slug:"));
assert!(!created_item.contains("workflow_state:"));
let listed = run(&temp, &["list", "--state", "planning"]); let listed = run(&temp, &["list", "--state", "planning"]);
assert!(listed.stdout.contains("state\tid\ttitle")); assert!(listed.stdout.contains("state\tid\ttitle"));
@ -1395,29 +1465,21 @@ mod tests {
assert_eq!(doctor.status, TicketCliStatus::Success); assert_eq!(doctor.status, TicketCliStatus::Success);
assert_eq!(doctor.stdout, "doctor: ok\n"); assert_eq!(doctor.stdout, "doctor: ok\n");
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); let final_show = run(&temp, &["show", &ticket_id]);
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id.clone())).unwrap(); assert!(final_show.stdout.contains("State: closed"));
assert!(ticket.resolution.is_some()); assert!(final_show.stdout.contains("Done via yoi ticket."));
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Closed); assert!(final_show.stdout.contains("implementation_report"));
assert!( assert!(final_show.stdout.contains("review"));
ticket
.events
.iter()
.any(|event| event.kind == TicketEventKind::ImplementationReport)
);
assert!(
ticket
.events
.iter()
.any(|event| event.kind == TicketEventKind::Review)
);
} }
#[test] #[test]
fn ticket_cli_show_omits_obsolete_overlay_fields_from_legacy_frontmatter() { fn ticket_cli_show_omits_obsolete_overlay_fields_from_legacy_frontmatter() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
let created = run(&temp, &["create", "--title", "Legacy Overlay"]); let initialized = run(&temp, &["init"]);
let ticket_id = created_id(&created); assert_eq!(initialized.status, TicketCliStatus::Success);
let local = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
let created = local.create(NewTicket::new("Legacy Overlay")).unwrap();
let ticket_id = created.id;
let item_path = temp let item_path = temp
.path() .path()
.join(".yoi/tickets") .join(".yoi/tickets")
@ -1434,6 +1496,9 @@ mod tests {
) )
.unwrap(); .unwrap();
let imported = run(&temp, &["import-local"]);
assert_eq!(imported.status, TicketCliStatus::Success);
let shown = run(&temp, &["show", &ticket_id]); let shown = run(&temp, &["show", &ticket_id]);
assert_eq!(shown.status, TicketCliStatus::Success); assert_eq!(shown.status, TicketCliStatus::Success);
assert!(shown.stdout.contains("# Legacy Overlay")); assert!(shown.stdout.contains("# Legacy Overlay"));
@ -1496,20 +1561,22 @@ mod tests {
} }
#[test] #[test]
fn ticket_cli_uses_configured_backend_root() { fn ticket_cli_import_local_uses_configured_backend_root() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
fs::create_dir_all(temp.path().join(".yoi")).unwrap(); fs::create_dir_all(temp.path().join(".yoi")).unwrap();
fs::write( fs::write(
temp.path().join(".yoi/workspace.toml"), temp.path().join(".yoi/workspace.toml"),
"[ticket]\nlanguage = \"Japanese\"\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \"custom-tickets\"\n", "workspace_id = \"workspace-test\"\n\n[ticket]\nlanguage = \"Japanese\"\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \"custom-tickets\"\n",
) )
.unwrap(); .unwrap();
let local = LocalTicketBackend::new(temp.path().join("custom-tickets"));
let created = local.create(NewTicket::new("Configured Root")).unwrap();
let created = run(&temp, &["create", "--title", "Configured Root"]); let imported = run(&temp, &["import-local"]);
let ticket_id = created_id(&created); assert_eq!(imported.status, TicketCliStatus::Success);
let shown = run(&temp, &["show", &created.id]);
assert!(temp.path().join("custom-tickets").join(ticket_id).exists()); assert_eq!(shown.status, TicketCliStatus::Success);
assert!(!temp.path().join("custom-tickets/open").exists()); assert!(shown.stdout.contains("# Configured Root"));
assert!(!temp.path().join("work-items").exists()); assert!(!temp.path().join("work-items").exists());
} }

View File

@ -262,14 +262,13 @@ in
Entrypoint = [ "/bin/yoi-workspace-server" ]; Entrypoint = [ "/bin/yoi-workspace-server" ];
Cmd = [ Cmd = [
"serve" "serve"
"--workspace"
"/workspace"
"--db"
"/server-data/workspace.db"
"--listen" "--listen"
"0.0.0.0:8787" "0.0.0.0:8787"
]; ];
Env = [ "PATH=/bin" ] ++ commonEnv; Env = [
"PATH=/bin"
"YOI_DATA_DIR=/server-data"
] ++ commonEnv;
ExposedPorts = { ExposedPorts = {
"8787/tcp" = { }; "8787/tcp" = { };
}; };

View File

@ -25,14 +25,15 @@ frontend_url = "http://127.0.0.1:5173"
# static_assets_dir = "web/workspace/dist" # static_assets_dir = "web/workspace/dist"
[data] [data]
# Backend data root override. Leave commented to use the user-data fallback: # Workspace-scoped runtime/data root override. Leave commented to use the user-data fallback:
# <data_dir>/workspace-server/<workspace_id>/ # <data_dir>/server/workspaces/<workspace_id>/
# Relative paths are resolved from the workspace root. # Relative paths are resolved from the workspace root.
# root = ".yoi/workspace-backend.data" # root = ".yoi/workspace-backend.data"
# Explicit control-plane SQLite DB path override. # Explicit control-plane SQLite DB path override. Normal `serve` uses the Yoi
# If omitted, this falls back to `<data.root>/workspace.db`. # server DB at `<data_dir>/server/server.db`; keep this commented unless a
# workspace_database_path = ".yoi/workspace-backend.data/workspace.db" # local test needs a custom DB path.
# workspace_database_path = ".yoi/workspace-backend.data/server.db"
# Explicit embedded Runtime fs-store root override. # Explicit embedded Runtime fs-store root override.
# If omitted, this falls back to `<data.root>/embedded-runtime`. # If omitted, this falls back to `<data.root>/embedded-runtime`.
@ -60,11 +61,5 @@ cookie_name = "yoi_workspace_session"
# display_name = "Main repository" # display_name = "Main repository"
# default_selector = "HEAD" # default_selector = "HEAD"
# Remote Runtime sources. Token values must not be written here. # Runtime registrations live in `$XDG_CONFIG_HOME/yoi/runtimes.toml`
# Use token_ref only after secret-ref resolution is implemented for this config. # or `YOI_CONFIG_DIR/runtimes.toml`, not in workspace backend config.
#
# [[runtimes.remote]]
# id = "example"
# endpoint = "http://127.0.0.1:38800"
# display_name = "Example Runtime"
# token_ref = "local:example-runtime-token"

23
scripts/dev-workspace.sh Executable file → Normal file
View File

@ -21,7 +21,22 @@
set -euo pipefail set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RUNTIME_DIR="${YOI_DEV_RUNTIME_DIR:-$ROOT_DIR/.yoi/dev}"
default_dev_runtime_dir() {
if [[ -n "${YOI_DEV_RUNTIME_DIR:-}" ]]; then
printf '%s\n' "$YOI_DEV_RUNTIME_DIR"
elif [[ -n "${YOI_RUNTIME_DIR:-}" ]]; then
printf '%s/yoi-dev\n' "$YOI_RUNTIME_DIR"
elif [[ -n "${YOI_HOME:-}" ]]; then
printf '%s/run/yoi-dev\n' "$YOI_HOME"
elif [[ -n "${XDG_RUNTIME_DIR:-}" ]]; then
printf '%s/yoi/dev\n' "$XDG_RUNTIME_DIR"
else
printf '%s/.local/share/yoi/run/dev\n' "${HOME:?HOME is required when XDG_RUNTIME_DIR is unset}"
fi
}
RUNTIME_DIR="$(default_dev_runtime_dir)"
PID_DIR="$RUNTIME_DIR/pids" PID_DIR="$RUNTIME_DIR/pids"
LOG_DIR="$RUNTIME_DIR/logs" LOG_DIR="$RUNTIME_DIR/logs"
@ -43,7 +58,7 @@ Usage: $(basename "$0") <start|stop|restart|status>
Manage the local Yoi development stack for this checkout: Manage the local Yoi development stack for this checkout:
runtime target/debug/worker-runtime-rest-server --bind $RUNTIME_BIND runtime target/debug/worker-runtime-rest-server --bind $RUNTIME_BIND
backend target/debug/yoi-workspace-server serve --workspace $ROOT_DIR --db $ROOT_DIR/.yoi/workspace.db --listen $BACKEND_LISTEN backend target/debug/yoi-workspace-server serve --listen $BACKEND_LISTEN
frontend deno run -A npm:vite@7.2.7 dev --host $FRONTEND_HOST --port $FRONTEND_PORT (cwd: web/workspace) frontend deno run -A npm:vite@7.2.7 dev --host $FRONTEND_HOST --port $FRONTEND_PORT (cwd: web/workspace)
Actions: Actions:
@ -64,7 +79,7 @@ Environment overrides:
YOI_DEV_RUNTIME_ENABLED=1 set to 0 to skip the standalone runtime process YOI_DEV_RUNTIME_ENABLED=1 set to 0 to skip the standalone runtime process
YOI_DEV_FRONTEND_HOST=0.0.0.0 YOI_DEV_FRONTEND_HOST=0.0.0.0
YOI_DEV_FRONTEND_PORT=5173 YOI_DEV_FRONTEND_PORT=5173
YOI_DEV_RUNTIME_DIR=$ROOT_DIR/.yoi/dev YOI_DEV_RUNTIME_DIR=$RUNTIME_DIR pid/log directory override (defaults to XDG runtime/data fallback)
EOF EOF
} }
@ -330,7 +345,7 @@ start_backend() {
return 1 return 1
fi fi
start_service backend "$ROOT_DIR" "$port" \ start_service backend "$ROOT_DIR" "$port" \
"$backend_bin" serve --workspace "$ROOT_DIR" --db "$ROOT_DIR/.yoi/workspace.db" --listen "$BACKEND_LISTEN" "$backend_bin" serve --listen "$BACKEND_LISTEN"
} }
start_frontend() { start_frontend() {

View File

@ -27,13 +27,12 @@ The Vite dev server proxies `/api/*` to `http://127.0.0.1:8787`, so frontend hot
If you want to run the backend from the repository root instead: If you want to run the backend from the repository root instead:
```bash ```bash
cargo run -p yoi-workspace-server -- serve \ cargo run -p yoi-workspace-server -- serve --listen 127.0.0.1:8787
--workspace . \
--db .yoi/workspace.db \
--listen 127.0.0.1:8787
``` ```
## Static build served by Rust backend The backend reads Workspace records from the Yoi server DB at `<data_dir>/server/server.db`. Run `cargo run -p yoi-workspace-server -- init --workspace .` first when the server DB has not been initialized.
## Static build
Build the SPA: Build the SPA:
@ -41,25 +40,7 @@ Build the SPA:
deno task build deno task build
``` ```
Then serve the static build and API from the Rust backend: Static asset packaging is a deployment concern. Local development normally uses the Vite dev server proxy plus the Rust backend command above.
```bash
cargo run -p yoi-workspace-server -- serve \
--workspace ../.. \
--db ../../.yoi/workspace.db \
--frontend build \
--listen 127.0.0.1:8787
```
From the repository root, the equivalent command is:
```bash
cargo run -p yoi-workspace-server -- serve \
--workspace . \
--db .yoi/workspace.db \
--frontend web/workspace/build \
--listen 127.0.0.1:8787
```
## Checks ## Checks

View File

@ -4,7 +4,7 @@
"tasks": { "tasks": {
"install": "deno install", "install": "deno install",
"dev": "deno run -A npm:vite@7.2.7 dev", "dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server -- serve --workspace . --db .yoi/workspace.db --listen 127.0.0.1:8787", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts", "test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts",
"build": "deno run -A npm:vite@7.2.7 build", "build": "deno run -A npm:vite@7.2.7 build",

View File

@ -122,28 +122,6 @@
} }
} }
@layer layout { @layer layout {
.workspace-layout {
display: grid;
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
width: 100vw;
height: 100dvh;
margin: 0;
padding: 0;
overflow: hidden;
min-width: 0;
}
.workspace-layout.sidebar-collapsed {
grid-template-columns: max-content minmax(0, 1fr);
}
.shell {
display: flex;
flex-direction: column;
gap: var(--space-6);
min-width: 0;
min-height: 0;
overflow-y: auto;
padding: var(--space-6);
}
.grid { .grid {
display: grid; display: grid;
gap: var(--space-5); gap: var(--space-5);
@ -157,242 +135,8 @@
display: grid; display: grid;
gap: var(--space-4); gap: var(--space-4);
} }
@media (max-width: 760px) {
.workspace-layout {
grid-template-columns: 1fr;
width: 100vw;
height: auto;
min-height: 100dvh;
overflow: visible;
}
.workspace-layout.sidebar-collapsed {
grid-template-columns: 1fr;
}
.shell {
overflow: visible;
padding: var(--space-5) var(--space-4);
}
}
} }
@layer components { @layer components {
.workspace-sidebar {
align-self: stretch;
min-width: 0;
min-height: 0;
overflow-y: auto;
padding: var(--space-4) var(--space-3);
}
.sidebar-header {
display: grid;
gap: var(--space-2);
margin-bottom: var(--space-2);
min-width: 0;
}
.sidebar-title-row {
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
}
.workspace-label {
flex: 1 1 auto;
min-width: 0;
}
.workspace-name {
display: block;
min-width: 0;
overflow: hidden;
border-radius: var(--radius-soft);
padding: 0.45rem 0.6rem;
color: var(--text-strong);
font-size: 1.05rem;
font-weight: 800;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.workspace-status {
margin: var(--space-1) 0 0;
color: var(--text-muted);
font-size: 0.78rem;
line-height: 1.35;
}
.workspace-status.error,
.section-state.error,
.error {
color: var(--danger);
}
.sidebar-actions-row {
display: flex;
justify-content: flex-end;
gap: var(--space-1);
}
.sidebar-icon-button,
.sidebar-collapse-button {
flex: 0 0 auto;
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: var(--radius-soft);
color: var(--text-muted);
text-decoration: none;
}
.sidebar-collapse-button {
border: 0;
background: transparent;
cursor: pointer;
}
.sidebar-icon {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.sidebar-icon-button:hover,
.sidebar-icon-button:focus-visible,
.sidebar-collapse-button:hover,
.sidebar-collapse-button:focus-visible {
background: var(--interactive-hover);
color: var(--text-muted);
}
.workspace-sidebar.collapsed {
overflow: hidden;
padding-inline: var(--space-2);
}
.workspace-sidebar.collapsed .sidebar-header {
margin-bottom: 0;
}
.workspace-sidebar.collapsed .sidebar-title-row,
.workspace-sidebar.collapsed .sidebar-actions-row {
justify-content: center;
}
.workspace-sidebar.collapsed .sidebar-actions-row {
align-items: center;
flex-direction: column;
}
.workspace-sidebar.collapsed .workspace-label {
display: none;
}
.sidebar-sections {
display: grid;
gap: var(--space-5);
min-width: 0;
}
.nav-section {
display: grid;
gap: var(--space-2);
}
.section-heading-row,
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.section-heading-row h2,
.section-header,
.eyebrow {
color: var(--text-faint);
font-size: 0.72rem;
font-weight: 750;
letter-spacing: 0.11em;
text-transform: uppercase;
}
.section-heading-row h2 {
margin: 0;
}
.section-heading-link {
color: inherit;
text-decoration: none;
}
.section-heading-link:hover,
.section-heading-link:focus-visible,
.section-heading-link.active {
color: var(--accent);
}
.eyebrow {
color: var(--accent-muted);
margin: 0;
}
.section-count {
color: var(--text-muted);
font-size: 0.72rem;
line-height: 1;
}
.nav-list {
display: grid;
gap: var(--space-1);
margin: 0;
padding: 0;
list-style: none;
}
.nav-item,
.objective-link {
display: grid;
gap: 3px;
min-width: 0;
margin-inline: calc(-1 * var(--space-2));
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-soft);
color: inherit;
text-align: left;
text-decoration: none;
transition: background-color 140ms ease,
color 140ms ease;
}
a.nav-item:hover,
a.nav-item:focus-visible,
a.objective-link:hover,
a.objective-link:focus-visible {
background: var(--interactive-hover);
}
a.nav-item.active,
a.objective-link.active {
background: var(--interactive-selected);
}
a.nav-item.active .item-title,
a.objective-link.active .item-title {
color: var(--accent);
}
.item-title,
.item-meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-title {
color: var(--text-strong);
font-weight: 650;
}
.worker-nav-item {
gap: 2px;
}
.worker-nav-item.disabled {
cursor: default;
opacity: 0.62;
}
.worker-nav-item.disabled .item-title {
color: var(--text-muted);
}
.worker-title-row {
display: grid;
grid-template-columns: minmax(0, max-content) minmax(0, 1fr);
align-items: baseline;
gap: var(--space-2);
min-width: 0;
}
.worker-task-title {
overflow: hidden;
color: var(--text-muted);
font-size: 0.82rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-meta,
.section-note, .section-note,
.section-state, .section-state,
.muted { .muted {

View File

@ -19,6 +19,10 @@ async function jsonOrThrow<T>(response: Response): Promise<T> {
return text ? JSON.parse(text) as T : (null as T); return text ? JSON.parse(text) as T : (null as T);
} }
function browserOrigin(): string | null {
return globalThis.location?.origin ?? null;
}
export async function loadWhoami(fetcher: typeof fetch = fetch): Promise<WhoamiResponse> { export async function loadWhoami(fetcher: typeof fetch = fetch): Promise<WhoamiResponse> {
return await fetcher("/api/auth/whoami", { credentials: "same-origin" }).then(jsonOrThrow<WhoamiResponse>); return await fetcher("/api/auth/whoami", { credentials: "same-origin" }).then(jsonOrThrow<WhoamiResponse>);
} }
@ -32,7 +36,7 @@ export async function registerPasskey(
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
credentials: "same-origin", credentials: "same-origin",
body: JSON.stringify({ handle, display_name: displayName }), body: JSON.stringify({ handle, display_name: displayName, browser_origin: browserOrigin() }),
}).then(jsonOrThrow<PasskeyRegistrationOptionsResponse>); }).then(jsonOrThrow<PasskeyRegistrationOptionsResponse>);
const credential = await navigator.credentials.create({ const credential = await navigator.credentials.create({
@ -61,7 +65,7 @@ export async function loginWithPasskey(
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
credentials: "same-origin", credentials: "same-origin",
body: JSON.stringify({ handle }), body: JSON.stringify({ handle, browser_origin: browserOrigin() }),
}).then(jsonOrThrow<PasskeyLoginOptionsResponse>); }).then(jsonOrThrow<PasskeyLoginOptionsResponse>);
const credential = await navigator.credentials.get({ const credential = await navigator.credentials.get({

View File

@ -5,9 +5,8 @@ export type AuthenticatedUser = {
display_name: string; display_name: string;
}; };
export type RequestActor = { export type RequestActor = AuthenticatedUser & {
user: AuthenticatedUser; auth_method: "browser_session" | "api_token" | string;
auth_kind: string;
}; };
export type WhoamiResponse = { export type WhoamiResponse = {

View File

@ -90,10 +90,15 @@ Deno.test("workspace Tickets surface uses read-only Backend Ticket APIs", async
"Tickets sidebar section should link to the workspace Tickets surface", "Tickets sidebar section should link to the workspace Tickets surface",
); );
assert( assert(
ticketsLoad.includes('workspaceApiPath(params.workspaceId, "/tickets")') && ticketsLoad.includes('`${workspaceApiPath(params.workspaceId, "/tickets")}?limit=1000`') &&
ticketsPage.includes("This surface is read-only") && ticketsPage.includes("Notion-style filtering and sorting") &&
ticketsPage.includes("toggleSort('updated_at')") &&
ticketsPage.includes("bind:value={visibilityFilter}") &&
ticketsPage.includes("sortKey = $state<SortKey>('panel')") &&
ticketsPage.includes("workspace_action_priority") &&
ticketsPage.includes("bind:value={stateFilter}") &&
ticketsPage.includes("workspaceRoute(data.workspaceId, `/tickets/${ticket.id}`)"), ticketsPage.includes("workspaceRoute(data.workspaceId, `/tickets/${ticket.id}`)"),
"Tickets list should read the workspace-scoped Ticket API and link to Ticket details", "Tickets list should read the workspace-scoped Ticket API and expose sortable/filterable table links",
); );
assert( assert(
ticketDetailLoad.includes("`/tickets/${encodeURIComponent(ticketId)}`") && ticketDetailLoad.includes("`/tickets/${encodeURIComponent(ticketId)}`") &&
@ -385,8 +390,32 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
new URL("../auth/api.ts", import.meta.url), new URL("../auth/api.ts", import.meta.url),
); );
const rootLayout = await Deno.readTextFile( const rootLayout = await Deno.readTextFile(
new URL("./../../../routes/+layout.svelte", import.meta.url),
);
const rootLayoutLoad = await Deno.readTextFile(
new URL("./../../../routes/+layout.ts", import.meta.url), new URL("./../../../routes/+layout.ts", import.meta.url),
); );
const globalSidebar = await Deno.readTextFile(
new URL("../sidebar/GlobalSidebar.svelte", import.meta.url),
);
const sidebarFrame = await Deno.readTextFile(
new URL("../sidebar/SidebarFrame.svelte", import.meta.url),
);
const sidebarCss = await Deno.readTextFile(
new URL("../sidebar/sidebar.css", import.meta.url),
);
const workspaceLayout = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/+layout.svelte", import.meta.url),
);
const workspaceLayoutLoad = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/+layout.ts", import.meta.url),
);
const sidebarOverride = await Deno.readTextFile(
new URL("../sidebar/SidebarOverride.svelte", import.meta.url),
);
const sidebar = await Deno.readTextFile(
new URL("../sidebar/WorkspaceSidebar.svelte", import.meta.url),
);
assert( assert(
accountPage.includes("registerPasskey") && accountPage.includes("registerPasskey") &&
@ -412,7 +441,61 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
"Auth model should stay on Backend auth APIs rather than workspace authorization APIs", "Auth model should stay on Backend auth APIs rather than workspace authorization APIs",
); );
assert( assert(
rootLayout.includes('"/account"') && rootLayout.includes('"/login/device"'), rootLayout.includes("SIDEBAR_CONTEXT") &&
rootLayout.includes("GlobalSidebar") &&
rootLayout.includes("SidebarFrame") &&
rootLayout.includes("{@render sidebar()}") &&
!rootLayout.includes("WorkspaceSidebar") &&
rootLayout.includes("workspace-topbar") &&
rootLayout.includes("topbar-icon-button") &&
rootLayout.includes('href="/account"') &&
rootLayout.includes("Open Account") &&
!sidebar.includes("accountHref") &&
!sidebar.includes("Open Account"),
"Root layout chrome should render a registered sidebar snippet or default global sidebar while account navigation stays in the header",
);
assert(
globalSidebar.includes("Global") &&
globalSidebar.includes("/account") &&
globalSidebar.includes("/login/device") &&
!globalSidebar.includes("Tickets") &&
!globalSidebar.includes("Repositories"),
"Root default sidebar should contain only global navigation, not workspace-scoped sections",
);
assert(
workspaceLayout.includes("{#snippet workspaceSidebar()}") &&
workspaceLayout.includes("WorkspaceSidebar") &&
workspaceLayout.includes("<SidebarOverride sidebar={workspaceSidebar} />") &&
workspaceLayoutLoad.includes("params.workspaceId") &&
workspaceLayoutLoad.includes("workspaceApiPath(workspaceId"),
"Workspace layout should load workspace data and register a WorkspaceSidebar snippet",
);
assert(
sidebarFrame.includes("let folded = $state(false)") &&
sidebarFrame.includes("sidebar-fold-button") &&
sidebarFrame.includes("Fold sidebar") &&
sidebarFrame.includes("Unfold sidebar") &&
!workspaceLayout.includes("sidebarFolded") &&
!workspaceLayout.includes("onToggleFold") &&
!sidebar.includes("folded?: boolean") &&
!sidebar.includes("onToggleFold?: () => void") &&
!sidebar.includes("sidebar-fold-button"),
"Sidebar fold control should belong to SidebarFrame, not WorkspaceSidebar",
);
assert(
sidebarCss.startsWith("@layer reset, tokens, base, layout, components;") &&
sidebarCss.includes(".sidebar-frame") &&
sidebarCss.includes(".sidebar-link") &&
sidebarCss.includes("text-decoration: none"),
"Sidebar styles should define their layer order before component rules so base link styles do not win by import order",
);
assert(
sidebarOverride.includes("controller.setSidebar(sidebar)") &&
sidebarOverride.includes("controller.clearSidebar(sidebar)"),
"SidebarOverride should register and clean up the child-provided sidebar snippet",
);
assert(
rootLayoutLoad.includes('"/account"') && rootLayoutLoad.includes('"/login/device"'),
"Root layout should not redirect account and device-login public routes to a workspace", "Root layout should not redirect account and device-login public routes to a workspace",
); );
}); });

View File

@ -0,0 +1,32 @@
<script lang="ts">
import './sidebar.css';
type Props = {
currentPath: string;
};
const { currentPath }: Props = $props();
const items = [
{ href: '/account', label: 'Account' },
{ href: '/login/device', label: 'Device Login' },
];
</script>
<div class="global-sidebar" aria-label="Global navigation">
<div class="global-sidebar-section">
<p class="sidebar-section-label">Global</p>
<nav class="sidebar-list" aria-label="Global pages">
{#each items as item}
<a
class="sidebar-link"
class:active={currentPath === item.href}
href={item.href}
aria-current={currentPath === item.href ? 'page' : undefined}
>
<span>{item.label}</span>
</a>
{/each}
</nav>
</div>
</div>

View File

@ -0,0 +1,46 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import './sidebar.css';
type Props = {
children: Snippet<[]>;
};
let { children }: Props = $props();
let folded = $state(false);
function toggleFold() {
folded = !folded;
}
</script>
<aside class="sidebar-frame" class:folded aria-label="Sidebar">
<div class="sidebar-control-row">
<button
class="sidebar-fold-button"
type="button"
aria-label={folded ? 'Unfold sidebar' : 'Fold sidebar'}
aria-expanded={!folded}
title={folded ? 'Unfold sidebar' : 'Fold sidebar'}
onclick={toggleFold}
>
{#if folded}
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="m6 17 5-5-5-5" />
<path d="m13 17 5-5-5-5" />
</svg>
{:else}
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="m11 17-5-5 5-5" />
<path d="m18 17-5-5 5-5" />
</svg>
{/if}
</button>
</div>
{#if !folded}
<div class="sidebar-frame-content">
{@render children()}
</div>
{/if}
</aside>

View File

@ -0,0 +1,15 @@
<script lang="ts">
import { getSidebarController, type SidebarSnippet } from './context';
type Props = {
sidebar: SidebarSnippet;
};
const { sidebar }: Props = $props();
const controller = getSidebarController();
$effect(() => {
controller.setSidebar(sidebar);
return () => controller.clearSidebar(sidebar);
});
</script>

View File

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { workspaceRoute } from '$lib/workspace/api/http'; import { workspaceRoute } from '$lib/workspace/api/http';
import './sidebar.css';
import ObjectivesNavSection from './ObjectivesNavSection.svelte'; import ObjectivesNavSection from './ObjectivesNavSection.svelte';
import RepositoriesNavSection from './RepositoriesNavSection.svelte'; import RepositoriesNavSection from './RepositoriesNavSection.svelte';
import TicketsNavSection from './TicketsNavSection.svelte'; import TicketsNavSection from './TicketsNavSection.svelte';
@ -12,8 +13,6 @@
repositories?: RepositoryListResponse | null; repositories?: RepositoryListResponse | null;
repositoriesError?: string | null; repositoriesError?: string | null;
currentPath?: string; currentPath?: string;
collapsed?: boolean;
onToggleCollapsed?: () => void;
}; };
let { let {
@ -21,22 +20,15 @@
workspaceError = null, workspaceError = null,
repositories = null, repositories = null,
repositoriesError = null, repositoriesError = null,
currentPath = '/', currentPath = '/'
collapsed = false,
onToggleCollapsed
}: Props = $props(); }: Props = $props();
let workspaceId = $derived(workspace?.workspace_id ?? ''); let workspaceId = $derived(workspace?.workspace_id ?? '');
let homeHref = $derived(workspaceId ? workspaceRoute(workspaceId) : '/'); let homeHref = $derived(workspaceId ? workspaceRoute(workspaceId) : '/');
let accountHref = '/account';
let settingsHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/settings') : '/settings'); let settingsHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/settings') : '/settings');
</script> </script>
<aside <div class="workspace-sidebar">
class:collapsed
class="workspace-sidebar"
aria-label="Workspace navigation"
>
<header class="sidebar-header"> <header class="sidebar-header">
<div class="sidebar-title-row"> <div class="sidebar-title-row">
<div class="workspace-label"> <div class="workspace-label">
@ -51,27 +43,6 @@
{/if} {/if}
{/if} {/if}
</div> </div>
<button
class="sidebar-collapse-button"
type="button"
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-expanded={!collapsed}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
onclick={onToggleCollapsed}
>
{#if collapsed}
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="m6 17 5-5-5-5" />
<path d="m13 17 5-5-5-5" />
</svg>
{:else}
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="m11 17-5-5 5-5" />
<path d="m18 17-5-5 5-5" />
</svg>
{/if}
</button>
</div> </div>
<div class="sidebar-actions-row"> <div class="sidebar-actions-row">
@ -88,17 +59,6 @@
/> />
</svg> </svg>
</a> </a>
<a
class="sidebar-icon-button"
href={accountHref}
aria-label="Open Account"
title="Account"
>
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</a>
<a <a
class="sidebar-icon-button" class="sidebar-icon-button"
href={settingsHref} href={settingsHref}
@ -115,12 +75,10 @@
</div> </div>
</header> </header>
{#if !collapsed}
<nav class="sidebar-sections" aria-label="Workspace sections"> <nav class="sidebar-sections" aria-label="Workspace sections">
<RepositoriesNavSection {repositories} {repositoriesError} {currentPath} {workspaceId} /> <RepositoriesNavSection {repositories} {repositoriesError} {currentPath} {workspaceId} />
<ObjectivesNavSection {currentPath} {workspaceId} /> <ObjectivesNavSection {currentPath} {workspaceId} />
<TicketsNavSection {currentPath} {workspaceId} /> <TicketsNavSection {currentPath} {workspaceId} />
<WorkersNavSection {currentPath} {workspaceId} /> <WorkersNavSection {currentPath} {workspaceId} />
</nav> </nav>
{/if} </div>
</aside>

View File

@ -0,0 +1,15 @@
import { getContext } from "svelte";
import type { Snippet } from "svelte";
export type SidebarSnippet = Snippet<[]>;
export type SidebarController = {
setSidebar(snippet: SidebarSnippet): void;
clearSidebar(snippet: SidebarSnippet): void;
};
export const SIDEBAR_CONTEXT = Symbol("yoi-sidebar-context");
export function getSidebarController(): SidebarController {
return getContext<SidebarController>(SIDEBAR_CONTEXT);
}

View File

@ -0,0 +1,249 @@
@layer reset, tokens, base, layout, components;
@layer components {
.sidebar-frame {
grid-column: 1;
grid-row: 1 / 3;
width: clamp(220px, 20vw, 280px);
min-width: 0;
min-height: 0;
overflow-y: auto;
padding: var(--space-4) var(--space-3);
border-right: 1px solid var(--line);
}
.sidebar-frame.folded {
width: max-content;
overflow: hidden;
padding-inline: var(--space-2);
}
.sidebar-frame-content,
.global-sidebar,
.workspace-sidebar {
min-width: 0;
}
.global-sidebar,
.global-sidebar-section,
.workspace-sidebar {
display: grid;
gap: var(--space-2);
}
.sidebar-header {
display: grid;
gap: var(--space-2);
margin-bottom: var(--space-2);
min-width: 0;
}
.sidebar-control-row,
.sidebar-actions-row {
display: flex;
justify-content: flex-end;
gap: var(--space-1);
}
.sidebar-control-row {
margin-bottom: var(--space-2);
}
.sidebar-frame.folded .sidebar-control-row {
justify-content: center;
margin-bottom: 0;
}
.sidebar-title-row {
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
}
.workspace-label {
flex: 1 1 auto;
min-width: 0;
}
.workspace-name {
display: block;
min-width: 0;
overflow: hidden;
border-radius: var(--radius-soft);
padding: 0.45rem 0.6rem;
color: var(--text-strong);
font-size: 1.05rem;
font-weight: 800;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.workspace-status {
margin: var(--space-1) 0 0;
color: var(--text-muted);
font-size: 0.78rem;
line-height: 1.35;
}
.workspace-status.error,
.section-state.error,
.error {
color: var(--danger);
}
.sidebar-icon-button,
.sidebar-fold-button {
flex: 0 0 auto;
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: var(--radius-soft);
color: var(--text-muted);
text-decoration: none;
}
.sidebar-fold-button {
border: 1px solid var(--line);
background: var(--bg-raised);
box-shadow: var(--shadow-soft);
color: var(--text-strong);
cursor: pointer;
}
.sidebar-icon {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.sidebar-icon-button:hover,
.sidebar-icon-button:focus-visible,
.sidebar-fold-button:hover,
.sidebar-fold-button:focus-visible {
background: var(--interactive-hover);
color: var(--text-muted);
}
.sidebar-sections {
display: grid;
gap: var(--space-5);
min-width: 0;
}
.nav-section {
display: grid;
gap: var(--space-2);
}
.section-heading-row,
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.section-heading-row h2,
.section-header,
.sidebar-section-label {
color: var(--text-faint);
font-size: 0.72rem;
font-weight: 750;
letter-spacing: 0.11em;
text-transform: uppercase;
}
.section-heading-row h2,
.sidebar-section-label {
margin: 0;
}
.section-heading-link {
color: inherit;
text-decoration: none;
}
.section-heading-link:hover,
.section-heading-link:focus-visible,
.section-heading-link.active {
color: var(--accent);
}
.section-count {
color: var(--text-muted);
font-size: 0.72rem;
line-height: 1;
}
.nav-list,
.sidebar-list {
display: grid;
gap: var(--space-1);
margin: 0;
padding: 0;
list-style: none;
}
.nav-item,
.objective-link,
.sidebar-link {
display: grid;
gap: 3px;
min-width: 0;
margin-inline: calc(-1 * var(--space-2));
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-soft);
color: inherit;
text-align: left;
text-decoration: none;
transition: background-color 140ms ease, color 140ms ease;
}
a.nav-item:hover,
a.nav-item:focus-visible,
a.objective-link:hover,
a.objective-link:focus-visible,
a.sidebar-link:hover,
a.sidebar-link:focus-visible {
background: var(--interactive-hover);
}
a.nav-item.active,
a.objective-link.active,
a.sidebar-link.active {
background: var(--interactive-selected);
}
a.nav-item.active .item-title,
a.objective-link.active .item-title,
a.sidebar-link.active {
color: var(--accent);
}
.item-title,
.item-meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-title {
color: var(--text-strong);
font-weight: 650;
}
.item-meta {
color: var(--text-muted);
font-size: 0.82rem;
}
.worker-nav-item {
gap: 2px;
}
.worker-nav-item.disabled {
cursor: default;
opacity: 0.62;
}
.worker-nav-item.disabled .item-title {
color: var(--text-muted);
}
.worker-title-row {
display: grid;
grid-template-columns: minmax(0, max-content) minmax(0, 1fr);
align-items: baseline;
gap: var(--space-2);
min-width: 0;
}
.worker-task-title {
overflow: hidden;
color: var(--text-muted);
font-size: 0.82rem;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 760px) {
.sidebar-frame,
.sidebar-frame.folded {
grid-column: 1;
grid-row: 1;
width: auto;
border-right: 0;
border-bottom: 1px solid var(--line);
}
}
}

View File

@ -115,6 +115,14 @@ export type WorkingDirectoryRepositoryOption = {
default_selector?: string | null; default_selector?: string | null;
}; };
export type WorkingDirectoryOccupancy = {
runtime_id: string;
runtime_worker_id: number;
worker_id: string;
display_name: string;
linked_at: string;
};
export type WorkingDirectorySummary = { export type WorkingDirectorySummary = {
working_directory_id: string; working_directory_id: string;
repository_id: string; repository_id: string;
@ -125,6 +133,7 @@ export type WorkingDirectorySummary = {
status: string; status: string;
cleanliness?: string | null; cleanliness?: string | null;
primary_worker_id?: number | null; primary_worker_id?: number | null;
occupied_by?: WorkingDirectoryOccupancy | null;
cleanup_target: { cleanup_target: {
kind: string; kind: string;
working_directory_id: string; working_directory_id: string;
@ -317,6 +326,7 @@ export type TicketSummary = {
updated_at?: string | null; updated_at?: string | null;
queued_by?: string | null; queued_by?: string | null;
queued_at?: string | null; queued_at?: string | null;
workspace_action_priority?: 'ready_for_queue' | 'active_work' | 'background' | null;
record_source?: string; record_source?: string;
}; };

View File

@ -86,6 +86,40 @@ Deno.test("defaultWorkerLaunchForm chooses active runtime, coder profile, reposi
assertEquals(form.working_directory_selector, "HEAD"); assertEquals(form.working_directory_selector, "HEAD");
}); });
Deno.test("defaultWorkerLaunchForm skips occupied working directories", () => {
const form = defaultWorkerLaunchForm(
{
...options,
working_directories: [
{
...options.working_directories[0],
occupied_by: {
runtime_id: "embedded",
runtime_worker_id: 12,
worker_id: "embedded:12",
display_name: "Worker 12",
linked_at: "2026-07-24T00:00:00Z",
},
},
],
},
{
runtime_id: "",
display_name: "",
profile: "",
initial_text: "hello",
working_directory_id: "",
working_directory_repository_id: "",
working_directory_selector: "",
relative_cwd: "",
},
);
assertEquals(form.working_directory_id, "");
assertEquals(form.working_directory_repository_id, "repo");
assertEquals(form.working_directory_selector, "HEAD");
});
Deno.test("buildBrowserCreateWorkerRequest sends working_directory id and relative cwd only", () => { Deno.test("buildBrowserCreateWorkerRequest sends working_directory id and relative cwd only", () => {
const request = buildBrowserCreateWorkerRequest({ const request = buildBrowserCreateWorkerRequest({
runtime_id: "embedded", runtime_id: "embedded",

View File

@ -38,7 +38,8 @@ export function defaultWorkerLaunchForm(
const availableWorkingDirectories = options?.working_directories.filter((directory) => const availableWorkingDirectories = options?.working_directories.filter((directory) =>
directory.status === "active" && directory.status === "active" &&
directory.cleanliness === "clean" && directory.cleanliness === "clean" &&
directory.primary_worker_id == null directory.primary_worker_id == null &&
directory.occupied_by == null
) ?? []; ) ?? [];
const selectedRuntime = current.runtime_id const selectedRuntime = current.runtime_id
? options?.runtimes.find((runtime) => runtime.runtime_id === current.runtime_id) ? options?.runtimes.find((runtime) => runtime.runtime_id === current.runtime_id)

View File

@ -1,27 +1,144 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { setContext } from 'svelte';
import WorkspaceAlerts from '$lib/workspace/alerts/WorkspaceAlerts.svelte'; import WorkspaceAlerts from '$lib/workspace/alerts/WorkspaceAlerts.svelte';
import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte'; import GlobalSidebar from '$lib/workspace/sidebar/GlobalSidebar.svelte';
import SidebarFrame from '$lib/workspace/sidebar/SidebarFrame.svelte';
import { SIDEBAR_CONTEXT, type SidebarSnippet } from '$lib/workspace/sidebar/context';
import '../app.css'; import '../app.css';
import type { LayoutProps } from './$types'; import type { LayoutProps } from './$types';
let { data, children }: LayoutProps = $props(); let { children }: LayoutProps = $props();
let sidebarCollapsed = $state(false); let sidebar = $state<SidebarSnippet | null>(null);
setContext(SIDEBAR_CONTEXT, {
setSidebar(snippet: SidebarSnippet) {
sidebar = snippet;
},
clearSidebar(snippet: SidebarSnippet) {
if (sidebar === snippet) sidebar = null;
},
});
</script> </script>
<WorkspaceAlerts /> <WorkspaceAlerts />
<div class:sidebar-collapsed={sidebarCollapsed} class="workspace-layout"> <div class="workspace-layout">
<WorkspaceSidebar <SidebarFrame>
workspace={data.workspace} {#if sidebar}
workspaceError={data.workspaceError} {@render sidebar()}
repositories={data.repositories} {:else}
repositoriesError={data.repositoriesError} <GlobalSidebar currentPath={page.url.pathname} />
currentPath={page.url.pathname} {/if}
collapsed={sidebarCollapsed} </SidebarFrame>
onToggleCollapsed={() => (sidebarCollapsed = !sidebarCollapsed)} <header class="workspace-topbar">
/> <nav class="workspace-topbar-actions" aria-label="Global navigation">
<a class="topbar-icon-button" href="/account" aria-label="Open Account" title="Account">
<svg class="topbar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</a>
</nav>
</header>
<main class="shell"> <main class="shell">
{@render children()} {@render children()}
</main> </main>
</div> </div>
<style>
.workspace-layout {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
width: 100vw;
height: 100dvh;
margin: 0;
padding: 0;
overflow: hidden;
min-width: 0;
}
.workspace-topbar {
grid-column: 2;
grid-row: 1;
display: flex;
align-items: center;
justify-content: flex-end;
min-width: 0;
min-height: 3.25rem;
padding: 0 var(--space-5);
border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--bg-raised) 88%, transparent);
backdrop-filter: blur(14px);
}
.workspace-topbar-actions {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.topbar-icon-button {
display: inline-flex;
width: 2.35rem;
height: 2.35rem;
align-items: center;
justify-content: center;
border-radius: 999px;
color: var(--text-muted);
text-decoration: none;
}
.topbar-icon-button:hover,
.topbar-icon-button:focus-visible {
background: var(--interactive-hover);
color: var(--text-muted);
}
.topbar-icon {
width: 1.1rem;
height: 1.1rem;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.shell {
grid-column: 2;
grid-row: 2;
display: flex;
flex-direction: column;
gap: var(--space-6);
min-width: 0;
min-height: 0;
overflow-y: auto;
padding: var(--space-6);
}
@media (max-width: 760px) {
.workspace-layout {
grid-template-columns: 1fr;
grid-template-rows: auto auto 1fr;
width: 100vw;
height: auto;
min-height: 100dvh;
overflow: visible;
}
.workspace-topbar {
grid-column: 1;
grid-row: 2;
padding: 0 var(--space-4);
}
.shell {
grid-column: 1;
grid-row: 3;
overflow: visible;
padding: var(--space-5) var(--space-4);
}
}
</style>

View File

@ -1,40 +1,25 @@
import { redirect } from "@sveltejs/kit"; import { redirect } from "@sveltejs/kit";
import { loadJson, workspaceApiPath, workspaceRoute } from "$lib/workspace/api/http"; import { loadJson, workspaceRoute } from "$lib/workspace/api/http";
import type { RepositoryListResponse, WorkspaceResponse } from "$lib/workspace/sidebar/types"; import type { WorkspaceResponse } from "$lib/workspace/sidebar/types";
import type { LayoutLoad } from "./$types"; import type { LayoutLoad } from "./$types";
export const ssr = false; export const ssr = false;
export const prerender = false; export const prerender = false;
export const load: LayoutLoad = async ({ fetch, params, url }) => { export const load: LayoutLoad = async ({ fetch, params, url }) => {
const workspaceId = params.workspaceId; if (params.workspaceId) {
return {};
}
if (!workspaceId) {
const workspace = await loadJson<WorkspaceResponse>(fetch, "/api/workspace");
const publicRoutes = new Set(["/account", "/login/device"]); const publicRoutes = new Set(["/account", "/login/device"]);
if (workspace.data && !publicRoutes.has(url.pathname)) { if (publicRoutes.has(url.pathname)) {
return {};
}
const workspace = await loadJson<WorkspaceResponse>(fetch, "/api/workspace");
if (workspace.data) {
const scopedPath = workspaceRoute(workspace.data.workspace_id); const scopedPath = workspaceRoute(workspace.data.workspace_id);
throw redirect(307, `${scopedPath}${url.search}`); throw redirect(307, `${scopedPath}${url.search}`);
} }
return { return {};
workspace: null,
workspaceError: workspace.error,
repositories: null,
repositoriesError: null,
};
}
const apiPath = (path: string) => workspaceApiPath(workspaceId, path);
const [workspace, repositories] = await Promise.all([
loadJson<WorkspaceResponse>(fetch, apiPath("/workspace")),
loadJson<RepositoryListResponse>(fetch, apiPath("/repositories")),
]);
return {
workspace: workspace.data,
workspaceError: workspace.error,
repositories: repositories.data,
repositoriesError: repositories.error,
};
}; };

View File

@ -22,9 +22,9 @@
try { try {
const whoami = await loadWhoami(); const whoami = await loadWhoami();
actor = whoami.actor; actor = whoami.actor;
if (actor?.user.handle) { if (actor?.handle) {
handle = actor.user.handle; handle = actor.handle;
displayName = actor.user.display_name; displayName = actor.display_name;
} }
} catch (err) { } catch (err) {
error = err instanceof Error ? err.message : String(err); error = err instanceof Error ? err.message : String(err);
@ -104,23 +104,23 @@
<dl class="account-details"> <dl class="account-details">
<div> <div>
<dt>Handle</dt> <dt>Handle</dt>
<dd>{actor.user.handle}</dd> <dd>{actor.handle}</dd>
</div> </div>
<div> <div>
<dt>Display name</dt> <dt>Display name</dt>
<dd>{actor.user.display_name}</dd> <dd>{actor.display_name}</dd>
</div> </div>
<div> <div>
<dt>User ID</dt> <dt>User ID</dt>
<dd><code>{actor.user.user_id}</code></dd> <dd><code>{actor.user_id}</code></dd>
</div> </div>
<div> <div>
<dt>Account ID</dt> <dt>Account ID</dt>
<dd><code>{actor.user.account_id}</code></dd> <dd><code>{actor.account_id}</code></dd>
</div> </div>
<div> <div>
<dt>Auth kind</dt> <dt>Auth kind</dt>
<dd>{actor.auth_kind}</dd> <dd>{actor.auth_method}</dd>
</div> </div>
</dl> </dl>
<div class="settings-action-row"> <div class="settings-action-row">

View File

@ -22,7 +22,7 @@
try { try {
const whoami = await loadWhoami(); const whoami = await loadWhoami();
actor = whoami.actor; actor = whoami.actor;
if (actor?.user.handle) handle = actor.user.handle; if (actor?.handle) handle = actor.handle;
} catch (err) { } catch (err) {
error = err instanceof Error ? err.message : String(err); error = err instanceof Error ? err.message : String(err);
} finally { } finally {
@ -85,7 +85,7 @@
{#if loading} {#if loading}
<p class="muted">Loading session…</p> <p class="muted">Loading session…</p>
{:else if actor} {:else if actor}
<p>Logged in as <strong>{actor.user.display_name}</strong> <span class="muted">@{actor.user.handle}</span>.</p> <p>Logged in as <strong>{actor.display_name}</strong> <span class="muted">@{actor.handle}</span>.</p>
{:else} {:else}
<p class="muted">You need to log in with a passkey before approving a device login.</p> <p class="muted">You need to log in with a passkey before approving a device login.</p>
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); void login(); }}> <form class="settings-form" onsubmit={(event) => { event.preventDefault(); void login(); }}>

View File

@ -0,0 +1,22 @@
<script lang="ts">
import { page } from '$app/state';
import SidebarOverride from '$lib/workspace/sidebar/SidebarOverride.svelte';
import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte';
import type { LayoutProps } from './$types';
let { data, children }: LayoutProps = $props();
</script>
{#snippet workspaceSidebar()}
<WorkspaceSidebar
workspace={data.workspace ?? null}
workspaceError={data.workspaceError ?? null}
repositories={data.repositories ?? null}
repositoriesError={data.repositoriesError ?? null}
currentPath={page.url.pathname}
/>
{/snippet}
<SidebarOverride sidebar={workspaceSidebar} />
{@render children()}

View File

@ -0,0 +1,19 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { RepositoryListResponse, WorkspaceResponse } from "$lib/workspace/sidebar/types";
import type { LayoutLoad } from "./$types";
export const load: LayoutLoad = async ({ fetch, params }) => {
const workspaceId = params.workspaceId;
const apiPath = (path: string) => workspaceApiPath(workspaceId, path);
const [workspace, repositories] = await Promise.all([
loadJson<WorkspaceResponse>(fetch, apiPath("/workspace")),
loadJson<RepositoryListResponse>(fetch, apiPath("/repositories")),
]);
return {
workspace: workspace.data,
workspaceError: workspace.error,
repositories: repositories.data,
repositoriesError: repositories.error,
};
};

View File

@ -129,6 +129,7 @@
<th>Commit</th> <th>Commit</th>
<th>Status</th> <th>Status</th>
<th>Cleanliness</th> <th>Cleanliness</th>
<th>Occupied by</th>
<th>Action</th> <th>Action</th>
</tr> </tr>
</thead> </thead>
@ -142,6 +143,14 @@
<td><code>{commitLabel(workdir)}</code></td> <td><code>{commitLabel(workdir)}</code></td>
<td>{workdir.status}</td> <td>{workdir.status}</td>
<td>{workdir.cleanliness ?? 'unknown'}</td> <td>{workdir.cleanliness ?? 'unknown'}</td>
<td>
{#if workdir.occupied_by}
<span>{workdir.occupied_by.display_name}</span>
<small>{workdir.occupied_by.runtime_id}:{workdir.occupied_by.runtime_worker_id}</small>
{:else}
<span class="muted"></span>
{/if}
</td>
<td> <td>
{#if cleanup} {#if cleanup}
<button <button

View File

@ -1,8 +1,150 @@
<script lang="ts"> <script lang="ts">
import { formatDate, workspaceRoute } from '$lib/workspace/api/http'; import { formatDate, workspaceRoute } from '$lib/workspace/api/http';
import type { TicketSummary } from '$lib/workspace/sidebar/types';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
type SortKey = 'panel' | 'title' | 'state' | 'priority' | 'updated_at' | 'queued_at' | 'id';
type SortDirection = 'asc' | 'desc';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
let query = $state('');
let visibilityFilter = $state<'open' | 'closed' | 'all'>('open');
let stateFilter = $state('all');
let priorityFilter = $state('all');
let queuedFilter = $state('all');
let sortKey = $state<SortKey>('panel');
let sortDirection = $state<SortDirection>('asc');
const tickets = $derived(data.tickets.data?.items ?? []);
const states = $derived(uniqueValues(tickets.map((ticket) => ticket.state)));
const priorities = $derived(uniqueValues(tickets.map((ticket) => ticket.priority).filter(isPresent)));
const filteredTickets = $derived(filterTickets(tickets));
const visibleTickets = $derived(sortTickets(filteredTickets));
function isPresent(value: string | null | undefined): value is string {
return Boolean(value && value.trim());
}
function uniqueValues(values: string[]): string[] {
return [...new Set(values.filter((value) => value.trim()))].sort((left, right) =>
left.localeCompare(right),
);
}
function filterTickets(items: TicketSummary[]): TicketSummary[] {
const needle = query.trim().toLowerCase();
return items.filter((ticket) => {
if (visibilityFilter === 'open' && ticket.state === 'closed') {
return false;
}
if (visibilityFilter === 'closed' && ticket.state !== 'closed') {
return false;
}
if (stateFilter !== 'all' && ticket.state !== stateFilter) {
return false;
}
if (priorityFilter !== 'all' && (ticket.priority ?? '') !== priorityFilter) {
return false;
}
if (queuedFilter === 'queued' && !ticket.queued_at) {
return false;
}
if (queuedFilter === 'unqueued' && ticket.queued_at) {
return false;
}
if (!needle) {
return true;
}
return [ticket.id, ticket.title, ticket.state, ticket.priority, ticket.queued_by, ticket.record_source]
.filter(isPresent)
.some((value) => value.toLowerCase().includes(needle));
});
}
function sortTickets(items: TicketSummary[]): TicketSummary[] {
return [...items].sort((left, right) => {
const result = compareTicketValues(left, right, sortKey);
return sortDirection === 'asc' ? result : -result;
});
}
function compareTicketValues(left: TicketSummary, right: TicketSummary, key: SortKey): number {
if (key === 'panel') {
return comparePanelOrder(left, right);
}
if (key === 'updated_at' || key === 'queued_at') {
return compareDate(left[key], right[key]);
}
return compareText(ticketValue(left, key), ticketValue(right, key));
}
function comparePanelOrder(left: TicketSummary, right: TicketSummary): number {
return compareNumber(panelActionPriority(left), panelActionPriority(right))
|| compareDate(right.updated_at, left.updated_at)
|| compareText(left.title, right.title);
}
function panelActionPriority(ticket: TicketSummary): number {
if (ticket.workspace_action_priority) {
if (ticket.workspace_action_priority === 'ready_for_queue') return 0;
if (ticket.workspace_action_priority === 'active_work') return 1;
if (ticket.workspace_action_priority === 'background') return 2;
}
if (ticket.state === 'ready') return 0;
if (ticket.state === 'queued' || ticket.state === 'inprogress') return 1;
return 2;
}
function compareNumber(left: number, right: number): number {
return left - right;
}
function ticketValue(ticket: TicketSummary, key: SortKey): string | null | undefined {
if (key === 'id') return ticket.id;
if (key === 'title') return ticket.title;
if (key === 'state') return ticket.state;
if (key === 'priority') return ticket.priority;
return null;
}
function compareText(left: string | null | undefined, right: string | null | undefined): number {
const leftText = left?.trim() ?? '';
const rightText = right?.trim() ?? '';
if (!leftText && rightText) return 1;
if (leftText && !rightText) return -1;
return leftText.localeCompare(rightText);
}
function compareDate(left: string | null | undefined, right: string | null | undefined): number {
const leftTime = left ? Date.parse(left) : Number.NEGATIVE_INFINITY;
const rightTime = right ? Date.parse(right) : Number.NEGATIVE_INFINITY;
return leftTime - rightTime;
}
function toggleSort(key: SortKey) {
if (sortKey === key) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
return;
}
sortKey = key;
sortDirection = key === 'updated_at' || key === 'queued_at' ? 'desc' : 'asc';
}
function sortLabel(key: SortKey): string {
if (sortKey !== key) return '';
return sortDirection === 'asc' ? '↑' : '↓';
}
function resetFilters() {
query = '';
visibilityFilter = 'open';
stateFilter = 'all';
priorityFilter = 'all';
queuedFilter = 'all';
sortKey = 'panel';
sortDirection = 'asc';
}
</script> </script>
<svelte:head> <svelte:head>
@ -10,47 +152,110 @@
<meta name="description" content="Workspace Tickets" /> <meta name="description" content="Workspace Tickets" />
</svelte:head> </svelte:head>
<section class="card"> <section class="card ticket-database-card">
<div class="detail-heading"> <div class="detail-heading">
<div> <div>
<p class="eyebrow">Workspace records</p> <p class="eyebrow">Workspace records</p>
<h2>Tickets</h2> <h2>Tickets</h2>
</div> </div>
{#if data.tickets.data} {#if data.tickets.data}
<span>{data.tickets.data.items.length} ticket{data.tickets.data.items.length === 1 ? '' : 's'}</span> <span>{visibleTickets.length} / {data.tickets.data.items.length} ticket{data.tickets.data.items.length === 1 ? '' : 's'}</span>
{/if} {/if}
</div> </div>
<p class="section-note"> <p class="section-note">
Tickets are read from the typed Ticket backend. This surface is read-only; creation and queue operations remain outside the browser UI for now. Tickets are read from the typed Ticket backend. This read-only table supports Notion-style filtering and sorting for browsing imported workspace Tickets.
</p> </p>
{#if data.tickets.data} {#if data.tickets.data}
{#if data.tickets.data.items.length === 0} {#if data.tickets.data.items.length === 0}
<p>No Ticket records are present.</p> <p>No Ticket records are present.</p>
{:else} {:else}
<div class="ticket-list" aria-label="Workspace Tickets"> <div class="ticket-database-toolbar" aria-label="Ticket table controls">
{#each data.tickets.data.items as ticket (ticket.id)} <label class="ticket-filter ticket-search">
<a class="ticket-row" href={workspaceRoute(data.workspaceId, `/tickets/${ticket.id}`)}> <span>Search</span>
<div class="ticket-main"> <input bind:value={query} type="search" placeholder="Title, id, state, source…" />
<div class="ticket-title-row"> </label>
<strong class="ticket-title">{ticket.title}</strong> <label class="ticket-filter">
<span class="state-pill">{ticket.state}</span> <span>Visibility</span>
</div> <select bind:value={visibilityFilter}>
<p class="ticket-summary"> <option value="open">Open</option>
{ticket.priority ? `${ticket.priority} priority` : 'priority unspecified'} · {ticket.record_source ?? 'ticket backend'} <option value="closed">Closed</option>
</p> <option value="all">All</option>
</div> </select>
<div class="ticket-meta" aria-label="Ticket metadata"> </label>
<span>Updated {ticket.updated_at ? formatDate(ticket.updated_at) : 'unknown'}</span> <label class="ticket-filter">
{#if ticket.queued_at} <span>State</span>
<span>Queued {formatDate(ticket.queued_at)}</span> <select bind:value={stateFilter}>
{/if} <option value="all">All states</option>
<code>{ticket.id}</code> {#each states as state}
</div> <option value={state}>{state}</option>
</a>
{/each} {/each}
</select>
</label>
<label class="ticket-filter">
<span>Priority</span>
<select bind:value={priorityFilter}>
<option value="all">All priorities</option>
{#each priorities as priority}
<option value={priority}>{priority}</option>
{/each}
</select>
</label>
<label class="ticket-filter">
<span>Queue</span>
<select bind:value={queuedFilter}>
<option value="all">All</option>
<option value="queued">Queued</option>
<option value="unqueued">Unqueued</option>
</select>
</label>
<button class="secondary-button" type="button" onclick={() => toggleSort('panel')}>Panel order {sortLabel('panel')}</button>
<button class="secondary-button" type="button" onclick={resetFilters}>Reset</button>
</div> </div>
<div class="ticket-table-wrap" aria-label="Workspace Tickets table">
<table class="ticket-table">
<thead>
<tr>
<th><button type="button" onclick={() => toggleSort('title')}>Title {sortLabel('title')}</button></th>
<th><button type="button" onclick={() => toggleSort('state')}>State {sortLabel('state')}</button></th>
<th><button type="button" onclick={() => toggleSort('priority')}>Priority {sortLabel('priority')}</button></th>
<th><button type="button" onclick={() => toggleSort('updated_at')}>Updated {sortLabel('updated_at')}</button></th>
<th><button type="button" onclick={() => toggleSort('queued_at')}>Queued {sortLabel('queued_at')}</button></th>
<th><button type="button" onclick={() => toggleSort('id')}>ID {sortLabel('id')}</button></th>
</tr>
</thead>
<tbody>
{#each visibleTickets as ticket (ticket.id)}
<tr>
<td class="ticket-title-cell">
<a href={workspaceRoute(data.workspaceId, `/tickets/${ticket.id}`)}>{ticket.title}</a>
<span>{ticket.record_source ?? 'ticket backend'}</span>
</td>
<td><span class="state-pill">{ticket.state}</span></td>
<td>{ticket.priority || '—'}</td>
<td>{ticket.updated_at ? formatDate(ticket.updated_at) : 'unknown'}</td>
<td>
{#if ticket.queued_at}
<span>{formatDate(ticket.queued_at)}</span>
{#if ticket.queued_by}
<small>{ticket.queued_by}</small>
{/if}
{:else}
<span class="muted"></span>
{/if}
</td>
<td><code>{ticket.id}</code></td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if visibleTickets.length === 0}
<p class="section-note">No tickets match the current filters.</p>
{/if}
{/if} {/if}
{#if data.tickets.data.invalid_records.length > 0} {#if data.tickets.data.invalid_records.length > 0}
@ -62,3 +267,126 @@
<p>Waiting for <code>/api/w/{data.workspaceId}/tickets</code></p> <p>Waiting for <code>/api/w/{data.workspaceId}/tickets</code></p>
{/if} {/if}
</section> </section>
<style>
.ticket-database-card {
overflow: hidden;
}
.ticket-database-toolbar {
display: grid;
grid-template-columns: minmax(16rem, 1.8fr) repeat(4, minmax(9rem, 1fr)) auto auto;
gap: 0.75rem;
align-items: end;
margin: 1rem 0;
}
.ticket-filter {
display: grid;
gap: 0.35rem;
color: var(--text-muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.ticket-filter input,
.ticket-filter select {
min-height: 2.35rem;
border: 1px solid var(--line);
border-radius: 0.6rem;
background: var(--bg-raised);
color: inherit;
font: inherit;
font-weight: 500;
letter-spacing: normal;
padding: 0 0.7rem;
text-transform: none;
}
.secondary-button {
min-height: 2.35rem;
border: 1px solid var(--line);
border-radius: 0.6rem;
background: var(--bg-raised);
color: inherit;
cursor: pointer;
font: inherit;
font-weight: 600;
padding: 0 0.9rem;
}
.ticket-table-wrap {
border: 1px solid var(--line);
border-radius: 0.8rem;
overflow: auto;
}
.ticket-table {
width: 100%;
min-width: 58rem;
border-collapse: collapse;
background: var(--bg-raised);
color: var(--text);
font-size: 0.9rem;
}
.ticket-table th,
.ticket-table td {
border-bottom: 1px solid var(--line);
padding: 0.72rem 0.8rem;
text-align: left;
vertical-align: top;
}
.ticket-table th {
background: var(--bg-subtle);
color: var(--text-muted);
font-size: 0.76rem;
letter-spacing: 0.04em;
position: sticky;
text-transform: uppercase;
top: 0;
z-index: 1;
}
.ticket-table th button {
all: unset;
cursor: pointer;
}
.ticket-table tbody tr:hover {
background: var(--interactive-hover);
}
.ticket-title-cell {
min-width: 22rem;
}
.ticket-title-cell a {
color: inherit;
display: block;
font-weight: 700;
text-decoration: none;
}
.ticket-title-cell a:hover {
text-decoration: underline;
}
.ticket-title-cell span,
.ticket-table small,
.muted {
color: var(--text-muted);
display: block;
font-size: 0.78rem;
margin-top: 0.2rem;
}
@media (max-width: 900px) {
.ticket-database-toolbar {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -5,7 +5,7 @@ import type { PageLoad } from "./$types";
export const load = (async ({ fetch, params }) => { export const load = (async ({ fetch, params }) => {
const tickets = await loadJson<TicketListResponse>( const tickets = await loadJson<TicketListResponse>(
fetch, fetch,
workspaceApiPath(params.workspaceId, "/tickets"), `${workspaceApiPath(params.workspaceId, "/tickets")}?limit=1000`,
); );
return { return {

View File

@ -50,7 +50,10 @@
selectedRuntimeAllowsNoWorkdir selectedRuntimeAllowsNoWorkdir
? [] ? []
: (options?.working_directories ?? []).filter((directory) => : (options?.working_directories ?? []).filter((directory) =>
directory.status === 'active' && directory.cleanliness === 'clean' && directory.primary_worker_id == null directory.status === 'active' &&
directory.cleanliness === 'clean' &&
directory.primary_worker_id == null &&
directory.occupied_by == null
), ),
); );
let canStartWorker = $derived(Boolean( let canStartWorker = $derived(Boolean(