diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index e8a5a282..1e538396 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -6,12 +6,17 @@ pub mod hosts; pub mod records; +pub mod repositories; pub mod server; pub mod store; pub use records::{ LocalProjectRecordReader, ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary, }; +pub use repositories::{ + GitCommitSummary, GitRemoteSummary, GitRepositorySummary, LocalRepositoryReader, + RepositoryLogRead, RepositorySummary, +}; pub use server::{AuthConfig, ServerConfig, WorkspaceApi, build_router, serve}; pub use store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord}; @@ -33,6 +38,8 @@ pub enum Error { MissingFrontmatter(String), #[error("unknown local host `{0}`")] UnknownHost(String), + #[error("unknown local repository `{0}`")] + UnknownRepository(String), #[error("store error: {0}")] Store(String), } diff --git a/crates/workspace-server/src/records.rs b/crates/workspace-server/src/records.rs index fe3e0d12..2f32122b 100644 --- a/crates/workspace-server/src/records.rs +++ b/crates/workspace-server/src/records.rs @@ -8,6 +8,7 @@ use ticket::{LocalTicketBackend, TicketFilter, TicketIdOrSlug}; use crate::{Error, Result}; const DETAIL_BODY_LIMIT: usize = 64 * 1024; +const SUMMARY_BODY_LIMIT: usize = 240; #[derive(Debug, Clone)] pub struct LocalProjectRecordReader { @@ -201,6 +202,7 @@ pub struct ObjectiveSummary { pub title: String, pub state: String, pub updated_at: Option, + pub summary: String, pub linked_tickets: Vec, pub record_source: String, } @@ -233,13 +235,14 @@ struct ObjectiveFrontmatter { fn read_objective_summary(path: &Path, id: &str) -> Result { validate_project_id(id)?; let raw = fs::read_to_string(path.join("item.md"))?; - let (frontmatter, _) = split_frontmatter(&raw, id)?; + let (frontmatter, body) = split_frontmatter(&raw, id)?; let meta: ObjectiveFrontmatter = serde_yaml::from_str(frontmatter)?; Ok(ObjectiveSummary { id: id.to_string(), title: meta.title, state: meta.state, updated_at: meta.updated_at, + summary: summarize_body(body), linked_tickets: meta.linked_tickets, record_source: "local_yoi_objective".to_string(), }) @@ -259,6 +262,20 @@ fn validate_project_id(id: &str) -> Result<()> { validate_record_id(id).map_err(|_| Error::InvalidRecordId(id.to_string())) } +fn summarize_body(body: &str) -> String { + let summary = body + .lines() + .map(str::trim) + .find(|line| !line.is_empty() && !line.starts_with('#')) + .unwrap_or_default(); + let (summary, truncated) = truncate_body(summary, SUMMARY_BODY_LIMIT); + if truncated { + format!("{summary}…") + } else { + summary + } +} + fn truncate_body(body: &str, limit: usize) -> (String, bool) { if body.len() <= limit { return (body.to_string(), false); diff --git a/crates/workspace-server/src/repositories.rs b/crates/workspace-server/src/repositories.rs new file mode 100644 index 00000000..a31a45a1 --- /dev/null +++ b/crates/workspace-server/src/repositories.rs @@ -0,0 +1,336 @@ +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use serde::{Deserialize, Serialize}; + +use crate::hosts::RuntimeDiagnostic; + +const LOCAL_REPOSITORY_ID: &str = "local"; +const MAX_COMMAND_OUTPUT: usize = 4096; +const DEFAULT_LOG_LIMIT: usize = 10; +const MAX_LOG_LIMIT: usize = 50; +const MAX_FIELD_LEN: usize = 240; + +#[derive(Debug, Clone)] +pub struct LocalRepositoryReader { + workspace_root: PathBuf, +} + +impl LocalRepositoryReader { + pub fn new(workspace_root: impl Into) -> Self { + Self { + workspace_root: workspace_root.into(), + } + } + + pub fn list(&self, workspace_display_name: &str) -> Vec { + vec![self.summary(workspace_display_name)] + } + + pub fn summary(&self, workspace_display_name: &str) -> RepositorySummary { + let git = inspect_git(&self.workspace_root); + RepositorySummary { + id: LOCAL_REPOSITORY_ID.to_string(), + display_name: workspace_display_name.to_string(), + kind: "local".to_string(), + workspace_root: self.workspace_root.clone(), + record_authority: "local_workspace_root".to_string(), + git, + } + } + + pub fn recent_log(&self, requested_limit: Option) -> RepositoryLogRead { + let limit = requested_limit + .unwrap_or(DEFAULT_LOG_LIMIT) + .clamp(1, MAX_LOG_LIMIT); + git_log(&self.workspace_root, limit) + } + + pub fn is_local_repository_id(id: &str) -> bool { + id == LOCAL_REPOSITORY_ID + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RepositorySummary { + pub id: String, + pub display_name: String, + pub kind: String, + pub workspace_root: PathBuf, + pub record_authority: String, + pub git: GitRepositorySummary, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GitRepositorySummary { + pub status: String, + pub root: Option, + pub branch: Option, + pub head: Option, + pub dirty: Option, + pub dirty_scope: String, + pub remote: Option, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GitRemoteSummary { + pub name: String, + pub url: String, + pub redacted: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GitCommitSummary { + pub hash: String, + pub subject: String, + pub author_name: String, + pub author_email: String, + pub timestamp: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RepositoryLogRead { + pub limit: usize, + pub items: Vec, + pub diagnostics: Vec, +} + +fn inspect_git(workspace_root: &Path) -> GitRepositorySummary { + let mut diagnostics = Vec::new(); + let root = match git_stdout(workspace_root, &["rev-parse", "--show-toplevel"]) { + Ok(root) => PathBuf::from(root.trim()), + Err(message) => { + diagnostics.push(diagnostic( + "git_unavailable", + "info", + format!("Workspace root is not available as a Git repository: {message}"), + )); + return GitRepositorySummary { + status: "unavailable".to_string(), + root: None, + branch: None, + head: None, + dirty: None, + dirty_scope: "tracked_changes_only".to_string(), + remote: None, + diagnostics, + }; + } + }; + + let branch = git_stdout(workspace_root, &["branch", "--show-current"]) + .ok() + .map(|value| truncate_field(value.trim(), MAX_FIELD_LEN)) + .filter(|value| !value.is_empty()) + .or_else(|| Some("detached".to_string())); + let head = match git_stdout(workspace_root, &["rev-parse", "--verify", "HEAD"]) { + Ok(value) => Some(truncate_field(value.trim(), 40)), + Err(message) => { + diagnostics.push(diagnostic( + "git_head_unavailable", + "warn", + format!("Git HEAD summary is unavailable: {message}"), + )); + None + } + }; + let dirty = match git_stdout( + workspace_root, + &["status", "--porcelain=v1", "--untracked-files=no"], + ) { + Ok(value) => Some(!value.trim().is_empty()), + Err(message) => { + diagnostics.push(diagnostic( + "git_status_unavailable", + "warn", + format!("Git dirty status is unavailable: {message}"), + )); + None + } + }; + let remote = match git_stdout(workspace_root, &["remote", "get-url", "origin"]) { + Ok(value) => { + let (url, redacted) = sanitize_remote_url(value.trim()); + Some(GitRemoteSummary { + name: "origin".to_string(), + url, + redacted, + }) + } + Err(_) => { + diagnostics.push(diagnostic( + "git_origin_remote_missing", + "info", + "No origin remote is configured or visible through the bounded Git summary." + .to_string(), + )); + None + } + }; + + GitRepositorySummary { + status: "available".to_string(), + root: Some(root), + branch, + head, + dirty, + dirty_scope: "tracked_changes_only".to_string(), + remote, + diagnostics, + } +} + +fn git_log(workspace_root: &Path, limit: usize) -> RepositoryLogRead { + let mut diagnostics = Vec::new(); + if let Err(message) = git_stdout(workspace_root, &["rev-parse", "--show-toplevel"]) { + diagnostics.push(diagnostic( + "git_unavailable", + "info", + format!("Recent Git log is unavailable for this local repository: {message}"), + )); + return RepositoryLogRead { + limit, + items: Vec::new(), + diagnostics, + }; + } + + match git_stdout( + workspace_root, + &[ + "log", + "--no-show-signature", + "--date=iso-strict", + "--format=%H%x1f%an%x1f%ae%x1f%aI%x1f%s%x1e", + "-n", + &limit.to_string(), + ], + ) { + Ok(output) => RepositoryLogRead { + limit, + items: parse_log(output.as_str()), + diagnostics, + }, + Err(message) => { + diagnostics.push(diagnostic( + "git_log_unavailable", + "warn", + format!("Recent Git log is unavailable: {message}"), + )); + RepositoryLogRead { + limit, + items: Vec::new(), + diagnostics, + } + } + } +} + +fn parse_log(output: &str) -> Vec { + output + .split('\u{1e}') + .filter_map(|record| { + let record = record.trim_matches('\n'); + if record.is_empty() { + return None; + } + let mut fields = record.split('\u{1f}'); + Some(GitCommitSummary { + hash: truncate_field(fields.next()?, 40), + author_name: truncate_field(fields.next().unwrap_or_default(), MAX_FIELD_LEN), + author_email: truncate_field(fields.next().unwrap_or_default(), MAX_FIELD_LEN), + timestamp: truncate_field(fields.next().unwrap_or_default(), MAX_FIELD_LEN), + subject: truncate_field(fields.next().unwrap_or_default(), MAX_FIELD_LEN), + }) + }) + .collect() +} + +fn git_stdout(workspace_root: &Path, args: &[&str]) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(workspace_root) + .args(args) + .output() + .map_err(|error| truncate_field(&error.to_string(), MAX_FIELD_LEN))?; + command_stdout(output) +} + +fn command_stdout(output: Output) -> Result { + if output.status.success() { + return Ok(truncate_output( + String::from_utf8_lossy(&output.stdout).as_ref(), + )); + } + let stderr = truncate_output(String::from_utf8_lossy(&output.stderr).as_ref()); + if stderr.trim().is_empty() { + Err(format!("git exited with status {}", output.status)) + } else { + Err(stderr.trim().to_string()) + } +} + +fn sanitize_remote_url(raw: &str) -> (String, bool) { + let bounded = truncate_field(raw, MAX_FIELD_LEN); + let Some(separator) = bounded.find("://") else { + return (bounded, false); + }; + let scheme_end = separator + 3; + let after_scheme = &bounded[scheme_end..]; + let Some(at_index) = after_scheme.find('@') else { + return (bounded, false); + }; + let host_and_path = &after_scheme[(at_index + 1)..]; + (format!("{}{}", &bounded[..scheme_end], host_and_path), true) +} + +fn truncate_output(value: &str) -> String { + truncate_field(value, MAX_COMMAND_OUTPUT) +} + +fn truncate_field(value: &str, limit: usize) -> String { + if value.len() <= limit { + return value.to_string(); + } + let mut end = limit; + while !value.is_char_boundary(end) { + end -= 1; + } + value[..end].to_string() +} + +fn diagnostic(code: &str, severity: &str, message: String) -> RuntimeDiagnostic { + RuntimeDiagnostic { + code: code.to_string(), + severity: severity.to_string(), + message, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitizes_userinfo_from_url_remotes() { + assert_eq!( + sanitize_remote_url("https://token@example.com/org/repo.git"), + ("https://example.com/org/repo.git".to_string(), true) + ); + assert_eq!( + sanitize_remote_url("git@example.com:org/repo.git"), + ("git@example.com:org/repo.git".to_string(), false) + ); + } + + #[test] + fn parses_bounded_git_log_records() { + let parsed = parse_log( + "0123456789abcdef\u{1f}Alice\u{1f}a@example.test\u{1f}2026-01-01T00:00:00+00:00\u{1f}Subject\u{1e}\n", + ); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].hash, "0123456789abcdef"); + assert_eq!(parsed[0].subject, "Subject"); + } +} diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 6c2ea706..0f1a2649 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -1,7 +1,7 @@ use std::path::{Component, Path, PathBuf}; use std::sync::Arc; -use axum::extract::{Path as AxumPath, State}; +use axum::extract::{Path as AxumPath, Query, State}; use axum::http::header::CONTENT_TYPE; use axum::http::{StatusCode, Uri}; use axum::response::{IntoResponse, Response}; @@ -11,7 +11,10 @@ use serde::{Deserialize, Serialize}; use tokio::net::TcpListener; use crate::hosts::{HostSummary, LocalRuntimeBridge, RuntimeDiagnostic, WorkerSummary}; -use crate::records::{LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail}; +use crate::records::{ + LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary, +}; +use crate::repositories::{LocalRepositoryReader, RepositoryLogRead, RepositorySummary}; use crate::store::{ControlPlaneStore, RunSummary, WorkspaceRecord}; use crate::{Error, Result}; @@ -95,6 +98,19 @@ impl WorkspaceApi { self.config.local_runtime_data_dir.clone(), ) } + + fn local_repository_reader(&self) -> LocalRepositoryReader { + LocalRepositoryReader::new(self.config.workspace_root.clone()) + } + + fn workspace_display_name(&self) -> String { + self.config + .workspace_root + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("workspace") + .to_string() + } } pub fn build_router(api: WorkspaceApi) -> Router { @@ -104,6 +120,13 @@ pub fn build_router(api: WorkspaceApi) -> Router { .route("/api/tickets/{id}", get(get_ticket)) .route("/api/objectives", get(list_objectives)) .route("/api/objectives/{id}", get(get_objective)) + .route("/api/repositories", get(list_repositories)) + .route("/api/repositories/{repository_id}", get(repository_detail)) + .route("/api/repositories/{repository_id}/log", get(repository_log)) + .route( + "/api/repositories/{repository_id}/tickets", + get(repository_tickets), + ) .route("/api/runs", get(list_runs)) .route("/api/hosts", get(list_hosts)) .route("/api/workers", get(list_workers)) @@ -164,6 +187,58 @@ pub struct RuntimeListResponse { pub diagnostics: Vec, } +#[derive(Debug, Serialize, Deserialize)] +pub struct RepositoryListResponse { + pub workspace_id: String, + pub items: Vec, + pub source: String, + pub diagnostics: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RepositoryDetailResponse { + pub workspace_id: String, + pub item: RepositorySummary, + pub source: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RepositoryLogResponse { + pub workspace_id: String, + pub repository_id: String, + pub limit: usize, + pub items: Vec, + pub diagnostics: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RepositoryTicketsResponse { + pub workspace_id: String, + pub repository_id: String, + pub limit: usize, + pub columns: Vec, + pub invalid_records: Vec, + pub record_authority: String, + pub source: String, + pub diagnostics: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TicketKanbanColumn { + pub state: String, + pub items: Vec, +} + +#[derive(Debug, Deserialize)] +struct LogQuery { + limit: Option, +} + +#[derive(Debug, Deserialize)] +struct TicketKanbanQuery { + limit: Option, +} + async fn get_workspace(State(api): State) -> ApiResult> { let schema_version = api.store.schema_version().await?; let stored = api.store.get_workspace(api.workspace_id()).await?; @@ -249,6 +324,80 @@ async fn get_objective( Ok(Json(api.records.objective(&id)?)) } +async fn list_repositories( + State(api): State, +) -> ApiResult> { + let reader = api.local_repository_reader(); + let items = reader.list(&api.workspace_display_name()); + Ok(Json(RepositoryListResponse { + workspace_id: api.config.workspace_id, + items, + source: "local_workspace_root".to_string(), + diagnostics: Vec::new(), + })) +} + +async fn repository_detail( + State(api): State, + AxumPath(repository_id): AxumPath, +) -> ApiResult> { + ensure_local_repository(&repository_id)?; + let reader = api.local_repository_reader(); + Ok(Json(RepositoryDetailResponse { + workspace_id: api.config.workspace_id.clone(), + item: reader.summary(&api.workspace_display_name()), + source: "local_workspace_root".to_string(), + })) +} + +async fn repository_log( + State(api): State, + AxumPath(repository_id): AxumPath, + Query(query): Query, +) -> ApiResult> { + ensure_local_repository(&repository_id)?; + let RepositoryLogRead { + limit, + items, + diagnostics, + } = api.local_repository_reader().recent_log(query.limit); + Ok(Json(RepositoryLogResponse { + workspace_id: api.config.workspace_id, + repository_id, + limit, + items, + diagnostics, + })) +} + +async fn repository_tickets( + State(api): State, + AxumPath(repository_id): AxumPath, + Query(query): Query, +) -> ApiResult> { + ensure_local_repository(&repository_id)?; + let limit = query.limit.unwrap_or(api.config.max_records).min(200); + let ProjectRecordList { + items, + invalid_records, + record_authority, + } = api.records.list_tickets(limit)?; + Ok(Json(RepositoryTicketsResponse { + workspace_id: api.config.workspace_id, + repository_id, + limit, + columns: ticket_kanban_columns(items), + invalid_records, + record_authority, + source: "workspace_local_ticket_fallback".to_string(), + diagnostics: vec![RuntimeDiagnostic { + code: "repository_ticket_target_metadata_absent".to_string(), + severity: "info".to_string(), + message: "Ticket target Repository metadata is not available yet; Kanban groups all workspace-local Tickets by state as a read-only fallback.".to_string(), + }], + })) +} + async fn list_runs( State(api): State, ) -> ApiResult>> { @@ -308,6 +457,60 @@ fn workers_response(api: WorkspaceApi) -> ApiResult Result<()> { + if LocalRepositoryReader::is_local_repository_id(repository_id) { + Ok(()) + } else { + Err(Error::UnknownRepository(repository_id.to_string())) + } +} + +fn ticket_kanban_columns(items: Vec) -> Vec { + let mut columns = vec![ + TicketKanbanColumn { + state: "planning".to_string(), + items: Vec::new(), + }, + TicketKanbanColumn { + state: "ready".to_string(), + items: Vec::new(), + }, + TicketKanbanColumn { + state: "queued".to_string(), + items: Vec::new(), + }, + TicketKanbanColumn { + state: "inprogress".to_string(), + items: Vec::new(), + }, + TicketKanbanColumn { + state: "done".to_string(), + items: Vec::new(), + }, + TicketKanbanColumn { + state: "closed".to_string(), + items: Vec::new(), + }, + TicketKanbanColumn { + state: "other".to_string(), + items: Vec::new(), + }, + ]; + for item in items { + let index = match item.state.as_str() { + "planning" => 0, + "ready" => 1, + "queued" => 2, + "inprogress" => 3, + "done" => 4, + "closed" => 5, + _ => 6, + }; + columns[index].items.push(item); + } + columns +} + async fn static_or_spa_fallback(State(api): State, uri: Uri) -> Response { if uri.path().starts_with("/api/") || uri.path() == "/api" { return ( @@ -407,9 +610,10 @@ impl From for ApiError { impl IntoResponse for ApiError { fn into_response(self) -> Response { let status = match &self.0 { - Error::InvalidRecordId(_) | Error::MissingFrontmatter(_) | Error::UnknownHost(_) => { - StatusCode::NOT_FOUND - } + Error::InvalidRecordId(_) + | Error::MissingFrontmatter(_) + | Error::UnknownHost(_) + | Error::UnknownRepository(_) => StatusCode::NOT_FOUND, Error::Ticket(_) => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; @@ -468,6 +672,44 @@ mod tests { let objectives = get_json(app.clone(), "/api/objectives").await; assert_eq!(objectives["items"][0]["id"], "00000000001J3"); + assert_eq!(objectives["items"][0]["summary"], "Objective body."); + + let repositories = get_json(app.clone(), "/api/repositories").await; + assert_eq!(repositories["items"][0]["id"], "local"); + assert_eq!(repositories["items"][0]["kind"], "local"); + + let repository_detail = get_json(app.clone(), "/api/repositories/local").await; + assert_eq!(repository_detail["item"]["id"], "local"); + + let repository_log = get_json(app.clone(), "/api/repositories/local/log?limit=3").await; + assert_eq!(repository_log["repository_id"], "local"); + assert_eq!(repository_log["limit"], 3); + + let repository_tickets = get_json(app.clone(), "/api/repositories/local/tickets").await; + assert_eq!(repository_tickets["repository_id"], "local"); + let ready_column = repository_tickets["columns"] + .as_array() + .unwrap() + .iter() + .find(|column| column["state"] == "ready") + .unwrap(); + assert_eq!(ready_column["items"][0]["id"], "00000000001J2"); + assert_eq!( + repository_tickets["diagnostics"][0]["code"], + "repository_ticket_target_metadata_absent" + ); + + let unknown_repository_response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/repositories/nope") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(unknown_repository_response.status(), StatusCode::NOT_FOUND); let hosts = get_json(app.clone(), "/api/hosts").await; assert_eq!(hosts["items"][0]["host_id"], "local-local-test"); diff --git a/web/workspace/src/lib/workspace-sidebar/ObjectivesNavSection.svelte b/web/workspace/src/lib/workspace-sidebar/ObjectivesNavSection.svelte index e448e99e..4d22690f 100644 --- a/web/workspace/src/lib/workspace-sidebar/ObjectivesNavSection.svelte +++ b/web/workspace/src/lib/workspace-sidebar/ObjectivesNavSection.svelte @@ -56,10 +56,18 @@

No objectives found.

{:else} @@ -117,6 +125,8 @@ background: rgba(15, 23, 42, 0.64); padding: 10px 12px; min-width: 0; + color: inherit; + text-decoration: none; } .item-title, diff --git a/web/workspace/src/lib/workspace-sidebar/RepositoriesNavSection.svelte b/web/workspace/src/lib/workspace-sidebar/RepositoriesNavSection.svelte index 759e8899..85223908 100644 --- a/web/workspace/src/lib/workspace-sidebar/RepositoriesNavSection.svelte +++ b/web/workspace/src/lib/workspace-sidebar/RepositoriesNavSection.svelte @@ -15,15 +15,16 @@

