Compare commits
22 Commits
7740191194
...
b9067a8110
| Author | SHA1 | Date | |
|---|---|---|---|
| b9067a8110 | |||
| 7a28395d75 | |||
| d3d2b28adc | |||
| e0b092dbc8 | |||
| b1570aba63 | |||
| 4ff599c011 | |||
| 19d5396897 | |||
| e975c648c2 | |||
| 60460b23dc | |||
| 62c1e4303b | |||
| 97397a1c4d | |||
| 6e53df0303 | |||
| f848a96343 | |||
| fb4d3c3f1f | |||
| b3854004a1 | |||
| 308e1ffdbc | |||
| 2bd69f9403 | |||
| 61b1735292 | |||
| 11663830e3 | |||
| 2285277060 | |||
| cd6468bb92 | |||
| 28304f13c3 |
|
|
@ -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"
|
||||
|
|
@ -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
1
Cargo.lock
generated
|
|
@ -4294,6 +4294,7 @@ dependencies = [
|
|||
"fs4",
|
||||
"llm-engine",
|
||||
"project-record",
|
||||
"rusqlite",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -105,6 +105,15 @@ pub struct BackendWorkingDirectoryCleanupTarget {
|
|||
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)]
|
||||
pub struct BackendWorkingDirectorySummary {
|
||||
pub working_directory_id: String,
|
||||
|
|
@ -122,7 +131,9 @@ pub struct BackendWorkingDirectorySummary {
|
|||
#[serde(default)]
|
||||
pub cleanliness: Option<String>,
|
||||
#[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)]
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
//! | 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` |
|
||||
//! | 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` |
|
||||
//!
|
||||
//! `YOI_HOME=$X` のとき config は `$X/config`、data は `$X` 直下、
|
||||
|
|
@ -42,6 +42,7 @@ pub fn data_dir() -> Option<PathBuf> {
|
|||
resolve_data_dir_from_parts(
|
||||
env_path("YOI_DATA_DIR"),
|
||||
env_path("YOI_HOME"),
|
||||
env_path("XDG_DATA_HOME"),
|
||||
env_path("HOME"),
|
||||
)
|
||||
}
|
||||
|
|
@ -131,6 +132,7 @@ fn resolve_config_dir_from_parts(
|
|||
fn resolve_data_dir_from_parts(
|
||||
yoi_data_dir: Option<PathBuf>,
|
||||
yoi_home: Option<PathBuf>,
|
||||
xdg_data_home: Option<PathBuf>,
|
||||
home: Option<PathBuf>,
|
||||
) -> Option<PathBuf> {
|
||||
if let Some(p) = yoi_data_dir {
|
||||
|
|
@ -139,7 +141,10 @@ fn resolve_data_dir_from_parts(
|
|||
if let Some(p) = yoi_home {
|
||||
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(
|
||||
|
|
@ -268,20 +273,35 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn data_dir_default_is_dot_yoi() {
|
||||
fn data_dir_falls_back_to_home_local_share() {
|
||||
assert_eq!(
|
||||
resolve_data_dir_from_parts(None, None, Some(PathBuf::from("/h"))).unwrap(),
|
||||
PathBuf::from("/h/.yoi")
|
||||
resolve_data_dir_from_parts(None, None, None, Some(PathBuf::from("/h"))).unwrap(),
|
||||
PathBuf::from("/h/.local/share/yoi")
|
||||
);
|
||||
}
|
||||
|
||||
#[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!(
|
||||
resolve_data_dir_from_parts(
|
||||
None,
|
||||
Some(PathBuf::from("/sand")),
|
||||
Some(PathBuf::from("/h"))
|
||||
Some(PathBuf::from("/xdg-data")),
|
||||
Some(PathBuf::from("/h")),
|
||||
)
|
||||
.unwrap(),
|
||||
PathBuf::from("/sand")
|
||||
|
|
@ -294,6 +314,7 @@ mod tests {
|
|||
resolve_data_dir_from_parts(
|
||||
Some(PathBuf::from("/explicit-data")),
|
||||
Some(PathBuf::from("/sand")),
|
||||
Some(PathBuf::from("/xdg-data")),
|
||||
Some(PathBuf::from("/h")),
|
||||
)
|
||||
.unwrap(),
|
||||
|
|
@ -365,7 +386,7 @@ mod tests {
|
|||
#[test]
|
||||
fn returns_none_when_nothing_set() {
|
||||
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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ schemars = { workspace = true }
|
|||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml = "0.9.34"
|
||||
rusqlite.workspace = true
|
||||
thiserror.workspace = true
|
||||
tempfile.workspace = true
|
||||
toml = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -219,7 +219,7 @@ fn default_store_dir() -> Result<PathBuf, PickerError> {
|
|||
PickerError::Io(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"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(
|
||||
io::ErrorKind::NotFound,
|
||||
"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)",
|
||||
))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,6 +138,15 @@ pub struct WorkingDirectoryCleanupTarget {
|
|||
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)]
|
||||
pub struct WorkingDirectorySummary {
|
||||
pub working_directory_id: String,
|
||||
|
|
@ -156,6 +165,8 @@ pub struct WorkingDirectorySummary {
|
|||
pub cleanliness: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
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)]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use crate::catalog::{CreateWorkerRequest, WorkingDirectoryRequest, WorkingDirectoryStatus};
|
||||
use crate::catalog::{WorkingDirectoryRequest, WorkingDirectoryStatus};
|
||||
use crate::config_bundle::ConfigBundle;
|
||||
use crate::error::RuntimeError;
|
||||
use crate::identity::WorkerRef;
|
||||
|
|
@ -11,24 +11,29 @@ use serde::{Deserialize, Serialize};
|
|||
use std::fmt;
|
||||
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,
|
||||
/// credentials, session files, or manifest paths. It only says whether Runtime
|
||||
/// has an execution backend attached for the Worker.
|
||||
/// This is intentionally a worker lifecycle projection, not a transport/backend
|
||||
/// handle state. Runtime restart boundaries invalidate live handles, so a
|
||||
/// 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)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkerExecutionBackendKind {
|
||||
/// Restoreable persisted state exists, but no live execution handle is attached.
|
||||
#[default]
|
||||
Unconnected,
|
||||
/// A durable execution binding was restored, but no live handle was recovered.
|
||||
Stale,
|
||||
Connected,
|
||||
#[serde(alias = "unconnected", alias = "stale")]
|
||||
Stopped,
|
||||
/// A live execution handle is currently attached. Legacy `connected` maps here.
|
||||
#[serde(alias = "connected")]
|
||||
Alive,
|
||||
/// Persisted execution state is structurally invalid and cannot be restored.
|
||||
Corrupted,
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// provider-private authority.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -49,12 +54,11 @@ impl WorkerExecutionBindingIdentity {
|
|||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkerExecutionRunState {
|
||||
#[default]
|
||||
Unconnected,
|
||||
Stopped,
|
||||
Idle,
|
||||
Busy,
|
||||
Rejected,
|
||||
Errored,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
/// Execution operation that produced a result.
|
||||
|
|
@ -69,7 +73,18 @@ pub enum WorkerExecutionOperation {
|
|||
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)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkerExecutionOutcome {
|
||||
|
|
@ -80,16 +95,6 @@ pub enum WorkerExecutionOutcome {
|
|||
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 {
|
||||
pub fn accepted(
|
||||
operation: WorkerExecutionOperation,
|
||||
|
|
@ -116,7 +121,7 @@ impl WorkerExecutionResult {
|
|||
Self {
|
||||
operation,
|
||||
outcome: WorkerExecutionOutcome::Rejected,
|
||||
run_state: WorkerExecutionRunState::Rejected,
|
||||
run_state: WorkerExecutionRunState::Stopped,
|
||||
message: Some(message.into()),
|
||||
}
|
||||
}
|
||||
|
|
@ -134,7 +139,7 @@ impl WorkerExecutionResult {
|
|||
Self {
|
||||
operation,
|
||||
outcome: WorkerExecutionOutcome::Unsupported,
|
||||
run_state: WorkerExecutionRunState::Rejected,
|
||||
run_state: WorkerExecutionRunState::Stopped,
|
||||
message: Some(message.into()),
|
||||
}
|
||||
}
|
||||
|
|
@ -159,28 +164,31 @@ pub struct WorkerExecutionStatus {
|
|||
pub binding: Option<WorkerExecutionBindingIdentity>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub working_directory: Option<WorkingDirectoryStatus>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_result: Option<WorkerExecutionResult>,
|
||||
}
|
||||
|
||||
impl WorkerExecutionStatus {
|
||||
pub fn unconnected() -> Self {
|
||||
pub fn stopped() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn connected(run_state: WorkerExecutionRunState) -> Self {
|
||||
pub fn alive(run_state: WorkerExecutionRunState) -> Self {
|
||||
Self {
|
||||
backend: WorkerExecutionBackendKind::Connected,
|
||||
backend: WorkerExecutionBackendKind::Alive,
|
||||
run_state,
|
||||
binding: None,
|
||||
working_directory: None,
|
||||
last_result: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stale(mut previous: Self) -> Self {
|
||||
previous.backend = WorkerExecutionBackendKind::Stale;
|
||||
previous.run_state = WorkerExecutionRunState::Unconnected;
|
||||
pub fn stopped_from(mut previous: Self) -> Self {
|
||||
previous.backend = WorkerExecutionBackendKind::Stopped;
|
||||
previous.run_state = WorkerExecutionRunState::Stopped;
|
||||
previous
|
||||
}
|
||||
|
||||
pub fn corrupted(mut previous: Self) -> Self {
|
||||
previous.backend = WorkerExecutionBackendKind::Corrupted;
|
||||
previous.run_state = WorkerExecutionRunState::Errored;
|
||||
previous
|
||||
}
|
||||
|
||||
|
|
@ -196,7 +204,6 @@ impl WorkerExecutionStatus {
|
|||
|
||||
pub fn with_result(mut self, result: WorkerExecutionResult) -> Self {
|
||||
self.run_state = result.run_state;
|
||||
self.last_result = Some(result);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
|
@ -266,13 +273,20 @@ impl WorkerExecutionContext {
|
|||
&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")]
|
||||
pub fn publish_protocol_event(
|
||||
&self,
|
||||
payload: protocol::Event,
|
||||
) -> 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)]
|
||||
pub struct WorkerExecutionSpawnRequest {
|
||||
pub worker_ref: WorkerRef,
|
||||
pub request: CreateWorkerRequest,
|
||||
pub request: crate::catalog::CreateWorkerRequest,
|
||||
pub context: WorkerExecutionContext,
|
||||
pub working_directory: Option<WorkingDirectoryBinding>,
|
||||
pub config_bundle: Option<ConfigBundle>,
|
||||
}
|
||||
|
||||
/// Restore request passed to an execution backend for a persisted Runtime Worker.
|
||||
///
|
||||
/// The persisted execution status is a restore hint, not a live handle. Backends
|
||||
/// must create a fresh controller/handle before returning `Connected`.
|
||||
/// Request passed to a [`WorkerExecutionBackend`] when restoring a persisted Worker.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WorkerExecutionRestoreRequest {
|
||||
pub worker_ref: WorkerRef,
|
||||
pub request: CreateWorkerRequest,
|
||||
pub request: crate::catalog::CreateWorkerRequest,
|
||||
pub context: WorkerExecutionContext,
|
||||
pub previous_execution: WorkerExecutionStatus,
|
||||
pub working_directory: Option<WorkingDirectoryBinding>,
|
||||
pub config_bundle: Option<ConfigBundle>,
|
||||
}
|
||||
|
||||
/// Result of backend Worker spawn/initialization.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
/// Backend outcome for Worker spawn/restore operations.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum WorkerExecutionSpawnResult {
|
||||
Connected {
|
||||
handle: WorkerExecutionHandle,
|
||||
|
|
@ -320,11 +331,20 @@ pub enum WorkerExecutionSpawnResult {
|
|||
Errored(WorkerExecutionResult),
|
||||
}
|
||||
|
||||
/// Backend boundary for Worker execution.
|
||||
///
|
||||
/// Runtime owns Worker catalog, protocol observation, and lifecycle state. A
|
||||
/// backend owns concrete execution. The default Runtime has no backend, so input
|
||||
/// to those Workers is rejected instead of producing providerless responses.
|
||||
impl WorkerExecutionSpawnResult {
|
||||
pub fn connected(
|
||||
handle: WorkerExecutionHandle,
|
||||
run_state: WorkerExecutionRunState,
|
||||
working_directory: Option<WorkingDirectoryStatus>,
|
||||
) -> Self {
|
||||
Self::Connected {
|
||||
handle,
|
||||
run_state,
|
||||
working_directory,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
||||
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 {
|
||||
WorkerExecutionResult::unsupported(
|
||||
WorkerExecutionOperation::Stop,
|
||||
|
|
@ -411,32 +440,30 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
|||
fn worker_snapshot(&self, _handle: &WorkerExecutionHandle) -> Option<protocol::Event> {
|
||||
None
|
||||
}
|
||||
|
||||
fn worker_completions(
|
||||
&self,
|
||||
_handle: &WorkerExecutionHandle,
|
||||
_kind: protocol::CompletionKind,
|
||||
_prefix: &str,
|
||||
) -> Vec<protocol::CompletionEntry> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct WorkerExecutionBackendRef {
|
||||
id: String,
|
||||
backend_id: String,
|
||||
backend: Arc<dyn WorkerExecutionBackend>,
|
||||
}
|
||||
|
||||
impl WorkerExecutionBackendRef {
|
||||
pub(crate) fn new(backend: Arc<dyn WorkerExecutionBackend>) -> Result<Self, RuntimeError> {
|
||||
let id = backend.backend_id().trim().to_string();
|
||||
if id.is_empty() {
|
||||
let backend_id = backend.backend_id().to_string();
|
||||
if backend_id.trim().is_empty() {
|
||||
return Err(RuntimeError::InvalidRequest(
|
||||
"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(
|
||||
|
|
@ -446,12 +473,6 @@ impl WorkerExecutionBackendRef {
|
|||
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(
|
||||
&self,
|
||||
request: WorkerExecutionRestoreRequest,
|
||||
|
|
@ -500,14 +521,6 @@ impl WorkerExecutionBackendRef {
|
|||
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")]
|
||||
pub(crate) fn worker_snapshot(
|
||||
&self,
|
||||
|
|
@ -524,12 +537,62 @@ impl WorkerExecutionBackendRef {
|
|||
) -> Vec<protocol::CompletionEntry> {
|
||||
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 {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("WorkerExecutionBackendRef")
|
||||
.field("id", &self.id)
|
||||
.field("backend_id", &self.backend_id)
|
||||
.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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -473,7 +473,7 @@ struct WorkerSnapshot {
|
|||
worker_id: WorkerId,
|
||||
status: WorkerStatus,
|
||||
request: CreateWorkerRequest,
|
||||
#[serde(default = "WorkerExecutionStatus::unconnected")]
|
||||
#[serde(default = "WorkerExecutionStatus::stopped")]
|
||||
execution: WorkerExecutionStatus,
|
||||
last_event_id: u64,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -357,7 +357,7 @@ impl Runtime {
|
|||
worker_id: worker_id.clone(),
|
||||
status: WorkerStatus::Running,
|
||||
request: request.clone(),
|
||||
execution: WorkerExecutionStatus::unconnected(),
|
||||
execution: WorkerExecutionStatus::stopped(),
|
||||
execution_handle: None,
|
||||
last_event_id: event_id,
|
||||
};
|
||||
|
|
@ -471,14 +471,13 @@ impl Runtime {
|
|||
match (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 mut execution = WorkerExecutionStatus::unconnected().with_result(result);
|
||||
execution.binding = worker.execution.binding.clone();
|
||||
worker.execution = execution;
|
||||
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 Err(RuntimeError::WorkerExecutionUnavailable {
|
||||
worker_id: worker_ref.worker_id.clone(),
|
||||
|
|
@ -510,11 +509,10 @@ impl Runtime {
|
|||
let worker = state.worker_mut(worker_ref)?;
|
||||
worker.last_event_id = event_id;
|
||||
worker.execution = WorkerExecutionStatus {
|
||||
backend: WorkerExecutionBackendKind::Connected,
|
||||
backend: WorkerExecutionBackendKind::Alive,
|
||||
run_state: dispatch_result.run_state,
|
||||
binding: worker.execution.binding.clone(),
|
||||
working_directory: worker.execution.working_directory.clone(),
|
||||
last_result: Some(dispatch_result),
|
||||
};
|
||||
|
||||
let status = worker.status;
|
||||
|
|
@ -592,14 +590,13 @@ impl Runtime {
|
|||
match (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 mut execution = WorkerExecutionStatus::unconnected().with_result(result);
|
||||
execution.binding = worker.execution.binding.clone();
|
||||
worker.execution = execution;
|
||||
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 Err(RuntimeError::WorkerExecutionUnavailable {
|
||||
worker_id: worker_ref.worker_id.clone(),
|
||||
|
|
@ -638,7 +635,7 @@ impl Runtime {
|
|||
let binding = WorkerExecutionBindingIdentity::from_handle(&handle);
|
||||
let worker = state.worker_mut(worker_ref)?;
|
||||
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 {
|
||||
execution = execution.with_working_directory(status);
|
||||
}
|
||||
|
|
@ -669,11 +666,10 @@ impl Runtime {
|
|||
let mut state = self.lock()?;
|
||||
let worker = state.worker_mut(worker_ref)?;
|
||||
worker.execution = WorkerExecutionStatus {
|
||||
backend: WorkerExecutionBackendKind::Connected,
|
||||
backend: WorkerExecutionBackendKind::Alive,
|
||||
run_state: result.run_state,
|
||||
binding: worker.execution.binding.clone(),
|
||||
working_directory: worker.execution.working_directory.clone(),
|
||||
last_result: Some(result),
|
||||
};
|
||||
state.persist_worker(&worker_ref.worker_id)?;
|
||||
Ok(())
|
||||
|
|
@ -1048,6 +1044,24 @@ impl Runtime {
|
|||
let candidates = {
|
||||
let mut state = self.lock()?;
|
||||
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(());
|
||||
};
|
||||
let backend_id = backend.backend_id().to_string();
|
||||
|
|
@ -1059,7 +1073,10 @@ impl Runtime {
|
|||
};
|
||||
if !worker.status.is_active()
|
||||
|| worker.execution_handle.is_some()
|
||||
|| worker.execution.backend != WorkerExecutionBackendKind::Stale
|
||||
|| !matches!(
|
||||
worker.execution.backend,
|
||||
WorkerExecutionBackendKind::Alive | WorkerExecutionBackendKind::Stopped
|
||||
)
|
||||
|| worker
|
||||
.execution
|
||||
.binding
|
||||
|
|
@ -1152,7 +1169,7 @@ impl Runtime {
|
|||
{
|
||||
let worker = state.worker_mut(worker_ref)?;
|
||||
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));
|
||||
if let Some(status) = working_directory {
|
||||
execution = execution.with_working_directory(status);
|
||||
|
|
@ -1269,22 +1286,21 @@ impl RuntimeState {
|
|||
let mut diagnostics = persisted.diagnostics;
|
||||
let mut next_diagnostic_id = persisted.next_diagnostic_id;
|
||||
for (worker_id, worker) in persisted.workers {
|
||||
let execution = if worker.execution.binding.is_some()
|
||||
&& worker.execution.backend == WorkerExecutionBackendKind::Connected
|
||||
let execution = if worker.execution.backend == WorkerExecutionBackendKind::Alive
|
||||
&& worker.execution.binding.is_none()
|
||||
{
|
||||
let stale = WorkerExecutionStatus::stale(worker.execution);
|
||||
diagnostics.push(RuntimeDiagnostic {
|
||||
id: next_diagnostic_id,
|
||||
severity: DiagnosticSeverity::Warning,
|
||||
code: "worker_execution_mapping_stale".to_string(),
|
||||
severity: DiagnosticSeverity::Error,
|
||||
code: "worker_execution_binding_missing".to_string(),
|
||||
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_ref: Some(worker.worker_ref.clone()),
|
||||
});
|
||||
next_diagnostic_id += 1;
|
||||
stale
|
||||
WorkerExecutionStatus::corrupted(worker.execution)
|
||||
} else {
|
||||
worker.execution
|
||||
};
|
||||
|
|
@ -1596,9 +1612,7 @@ impl RuntimeState {
|
|||
});
|
||||
let worker = self.worker_mut(worker_ref)?;
|
||||
worker.execution_handle = None;
|
||||
let mut execution = WorkerExecutionStatus::stale(worker.execution.clone());
|
||||
execution.last_result = Some(result);
|
||||
worker.execution = execution;
|
||||
worker.execution = WorkerExecutionStatus::corrupted(worker.execution.clone());
|
||||
self.persist_runtime_snapshot()?;
|
||||
self.persist_worker(&worker_ref.worker_id)?;
|
||||
Ok(())
|
||||
|
|
@ -2623,7 +2637,7 @@ mod tests {
|
|||
assert_eq!(restored_worker.status, WorkerStatus::Stopped);
|
||||
assert_eq!(
|
||||
restored_worker.execution.backend,
|
||||
WorkerExecutionBackendKind::Stale
|
||||
WorkerExecutionBackendKind::Stopped
|
||||
);
|
||||
assert_eq!(
|
||||
restored_worker
|
||||
|
|
@ -2633,16 +2647,6 @@ mod tests {
|
|||
.map(|binding| binding.backend_id.as_str()),
|
||||
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")]
|
||||
{
|
||||
let observations = restored
|
||||
|
|
@ -2708,6 +2712,19 @@ mod tests {
|
|||
.unwrap();
|
||||
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 restored = Runtime::with_fs_store_and_execution_backend(
|
||||
crate::fs_store::FsRuntimeStoreOptions {
|
||||
|
|
@ -2724,7 +2741,7 @@ mod tests {
|
|||
assert_eq!(restored_worker.status, WorkerStatus::Running);
|
||||
assert_eq!(
|
||||
restored_worker.execution.backend,
|
||||
WorkerExecutionBackendKind::Connected
|
||||
WorkerExecutionBackendKind::Alive
|
||||
);
|
||||
assert!(restored_worker.execution.binding.is_some());
|
||||
restored
|
||||
|
|
@ -2748,7 +2765,7 @@ mod tests {
|
|||
|
||||
#[cfg(feature = "fs-store")]
|
||||
#[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 runtime = Runtime::with_fs_store_and_execution_backend(
|
||||
crate::fs_store::FsRuntimeStoreOptions {
|
||||
|
|
@ -2785,7 +2802,7 @@ mod tests {
|
|||
assert_eq!(restored_worker.status, WorkerStatus::Running);
|
||||
assert_eq!(
|
||||
restored_worker.execution.backend,
|
||||
WorkerExecutionBackendKind::Stale
|
||||
WorkerExecutionBackendKind::Corrupted
|
||||
);
|
||||
assert!(
|
||||
restored
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ impl ProfileRuntimeWorkerFactory {
|
|||
.clone()
|
||||
.or_else(paths::sessions_dir)
|
||||
.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()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ impl WorkingDirectory {
|
|||
status: self.status.clone(),
|
||||
cleanliness: None,
|
||||
primary_worker_id: None,
|
||||
occupied_by: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -221,6 +222,7 @@ impl LocalGitWorktreeMaterializer {
|
|||
status: WorkingDirectoryStatusKind::Corrupted,
|
||||
cleanliness: Some("unknown".to_string()),
|
||||
primary_worker_id: None,
|
||||
occupied_by: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -449,8 +449,8 @@ async fn run_cli_inner(cli: Cli) -> ExitCode {
|
|||
}
|
||||
};
|
||||
// Initialize persistent store. `paths::sessions_dir()` only
|
||||
// returns None when none of YOI_HOME / YOI_DATA_DIR /
|
||||
// HOME is set — surface that as a hard error to match the
|
||||
// returns None when none of YOI_DATA_DIR / YOI_HOME /
|
||||
// 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
|
||||
// relative path under cwd.
|
||||
let store_dir = match cli.store.clone() {
|
||||
|
|
@ -460,7 +460,7 @@ async fn run_cli_inner(cli: Cli) -> ExitCode {
|
|||
None => {
|
||||
eprintln!(
|
||||
"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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use crate::server::{AuthConfig, ServerConfig};
|
|||
use crate::{Error, Result};
|
||||
|
||||
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 =
|
||||
include_str!("../../../resources/workspace-backend.default.toml");
|
||||
const DEFAULT_LISTEN: &str = "127.0.0.1:8787";
|
||||
|
|
@ -49,6 +50,11 @@ pub struct WorkspaceBackendConfigFile {
|
|||
pub auth: WorkspaceBackendAuthConfig,
|
||||
#[serde(default)]
|
||||
pub repositories: Vec<WorkspaceRepositoryConfigFile>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BackendRuntimesConfigFile {
|
||||
#[serde(default)]
|
||||
pub runtimes: WorkspaceBackendRuntimesConfig,
|
||||
}
|
||||
|
|
@ -198,6 +204,74 @@ pub struct ResolvedWorkspaceBackendConfig {
|
|||
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 {
|
||||
pub fn path_for_workspace(workspace_root: impl AsRef<Path>) -> PathBuf {
|
||||
workspace_root
|
||||
|
|
@ -276,6 +350,19 @@ impl WorkspaceBackendConfigFile {
|
|||
&self,
|
||||
workspace_root: impl AsRef<Path>,
|
||||
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> {
|
||||
let workspace_root = workspace_root.as_ref();
|
||||
let data_root = self
|
||||
|
|
@ -291,7 +378,7 @@ impl WorkspaceBackendConfigFile {
|
|||
.workspace_database_path
|
||||
.as_ref()
|
||||
.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
|
||||
.data
|
||||
.embedded_runtime_store_root
|
||||
|
|
@ -312,6 +399,7 @@ impl WorkspaceBackendConfigFile {
|
|||
})?;
|
||||
|
||||
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
|
||||
|
|
@ -329,7 +417,7 @@ impl WorkspaceBackendConfigFile {
|
|||
.iter()
|
||||
.map(|repository| resolve_repository(workspace_root, repository))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
server.remote_runtime_sources = self
|
||||
server.remote_runtime_sources = runtime_config
|
||||
.runtimes
|
||||
.remote
|
||||
.iter()
|
||||
|
|
@ -352,7 +440,9 @@ impl WorkspaceBackendConfigFile {
|
|||
|
||||
impl ResolvedWorkspaceBackendConfig {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -480,7 +570,7 @@ mod tests {
|
|||
assert_eq!(resolved.listen, "127.0.0.1:8787".parse().unwrap());
|
||||
assert_eq!(resolved.server.frontend_url, DEFAULT_FRONTEND_URL);
|
||||
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!(
|
||||
resolved
|
||||
.server
|
||||
|
|
@ -553,7 +643,7 @@ embedded_runtime_store_root = "/tmp/yoi-runtime"
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn data_root_derives_database_and_runtime_store_paths() {
|
||||
fn data_root_derives_runtime_store_path_only() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = WorkspaceBackendConfigFile::parse_str(
|
||||
r#"
|
||||
|
|
@ -565,10 +655,7 @@ root = ".local-data"
|
|||
.unwrap();
|
||||
let resolved = config.resolve(dir.path(), identity()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved.database_path,
|
||||
dir.path().join(".local-data/workspace.db")
|
||||
);
|
||||
assert!(resolved.database_path.ends_with("server.db"));
|
||||
assert_eq!(
|
||||
resolved.server.embedded_runtime_store_root,
|
||||
dir.path().join(".local-data/embedded-runtime")
|
||||
|
|
@ -663,15 +750,80 @@ uri = "https://example.com/org/repo.git"
|
|||
}
|
||||
|
||||
#[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(
|
||||
r#"
|
||||
[[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"
|
||||
endpoint = "http://127.0.0.1:8790"
|
||||
token = "secret"
|
||||
"#,
|
||||
"test",
|
||||
"runtimes.toml",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
|
|
@ -683,17 +835,22 @@ token = "secret"
|
|||
#[test]
|
||||
fn token_ref_fails_closed_until_secret_resolution_exists() {
|
||||
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#"
|
||||
[[runtimes.remote]]
|
||||
id = "remote"
|
||||
endpoint = "http://127.0.0.1:8790"
|
||||
token_ref = "local:remote-token"
|
||||
"#,
|
||||
"test",
|
||||
"runtimes.toml",
|
||||
)
|
||||
.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"),
|
||||
Err(error) => error,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1629,38 +1629,24 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||
}),
|
||||
};
|
||||
match self.runtime.create_worker(create_request) {
|
||||
Ok(detail) => {
|
||||
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 {
|
||||
Ok(detail) => WorkerSpawnResult {
|
||||
state: WorkerOperationState::Accepted,
|
||||
worker: Some(self.map_worker_detail(detail)),
|
||||
acceptance_evidence: vec![
|
||||
WorkerSpawnAcceptanceEvidence {
|
||||
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(),
|
||||
},
|
||||
WorkerSpawnAcceptanceEvidence {
|
||||
kind: "embedded_runtime_backend_internal_projection".to_string(),
|
||||
detail:
|
||||
"only runtime_id plus worker_id backend projections were exposed"
|
||||
detail: "only runtime_id plus worker_id backend projections were exposed"
|
||||
.to_string(),
|
||||
},
|
||||
],
|
||||
diagnostics,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
diagnostics.push(embedded_runtime_diagnostic(&err));
|
||||
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(
|
||||
execution_enabled: bool,
|
||||
status: EmbeddedWorkerStatus,
|
||||
|
|
@ -2749,26 +2703,11 @@ fn runtime_worker_can_stop(
|
|||
) -> bool {
|
||||
execution_enabled
|
||||
&& status == EmbeddedWorkerStatus::Running
|
||||
&& execution.backend == worker_runtime::execution::WorkerExecutionBackendKind::Connected
|
||||
&& !matches!(
|
||||
&& execution.backend == worker_runtime::execution::WorkerExecutionBackendKind::Alive
|
||||
&& matches!(
|
||||
execution.run_state,
|
||||
WorkerExecutionRunState::Stopped
|
||||
| WorkerExecutionRunState::Rejected
|
||||
| WorkerExecutionRunState::Errored
|
||||
| WorkerExecutionRunState::Unconnected
|
||||
WorkerExecutionRunState::Idle | WorkerExecutionRunState::Busy
|
||||
)
|
||||
&& !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 {
|
||||
|
|
@ -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(),
|
||||
)];
|
||||
|
||||
if execution.backend == WorkerExecutionBackendKind::Stale {
|
||||
diagnostics.push(diagnostic(
|
||||
"embedded_worker_execution_stale",
|
||||
DiagnosticSeverity::Warning,
|
||||
"Worker execution handle is not connected in this server process; persisted execution binding was marked stale".to_string(),
|
||||
));
|
||||
} else if execution.backend == WorkerExecutionBackendKind::Unconnected
|
||||
|| execution.run_state == WorkerExecutionRunState::Unconnected
|
||||
{
|
||||
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",
|
||||
match execution.backend {
|
||||
WorkerExecutionBackendKind::Stopped => diagnostics.push(diagnostic(
|
||||
"embedded_worker_execution_stopped",
|
||||
DiagnosticSeverity::Info,
|
||||
"Worker execution is stopped; the runtime will restore it before dispatching input or protocol methods".to_string(),
|
||||
)),
|
||||
WorkerExecutionBackendKind::Corrupted => diagnostics.push(diagnostic(
|
||||
"embedded_worker_execution_corrupted",
|
||||
DiagnosticSeverity::Error,
|
||||
"Worker execution spawn was rejected; backend-private details are not exposed"
|
||||
.to_string(),
|
||||
"Worker execution state is corrupted and cannot be restored without repair".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
|
||||
}
|
||||
|
|
@ -2821,19 +2759,19 @@ fn embedded_worker_execution_status_label(
|
|||
match status {
|
||||
EmbeddedWorkerStatus::Stopped => "stopped",
|
||||
EmbeddedWorkerStatus::Cancelled => "cancelled",
|
||||
EmbeddedWorkerStatus::Running => {
|
||||
if execution.backend == worker_runtime::execution::WorkerExecutionBackendKind::Stale {
|
||||
return "stale";
|
||||
}
|
||||
EmbeddedWorkerStatus::Running => match execution.backend {
|
||||
worker_runtime::execution::WorkerExecutionBackendKind::Stopped => "stopped",
|
||||
worker_runtime::execution::WorkerExecutionBackendKind::Corrupted => "corrupted",
|
||||
worker_runtime::execution::WorkerExecutionBackendKind::Alive => {
|
||||
match execution.run_state {
|
||||
WorkerExecutionRunState::Idle => "idle",
|
||||
WorkerExecutionRunState::Busy => "running",
|
||||
WorkerExecutionRunState::Stopped => "stopped",
|
||||
WorkerExecutionRunState::Rejected => "rejected",
|
||||
WorkerExecutionRunState::Errored => "errored",
|
||||
WorkerExecutionRunState::Unconnected => "unconnected",
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4331,7 +4269,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[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![
|
||||
mock_response(
|
||||
"GET",
|
||||
|
|
@ -4343,30 +4281,26 @@ mod tests {
|
|||
worker_json_with_execution(
|
||||
"remote:primary",
|
||||
"1",
|
||||
"stale",
|
||||
"unconnected",
|
||||
None,
|
||||
"stopped",
|
||||
"stopped",
|
||||
),
|
||||
worker_json_with_execution(
|
||||
"remote:primary",
|
||||
"2",
|
||||
"unconnected",
|
||||
"unconnected",
|
||||
None,
|
||||
"corrupted",
|
||||
"errored",
|
||||
),
|
||||
worker_json_with_execution(
|
||||
"remote:primary",
|
||||
"3",
|
||||
"connected",
|
||||
"alive",
|
||||
"rejected",
|
||||
Some("rejected"),
|
||||
),
|
||||
worker_json_with_execution(
|
||||
"remote:primary",
|
||||
"4",
|
||||
"connected",
|
||||
"alive",
|
||||
"errored",
|
||||
Some("errored"),
|
||||
)
|
||||
]
|
||||
})
|
||||
|
|
@ -4381,9 +4315,8 @@ mod tests {
|
|||
"worker": worker_json_with_execution(
|
||||
"remote:primary",
|
||||
"1",
|
||||
"stale",
|
||||
"unconnected",
|
||||
None,
|
||||
"stopped",
|
||||
"stopped",
|
||||
)})
|
||||
.to_string(),
|
||||
),
|
||||
|
|
@ -4411,14 +4344,14 @@ mod tests {
|
|||
worker.worker_id
|
||||
);
|
||||
}
|
||||
assert_eq!(workers.items[0].state, "stale");
|
||||
assert_eq!(workers.items[1].state, "unconnected");
|
||||
assert_eq!(workers.items[0].state, "stopped");
|
||||
assert_eq!(workers.items[1].state, "corrupted");
|
||||
assert_eq!(workers.items[2].state, "rejected");
|
||||
assert_eq!(workers.items[3].state, "errored");
|
||||
|
||||
let stale_detail = registry.worker("remote:primary", "1").unwrap();
|
||||
assert!(!stale_detail.capabilities.can_stop);
|
||||
assert_eq!(stale_detail.state, "stale");
|
||||
let stopped_detail = registry.worker("remote:primary", "1").unwrap();
|
||||
assert!(!stopped_detail.capabilities.can_stop);
|
||||
assert_eq!(stopped_detail.state, "stopped");
|
||||
|
||||
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 {
|
||||
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(
|
||||
|
|
@ -4607,23 +4540,14 @@ mod tests {
|
|||
worker_id: &str,
|
||||
backend: &str,
|
||||
run_state: &str,
|
||||
last_outcome: Option<&str>,
|
||||
) -> 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();
|
||||
json!({
|
||||
"worker_ref": { "runtime_id": runtime_id, "worker_id": worker_id },
|
||||
"runtime_id": runtime_id,
|
||||
"worker_id": worker_id,
|
||||
"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" },
|
||||
"profile": { "kind": "builtin", "value": "coder" },
|
||||
"profile_source": {
|
||||
|
|
|
|||
|
|
@ -19,8 +19,9 @@ pub mod skills;
|
|||
pub mod store;
|
||||
|
||||
pub use config::{
|
||||
ConfigDiff, ResolvedWorkspaceBackendConfig, WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH,
|
||||
WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile,
|
||||
BackendRuntimesConfigFile, ConfigDiff, ResolvedWorkspaceBackendConfig,
|
||||
WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, WORKSPACE_BACKEND_CONFIG_TEMPLATE,
|
||||
WorkspaceBackendConfigFile,
|
||||
};
|
||||
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
|
||||
pub use records::{
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ use std::process::ExitCode;
|
|||
use std::sync::Arc;
|
||||
|
||||
use tokio::net::TcpListener;
|
||||
use yoi_workspace_server::store::RepositoryRecord;
|
||||
use yoi_workspace_server::{
|
||||
SqliteWorkspaceStore, WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile,
|
||||
WorkspaceIdentity, serve,
|
||||
BackendRuntimesConfigFile, ControlPlaneStore, ServerConfig, SqliteWorkspaceStore,
|
||||
WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile, WorkspaceIdentity,
|
||||
WorkspaceRecord, serve,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
@ -21,9 +23,6 @@ enum Command {
|
|||
|
||||
#[derive(Debug)]
|
||||
struct ServeOptions {
|
||||
workspace: PathBuf,
|
||||
db: Option<PathBuf>,
|
||||
frontend: Option<PathBuf>,
|
||||
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<_>>();
|
||||
match parse_command(&args)? {
|
||||
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::ConfigDiff(options) => run_config_diff(options),
|
||||
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)?;
|
||||
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!(
|
||||
"yoi-workspace-server: initialized workspace `{}` ({})",
|
||||
"yoi-workspace-server: initialized workspace `{}` ({}) in server DB `{}`",
|
||||
options.workspace.display(),
|
||||
identity.workspace_id
|
||||
identity.workspace_id,
|
||||
database_path.display()
|
||||
);
|
||||
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>> {
|
||||
let identity = WorkspaceIdentity::load_required(&options.workspace)?;
|
||||
let config_file = WorkspaceBackendConfigFile::load_for_workspace(&options.workspace)?;
|
||||
let mut resolved = config_file.resolve(&options.workspace, identity)?;
|
||||
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 database_path = ServerConfig::default_server_database_path();
|
||||
if let Some(parent) = database_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
|
||||
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 {
|
||||
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?;
|
||||
eprintln!(
|
||||
"yoi-workspace-server: serving workspace `{}` on http://{}",
|
||||
options.workspace.display(),
|
||||
"yoi-workspace-server: serving workspace `{}` from server DB `{}` on http://{}",
|
||||
workspace.workspace_id,
|
||||
database_path.display(),
|
||||
listener.local_addr()?
|
||||
);
|
||||
serve(resolved.server, store, listener).await?;
|
||||
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> {
|
||||
let Some((subcommand, rest)) = args.split_first() else {
|
||||
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> {
|
||||
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 index = 0;
|
||||
while index < args.len() {
|
||||
let arg = &args[index];
|
||||
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" => {
|
||||
index += 1;
|
||||
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()))?;
|
||||
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=") => {
|
||||
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!(
|
||||
"unexpected positional argument `{arg}`; use --workspace <PATH>"
|
||||
"unexpected positional argument `{arg}`; serve reads the workspace from the server DB"
|
||||
)));
|
||||
}
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
|
||||
let workspace = workspace.canonicalize().map_err(|error| {
|
||||
CliError(format!(
|
||||
"failed to canonicalize workspace `{}`: {error}",
|
||||
workspace.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(ServeOptions {
|
||||
workspace,
|
||||
db,
|
||||
frontend,
|
||||
listen,
|
||||
})
|
||||
Ok(ServeOptions { listen })
|
||||
}
|
||||
|
||||
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() {
|
||||
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() {
|
||||
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]
|
||||
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();
|
||||
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(),
|
||||
})
|
||||
},
|
||||
database_path.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
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/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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
use project_record::validate_record_id;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ticket::config::TicketConfig;
|
||||
use ticket::{LocalTicketBackend, TicketIdOrSlug, TicketListQuery};
|
||||
use ticket::{
|
||||
SqliteTicketBackend, TicketBackend, TicketIdOrSlug, TicketListQuery,
|
||||
TicketWorkspaceActionPriority, project_ticket_workspace_item,
|
||||
};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
|
|
@ -14,19 +16,19 @@ const SUMMARY_BODY_LIMIT: usize = 240;
|
|||
#[derive(Debug, Clone)]
|
||||
pub struct LocalProjectRecordReader {
|
||||
workspace_root: PathBuf,
|
||||
ticket_backend: LocalTicketBackend,
|
||||
ticket_backend: SqliteTicketBackend,
|
||||
}
|
||||
|
||||
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 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 {
|
||||
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>> {
|
||||
let partial = self.ticket_backend.list_partial(TicketListQuery::all())?;
|
||||
let mut items = partial
|
||||
.tickets
|
||||
.into_iter()
|
||||
.map(|item| TicketSummary {
|
||||
let mut items = Vec::new();
|
||||
for item in self.ticket_backend.list(TicketListQuery::all())? {
|
||||
let ticket = self
|
||||
.ticket_backend
|
||||
.show(TicketIdOrSlug::Id(item.id.clone()))?;
|
||||
let projection = project_ticket_workspace_item(&item, &ticket.relations.blockers, None);
|
||||
items.push(TicketSummary {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
state: item.workflow_state.as_str().to_string(),
|
||||
|
|
@ -47,35 +51,29 @@ impl LocalProjectRecordReader {
|
|||
updated_at: item.updated_at,
|
||||
queued_by: item.queued_by,
|
||||
queued_at: item.queued_at,
|
||||
record_source: "local_yoi_ticket".to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
workspace_action_priority: workspace_action_priority_name(projection.priority)
|
||||
.to_string(),
|
||||
record_source: "sqlite_yoi_ticket".to_string(),
|
||||
});
|
||||
}
|
||||
items.sort_by(|a, b| {
|
||||
b.updated_at
|
||||
.cmp(&a.updated_at)
|
||||
.then_with(|| a.id.cmp(&b.id))
|
||||
});
|
||||
items.truncate(limit.min(200));
|
||||
items.truncate(limit);
|
||||
Ok(ProjectRecordList {
|
||||
items,
|
||||
invalid_records: partial
|
||||
.invalid_records
|
||||
.into_iter()
|
||||
.map(|record| InvalidProjectRecord {
|
||||
label: record.label,
|
||||
reason: record.reason,
|
||||
})
|
||||
.collect(),
|
||||
invalid_records: Vec::new(),
|
||||
record_authority: "local_yoi_project_records".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ticket(&self, id: &str) -> Result<TicketDetail> {
|
||||
validate_project_id(id)?;
|
||||
let partial = self
|
||||
let ticket = self
|
||||
.ticket_backend
|
||||
.show_partial(TicketIdOrSlug::Id(id.to_string()))?;
|
||||
let ticket = partial.ticket;
|
||||
.show(TicketIdOrSlug::Id(id.to_string()))?;
|
||||
let (body, body_truncated) =
|
||||
truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT);
|
||||
Ok(TicketDetail {
|
||||
|
|
@ -92,7 +90,7 @@ impl LocalProjectRecordReader {
|
|||
body_truncated,
|
||||
event_count: ticket.events.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)]
|
||||
pub struct ProjectRecordList<T> {
|
||||
pub items: Vec<T>,
|
||||
|
|
@ -179,6 +185,7 @@ pub struct TicketSummary {
|
|||
pub updated_at: Option<String>,
|
||||
pub queued_by: Option<String>,
|
||||
pub queued_at: Option<String>,
|
||||
pub workspace_action_priority: String,
|
||||
pub record_source: String,
|
||||
}
|
||||
|
||||
|
|
@ -296,16 +303,27 @@ mod tests {
|
|||
use super::*;
|
||||
|
||||
#[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();
|
||||
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");
|
||||
|
||||
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();
|
||||
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].state, "ready");
|
||||
assert_eq!(
|
||||
tickets.items[0].workspace_action_priority,
|
||||
"ready_for_queue"
|
||||
);
|
||||
|
||||
let ticket = reader.ticket("00000000001J2").unwrap();
|
||||
assert!(ticket.body.contains("Ticket body"));
|
||||
|
|
@ -318,34 +336,16 @@ mod tests {
|
|||
assert!(objective.body.contains("Objective body"));
|
||||
}
|
||||
#[test]
|
||||
fn reads_tickets_from_workspace_settings_backend_root() {
|
||||
fn does_not_read_legacy_ticket_files_without_sqlite_import() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(dir.path().join(".yoi")).unwrap();
|
||||
fs::write(
|
||||
dir.path().join(".yoi/workspace.toml"),
|
||||
r#"
|
||||
[ticket]
|
||||
language = "Japanese"
|
||||
write_ticket(dir.path(), "00000000001J5", "Legacy file", "ready");
|
||||
let db_path = dir.path().join("workspace.db");
|
||||
|
||||
[ticket.backend]
|
||||
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 reader = LocalProjectRecordReader::new(dir.path(), &db_path, "workspace-test").unwrap();
|
||||
let tickets = reader.list_tickets(20).unwrap();
|
||||
assert_eq!(tickets.items.len(), 1);
|
||||
assert_eq!(tickets.items[0].id, "00000000001J4");
|
||||
assert!(tickets.items.is_empty());
|
||||
}
|
||||
|
||||
fn write_ticket(root: &Path, id: &str, title: &str, state: &str) {
|
||||
write_ticket_at(&root.join(".yoi/tickets"), id, title, state);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ impl RepositoryRegistryReader {
|
|||
kind: repository.provider.clone(),
|
||||
provider: repository.provider.clone(),
|
||||
default_selector: repository.default_selector.clone(),
|
||||
record_authority: "workspace-backend-config".to_string(),
|
||||
record_authority: "workspace-control-plane".to_string(),
|
||||
git,
|
||||
diagnostics,
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -82,6 +82,21 @@ pub struct WorkspaceRecord {
|
|||
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)]
|
||||
pub struct AccountRecord {
|
||||
pub account_id: String,
|
||||
|
|
@ -211,6 +226,9 @@ pub trait ControlPlaneStore: Send + Sync {
|
|||
async fn schema_version(&self) -> Result<i64>;
|
||||
async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()>;
|
||||
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 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<()> {
|
||||
self.with_conn(|conn| {
|
||||
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 {
|
||||
format!(
|
||||
"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]
|
||||
async fn worker_workdir_registry_round_trips_and_preserves_pinned_retention() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -1448,7 +1448,7 @@ fn print_help() {
|
|||
|
||||
fn print_workspace_help() {
|
||||
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]
|
||||
fn parse_workspace_init_passthrough() {
|
||||
match parse_args_from(["workspace", "init", "--workspace", "/tmp/ws"]).unwrap() {
|
||||
|
|
|
|||
|
|
@ -6,14 +6,14 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use ticket::config::{
|
||||
DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TICKET_CONFIG_RELATIVE_PATH, TicketConfig,
|
||||
WORKSPACE_SETTINGS_RELATIVE_PATH, ticket_config_scaffold,
|
||||
TICKET_CONFIG_RELATIVE_PATH, TicketConfig, WORKSPACE_SETTINGS_RELATIVE_PATH,
|
||||
ticket_config_scaffold,
|
||||
};
|
||||
use ticket::{
|
||||
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend,
|
||||
TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary, TicketListQuery,
|
||||
TicketListState, TicketRelationKind, TicketReview, TicketReviewResult, TicketSummary,
|
||||
TicketWorkflowState,
|
||||
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation,
|
||||
SqliteTicketBackend, TicketBackend, TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug,
|
||||
TicketIntakeSummary, TicketListQuery, TicketListState, TicketRelationKind, TicketReview,
|
||||
TicketReviewResult, TicketSummary, TicketWorkflowState,
|
||||
};
|
||||
|
||||
const DEFAULT_LIST_LIMIT: usize = 50;
|
||||
|
|
@ -30,6 +30,7 @@ pub enum TicketCli {
|
|||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TicketCommand {
|
||||
Init,
|
||||
ImportLocal,
|
||||
Create(CreateOptions),
|
||||
List(ListOptions),
|
||||
Show { query: String },
|
||||
|
|
@ -179,6 +180,14 @@ pub fn parse_ticket_args(args: &[String]) -> Result<TicketCli, TicketCliError> {
|
|||
}
|
||||
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..])?),
|
||||
"list" => TicketCommand::List(parse_list(&args[1..])?),
|
||||
"show" => TicketCommand::Show {
|
||||
|
|
@ -232,19 +241,22 @@ fn run_command(
|
|||
) -> Result<TicketCliOutput, TicketCliError> {
|
||||
match command {
|
||||
TicketCommand::Init => init(workspace),
|
||||
TicketCommand::ImportLocal => import_local(workspace),
|
||||
command => {
|
||||
let backend = backend_for_workspace(workspace)?;
|
||||
match command {
|
||||
TicketCommand::Create(options) => create(&backend, options),
|
||||
TicketCommand::List(options) => list(&backend, options),
|
||||
TicketCommand::Show { query } => show(&backend, query),
|
||||
TicketCommand::Comment(options) => comment(&backend, options),
|
||||
TicketCommand::Review(options) => review(&backend, options),
|
||||
TicketCommand::State(options) => state(&backend, options),
|
||||
TicketCommand::Close(options) => close(&backend, options),
|
||||
TicketCommand::Relation(options) => relation(&backend, options),
|
||||
TicketCommand::Doctor => doctor(&backend),
|
||||
TicketCommand::Init => unreachable!("init handled before backend setup"),
|
||||
TicketCommand::Create(options) => create(backend.as_ref(), options),
|
||||
TicketCommand::List(options) => list(backend.as_ref(), options),
|
||||
TicketCommand::Show { query } => show(backend.as_ref(), query),
|
||||
TicketCommand::Comment(options) => comment(backend.as_ref(), options),
|
||||
TicketCommand::Review(options) => review(backend.as_ref(), options),
|
||||
TicketCommand::State(options) => state(backend.as_ref(), options),
|
||||
TicketCommand::Close(options) => close(backend.as_ref(), options),
|
||||
TicketCommand::Relation(options) => relation(backend.as_ref(), options),
|
||||
TicketCommand::Doctor => doctor(backend.as_ref()),
|
||||
TicketCommand::Init | TicketCommand::ImportLocal => {
|
||||
unreachable!("handled before backend setup")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -262,9 +274,6 @@ fn init(workspace: &Path) -> Result<TicketCliOutput, TicketCliError> {
|
|||
|
||||
let yoi_dir = workspace.join(".yoi");
|
||||
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 scaffold = ticket_config_scaffold();
|
||||
|
|
@ -311,8 +320,8 @@ fn init(workspace: &Path) -> Result<TicketCliOutput, TicketCliError> {
|
|||
"updated"
|
||||
};
|
||||
Ok(success(format!(
|
||||
"{verb}\t{}\nensured\t{}\n",
|
||||
WORKSPACE_SETTINGS_RELATIVE_PATH, DEFAULT_TICKET_BACKEND_RELATIVE_PATH
|
||||
"{verb}\t{}\nbackend\tworkspace-sqlite\n",
|
||||
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)?;
|
||||
Ok(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)?;
|
||||
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(
|
||||
backend: &LocalTicketBackend,
|
||||
backend: &dyn TicketBackend,
|
||||
options: CreateOptions,
|
||||
) -> Result<TicketCliOutput, TicketCliError> {
|
||||
let mut input = NewTicket::new(options.title);
|
||||
|
|
@ -374,7 +452,7 @@ fn create(
|
|||
}
|
||||
|
||||
fn list(
|
||||
backend: &LocalTicketBackend,
|
||||
backend: &dyn TicketBackend,
|
||||
options: ListOptions,
|
||||
) -> Result<TicketCliOutput, TicketCliError> {
|
||||
let filter = match options.state {
|
||||
|
|
@ -410,7 +488,7 @@ fn list(
|
|||
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 mut stdout = String::new();
|
||||
stdout.push_str(&format!("# {}\n\n", ticket.meta.title));
|
||||
|
|
@ -545,7 +623,7 @@ fn is_obsolete_ticket_frontmatter_key(key: &str) -> bool {
|
|||
}
|
||||
|
||||
fn comment(
|
||||
backend: &LocalTicketBackend,
|
||||
backend: &dyn TicketBackend,
|
||||
options: CommentOptions,
|
||||
) -> Result<TicketCliOutput, TicketCliError> {
|
||||
let role = options.role.as_str().to_string();
|
||||
|
|
@ -556,7 +634,7 @@ fn comment(
|
|||
}
|
||||
|
||||
fn review(
|
||||
backend: &LocalTicketBackend,
|
||||
backend: &dyn TicketBackend,
|
||||
options: ReviewOptions,
|
||||
) -> Result<TicketCliOutput, TicketCliError> {
|
||||
let result = options.result.as_str().to_string();
|
||||
|
|
@ -573,7 +651,7 @@ fn review(
|
|||
}
|
||||
|
||||
fn state(
|
||||
backend: &LocalTicketBackend,
|
||||
backend: &dyn TicketBackend,
|
||||
options: StateOptions,
|
||||
) -> Result<TicketCliOutput, TicketCliError> {
|
||||
let id = TicketIdOrSlug::Query(options.query.clone());
|
||||
|
|
@ -626,7 +704,7 @@ fn state(
|
|||
}
|
||||
|
||||
fn close(
|
||||
backend: &LocalTicketBackend,
|
||||
backend: &dyn TicketBackend,
|
||||
options: CloseOptions,
|
||||
) -> Result<TicketCliOutput, TicketCliError> {
|
||||
backend.close(
|
||||
|
|
@ -637,7 +715,7 @@ fn close(
|
|||
}
|
||||
|
||||
fn relation(
|
||||
backend: &LocalTicketBackend,
|
||||
backend: &dyn TicketBackend,
|
||||
options: RelationOptions,
|
||||
) -> Result<TicketCliOutput, TicketCliError> {
|
||||
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 mut stdout = String::new();
|
||||
if report.is_ok() {
|
||||
|
|
@ -1166,14 +1244,13 @@ fn default_author() -> String {
|
|||
}
|
||||
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
use ticket::TicketEventKind;
|
||||
use ticket::config::TicketRole;
|
||||
|
||||
fn args(items: &[&str]) -> Vec<String> {
|
||||
|
|
@ -1181,6 +1258,13 @@ mod tests {
|
|||
}
|
||||
|
||||
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();
|
||||
run_in_workspace(cli, temp.path()).unwrap()
|
||||
}
|
||||
|
|
@ -1201,9 +1285,8 @@ mod tests {
|
|||
let initialized = run(&temp, &["init"]);
|
||||
assert_eq!(initialized.status, TicketCliStatus::Success);
|
||||
assert!(initialized.stdout.contains("created\t.yoi/workspace.toml"));
|
||||
assert!(initialized.stdout.contains("ensured\t.yoi/tickets"));
|
||||
assert!(temp.path().join(".yoi/tickets").exists());
|
||||
assert!(temp.path().join(".yoi/tickets/.gitkeep").exists());
|
||||
assert!(initialized.stdout.contains("backend\tworkspace-sqlite"));
|
||||
assert!(!temp.path().join(".yoi/tickets").exists());
|
||||
|
||||
let config = fs::read_to_string(temp.path().join(".yoi/workspace.toml")).unwrap();
|
||||
assert!(config.contains("workspace_id = \""));
|
||||
|
|
@ -1299,21 +1382,8 @@ mod tests {
|
|||
assert_eq!(created.status, TicketCliStatus::Success);
|
||||
assert!(created.stdout.contains("created\t"));
|
||||
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());
|
||||
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"]);
|
||||
assert!(listed.stdout.contains("state\tid\ttitle"));
|
||||
|
|
@ -1395,29 +1465,21 @@ mod tests {
|
|||
assert_eq!(doctor.status, TicketCliStatus::Success);
|
||||
assert_eq!(doctor.stdout, "doctor: ok\n");
|
||||
|
||||
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id.clone())).unwrap();
|
||||
assert!(ticket.resolution.is_some());
|
||||
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Closed);
|
||||
assert!(
|
||||
ticket
|
||||
.events
|
||||
.iter()
|
||||
.any(|event| event.kind == TicketEventKind::ImplementationReport)
|
||||
);
|
||||
assert!(
|
||||
ticket
|
||||
.events
|
||||
.iter()
|
||||
.any(|event| event.kind == TicketEventKind::Review)
|
||||
);
|
||||
let final_show = run(&temp, &["show", &ticket_id]);
|
||||
assert!(final_show.stdout.contains("State: closed"));
|
||||
assert!(final_show.stdout.contains("Done via yoi ticket."));
|
||||
assert!(final_show.stdout.contains("implementation_report"));
|
||||
assert!(final_show.stdout.contains("review"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_cli_show_omits_obsolete_overlay_fields_from_legacy_frontmatter() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let created = run(&temp, &["create", "--title", "Legacy Overlay"]);
|
||||
let ticket_id = created_id(&created);
|
||||
let initialized = run(&temp, &["init"]);
|
||||
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
|
||||
.path()
|
||||
.join(".yoi/tickets")
|
||||
|
|
@ -1434,6 +1496,9 @@ mod tests {
|
|||
)
|
||||
.unwrap();
|
||||
|
||||
let imported = run(&temp, &["import-local"]);
|
||||
assert_eq!(imported.status, TicketCliStatus::Success);
|
||||
|
||||
let shown = run(&temp, &["show", &ticket_id]);
|
||||
assert_eq!(shown.status, TicketCliStatus::Success);
|
||||
assert!(shown.stdout.contains("# Legacy Overlay"));
|
||||
|
|
@ -1496,20 +1561,22 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_cli_uses_configured_backend_root() {
|
||||
fn ticket_cli_import_local_uses_configured_backend_root() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
fs::create_dir_all(temp.path().join(".yoi")).unwrap();
|
||||
fs::write(
|
||||
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();
|
||||
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 ticket_id = created_id(&created);
|
||||
|
||||
assert!(temp.path().join("custom-tickets").join(ticket_id).exists());
|
||||
assert!(!temp.path().join("custom-tickets/open").exists());
|
||||
let imported = run(&temp, &["import-local"]);
|
||||
assert_eq!(imported.status, TicketCliStatus::Success);
|
||||
let shown = run(&temp, &["show", &created.id]);
|
||||
assert_eq!(shown.status, TicketCliStatus::Success);
|
||||
assert!(shown.stdout.contains("# Configured Root"));
|
||||
assert!(!temp.path().join("work-items").exists());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -262,14 +262,13 @@ in
|
|||
Entrypoint = [ "/bin/yoi-workspace-server" ];
|
||||
Cmd = [
|
||||
"serve"
|
||||
"--workspace"
|
||||
"/workspace"
|
||||
"--db"
|
||||
"/server-data/workspace.db"
|
||||
"--listen"
|
||||
"0.0.0.0:8787"
|
||||
];
|
||||
Env = [ "PATH=/bin" ] ++ commonEnv;
|
||||
Env = [
|
||||
"PATH=/bin"
|
||||
"YOI_DATA_DIR=/server-data"
|
||||
] ++ commonEnv;
|
||||
ExposedPorts = {
|
||||
"8787/tcp" = { };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,14 +25,15 @@ frontend_url = "http://127.0.0.1:5173"
|
|||
# static_assets_dir = "web/workspace/dist"
|
||||
|
||||
[data]
|
||||
# Backend data root override. Leave commented to use the user-data fallback:
|
||||
# <data_dir>/workspace-server/<workspace_id>/
|
||||
# Workspace-scoped runtime/data root override. Leave commented to use the user-data fallback:
|
||||
# <data_dir>/server/workspaces/<workspace_id>/
|
||||
# Relative paths are resolved from the workspace root.
|
||||
# root = ".yoi/workspace-backend.data"
|
||||
|
||||
# Explicit control-plane SQLite DB path override.
|
||||
# If omitted, this falls back to `<data.root>/workspace.db`.
|
||||
# workspace_database_path = ".yoi/workspace-backend.data/workspace.db"
|
||||
# Explicit control-plane SQLite DB path override. Normal `serve` uses the Yoi
|
||||
# server DB at `<data_dir>/server/server.db`; keep this commented unless a
|
||||
# local test needs a custom DB path.
|
||||
# workspace_database_path = ".yoi/workspace-backend.data/server.db"
|
||||
|
||||
# Explicit embedded Runtime fs-store root override.
|
||||
# If omitted, this falls back to `<data.root>/embedded-runtime`.
|
||||
|
|
@ -60,11 +61,5 @@ cookie_name = "yoi_workspace_session"
|
|||
# display_name = "Main repository"
|
||||
# default_selector = "HEAD"
|
||||
|
||||
# Remote Runtime sources. Token values must not be written here.
|
||||
# Use token_ref only after secret-ref resolution is implemented for this config.
|
||||
#
|
||||
# [[runtimes.remote]]
|
||||
# id = "example"
|
||||
# endpoint = "http://127.0.0.1:38800"
|
||||
# display_name = "Example Runtime"
|
||||
# token_ref = "local:example-runtime-token"
|
||||
# Runtime registrations live in `$XDG_CONFIG_HOME/yoi/runtimes.toml`
|
||||
# or `YOI_CONFIG_DIR/runtimes.toml`, not in workspace backend config.
|
||||
|
|
|
|||
23
scripts/dev-workspace.sh
Executable file → Normal file
23
scripts/dev-workspace.sh
Executable file → Normal file
|
|
@ -21,7 +21,22 @@
|
|||
set -euo pipefail
|
||||
|
||||
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"
|
||||
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:
|
||||
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)
|
||||
|
||||
Actions:
|
||||
|
|
@ -64,7 +79,7 @@ Environment overrides:
|
|||
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_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
|
||||
}
|
||||
|
||||
|
|
@ -330,7 +345,7 @@ start_backend() {
|
|||
return 1
|
||||
fi
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
```bash
|
||||
cargo run -p yoi-workspace-server -- serve \
|
||||
--workspace . \
|
||||
--db .yoi/workspace.db \
|
||||
--listen 127.0.0.1:8787
|
||||
cargo run -p yoi-workspace-server -- serve --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:
|
||||
|
||||
|
|
@ -41,25 +40,7 @@ Build the SPA:
|
|||
deno task build
|
||||
```
|
||||
|
||||
Then serve the static build and API from the Rust backend:
|
||||
|
||||
```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
|
||||
```
|
||||
Static asset packaging is a deployment concern. Local development normally uses the Vite dev server proxy plus the Rust backend command above.
|
||||
|
||||
## Checks
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"tasks": {
|
||||
"install": "deno install",
|
||||
"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",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -122,28 +122,6 @@
|
|||
}
|
||||
}
|
||||
@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 {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
|
|
@ -157,242 +135,8 @@
|
|||
display: grid;
|
||||
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 {
|
||||
.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-state,
|
||||
.muted {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ async function jsonOrThrow<T>(response: Response): Promise<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> {
|
||||
return await fetcher("/api/auth/whoami", { credentials: "same-origin" }).then(jsonOrThrow<WhoamiResponse>);
|
||||
}
|
||||
|
|
@ -32,7 +36,7 @@ export async function registerPasskey(
|
|||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ handle, display_name: displayName }),
|
||||
body: JSON.stringify({ handle, display_name: displayName, browser_origin: browserOrigin() }),
|
||||
}).then(jsonOrThrow<PasskeyRegistrationOptionsResponse>);
|
||||
|
||||
const credential = await navigator.credentials.create({
|
||||
|
|
@ -61,7 +65,7 @@ export async function loginWithPasskey(
|
|||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ handle }),
|
||||
body: JSON.stringify({ handle, browser_origin: browserOrigin() }),
|
||||
}).then(jsonOrThrow<PasskeyLoginOptionsResponse>);
|
||||
|
||||
const credential = await navigator.credentials.get({
|
||||
|
|
|
|||
|
|
@ -5,9 +5,8 @@ export type AuthenticatedUser = {
|
|||
display_name: string;
|
||||
};
|
||||
|
||||
export type RequestActor = {
|
||||
user: AuthenticatedUser;
|
||||
auth_kind: string;
|
||||
export type RequestActor = AuthenticatedUser & {
|
||||
auth_method: "browser_session" | "api_token" | string;
|
||||
};
|
||||
|
||||
export type WhoamiResponse = {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
assert(
|
||||
ticketsLoad.includes('workspaceApiPath(params.workspaceId, "/tickets")') &&
|
||||
ticketsPage.includes("This surface is read-only") &&
|
||||
ticketsLoad.includes('`${workspaceApiPath(params.workspaceId, "/tickets")}?limit=1000`') &&
|
||||
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}`)"),
|
||||
"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(
|
||||
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),
|
||||
);
|
||||
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),
|
||||
);
|
||||
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(
|
||||
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",
|
||||
);
|
||||
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",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
32
web/workspace/src/lib/workspace/sidebar/GlobalSidebar.svelte
Normal file
32
web/workspace/src/lib/workspace/sidebar/GlobalSidebar.svelte
Normal 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>
|
||||
46
web/workspace/src/lib/workspace/sidebar/SidebarFrame.svelte
Normal file
46
web/workspace/src/lib/workspace/sidebar/SidebarFrame.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { workspaceRoute } from '$lib/workspace/api/http';
|
||||
import './sidebar.css';
|
||||
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
|
||||
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
|
||||
import TicketsNavSection from './TicketsNavSection.svelte';
|
||||
|
|
@ -12,8 +13,6 @@
|
|||
repositories?: RepositoryListResponse | null;
|
||||
repositoriesError?: string | null;
|
||||
currentPath?: string;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapsed?: () => void;
|
||||
};
|
||||
|
||||
let {
|
||||
|
|
@ -21,22 +20,15 @@
|
|||
workspaceError = null,
|
||||
repositories = null,
|
||||
repositoriesError = null,
|
||||
currentPath = '/',
|
||||
collapsed = false,
|
||||
onToggleCollapsed
|
||||
currentPath = '/'
|
||||
}: Props = $props();
|
||||
|
||||
let workspaceId = $derived(workspace?.workspace_id ?? '');
|
||||
let homeHref = $derived(workspaceId ? workspaceRoute(workspaceId) : '/');
|
||||
let accountHref = '/account';
|
||||
let settingsHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/settings') : '/settings');
|
||||
</script>
|
||||
|
||||
<aside
|
||||
class:collapsed
|
||||
class="workspace-sidebar"
|
||||
aria-label="Workspace navigation"
|
||||
>
|
||||
<div class="workspace-sidebar">
|
||||
<header class="sidebar-header">
|
||||
<div class="sidebar-title-row">
|
||||
<div class="workspace-label">
|
||||
|
|
@ -51,27 +43,6 @@
|
|||
{/if}
|
||||
{/if}
|
||||
</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 class="sidebar-actions-row">
|
||||
|
|
@ -88,17 +59,6 @@
|
|||
/>
|
||||
</svg>
|
||||
</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
|
||||
class="sidebar-icon-button"
|
||||
href={settingsHref}
|
||||
|
|
@ -115,12 +75,10 @@
|
|||
</div>
|
||||
</header>
|
||||
|
||||
{#if !collapsed}
|
||||
<nav class="sidebar-sections" aria-label="Workspace sections">
|
||||
<RepositoriesNavSection {repositories} {repositoriesError} {currentPath} {workspaceId} />
|
||||
<ObjectivesNavSection {currentPath} {workspaceId} />
|
||||
<TicketsNavSection {currentPath} {workspaceId} />
|
||||
<WorkersNavSection {currentPath} {workspaceId} />
|
||||
</nav>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
|
|
|
|||
15
web/workspace/src/lib/workspace/sidebar/context.ts
Normal file
15
web/workspace/src/lib/workspace/sidebar/context.ts
Normal 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);
|
||||
}
|
||||
249
web/workspace/src/lib/workspace/sidebar/sidebar.css
Normal file
249
web/workspace/src/lib/workspace/sidebar/sidebar.css
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -115,6 +115,14 @@ export type WorkingDirectoryRepositoryOption = {
|
|||
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 = {
|
||||
working_directory_id: string;
|
||||
repository_id: string;
|
||||
|
|
@ -125,6 +133,7 @@ export type WorkingDirectorySummary = {
|
|||
status: string;
|
||||
cleanliness?: string | null;
|
||||
primary_worker_id?: number | null;
|
||||
occupied_by?: WorkingDirectoryOccupancy | null;
|
||||
cleanup_target: {
|
||||
kind: string;
|
||||
working_directory_id: string;
|
||||
|
|
@ -317,6 +326,7 @@ export type TicketSummary = {
|
|||
updated_at?: string | null;
|
||||
queued_by?: string | null;
|
||||
queued_at?: string | null;
|
||||
workspace_action_priority?: 'ready_for_queue' | 'active_work' | 'background' | null;
|
||||
record_source?: string;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -86,6 +86,40 @@ Deno.test("defaultWorkerLaunchForm chooses active runtime, coder profile, reposi
|
|||
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", () => {
|
||||
const request = buildBrowserCreateWorkerRequest({
|
||||
runtime_id: "embedded",
|
||||
|
|
|
|||
|
|
@ -38,7 +38,8 @@ export function defaultWorkerLaunchForm(
|
|||
const availableWorkingDirectories = options?.working_directories.filter((directory) =>
|
||||
directory.status === "active" &&
|
||||
directory.cleanliness === "clean" &&
|
||||
directory.primary_worker_id == null
|
||||
directory.primary_worker_id == null &&
|
||||
directory.occupied_by == null
|
||||
) ?? [];
|
||||
const selectedRuntime = current.runtime_id
|
||||
? options?.runtimes.find((runtime) => runtime.runtime_id === current.runtime_id)
|
||||
|
|
|
|||
|
|
@ -1,27 +1,144 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { setContext } from '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 type { LayoutProps } from './$types';
|
||||
|
||||
let { data, children }: LayoutProps = $props();
|
||||
let sidebarCollapsed = $state(false);
|
||||
let { children }: LayoutProps = $props();
|
||||
let sidebar = $state<SidebarSnippet | null>(null);
|
||||
|
||||
setContext(SIDEBAR_CONTEXT, {
|
||||
setSidebar(snippet: SidebarSnippet) {
|
||||
sidebar = snippet;
|
||||
},
|
||||
clearSidebar(snippet: SidebarSnippet) {
|
||||
if (sidebar === snippet) sidebar = null;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<WorkspaceAlerts />
|
||||
|
||||
<div class:sidebar-collapsed={sidebarCollapsed} class="workspace-layout">
|
||||
<WorkspaceSidebar
|
||||
workspace={data.workspace}
|
||||
workspaceError={data.workspaceError}
|
||||
repositories={data.repositories}
|
||||
repositoriesError={data.repositoriesError}
|
||||
currentPath={page.url.pathname}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggleCollapsed={() => (sidebarCollapsed = !sidebarCollapsed)}
|
||||
/>
|
||||
<div class="workspace-layout">
|
||||
<SidebarFrame>
|
||||
{#if sidebar}
|
||||
{@render sidebar()}
|
||||
{:else}
|
||||
<GlobalSidebar currentPath={page.url.pathname} />
|
||||
{/if}
|
||||
</SidebarFrame>
|
||||
<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">
|
||||
{@render children()}
|
||||
</main>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -1,40 +1,25 @@
|
|||
import { redirect } from "@sveltejs/kit";
|
||||
import { loadJson, workspaceApiPath, workspaceRoute } from "$lib/workspace/api/http";
|
||||
import type { RepositoryListResponse, WorkspaceResponse } from "$lib/workspace/sidebar/types";
|
||||
import { loadJson, workspaceRoute } from "$lib/workspace/api/http";
|
||||
import type { WorkspaceResponse } from "$lib/workspace/sidebar/types";
|
||||
import type { LayoutLoad } from "./$types";
|
||||
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
|
||||
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"]);
|
||||
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);
|
||||
throw redirect(307, `${scopedPath}${url.search}`);
|
||||
}
|
||||
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,
|
||||
};
|
||||
return {};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -22,9 +22,9 @@
|
|||
try {
|
||||
const whoami = await loadWhoami();
|
||||
actor = whoami.actor;
|
||||
if (actor?.user.handle) {
|
||||
handle = actor.user.handle;
|
||||
displayName = actor.user.display_name;
|
||||
if (actor?.handle) {
|
||||
handle = actor.handle;
|
||||
displayName = actor.display_name;
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
|
|
@ -104,23 +104,23 @@
|
|||
<dl class="account-details">
|
||||
<div>
|
||||
<dt>Handle</dt>
|
||||
<dd>{actor.user.handle}</dd>
|
||||
<dd>{actor.handle}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Display name</dt>
|
||||
<dd>{actor.user.display_name}</dd>
|
||||
<dd>{actor.display_name}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>User ID</dt>
|
||||
<dd><code>{actor.user.user_id}</code></dd>
|
||||
<dd><code>{actor.user_id}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Account ID</dt>
|
||||
<dd><code>{actor.user.account_id}</code></dd>
|
||||
<dd><code>{actor.account_id}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Auth kind</dt>
|
||||
<dd>{actor.auth_kind}</dd>
|
||||
<dd>{actor.auth_method}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="settings-action-row">
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
try {
|
||||
const whoami = await loadWhoami();
|
||||
actor = whoami.actor;
|
||||
if (actor?.user.handle) handle = actor.user.handle;
|
||||
if (actor?.handle) handle = actor.handle;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
|
|
@ -85,7 +85,7 @@
|
|||
{#if loading}
|
||||
<p class="muted">Loading session…</p>
|
||||
{: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}
|
||||
<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(); }}>
|
||||
|
|
|
|||
22
web/workspace/src/routes/w/[workspaceId]/+layout.svelte
Normal file
22
web/workspace/src/routes/w/[workspaceId]/+layout.svelte
Normal 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()}
|
||||
19
web/workspace/src/routes/w/[workspaceId]/+layout.ts
Normal file
19
web/workspace/src/routes/w/[workspaceId]/+layout.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
|
@ -129,6 +129,7 @@
|
|||
<th>Commit</th>
|
||||
<th>Status</th>
|
||||
<th>Cleanliness</th>
|
||||
<th>Occupied by</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -142,6 +143,14 @@
|
|||
<td><code>{commitLabel(workdir)}</code></td>
|
||||
<td>{workdir.status}</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>
|
||||
{#if cleanup}
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,8 +1,150 @@
|
|||
<script lang="ts">
|
||||
import { formatDate, workspaceRoute } from '$lib/workspace/api/http';
|
||||
import type { TicketSummary } from '$lib/workspace/sidebar/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 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>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -10,47 +152,110 @@
|
|||
<meta name="description" content="Workspace Tickets" />
|
||||
</svelte:head>
|
||||
|
||||
<section class="card">
|
||||
<section class="card ticket-database-card">
|
||||
<div class="detail-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Workspace records</p>
|
||||
<h2>Tickets</h2>
|
||||
</div>
|
||||
{#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}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
{#if data.tickets.data}
|
||||
{#if data.tickets.data.items.length === 0}
|
||||
<p>No Ticket records are present.</p>
|
||||
{:else}
|
||||
<div class="ticket-list" aria-label="Workspace Tickets">
|
||||
{#each data.tickets.data.items as ticket (ticket.id)}
|
||||
<a class="ticket-row" href={workspaceRoute(data.workspaceId, `/tickets/${ticket.id}`)}>
|
||||
<div class="ticket-main">
|
||||
<div class="ticket-title-row">
|
||||
<strong class="ticket-title">{ticket.title}</strong>
|
||||
<span class="state-pill">{ticket.state}</span>
|
||||
</div>
|
||||
<p class="ticket-summary">
|
||||
{ticket.priority ? `${ticket.priority} priority` : 'priority unspecified'} · {ticket.record_source ?? 'ticket backend'}
|
||||
</p>
|
||||
</div>
|
||||
<div class="ticket-meta" aria-label="Ticket metadata">
|
||||
<span>Updated {ticket.updated_at ? formatDate(ticket.updated_at) : 'unknown'}</span>
|
||||
{#if ticket.queued_at}
|
||||
<span>Queued {formatDate(ticket.queued_at)}</span>
|
||||
{/if}
|
||||
<code>{ticket.id}</code>
|
||||
</div>
|
||||
</a>
|
||||
<div class="ticket-database-toolbar" aria-label="Ticket table controls">
|
||||
<label class="ticket-filter ticket-search">
|
||||
<span>Search</span>
|
||||
<input bind:value={query} type="search" placeholder="Title, id, state, source…" />
|
||||
</label>
|
||||
<label class="ticket-filter">
|
||||
<span>Visibility</span>
|
||||
<select bind:value={visibilityFilter}>
|
||||
<option value="open">Open</option>
|
||||
<option value="closed">Closed</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="ticket-filter">
|
||||
<span>State</span>
|
||||
<select bind:value={stateFilter}>
|
||||
<option value="all">All states</option>
|
||||
{#each states as state}
|
||||
<option value={state}>{state}</option>
|
||||
{/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 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 data.tickets.data.invalid_records.length > 0}
|
||||
|
|
@ -62,3 +267,126 @@
|
|||
<p>Waiting for <code>/api/w/{data.workspaceId}/tickets</code>…</p>
|
||||
{/if}
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type { PageLoad } from "./$types";
|
|||
export const load = (async ({ fetch, params }) => {
|
||||
const tickets = await loadJson<TicketListResponse>(
|
||||
fetch,
|
||||
workspaceApiPath(params.workspaceId, "/tickets"),
|
||||
`${workspaceApiPath(params.workspaceId, "/tickets")}?limit=1000`,
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -50,7 +50,10 @@
|
|||
selectedRuntimeAllowsNoWorkdir
|
||||
? []
|
||||
: (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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user