- Repository API is not wired yet; this placeholder keeps the navigation seam - ready without adding repository authority. + Repository authority remains the current workspace root and canonical project records.

@@ -74,6 +75,8 @@ background: rgba(15, 23, 42, 0.64); padding: 10px 12px; min-width: 0; + color: inherit; + text-decoration: none; } .nav-item.active { diff --git a/web/workspace/src/lib/workspace-sidebar/types.ts b/web/workspace/src/lib/workspace-sidebar/types.ts index 8dc8286b..f461829c 100644 --- a/web/workspace/src/lib/workspace-sidebar/types.ts +++ b/web/workspace/src/lib/workspace-sidebar/types.ts @@ -59,11 +59,87 @@ export type ListResponse = { diagnostics: Diagnostic[]; }; +export type RepositorySummary = { + id: string; + display_name: string; + kind: string; + workspace_root: string; + record_authority: string; + git: GitRepositorySummary; +}; + +export type GitRepositorySummary = { + status: string; + root?: string | null; + branch?: string | null; + head?: string | null; + dirty?: boolean | null; + dirty_scope: string; + remote?: GitRemoteSummary | null; + diagnostics: Diagnostic[]; +}; + +export type GitRemoteSummary = { + name: string; + url: string; + redacted: boolean; +}; + +export type GitCommitSummary = { + hash: string; + subject: string; + author_name: string; + author_email: string; + timestamp: string; +}; + +export type RepositoryDetailResponse = { + workspace_id: string; + item: RepositorySummary; + source: string; +}; + +export type RepositoryLogResponse = { + workspace_id: string; + repository_id: string; + limit: number; + items: GitCommitSummary[]; + diagnostics: Diagnostic[]; +}; + +export type TicketSummary = { + id: string; + title: string; + state: string; + priority?: string | null; + updated_at?: string | null; + queued_by?: string | null; + queued_at?: string | null; + record_source?: string; +}; + +export type TicketKanbanColumn = { + state: string; + items: TicketSummary[]; +}; + +export type RepositoryTicketsResponse = { + workspace_id: string; + repository_id: string; + limit: number; + columns: TicketKanbanColumn[]; + invalid_records: InvalidProjectRecord[]; + record_authority: string; + source: string; + diagnostics: Diagnostic[]; +}; + export type ObjectiveSummary = { id: string; title: string; state: string; updated_at?: string | null; + summary: string; linked_tickets?: string[]; record_source?: string; }; diff --git a/web/workspace/src/routes/+page.svelte b/web/workspace/src/routes/+page.svelte index a7d8477a..32f89cbb 100644 --- a/web/workspace/src/routes/+page.svelte +++ b/web/workspace/src/routes/+page.svelte @@ -1,11 +1,32 @@ @@ -83,156 +190,170 @@

Local / single-workspace bootstrap

Yoi Workspace Control Plane

- Static SPA shell for reading canonical .yoi project records - and the local Host / Worker execution view through bounded backend APIs. - Ticket and Objective lifecycle authority stays in the existing local record - workflow. + Static SPA shell for reading canonical .yoi project records, + bounded local Repository summaries, and the local Host / Worker execution + view. Ticket and Objective lifecycle authority stays in the existing local + record workflow. +

+ -
-

Workspace

- {#if workspace} -
-
-
ID
-
{workspace.workspace_id}
-
-
-
Name
-
{workspace.display_name}
-
-
-
Record authority
-
{workspace.record_authority}
-
-
-
Host / Worker bridge
-
{workspace.extension_points.host_worker_bridge.status}
-
-
- {:else if workspaceError} -

{workspaceError}

- {:else} -

Waiting for /api/workspace

- {/if} -
- -
-
-

Read API surface

-
    - {#each endpoints as endpoint} -
  • {endpoint.path} — {endpoint.label}
  • - {/each} -
-
- -
-

Reserved seams

-

- Event streams remain represented as extension-point state in the backend - response. Hosts and Workers are read-only local observations; no - scheduler, lifecycle control, or hosted multi-tenant behavior is - implemented in this slice. -

-
-
- -
-
-

Hosts

- {#if hosts} - {#if hosts.items.length === 0} -

No local Hosts are visible.

+ {#if route.page === 'repository'} +
+
+

Repository summary

+ {#if repository} +
+
+
ID
+
{repository.id}
+
+
+
Kind
+
{repository.kind}
+
+
+
Workspace root
+
{repository.workspace_root}
+
+
+
Record authority
+
{repository.record_authority}
+
+
+
Git
+
{repository.git.status}
+
+
+ {:else if repositoryError} +

{repositoryError}

{:else} -
- {#each hosts.items as host} -
-
- {host.label} - {host.status} -
-
-
-
ID
-
{host.host_id}
-
-
-
Kind
-
{host.kind}
-
-
-
Local inspection
-
{host.capabilities.local_pod_inspection}
-
-
-
Platform
-
{host.capabilities.os} / {host.capabilities.arch}
-
-
-
- {/each} -
+

Waiting for /api/repositories/local

{/if} - {:else if hostsError} -

{hostsError}

- {:else} -

Waiting for /api/hosts

- {/if} -
+
-
-

Workers

- {#if workers} - {#if workers.items.length === 0} -

No local Workers are visible.

+
+

Git summary

+ {#if repository} + {#if repository.git.status === 'available'} +
+
+
Root
+
{repository.git.root ?? 'unknown'}
+
+
+
Branch
+
{repository.git.branch ?? 'unknown'}
+
+
+
HEAD
+
{shortHash(repository.git.head)}
+
+
+
Dirty
+
{repository.git.dirty === null || repository.git.dirty === undefined ? 'unknown' : repository.git.dirty ? 'yes' : 'no'} {repository.git.dirty_scope}
+
+
+
Remote
+
+ {#if repository.git.remote} + {repository.git.remote.name} · {repository.git.remote.url} + {#if repository.git.remote.redacted}credentials redacted{/if} + {:else} + not configured + {/if} +
+
+
+ {:else} +

Git metadata is unavailable for this local Repository.

+ {/if} + {:else if repositoryError} +

{repositoryError}

+ {:else} +

Waiting for Git summary…

+ {/if} +
+
+ +
+

Recent Git log

+ {#if repositoryLog} + {#if repositoryLog.items.length === 0} +

No recent commits are available from the bounded Git log API.

{:else}
- - - - - + + + + - {#each workers.items as worker} + {#each repositoryLog.items as commit (commit.hash)} - - - - - + + + + {/each}
WorkerHostStateWorkspaceImplementationCommitSubjectAuthorTimestamp
- {worker.label} - {#if worker.role || worker.profile} - {worker.role ?? 'role unknown'} / {worker.profile ?? 'profile unknown'} - {/if} - {worker.host_id}{worker.state} · {worker.status}{worker.workspace_root ?? 'unknown'}{worker.implementation.kind}{shortHash(commit.hash)}{commit.subject}{commit.author_name} {commit.author_email}{commit.timestamp}
{/if} - {:else if workersError} -

{workersError}

+ {:else if repositoryLogError} +

{repositoryLogError}

{:else} -

Waiting for /api/workers

+

Waiting for /api/repositories/local/log

{/if} - -
+ - {#if hosts || workers} - {@const diagnostics = diagnosticsFor(hosts?.diagnostics, workers?.diagnostics)} - {#if diagnostics.length > 0} +
+

Repository Ticket Kanban

+

+ Read-only grouping of canonical Ticket records. No drag/drop or lifecycle mutation is exposed. +

+ {#if repositoryTickets} +
+ {#each repositoryTickets.columns as column (column.state)} +
+

{column.state} {column.items.length}

+ {#if column.items.length === 0} +

No tickets.

+ {:else} +
    + {#each column.items as ticket (ticket.id)} +
  • + {ticket.title} + {ticket.id} · updated {formatDate(ticket.updated_at)} +
  • + {/each} +
+ {/if} +
+ {/each} +
+ {:else if repositoryTicketsError} +

{repositoryTicketsError}

+ {:else} +

Waiting for /api/repositories/local/tickets

+ {/if} +
+ + {@const repositoryDiagnostics = diagnosticsFor(repository?.git.diagnostics, repositoryLog?.diagnostics, repositoryTickets?.diagnostics)} + {#if repositoryDiagnostics.length > 0}
-

Diagnostics

+

Repository diagnostics

    - {#each diagnostics as diagnostic} + {#each repositoryDiagnostics as diagnostic}
  • {diagnostic.severity} {diagnostic.code} @@ -242,6 +363,216 @@
{/if} + {:else if route.page === 'objectives'} +
+

Objectives

+

+ Objectives are read from canonical filesystem records through /api/objectives. +

+ {#if objectives} + {#if objectives.items.length === 0} +

No Objective records are present.

+ {:else} +
+ {#each objectives.items as objective (objective.id)} +
+
+ {objective.title} + {objective.state} +
+

{objective.summary || 'No summary text is available.'}

+
+
+
ID
+
{objective.id}
+
+
+
Updated
+
{formatDate(objective.updated_at)}
+
+
+
Linked tickets
+
{objective.linked_tickets?.length ? objective.linked_tickets.join(', ') : 'none'}
+
+
+

Detail placeholder

+
+ {/each} +
+ {/if} + {#if objectives.invalid_records.length > 0} +

{objectives.invalid_records.length} invalid objective record(s) hidden.

+ {/if} + {:else if objectivesError} +

{objectivesError}

+ {:else} +

Waiting for /api/objectives

+ {/if} +
+ + {#if route.objectiveId} +
+

Objective detail

+

+ Selected Objective {route.objectiveId}. This slice keeps detail navigation as a + static SPA placeholder; canonical Objective content remains in the filesystem record. +

+
+ {/if} + {:else} +
+

Workspace

+ {#if workspace} +
+
+
ID
+
{workspace.workspace_id}
+
+
+
Name
+
{workspace.display_name}
+
+
+
Record authority
+
{workspace.record_authority}
+
+
+
Host / Worker bridge
+
{workspace.extension_points.host_worker_bridge.status}
+
+
+ {:else if workspaceError} +

{workspaceError}

+ {:else} +

Waiting for /api/workspace

+ {/if} +
+ +
+
+

Read API surface

+
    + {#each endpoints as endpoint} +
  • {endpoint.path} — {endpoint.label}
  • + {/each} +
+
+ +
+

Reserved seams

+

+ Event streams remain represented as extension-point state in the backend + response. Hosts and Workers are read-only local observations; no + scheduler, lifecycle control, or hosted multi-tenant behavior is + implemented in this slice. +

+
+
+ +
+
+

Hosts

+ {#if hosts} + {#if hosts.items.length === 0} +

No local Hosts are visible.

+ {:else} +
+ {#each hosts.items as host} +
+
+ {host.label} + {host.status} +
+
+
+
ID
+
{host.host_id}
+
+
+
Kind
+
{host.kind}
+
+
+
Local inspection
+
{host.capabilities.local_pod_inspection}
+
+
+
Platform
+
{host.capabilities.os} / {host.capabilities.arch}
+
+
+
+ {/each} +
+ {/if} + {:else if hostsError} +

{hostsError}

+ {:else} +

Waiting for /api/hosts

+ {/if} +
+ +
+

Workers

+ {#if workers} + {#if workers.items.length === 0} +

No local Workers are visible.

+ {:else} +
+ + + + + + + + + + + + {#each workers.items as worker} + + + + + + + + {/each} + +
WorkerHostStateWorkspaceImplementation
+ {worker.label} + {#if worker.role || worker.profile} + {worker.role ?? 'role unknown'} / {worker.profile ?? 'profile unknown'} + {/if} + {worker.host_id}{worker.state} · {worker.status}{worker.workspace_root ?? 'unknown'}{worker.implementation.kind}
+
+ {/if} + {:else if workersError} +

{workersError}

+ {:else} +

Waiting for /api/workers

+ {/if} +
+
+ + {#if hosts || workers} + {@const diagnostics = diagnosticsFor(hosts?.diagnostics, workers?.diagnostics)} + {#if diagnostics.length > 0} +
+

Diagnostics

+
    + {#each diagnostics as diagnostic} +
  • + {diagnostic.severity} + {diagnostic.code} + {diagnostic.message} +
  • + {/each} +
+
+ {/if} + {/if} {/if} @@ -283,6 +614,24 @@ max-width: 68ch; } + .page-links { + display: flex; + flex-wrap: wrap; + gap: 10px; + } + + .page-links a, + .card a { + color: #7dd3fc; + } + + .page-links a { + border: 1px solid rgba(125, 211, 252, 0.28); + border-radius: 999px; + padding: 6px 12px; + text-decoration: none; + } + .eyebrow { color: #38bdf8; font-weight: 700; @@ -297,7 +646,8 @@ overflow-wrap: anywhere; } - h2 { + h2, + h3 { margin-top: 0; } @@ -331,18 +681,29 @@ min-width: 0; } + .section-note, + .muted { + color: #94a3b8; + } + .stack { display: grid; gap: 12px; } - .runtime-card { + .runtime-card, + .kanban-column { border: 1px solid rgba(148, 163, 184, 0.18); border-radius: 16px; padding: 16px; background: rgba(15, 23, 42, 0.55); } + .selected-card.selected { + border-color: rgba(56, 189, 248, 0.5); + background: rgba(14, 165, 233, 0.1); + } + .runtime-heading { display: flex; justify-content: space-between; @@ -403,6 +764,36 @@ margin-top: 4px; } + .kanban { + display: grid; + gap: 14px; + grid-template-columns: repeat(auto-fit, minmax(min(190px, 100%), 1fr)); + margin-top: 16px; + } + + .kanban-column h3 { + display: flex; + justify-content: space-between; + gap: 10px; + color: #cbd5e1; + font-size: 0.95rem; + text-transform: uppercase; + } + + .kanban-column ul { + display: grid; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; + } + + .kanban-column li { + border-radius: 12px; + background: rgba(30, 41, 59, 0.72); + padding: 10px; + } + .diagnostics { margin-top: 16px; } @@ -413,6 +804,10 @@ margin-bottom: 12px; } + .detail-placeholder { + border-style: dashed; + } + .error { color: #fca5a5; }