Compare commits
19
Commits
41cd2d044a
...
4a276b0af0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a276b0af0 | ||
|
|
c2b82a2591 | ||
|
|
99daaf31b6 | ||
|
|
94e51ea6d1 | ||
|
|
f2d2ab0102 | ||
|
|
6f42f23d2b | ||
|
|
8f54fa2a00 | ||
|
|
c110965911 | ||
|
|
2a48dfc41a | ||
|
|
0451142d41 | ||
|
|
65f18b0cdb | ||
|
|
ea31c7ca81 | ||
|
|
dd47dba5b0 | ||
|
|
cbedc76d06 | ||
|
|
a4aa1a1848 | ||
|
|
ee8ee360ef | ||
|
|
72cae33ea6 | ||
|
|
0cd5ca11cc | ||
|
|
ccb9d03865 |
Generated
+11
@@ -540,6 +540,7 @@ dependencies = [
|
|||||||
"tokio-tungstenite 0.29.0",
|
"tokio-tungstenite 0.29.0",
|
||||||
"uuid",
|
"uuid",
|
||||||
"workdir",
|
"workdir",
|
||||||
|
"workspace-api",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6135,6 +6136,15 @@ dependencies = [
|
|||||||
"worker",
|
"worker",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "workspace-api"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"workdir",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "writeable"
|
name = "writeable"
|
||||||
version = "0.6.3"
|
version = "0.6.3"
|
||||||
@@ -6265,6 +6275,7 @@ dependencies = [
|
|||||||
"workdir",
|
"workdir",
|
||||||
"worker",
|
"worker",
|
||||||
"worker-runtime",
|
"worker-runtime",
|
||||||
|
"workspace-api",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ members = [
|
|||||||
"crates/ticket",
|
"crates/ticket",
|
||||||
"crates/merge-request",
|
"crates/merge-request",
|
||||||
"crates/project-record",
|
"crates/project-record",
|
||||||
|
"crates/workspace-api",
|
||||||
"crates/workspace-server",
|
"crates/workspace-server",
|
||||||
"tests/e2e",
|
"tests/e2e",
|
||||||
]
|
]
|
||||||
@@ -57,6 +58,7 @@ default-members = [
|
|||||||
"crates/ticket",
|
"crates/ticket",
|
||||||
"crates/merge-request",
|
"crates/merge-request",
|
||||||
"crates/project-record",
|
"crates/project-record",
|
||||||
|
"crates/workspace-api",
|
||||||
"crates/workspace-server",
|
"crates/workspace-server",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -78,6 +80,7 @@ ticket = { path = "crates/ticket" }
|
|||||||
project-record = { path = "crates/project-record" }
|
project-record = { path = "crates/project-record" }
|
||||||
worker = { path = "crates/worker" }
|
worker = { path = "crates/worker" }
|
||||||
worker-runtime = { path = "crates/worker-runtime" }
|
worker-runtime = { path = "crates/worker-runtime" }
|
||||||
|
workspace-api = { path = "crates/workspace-api" }
|
||||||
yoi-plugin-pdk = { path = "crates/plugin-pdk" }
|
yoi-plugin-pdk = { path = "crates/plugin-pdk" }
|
||||||
yoi = { path = "crates/yoi" }
|
yoi = { path = "crates/yoi" }
|
||||||
protocol = { path = "crates/protocol" }
|
protocol = { path = "crates/protocol" }
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ thiserror = { workspace = true }
|
|||||||
tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time", "process", "fs"] }
|
tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time", "process", "fs"] }
|
||||||
tokio-tungstenite = { workspace = true }
|
tokio-tungstenite = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
workspace-api.workspace = true
|
||||||
workdir = { workspace = true }
|
workdir = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
use futures::{SinkExt, StreamExt};
|
use futures::{SinkExt, StreamExt};
|
||||||
use protocol::stream::{decode_event, encode_method};
|
use protocol::stream::{decode_event, encode_method};
|
||||||
use protocol::{ErrorCode, Event, Method};
|
use protocol::{ErrorCode, Event, Method};
|
||||||
use serde::Deserialize;
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio_tungstenite::connect_async;
|
use tokio_tungstenite::connect_async;
|
||||||
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
||||||
pub use workdir::workspace::WorkingDirectorySummary as BackendWorkingDirectorySummary;
|
pub use workdir::workspace::WorkingDirectorySummary as BackendWorkingDirectorySummary;
|
||||||
|
pub use workspace_api::{
|
||||||
|
Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity,
|
||||||
|
ListResponse as BackendRuntimeListResponse, RuntimeSummary as BackendRuntimeSummary,
|
||||||
|
WorkerCapabilitySummary as BackendWorkerCapabilitySummary,
|
||||||
|
WorkerImplementationSummary as BackendWorkerImplementationSummary,
|
||||||
|
WorkerRestoreResponse as BackendWorkerRestoreResponse,
|
||||||
|
WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary,
|
||||||
|
WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct BackendRuntimeTarget {
|
pub struct BackendRuntimeTarget {
|
||||||
@@ -93,94 +101,6 @@ impl BackendRuntimeListTarget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
pub struct BackendRuntimeListResponse<T> {
|
|
||||||
pub workspace_id: String,
|
|
||||||
pub limit: usize,
|
|
||||||
pub items: Vec<T>,
|
|
||||||
pub source: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub diagnostics: Vec<BackendDiagnostic>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct BackendRuntimeSummary {
|
|
||||||
pub runtime_id: String,
|
|
||||||
pub label: String,
|
|
||||||
pub kind: String,
|
|
||||||
pub status: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub host_ids: Vec<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub diagnostics: Vec<BackendDiagnostic>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct BackendWorkerWorkspaceSummary {
|
|
||||||
pub visibility: String,
|
|
||||||
pub identity: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct BackendWorkerImplementationSummary {
|
|
||||||
pub kind: String,
|
|
||||||
pub display_hint: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct BackendWorkerCapabilitySummary {
|
|
||||||
pub can_stop: bool,
|
|
||||||
pub can_spawn_followup: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct BackendWorkerSummary {
|
|
||||||
pub runtime_id: String,
|
|
||||||
pub worker_id: String,
|
|
||||||
pub resource_key: String,
|
|
||||||
pub host_id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub display_name: String,
|
|
||||||
pub label: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub profile: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub singleton_key: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub tags: Vec<String>,
|
|
||||||
pub workspace: BackendWorkerWorkspaceSummary,
|
|
||||||
pub state: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub last_seen_at: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub pinned: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub retention_state: String,
|
|
||||||
pub implementation: BackendWorkerImplementationSummary,
|
|
||||||
pub capabilities: BackendWorkerCapabilitySummary,
|
|
||||||
#[serde(default)]
|
|
||||||
pub working_directory: Option<BackendWorkingDirectorySummary>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub diagnostics: Vec<BackendDiagnostic>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct BackendWorkerRestoreResult {
|
|
||||||
pub state: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub worker: Option<BackendWorkerSummary>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub diagnostics: Vec<BackendDiagnostic>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct BackendWorkerRestoreResponse {
|
|
||||||
pub workspace_id: String,
|
|
||||||
pub runtime_id: String,
|
|
||||||
pub worker_id: String,
|
|
||||||
pub result: BackendWorkerRestoreResult,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct BackendRuntimeClient {
|
pub struct BackendRuntimeClient {
|
||||||
target: BackendRuntimeTarget,
|
target: BackendRuntimeTarget,
|
||||||
@@ -277,7 +197,7 @@ pub async fn list_backend_workers(
|
|||||||
}
|
}
|
||||||
Err(error) => diagnostics.push(BackendDiagnostic {
|
Err(error) => diagnostics.push(BackendDiagnostic {
|
||||||
code: "runtime_worker_list_failed".to_string(),
|
code: "runtime_worker_list_failed".to_string(),
|
||||||
severity: Some("error".to_string()),
|
severity: BackendDiagnosticSeverity::Error,
|
||||||
message: format!(
|
message: format!(
|
||||||
"failed to list workers for runtime {}: {error}",
|
"failed to list workers for runtime {}: {error}",
|
||||||
runtime.runtime_id
|
runtime.runtime_id
|
||||||
@@ -619,14 +539,6 @@ fn percent_encode(input: &str, keep: impl Fn(u8) -> bool) -> String {
|
|||||||
encoded
|
encoded
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct BackendDiagnostic {
|
|
||||||
pub code: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub severity: Option<String>,
|
|
||||||
pub message: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ pub use backend_auth::{
|
|||||||
poll_device_login, start_device_login, wait_for_device_login,
|
poll_device_login, start_device_login, wait_for_device_login,
|
||||||
};
|
};
|
||||||
pub use backend_runtime::{
|
pub use backend_runtime::{
|
||||||
BackendDiagnostic, BackendRuntimeClient, BackendRuntimeClientError, BackendRuntimeListResponse,
|
BackendDiagnostic, BackendDiagnosticSeverity, BackendRuntimeClient, BackendRuntimeClientError,
|
||||||
BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget,
|
BackendRuntimeListResponse, BackendRuntimeListTarget, BackendRuntimeSummary,
|
||||||
BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary,
|
BackendRuntimeTarget, BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary,
|
||||||
BackendWorkerRestoreResponse, BackendWorkerRestoreResult, BackendWorkerSummary,
|
BackendWorkerRestoreResponse, BackendWorkerRestoreResult, BackendWorkerSummary,
|
||||||
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, list_backend_stopped_workers,
|
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, list_backend_stopped_workers,
|
||||||
list_backend_workers, restore_backend_worker,
|
list_backend_workers, restore_backend_worker,
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ pub enum MergeRequestState {
|
|||||||
Closed,
|
Closed,
|
||||||
}
|
}
|
||||||
impl MergeRequestState {
|
impl MergeRequestState {
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Open => "open",
|
||||||
|
Self::Merged => "merged",
|
||||||
|
Self::Closed => "closed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn parse(v: &str) -> Result<Self, MergeRequestError> {
|
fn parse(v: &str) -> Result<Self, MergeRequestError> {
|
||||||
match v {
|
match v {
|
||||||
"draft" | "open" => Ok(Self::Open),
|
"draft" | "open" => Ok(Self::Open),
|
||||||
@@ -216,6 +224,23 @@ impl MergeRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct MergeRequestListQuery {
|
||||||
|
pub state: Option<MergeRequestState>,
|
||||||
|
pub repository_id: Option<String>,
|
||||||
|
pub ticket_id: Option<String>,
|
||||||
|
pub selector_from: Option<String>,
|
||||||
|
pub selector_to: Option<String>,
|
||||||
|
pub cursor: Option<String>,
|
||||||
|
pub limit: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct MergeRequestListPage {
|
||||||
|
pub items: Vec<MergeRequest>,
|
||||||
|
pub next_cursor: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct OpenMergeRequest {
|
pub struct OpenMergeRequest {
|
||||||
pub merge_request_id: String,
|
pub merge_request_id: String,
|
||||||
@@ -935,6 +960,80 @@ impl MergeRequestStore {
|
|||||||
None => Err(MergeRequestError::NotFound),
|
None => Err(MergeRequestError::NotFound),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pub fn get_by_id(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
merge_request_id: &str,
|
||||||
|
) -> Result<MergeRequest, MergeRequestError> {
|
||||||
|
let c = self.lock()?;
|
||||||
|
load_mr(&c, workspace_id, merge_request_id)?.ok_or(MergeRequestError::NotFound)
|
||||||
|
}
|
||||||
|
pub fn list(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
query: &MergeRequestListQuery,
|
||||||
|
) -> Result<MergeRequestListPage, MergeRequestError> {
|
||||||
|
let c = self.lock()?;
|
||||||
|
let limit = query.limit.clamp(1, 100);
|
||||||
|
let cursor_position = match query.cursor.as_deref() {
|
||||||
|
Some(cursor) => Some(
|
||||||
|
c.query_row(
|
||||||
|
"SELECT updated_at,merge_request_id FROM merge_requests WHERE workspace_id=?1 AND merge_request_id=?2",
|
||||||
|
params![workspace_id, cursor],
|
||||||
|
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.ok_or_else(|| MergeRequestError::Validation("invalid merge request cursor".into()))?,
|
||||||
|
),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let cursor_updated_at = cursor_position
|
||||||
|
.as_ref()
|
||||||
|
.map(|(updated_at, _)| updated_at.as_str());
|
||||||
|
let cursor_id = cursor_position.as_ref().map(|(_, id)| id.as_str());
|
||||||
|
let state = query.state.map(MergeRequestState::as_str);
|
||||||
|
let mut statement = c.prepare(
|
||||||
|
"SELECT mr.merge_request_id
|
||||||
|
FROM merge_requests mr
|
||||||
|
WHERE mr.workspace_id=?1
|
||||||
|
AND (?2 IS NULL OR mr.state=?2)
|
||||||
|
AND (?3 IS NULL OR mr.repository_id=?3)
|
||||||
|
AND (?4 IS NULL OR mr.selector_from=?4)
|
||||||
|
AND (?5 IS NULL OR mr.selector_to=?5)
|
||||||
|
AND (?6 IS NULL OR EXISTS (
|
||||||
|
SELECT 1 FROM merge_request_ticket_relations relation
|
||||||
|
WHERE relation.workspace_id=mr.workspace_id
|
||||||
|
AND relation.merge_request_id=mr.merge_request_id
|
||||||
|
AND relation.ticket_id=?6
|
||||||
|
))
|
||||||
|
AND (?7 IS NULL OR mr.updated_at<?7 OR (mr.updated_at=?7 AND mr.merge_request_id>?8))
|
||||||
|
ORDER BY mr.updated_at DESC,mr.merge_request_id ASC
|
||||||
|
LIMIT ?9",
|
||||||
|
)?;
|
||||||
|
let rows = statement.query_map(
|
||||||
|
params![
|
||||||
|
workspace_id,
|
||||||
|
state,
|
||||||
|
query.repository_id.as_deref(),
|
||||||
|
query.selector_from.as_deref(),
|
||||||
|
query.selector_to.as_deref(),
|
||||||
|
query.ticket_id.as_deref(),
|
||||||
|
cursor_updated_at,
|
||||||
|
cursor_id,
|
||||||
|
(limit + 1) as i64,
|
||||||
|
],
|
||||||
|
|row| row.get::<_, String>(0),
|
||||||
|
)?;
|
||||||
|
let mut ids = rows.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
let has_more = ids.len() > limit;
|
||||||
|
ids.truncate(limit);
|
||||||
|
let next_cursor = has_more.then(|| ids.last().cloned()).flatten();
|
||||||
|
let items = ids
|
||||||
|
.iter()
|
||||||
|
.map(|id| load_mr(&c, workspace_id, id)?.ok_or(MergeRequestError::NotFound))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
Ok(MergeRequestListPage { items, next_cursor })
|
||||||
|
}
|
||||||
pub fn thread_page(
|
pub fn thread_page(
|
||||||
&self,
|
&self,
|
||||||
ws: &str,
|
ws: &str,
|
||||||
@@ -946,6 +1045,25 @@ impl MergeRequestStore {
|
|||||||
let c = self.lock()?;
|
let c = self.lock()?;
|
||||||
load_thread(&c, ws, &mr.merge_request_id, after, limit.clamp(1, 200))
|
load_thread(&c, ws, &mr.merge_request_id, after, limit.clamp(1, 200))
|
||||||
}
|
}
|
||||||
|
pub fn thread_page_by_id(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
merge_request_id: &str,
|
||||||
|
after: Option<u64>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<MergeRequestThreadEvent>, MergeRequestError> {
|
||||||
|
let c = self.lock()?;
|
||||||
|
if load_mr(&c, workspace_id, merge_request_id)?.is_none() {
|
||||||
|
return Err(MergeRequestError::NotFound);
|
||||||
|
}
|
||||||
|
load_thread(
|
||||||
|
&c,
|
||||||
|
workspace_id,
|
||||||
|
merge_request_id,
|
||||||
|
after,
|
||||||
|
limit.clamp(1, 200),
|
||||||
|
)
|
||||||
|
}
|
||||||
fn assigned(&self, a: &MergeRequestAuth, t: &str, r: &str) -> Result<(), MergeRequestError> {
|
fn assigned(&self, a: &MergeRequestAuth, t: &str, r: &str) -> Result<(), MergeRequestError> {
|
||||||
self.repo(a, r)?;
|
self.repo(a, r)?;
|
||||||
let x = self
|
let x = self
|
||||||
|
|||||||
@@ -436,6 +436,75 @@ fn selector_repair_rejects_unapproved_resolved_subject() {
|
|||||||
assert!(matches!(result, Err(MergeRequestError::NotReady(_))));
|
assert!(matches!(result, Err(MergeRequestError::NotReady(_))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_class_list_and_detail_are_workspace_scoped_and_cursor_bounded() {
|
||||||
|
let (dir, store) = fixture();
|
||||||
|
open(&store);
|
||||||
|
Connection::open(dir.path().join("db"))
|
||||||
|
.unwrap()
|
||||||
|
.execute(
|
||||||
|
"UPDATE merge_requests SET state='closed' WHERE workspace_id='W' AND merge_request_id='MR'",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.open_merge_request(OpenMergeRequest {
|
||||||
|
merge_request_id: "MR-2".into(),
|
||||||
|
ticket_id: "T".into(),
|
||||||
|
repository_id: "R".into(),
|
||||||
|
selector_from: "work/t-2".into(),
|
||||||
|
selector_to: "develop".into(),
|
||||||
|
summary: "second".into(),
|
||||||
|
auth: auth(),
|
||||||
|
now: at(8),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let first = store
|
||||||
|
.list(
|
||||||
|
"W",
|
||||||
|
&MergeRequestListQuery {
|
||||||
|
ticket_id: Some("T".into()),
|
||||||
|
limit: 1,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(first.items[0].merge_request_id, "MR-2");
|
||||||
|
assert_eq!(first.next_cursor.as_deref(), Some("MR-2"));
|
||||||
|
|
||||||
|
let second = store
|
||||||
|
.list(
|
||||||
|
"W",
|
||||||
|
&MergeRequestListQuery {
|
||||||
|
ticket_id: Some("T".into()),
|
||||||
|
cursor: first.next_cursor,
|
||||||
|
limit: 1,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(second.items[0].merge_request_id, "MR");
|
||||||
|
assert!(second.next_cursor.is_none());
|
||||||
|
|
||||||
|
let closed = store
|
||||||
|
.list(
|
||||||
|
"W",
|
||||||
|
&MergeRequestListQuery {
|
||||||
|
state: Some(MergeRequestState::Closed),
|
||||||
|
limit: 10,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(closed.items.len(), 1);
|
||||||
|
assert_eq!(store.get_by_id("W", "MR").unwrap().merge_request_id, "MR");
|
||||||
|
assert!(matches!(
|
||||||
|
store.get_by_id("other", "MR"),
|
||||||
|
Err(MergeRequestError::NotFound)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn transactional_completion_rejects_assignment_changed_in_control_plane_db() {
|
fn transactional_completion_rejects_assignment_changed_in_control_plane_db() {
|
||||||
let (dir, store) = fixture();
|
let (dir, store) = fixture();
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ pub(crate) async fn run(
|
|||||||
}
|
}
|
||||||
Err(error) => response.diagnostics.push(client::BackendDiagnostic {
|
Err(error) => response.diagnostics.push(client::BackendDiagnostic {
|
||||||
code: "backend_stopped_workers_list_failed".to_string(),
|
code: "backend_stopped_workers_list_failed".to_string(),
|
||||||
severity: Some("error".to_string()),
|
severity: client::BackendDiagnosticSeverity::Error,
|
||||||
message: error.to_string(),
|
message: error.to_string(),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
@@ -396,6 +396,7 @@ mod tests {
|
|||||||
workspace: BackendWorkerWorkspaceSummary {
|
workspace: BackendWorkerWorkspaceSummary {
|
||||||
visibility: "workspace".to_string(),
|
visibility: "workspace".to_string(),
|
||||||
identity: "ws".to_string(),
|
identity: "ws".to_string(),
|
||||||
|
workspace_id: Some("ws".to_string()),
|
||||||
},
|
},
|
||||||
state: "running".to_string(),
|
state: "running".to_string(),
|
||||||
last_seen_at: None,
|
last_seen_at: None,
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ use fs_operation::{
|
|||||||
EditRequest, EditResult, FsPath, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest,
|
EditRequest, EditResult, FsPath, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest,
|
||||||
ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
|
ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
|
||||||
};
|
};
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, Workdir,
|
CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest,
|
||||||
WorkdirError, WorkdirSession, WorkdirSessionCapabilities, WorkdirSessionCapability,
|
CommandSnapshot, CommandStatus, Workdir, WorkdirError, WorkdirSession,
|
||||||
WorkdirSessionHandle,
|
WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirSessionHandle,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
@@ -520,6 +521,22 @@ impl WorkdirSession for DelegatingWorkdirSession {
|
|||||||
self.source.cancel_command(handle).await
|
self.source.cancel_command(handle).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn subscribe_command_events(&self) -> Option<broadcast::Receiver<CommandEvent>> {
|
||||||
|
self.ensure_capability(WorkdirSessionCapability::Command, "command observation")
|
||||||
|
.ok()?;
|
||||||
|
self.source.subscribe_command_events()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_snapshot(&self) -> Vec<CommandSnapshot> {
|
||||||
|
if self
|
||||||
|
.ensure_capability(WorkdirSessionCapability::Command, "command observation")
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
self.source.command_snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
async fn close(&self) -> Result<(), WorkdirError> {
|
async fn close(&self) -> Result<(), WorkdirError> {
|
||||||
self.validity.active.store(false, Ordering::Release);
|
self.validity.active.store(false, Ordering::Release);
|
||||||
if self.closes_source {
|
if self.closes_source {
|
||||||
@@ -732,6 +749,53 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delegation_capable_session_forwards_command_telemetry() {
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
let parent = session(root.path());
|
||||||
|
let mut events = parent
|
||||||
|
.subscribe_command_events()
|
||||||
|
.expect("delegation wrapper must preserve command observation");
|
||||||
|
let handle = parent
|
||||||
|
.start_command(CommandRequest {
|
||||||
|
command: "printf ready; sleep 0.2; printf done".into(),
|
||||||
|
timeout_secs: 5,
|
||||||
|
output_limit: 1024,
|
||||||
|
tool_call_id: Some("tool-delegated".into()),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let first_output = loop {
|
||||||
|
let event = tokio::time::timeout(std::time::Duration::from_secs(2), events.recv())
|
||||||
|
.await
|
||||||
|
.expect("delegated command telemetry should not stall")
|
||||||
|
.unwrap();
|
||||||
|
if let CommandEvent::Output { content, .. } = event {
|
||||||
|
break content;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert_eq!(first_output, "ready");
|
||||||
|
let snapshots = parent.command_snapshot();
|
||||||
|
assert_eq!(snapshots.len(), 1);
|
||||||
|
assert_eq!(snapshots[0].command_id, handle.0);
|
||||||
|
assert_eq!(snapshots[0].status, CommandStatus::Running);
|
||||||
|
assert_eq!(snapshots[0].stdout.content, "ready");
|
||||||
|
|
||||||
|
let output = parent
|
||||||
|
.command_output(CommandOutputRequest {
|
||||||
|
handle,
|
||||||
|
cursor: 0,
|
||||||
|
limit: 1024,
|
||||||
|
wait: true,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(output.status, CommandStatus::Completed);
|
||||||
|
assert_eq!(output.content, "readydone");
|
||||||
|
assert!(parent.command_snapshot().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn non_recursive_rule_covers_target_and_direct_children_only() {
|
fn non_recursive_rule_covers_target_and_direct_children_only() {
|
||||||
let rule = WorkdirDelegationRule {
|
let rule = WorkdirDelegationRule {
|
||||||
@@ -776,6 +840,8 @@ mod tests {
|
|||||||
.capabilities
|
.capabilities
|
||||||
.supports(WorkdirSessionCapability::Command)
|
.supports(WorkdirSessionCapability::Command)
|
||||||
);
|
);
|
||||||
|
assert!(child.scoped_session.subscribe_command_events().is_none());
|
||||||
|
assert!(child.scoped_session.command_snapshot().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "workspace-api"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
workdir.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json.workspace = true
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
//! Shared Workspace HTTP resource contracts.
|
||||||
|
//!
|
||||||
|
//! This crate owns transport DTOs exposed by the Workspace Server and consumed
|
||||||
|
//! by Rust clients. Runtime-internal projections remain in their owning crates;
|
||||||
|
//! callers must explicitly construct these Workspace-authoritative resources.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use workdir::workspace::WorkingDirectorySummary;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum DiagnosticSeverity {
|
||||||
|
Info,
|
||||||
|
Warning,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct Diagnostic {
|
||||||
|
pub code: String,
|
||||||
|
pub severity: DiagnosticSeverity,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct ListResponse<T> {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub limit: usize,
|
||||||
|
pub items: Vec<T>,
|
||||||
|
pub source: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub diagnostics: Vec<Diagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RuntimeSourceKind {
|
||||||
|
EmbeddedWorkerRuntime,
|
||||||
|
RemoteHttp,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RuntimeSourceStatus {
|
||||||
|
Active,
|
||||||
|
Reserved,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RuntimeIdentityAuthority {
|
||||||
|
RuntimeRegistryProjection,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct RuntimeSourceSummary {
|
||||||
|
pub kind: RuntimeSourceKind,
|
||||||
|
pub status: RuntimeSourceStatus,
|
||||||
|
pub identity_authority: RuntimeIdentityAuthority,
|
||||||
|
pub note: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct RuntimeCapabilitySummary {
|
||||||
|
pub can_list_hosts: bool,
|
||||||
|
pub can_list_workers: bool,
|
||||||
|
pub can_get_worker: bool,
|
||||||
|
pub can_spawn_worker: bool,
|
||||||
|
pub can_stop_worker: bool,
|
||||||
|
pub has_workspace_fs: bool,
|
||||||
|
pub has_shell: bool,
|
||||||
|
pub has_git: bool,
|
||||||
|
pub supports_worktrees: bool,
|
||||||
|
pub supports_backend_internal_tools: bool,
|
||||||
|
pub workspace_scope: String,
|
||||||
|
pub max_workers: usize,
|
||||||
|
pub os: String,
|
||||||
|
pub arch: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct RuntimeSummary {
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub label: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub status: String,
|
||||||
|
pub source: RuntimeSourceSummary,
|
||||||
|
#[serde(default)]
|
||||||
|
pub host_ids: Vec<String>,
|
||||||
|
pub capabilities: RuntimeCapabilitySummary,
|
||||||
|
#[serde(default)]
|
||||||
|
pub diagnostics: Vec<Diagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct WorkerWorkspaceSummary {
|
||||||
|
pub visibility: String,
|
||||||
|
pub identity: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub workspace_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct WorkerImplementationSummary {
|
||||||
|
pub kind: String,
|
||||||
|
pub display_hint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct WorkerCapabilitySummary {
|
||||||
|
pub can_stop: bool,
|
||||||
|
pub can_spawn_followup: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Workspace-authoritative Worker projection.
|
||||||
|
///
|
||||||
|
/// `resource_key` is required here even though Runtime-internal Worker summaries
|
||||||
|
/// do not carry one. The Workspace Server must resolve it from Workspace
|
||||||
|
/// authority before constructing this response.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct WorkerSummary {
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub worker_id: String,
|
||||||
|
pub resource_key: String,
|
||||||
|
pub host_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub display_name: String,
|
||||||
|
pub label: String,
|
||||||
|
pub profile: Option<String>,
|
||||||
|
pub singleton_key: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub workspace: WorkerWorkspaceSummary,
|
||||||
|
pub state: String,
|
||||||
|
pub last_seen_at: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub pinned: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub retention_state: String,
|
||||||
|
pub implementation: WorkerImplementationSummary,
|
||||||
|
pub capabilities: WorkerCapabilitySummary,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub working_directory: Option<WorkingDirectorySummary>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub diagnostics: Vec<Diagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum WorkerOperationState {
|
||||||
|
Accepted,
|
||||||
|
Unsupported,
|
||||||
|
Rejected,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct WorkerRestoreResult {
|
||||||
|
pub state: WorkerOperationState,
|
||||||
|
pub worker: Option<WorkerSummary>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub diagnostics: Vec<Diagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct WorkerRestoreResponse {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub worker_id: String,
|
||||||
|
pub result: WorkerRestoreResult,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_resource_key_is_required() {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"runtime_id": "arcadia",
|
||||||
|
"worker_id": "worker-1",
|
||||||
|
"host_id": "host",
|
||||||
|
"display_name": "Coder",
|
||||||
|
"label": "Coder",
|
||||||
|
"workspace": {
|
||||||
|
"visibility": "workspace",
|
||||||
|
"identity": "workspace-test"
|
||||||
|
},
|
||||||
|
"state": "idle",
|
||||||
|
"implementation": {"kind": "worker", "display_hint": "Coder"},
|
||||||
|
"capabilities": {"can_stop": true, "can_spawn_followup": false}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(serde_json::from_value::<WorkerSummary>(payload).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread"
|
|||||||
tower.workspace = true
|
tower.workspace = true
|
||||||
tokio-tungstenite.workspace = true
|
tokio-tungstenite.workspace = true
|
||||||
worker.workspace = true
|
worker.workspace = true
|
||||||
|
workspace-api.workspace = true
|
||||||
workdir = { workspace = true, features = ["http-client"] }
|
workdir = { workspace = true, features = ["http-client"] }
|
||||||
worker-runtime.workspace = true
|
worker-runtime.workspace = true
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
|
|||||||
@@ -1590,7 +1590,7 @@ fn ticket_evidence_event(sequence: usize, event: &TicketEvent) -> TicketEvidence
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn merge_request_summary(
|
pub(crate) fn merge_request_summary(
|
||||||
request: MergeRequest,
|
request: MergeRequest,
|
||||||
current_subject_ref: Option<String>,
|
current_subject_ref: Option<String>,
|
||||||
) -> TicketMergeRequestSummary {
|
) -> TicketMergeRequestSummary {
|
||||||
|
|||||||
@@ -246,8 +246,6 @@ pub struct WorkerCapabilitySummary {
|
|||||||
pub struct WorkerSummary {
|
pub struct WorkerSummary {
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub worker: RuntimeWorkerRef,
|
pub worker: RuntimeWorkerRef,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub resource_key: Option<String>,
|
|
||||||
pub host_id: String,
|
pub host_id: String,
|
||||||
/// Human-readable display name. This is not identity and may be duplicated.
|
/// Human-readable display name. This is not identity and may be duplicated.
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
@@ -271,6 +269,119 @@ pub struct WorkerSummary {
|
|||||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<RuntimeDiagnostic> for workspace_api::Diagnostic {
|
||||||
|
fn from(diagnostic: RuntimeDiagnostic) -> Self {
|
||||||
|
let severity = match diagnostic.severity {
|
||||||
|
DiagnosticSeverity::Info => workspace_api::DiagnosticSeverity::Info,
|
||||||
|
DiagnosticSeverity::Warning => workspace_api::DiagnosticSeverity::Warning,
|
||||||
|
DiagnosticSeverity::Error => workspace_api::DiagnosticSeverity::Error,
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
code: diagnostic.code,
|
||||||
|
severity,
|
||||||
|
message: diagnostic.message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<RuntimeSourceSummary> for workspace_api::RuntimeSourceSummary {
|
||||||
|
fn from(source: RuntimeSourceSummary) -> Self {
|
||||||
|
let kind = match source.kind {
|
||||||
|
RuntimeSourceKind::EmbeddedWorkerRuntime => {
|
||||||
|
workspace_api::RuntimeSourceKind::EmbeddedWorkerRuntime
|
||||||
|
}
|
||||||
|
RuntimeSourceKind::RemoteHttp => workspace_api::RuntimeSourceKind::RemoteHttp,
|
||||||
|
};
|
||||||
|
let status = match source.status {
|
||||||
|
RuntimeSourceStatus::Active => workspace_api::RuntimeSourceStatus::Active,
|
||||||
|
RuntimeSourceStatus::Reserved => workspace_api::RuntimeSourceStatus::Reserved,
|
||||||
|
};
|
||||||
|
let identity_authority = match source.identity_authority {
|
||||||
|
RuntimeIdentityAuthority::RuntimeRegistryProjection => {
|
||||||
|
workspace_api::RuntimeIdentityAuthority::RuntimeRegistryProjection
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
kind,
|
||||||
|
status,
|
||||||
|
identity_authority,
|
||||||
|
note: source.note,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<RuntimeCapabilitySummary> for workspace_api::RuntimeCapabilitySummary {
|
||||||
|
fn from(capabilities: RuntimeCapabilitySummary) -> Self {
|
||||||
|
Self {
|
||||||
|
can_list_hosts: capabilities.can_list_hosts,
|
||||||
|
can_list_workers: capabilities.can_list_workers,
|
||||||
|
can_get_worker: capabilities.can_get_worker,
|
||||||
|
can_spawn_worker: capabilities.can_spawn_worker,
|
||||||
|
can_stop_worker: capabilities.can_stop_worker,
|
||||||
|
has_workspace_fs: capabilities.has_workspace_fs,
|
||||||
|
has_shell: capabilities.has_shell,
|
||||||
|
has_git: capabilities.has_git,
|
||||||
|
supports_worktrees: capabilities.supports_worktrees,
|
||||||
|
supports_backend_internal_tools: capabilities.supports_backend_internal_tools,
|
||||||
|
workspace_scope: capabilities.workspace_scope,
|
||||||
|
max_workers: capabilities.max_workers,
|
||||||
|
os: capabilities.os,
|
||||||
|
arch: capabilities.arch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<RuntimeSummary> for workspace_api::RuntimeSummary {
|
||||||
|
fn from(runtime: RuntimeSummary) -> Self {
|
||||||
|
Self {
|
||||||
|
runtime_id: runtime.runtime_id,
|
||||||
|
label: runtime.label,
|
||||||
|
kind: runtime.kind,
|
||||||
|
status: runtime.status,
|
||||||
|
source: runtime.source.into(),
|
||||||
|
host_ids: runtime.host_ids,
|
||||||
|
capabilities: runtime.capabilities.into(),
|
||||||
|
diagnostics: runtime.diagnostics.into_iter().map(Into::into).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn workspace_worker_summary(
|
||||||
|
summary: WorkerSummary,
|
||||||
|
resource_key: String,
|
||||||
|
) -> workspace_api::WorkerSummary {
|
||||||
|
workspace_api::WorkerSummary {
|
||||||
|
runtime_id: summary.worker.runtime_id,
|
||||||
|
worker_id: summary.worker.worker_id,
|
||||||
|
resource_key,
|
||||||
|
host_id: summary.host_id,
|
||||||
|
display_name: summary.display_name,
|
||||||
|
label: summary.label,
|
||||||
|
profile: summary.profile,
|
||||||
|
singleton_key: summary.singleton_key,
|
||||||
|
tags: summary.tags,
|
||||||
|
workspace: workspace_api::WorkerWorkspaceSummary {
|
||||||
|
visibility: summary.workspace.visibility,
|
||||||
|
identity: summary.workspace.identity,
|
||||||
|
workspace_id: summary.workspace.workspace_id,
|
||||||
|
},
|
||||||
|
state: summary.state,
|
||||||
|
last_seen_at: summary.last_seen_at,
|
||||||
|
pinned: summary.pinned,
|
||||||
|
retention_state: summary.retention_state,
|
||||||
|
implementation: workspace_api::WorkerImplementationSummary {
|
||||||
|
kind: summary.implementation.kind,
|
||||||
|
display_hint: summary.implementation.display_hint,
|
||||||
|
},
|
||||||
|
capabilities: workspace_api::WorkerCapabilitySummary {
|
||||||
|
can_stop: summary.capabilities.can_stop,
|
||||||
|
can_spawn_followup: summary.capabilities.can_spawn_followup,
|
||||||
|
},
|
||||||
|
working_directory: summary.working_directory,
|
||||||
|
diagnostics: summary.diagnostics.into_iter().map(Into::into).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct WorkerRestoreResult {
|
pub struct WorkerRestoreResult {
|
||||||
pub state: WorkerOperationState,
|
pub state: WorkerOperationState,
|
||||||
@@ -509,6 +620,16 @@ pub enum WorkerOperationState {
|
|||||||
Rejected,
|
Rejected,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<WorkerOperationState> for workspace_api::WorkerOperationState {
|
||||||
|
fn from(state: WorkerOperationState) -> Self {
|
||||||
|
match state {
|
||||||
|
WorkerOperationState::Accepted => Self::Accepted,
|
||||||
|
WorkerOperationState::Unsupported => Self::Unsupported,
|
||||||
|
WorkerOperationState::Rejected => Self::Rejected,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct WorkerSpawnAcceptanceEvidence {
|
pub struct WorkerSpawnAcceptanceEvidence {
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
@@ -1680,7 +1801,6 @@ impl EmbeddedWorkerRuntime {
|
|||||||
);
|
);
|
||||||
WorkerSummary {
|
WorkerSummary {
|
||||||
worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()),
|
worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()),
|
||||||
resource_key: None,
|
|
||||||
host_id: self.host_id.clone(),
|
host_id: self.host_id.clone(),
|
||||||
display_name: display.display_name.clone(),
|
display_name: display.display_name.clone(),
|
||||||
label: display.display_name,
|
label: display.display_name,
|
||||||
@@ -1720,7 +1840,6 @@ impl EmbeddedWorkerRuntime {
|
|||||||
);
|
);
|
||||||
WorkerSummary {
|
WorkerSummary {
|
||||||
worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()),
|
worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()),
|
||||||
resource_key: None,
|
|
||||||
host_id: self.host_id.clone(),
|
host_id: self.host_id.clone(),
|
||||||
display_name: display.display_name.clone(),
|
display_name: display.display_name.clone(),
|
||||||
label: display.display_name,
|
label: display.display_name,
|
||||||
@@ -2806,7 +2925,6 @@ impl RemoteWorkerRuntime {
|
|||||||
);
|
);
|
||||||
WorkerSummary {
|
WorkerSummary {
|
||||||
worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()),
|
worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()),
|
||||||
resource_key: None,
|
|
||||||
host_id: self.host_id.clone(),
|
host_id: self.host_id.clone(),
|
||||||
display_name: display.display_name.clone(),
|
display_name: display.display_name.clone(),
|
||||||
label: display.display_name,
|
label: display.display_name,
|
||||||
@@ -2850,7 +2968,6 @@ impl RemoteWorkerRuntime {
|
|||||||
);
|
);
|
||||||
WorkerSummary {
|
WorkerSummary {
|
||||||
worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()),
|
worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()),
|
||||||
resource_key: None,
|
|
||||||
host_id: self.host_id.clone(),
|
host_id: self.host_id.clone(),
|
||||||
display_name: display.display_name.clone(),
|
display_name: display.display_name.clone(),
|
||||||
label: display.display_name,
|
label: display.display_name,
|
||||||
@@ -4222,7 +4339,6 @@ pub fn placeholder_worker(host_id: impl Into<String>) -> WorkerSummary {
|
|||||||
let host_id = host_id.into();
|
let host_id = host_id.into();
|
||||||
WorkerSummary {
|
WorkerSummary {
|
||||||
worker: RuntimeWorkerRef::new("placeholder", "worker-placeholder"),
|
worker: RuntimeWorkerRef::new("placeholder", "worker-placeholder"),
|
||||||
resource_key: None,
|
|
||||||
host_id,
|
host_id,
|
||||||
display_name: "Worker runtime actions are not implemented".to_string(),
|
display_name: "Worker runtime actions are not implemented".to_string(),
|
||||||
label: "Worker runtime actions are not implemented".to_string(),
|
label: "Worker runtime actions are not implemented".to_string(),
|
||||||
@@ -4616,7 +4732,6 @@ mod tests {
|
|||||||
host_id: host_id.to_string(),
|
host_id: host_id.to_string(),
|
||||||
workers: vec![WorkerSummary {
|
workers: vec![WorkerSummary {
|
||||||
worker: RuntimeWorkerRef::new(runtime_id, worker_id),
|
worker: RuntimeWorkerRef::new(runtime_id, worker_id),
|
||||||
resource_key: None,
|
|
||||||
host_id: host_id.to_string(),
|
host_id: host_id.to_string(),
|
||||||
display_name: label.to_string(),
|
display_name: label.to_string(),
|
||||||
label: label.to_string(),
|
label: label.to_string(),
|
||||||
|
|||||||
@@ -285,6 +285,21 @@ pub struct TicketMergeRequestSummary {
|
|||||||
pub review_excerpt: Option<String>,
|
pub review_excerpt: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub struct MergeRequestListItem {
|
||||||
|
pub summary: TicketMergeRequestSummary,
|
||||||
|
pub ticket_ids: Vec<String>,
|
||||||
|
pub thread_event_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub struct MergeRequestListResponse {
|
||||||
|
pub items: Vec<MergeRequestListItem>,
|
||||||
|
pub next_cursor: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct TicketEvidenceSummary {
|
pub struct TicketEvidenceSummary {
|
||||||
@@ -475,6 +490,8 @@ pub fn ticket_api_typescript() -> String {
|
|||||||
TicketEvidenceEvent::decl(&config),
|
TicketEvidenceEvent::decl(&config),
|
||||||
TicketAssignmentSummary::decl(&config),
|
TicketAssignmentSummary::decl(&config),
|
||||||
TicketMergeRequestSummary::decl(&config),
|
TicketMergeRequestSummary::decl(&config),
|
||||||
|
MergeRequestListItem::decl(&config),
|
||||||
|
MergeRequestListResponse::decl(&config),
|
||||||
TicketEvidenceSummary::decl(&config),
|
TicketEvidenceSummary::decl(&config),
|
||||||
TicketQueryRequest::decl(&config),
|
TicketQueryRequest::decl(&config),
|
||||||
TicketQueryItem::decl(&config),
|
TicketQueryItem::decl(&config),
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ use crate::auth::{
|
|||||||
};
|
};
|
||||||
use crate::authority::{
|
use crate::authority::{
|
||||||
MemoryAuthority, ObjectiveAuthority, ObjectiveCreateInput, ObjectiveEditInput,
|
MemoryAuthority, ObjectiveAuthority, ObjectiveCreateInput, ObjectiveEditInput,
|
||||||
SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource,
|
SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource, merge_request_summary,
|
||||||
};
|
};
|
||||||
use crate::companion::{
|
use crate::companion::{
|
||||||
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
|
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
|
||||||
@@ -77,13 +77,13 @@ use crate::hosts::{
|
|||||||
ConfigBundleCheckResult, ConfigBundleSyncResult, DiagnosticSeverity, EMBEDDED_RUNTIME_ID,
|
ConfigBundleCheckResult, ConfigBundleSyncResult, DiagnosticSeverity, EMBEDDED_RUNTIME_ID,
|
||||||
EmbeddedWorkerRuntime, HostSummary, RemoteRuntimeConfig, RemoteWorkerRuntime,
|
EmbeddedWorkerRuntime, HostSummary, RemoteRuntimeConfig, RemoteWorkerRuntime,
|
||||||
RuntimeDiagnostic, RuntimeRegistry, RuntimeRegistryError, RuntimeRegistryUnregisterResult,
|
RuntimeDiagnostic, RuntimeRegistry, RuntimeRegistryError, RuntimeRegistryUnregisterResult,
|
||||||
RuntimeSummary, TicketWorkerRole, WorkerCapabilitySummary, WorkerCompletionsRequest,
|
TicketWorkerRole, WorkerCapabilitySummary, WorkerCompletionsRequest, WorkerCompletionsResult,
|
||||||
WorkerCompletionsResult, WorkerControlOperation, WorkerCreateBinding,
|
WorkerControlOperation, WorkerCreateBinding, WorkerImplementationSummary, WorkerInputKind,
|
||||||
WorkerImplementationSummary, WorkerInputKind, WorkerInputRequest, WorkerInputResult,
|
WorkerInputRequest, WorkerInputResult, WorkerLifecycleRequest, WorkerLifecycleResult,
|
||||||
WorkerLifecycleRequest, WorkerLifecycleResult, WorkerOperationState, WorkerRestoreResult,
|
WorkerOperationState, WorkerRestoreResult, WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent,
|
||||||
WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest, WorkerSpawnResult,
|
WorkerSpawnRequest, WorkerSpawnResult, WorkerSpawnWorkingDirectoryRequest, WorkerSummary,
|
||||||
WorkerSpawnWorkingDirectoryRequest, WorkerSummary, WorkerTicketAssignmentRequest,
|
WorkerTicketAssignmentRequest, WorkerWorkspaceSummary, worker_spawn_create_fingerprint,
|
||||||
WorkerWorkspaceSummary, worker_spawn_create_fingerprint,
|
workspace_worker_summary,
|
||||||
};
|
};
|
||||||
use crate::identity::WorkspaceIdentity;
|
use crate::identity::WorkspaceIdentity;
|
||||||
use crate::memory_backend::execute_memory_backend_operation_with_authority;
|
use crate::memory_backend::execute_memory_backend_operation_with_authority;
|
||||||
@@ -97,8 +97,9 @@ use crate::observation::{
|
|||||||
};
|
};
|
||||||
use crate::profile_settings::UpdateWorkspaceMetadataRequest;
|
use crate::profile_settings::UpdateWorkspaceMetadataRequest;
|
||||||
use crate::records::{
|
use crate::records::{
|
||||||
ObjectiveDetail, ObjectiveQueryRequest, ObjectiveQueryResponse, ObjectiveShowRequest,
|
MergeRequestListItem, MergeRequestListResponse, ObjectiveDetail, ObjectiveQueryRequest,
|
||||||
ProjectRecordList, TicketDetail, TicketQueryRequest, TicketQueryResponse, TicketShowRequest,
|
ObjectiveQueryResponse, ObjectiveShowRequest, ProjectRecordList, TicketDetail,
|
||||||
|
TicketQueryRequest, TicketQueryResponse, TicketShowRequest,
|
||||||
};
|
};
|
||||||
use crate::repositories::{
|
use crate::repositories::{
|
||||||
ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError,
|
ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError,
|
||||||
@@ -1729,9 +1730,17 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
|||||||
"/api/w/{workspace_id}/tickets/{id}/workflow/queue",
|
"/api/w/{workspace_id}/tickets/{id}/workflow/queue",
|
||||||
post(scoped_queue_ticket_record),
|
post(scoped_queue_ticket_record),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/merge-requests",
|
||||||
|
get(scoped_list_merge_requests),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/merge-requests/{merge_request_id}",
|
||||||
|
get(scoped_show_merge_request),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/tickets/{id}/merge-request",
|
"/api/w/{workspace_id}/tickets/{id}/merge-request",
|
||||||
get(scoped_show_merge_request).post(scoped_open_merge_request),
|
post(scoped_open_merge_request),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/tickets/{id}/merge-request/readiness",
|
"/api/w/{workspace_id}/tickets/{id}/merge-request/readiness",
|
||||||
@@ -2255,14 +2264,6 @@ enum RuntimeWorkersStatusFilter {
|
|||||||
Stopped,
|
Stopped,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct WorkerRestoreResponse {
|
|
||||||
pub workspace_id: String,
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub worker_ref: RuntimeWorkerRef,
|
|
||||||
pub result: WorkerRestoreResult,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum CleanupTargetKind {
|
pub enum CleanupTargetKind {
|
||||||
@@ -4401,39 +4402,172 @@ fn resolve_workspace_ticket_reference(
|
|||||||
.ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(reference.to_string())).into())
|
.ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(reference.to_string())).into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct MergeRequestListHttpQuery {
|
||||||
|
state: Option<String>,
|
||||||
|
repository_id: Option<String>,
|
||||||
|
ticket_ref: Option<String>,
|
||||||
|
selector_from: Option<String>,
|
||||||
|
selector_to: Option<String>,
|
||||||
|
cursor: Option<String>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
struct MergeRequestRefResponse {
|
||||||
|
status: String,
|
||||||
|
#[serde(rename = "ref")]
|
||||||
|
revision_ref: Option<String>,
|
||||||
|
observed_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
struct MergeRequestLinkedTicketResponse {
|
||||||
|
ticket_id: String,
|
||||||
|
key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
struct MergeRequestDetailResponse {
|
||||||
|
#[serde(flatten)]
|
||||||
|
merge_request: merge_request::MergeRequest,
|
||||||
|
source: MergeRequestRefResponse,
|
||||||
|
target: MergeRequestRefResponse,
|
||||||
|
linked_tickets: Vec<MergeRequestLinkedTicketResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_list_merge_requests(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(workspace_id): AxumPath<String>,
|
||||||
|
Query(query): Query<MergeRequestListHttpQuery>,
|
||||||
|
) -> ApiResult<Json<MergeRequestListResponse>> {
|
||||||
|
let workspace_id = parse_workspace_id(&workspace_id)?;
|
||||||
|
require_workspace_access(&workspace_id, &api)?;
|
||||||
|
let ticket_id = query
|
||||||
|
.ticket_ref
|
||||||
|
.as_deref()
|
||||||
|
.map(|reference| resolve_workspace_ticket_reference(&api, &workspace_id, reference))
|
||||||
|
.transpose()?;
|
||||||
|
let state = query
|
||||||
|
.state
|
||||||
|
.as_deref()
|
||||||
|
.map(|state| match state {
|
||||||
|
"open" => Ok(merge_request::MergeRequestState::Open),
|
||||||
|
"merged" => Ok(merge_request::MergeRequestState::Merged),
|
||||||
|
"closed" => Ok(merge_request::MergeRequestState::Closed),
|
||||||
|
_ => Err(settings_bad_request(
|
||||||
|
"invalid_merge_request_state",
|
||||||
|
"state must be one of open, merged, or closed",
|
||||||
|
)),
|
||||||
|
})
|
||||||
|
.transpose()?;
|
||||||
|
let store = merge_request_store(&api, &workspace_id)?;
|
||||||
|
let page = store.list(
|
||||||
|
&workspace_id,
|
||||||
|
&merge_request::MergeRequestListQuery {
|
||||||
|
state,
|
||||||
|
repository_id: query.repository_id,
|
||||||
|
ticket_id,
|
||||||
|
selector_from: query.selector_from,
|
||||||
|
selector_to: query.selector_to,
|
||||||
|
cursor: query.cursor,
|
||||||
|
limit: query.limit.unwrap_or(50),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
let reader = api.repository_reader();
|
||||||
|
let items = page
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.map(|merge_request| {
|
||||||
|
let current_subject_ref = merge_request.selector_from.as_deref().and_then(|selector| {
|
||||||
|
reader
|
||||||
|
.observe_merge_target(&merge_request.repository_id, Some(selector))
|
||||||
|
.ok()
|
||||||
|
.map(|observation| observation.commit)
|
||||||
|
});
|
||||||
|
let ticket_ids = merge_request.ticket_ids.clone();
|
||||||
|
let thread_event_count = merge_request.thread.len();
|
||||||
|
MergeRequestListItem {
|
||||||
|
summary: merge_request_summary(merge_request, current_subject_ref),
|
||||||
|
ticket_ids,
|
||||||
|
thread_event_count,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Json(MergeRequestListResponse {
|
||||||
|
items,
|
||||||
|
next_cursor: page.next_cursor,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
async fn scoped_show_merge_request(
|
async fn scoped_show_merge_request(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
|
AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>,
|
||||||
) -> ApiResult<Json<serde_json::Value>> {
|
Query(query): Query<MergeRequestThreadQuery>,
|
||||||
|
) -> ApiResult<Json<MergeRequestDetailResponse>> {
|
||||||
let workspace_id = parse_workspace_id(&workspace_id)?;
|
let workspace_id = parse_workspace_id(&workspace_id)?;
|
||||||
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
|
require_workspace_access(&workspace_id, &api)?;
|
||||||
let store = merge_request_store(&api, &workspace_id)?;
|
let store = merge_request_store(&api, &workspace_id)?;
|
||||||
let mut mr = store.get(&workspace_id, &ticket_id)?;
|
let mut mr = store.get_by_id(&workspace_id, &merge_request_id)?;
|
||||||
mr.thread = store.thread_page(&workspace_id, &ticket_id, None, 100)?;
|
mr.thread = store.thread_page_by_id(
|
||||||
|
&workspace_id,
|
||||||
|
&merge_request_id,
|
||||||
|
query.after,
|
||||||
|
query.limit.unwrap_or(100),
|
||||||
|
)?;
|
||||||
let reader = api.repository_reader();
|
let reader = api.repository_reader();
|
||||||
let observed_at = Utc::now().to_rfc3339();
|
let observed_at = Utc::now().to_rfc3339();
|
||||||
let source = match mr.selector_from.as_deref() {
|
let source = match mr.selector_from.as_deref() {
|
||||||
Some(selector) => match reader.observe_merge_target(&mr.repository_id, Some(selector)) {
|
Some(selector) => match reader.observe_merge_target(&mr.repository_id, Some(selector)) {
|
||||||
Ok(value) => {
|
Ok(value) => MergeRequestRefResponse {
|
||||||
serde_json::json!({"status":"known","ref":value.commit,"observed_at":observed_at})
|
status: "known".into(),
|
||||||
}
|
revision_ref: Some(value.commit),
|
||||||
Err(_) => serde_json::json!({"status":"unknown","observed_at":observed_at}),
|
observed_at: observed_at.clone(),
|
||||||
|
},
|
||||||
|
Err(_) => MergeRequestRefResponse {
|
||||||
|
status: "unknown".into(),
|
||||||
|
revision_ref: None,
|
||||||
|
observed_at: observed_at.clone(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
None => MergeRequestRefResponse {
|
||||||
|
status: "requires_repair".into(),
|
||||||
|
revision_ref: None,
|
||||||
|
observed_at: observed_at.clone(),
|
||||||
},
|
},
|
||||||
None => serde_json::json!({"status":"requires_repair","observed_at":observed_at}),
|
|
||||||
};
|
};
|
||||||
let target = match reader.observe_merge_target(&mr.repository_id, Some(&mr.selector_to)) {
|
let target = match reader.observe_merge_target(&mr.repository_id, Some(&mr.selector_to)) {
|
||||||
Ok(value) => {
|
Ok(value) => MergeRequestRefResponse {
|
||||||
serde_json::json!({"status":"known","ref":value.commit,"observed_at":observed_at})
|
status: "known".into(),
|
||||||
}
|
revision_ref: Some(value.commit),
|
||||||
Err(_) => serde_json::json!({"status":"unknown","observed_at":observed_at}),
|
observed_at,
|
||||||
|
},
|
||||||
|
Err(_) => MergeRequestRefResponse {
|
||||||
|
status: "unknown".into(),
|
||||||
|
revision_ref: None,
|
||||||
|
observed_at,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
let mut response =
|
let linked_tickets = mr
|
||||||
serde_json::to_value(mr).map_err(|error| Error::InvalidInput(error.to_string()))?;
|
.ticket_ids
|
||||||
if let Some(object) = response.as_object_mut() {
|
.iter()
|
||||||
object.insert("source".into(), source);
|
.map(|ticket_id| {
|
||||||
object.insert("target".into(), target);
|
Ok(MergeRequestLinkedTicketResponse {
|
||||||
}
|
ticket_id: ticket_id.clone(),
|
||||||
Ok(Json(response))
|
key: api.store.resource_key(
|
||||||
|
&workspace_id,
|
||||||
|
WorkspaceResourceKind::Ticket,
|
||||||
|
ticket_id,
|
||||||
|
)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
Ok(Json(MergeRequestDetailResponse {
|
||||||
|
merge_request: mr,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
linked_tickets,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_merge_request_readiness(
|
async fn scoped_merge_request_readiness(
|
||||||
@@ -6392,7 +6526,7 @@ async fn scoped_get_profile_source_archive(
|
|||||||
async fn scoped_list_runtimes(
|
async fn scoped_list_runtimes(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
) -> ApiResult<Json<RuntimeListResponse<RuntimeSummary>>> {
|
) -> ApiResult<Json<workspace_api::ListResponse<workspace_api::RuntimeSummary>>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
list_runtimes(State(api)).await
|
list_runtimes(State(api)).await
|
||||||
}
|
}
|
||||||
@@ -6580,7 +6714,7 @@ async fn scoped_worker_remove_source_boundary(
|
|||||||
async fn scoped_get_workspace_worker(
|
async fn scoped_get_workspace_worker(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedWorkspaceWorkerReferencePath>,
|
AxumPath(path): AxumPath<ScopedWorkspaceWorkerReferencePath>,
|
||||||
) -> ApiResult<Json<WorkerSummary>> {
|
) -> ApiResult<Json<workspace_api::WorkerSummary>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
let worker_id = api
|
let worker_id = api
|
||||||
.store
|
.store
|
||||||
@@ -6596,7 +6730,7 @@ async fn scoped_get_workspace_worker(
|
|||||||
workers
|
workers
|
||||||
.items
|
.items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|worker| worker.worker.worker_id == worker_id)
|
.find(|worker| worker.worker_id == worker_id)
|
||||||
.map(Json)
|
.map(Json)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
Error::UnknownWorker {
|
Error::UnknownWorker {
|
||||||
@@ -6609,7 +6743,7 @@ async fn scoped_get_workspace_worker(
|
|||||||
async fn scoped_list_workers(
|
async fn scoped_list_workers(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
) -> ApiResult<Json<RuntimeListResponse<WorkerSummary>>> {
|
) -> ApiResult<Json<workspace_api::ListResponse<workspace_api::WorkerSummary>>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
list_workers(State(api)).await
|
list_workers(State(api)).await
|
||||||
}
|
}
|
||||||
@@ -6868,7 +7002,7 @@ async fn restore_known_worker(
|
|||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedRuntimeWorkerPath>,
|
AxumPath(path): AxumPath<ScopedRuntimeWorkerPath>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> ApiResult<Json<WorkerRestoreResponse>> {
|
) -> ApiResult<Json<workspace_api::WorkerRestoreResponse>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
let source = authenticate_worker_mutation_source(&api, &path.workspace_id, &headers)?;
|
let source = authenticate_worker_mutation_source(&api, &path.workspace_id, &headers)?;
|
||||||
let subject = path.worker.clone();
|
let subject = path.worker.clone();
|
||||||
@@ -7448,7 +7582,7 @@ fn build_runtime_cleanup_plan(
|
|||||||
.items
|
.items
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|worker| worker.state == "running")
|
.filter(|worker| worker.state == "running")
|
||||||
.map(|worker| worker.worker.clone())
|
.map(|worker| RuntimeWorkerRef::new(&worker.runtime_id, &worker.worker_id))
|
||||||
.collect();
|
.collect();
|
||||||
let (workdir_summaries, mut diagnostics) =
|
let (workdir_summaries, mut diagnostics) =
|
||||||
match runtime_working_directory_summaries(api, runtime_id) {
|
match runtime_working_directory_summaries(api, runtime_id) {
|
||||||
@@ -7965,7 +8099,7 @@ async fn scoped_list_runtime_workers(
|
|||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
||||||
Query(query): Query<RuntimeWorkersQuery>,
|
Query(query): Query<RuntimeWorkersQuery>,
|
||||||
) -> ApiResult<Json<RuntimeListResponse<WorkerSummary>>> {
|
) -> ApiResult<Json<workspace_api::ListResponse<workspace_api::WorkerSummary>>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
list_runtime_workers(State(api), AxumPath(path.runtime_id), Query(query)).await
|
list_runtime_workers(State(api), AxumPath(path.runtime_id), Query(query)).await
|
||||||
}
|
}
|
||||||
@@ -8024,7 +8158,7 @@ async fn scoped_restore_runtime_worker(
|
|||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedRuntimeWorkerPath>,
|
AxumPath(path): AxumPath<ScopedRuntimeWorkerPath>,
|
||||||
Query(query): Query<RestoreTicketAssignmentQuery>,
|
Query(query): Query<RestoreTicketAssignmentQuery>,
|
||||||
) -> ApiResult<Json<WorkerRestoreResponse>> {
|
) -> ApiResult<Json<workspace_api::WorkerRestoreResponse>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
let workspace_id = path.workspace_id.clone();
|
let workspace_id = path.workspace_id.clone();
|
||||||
let runtime_id = path.worker.runtime_id.clone();
|
let runtime_id = path.worker.runtime_id.clone();
|
||||||
@@ -8077,11 +8211,13 @@ async fn scoped_restore_runtime_worker(
|
|||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
assign_ticket_worker_from_lifecycle(&api, assignment, &runtime_id, &worker_id)?;
|
assign_ticket_worker_from_lifecycle(&api, assignment, &runtime_id, &worker_id)?;
|
||||||
return Ok(Json(WorkerRestoreResponse {
|
let worker = project_workspace_worker(&api, worker)?;
|
||||||
|
return Ok(Json(workspace_api::WorkerRestoreResponse {
|
||||||
workspace_id,
|
workspace_id,
|
||||||
worker_ref: RuntimeWorkerRef::new(&runtime_id, &worker_id),
|
runtime_id: runtime_id.clone(),
|
||||||
result: crate::hosts::WorkerRestoreResult {
|
worker_id: worker_id.clone(),
|
||||||
state: WorkerOperationState::Accepted,
|
result: workspace_api::WorkerRestoreResult {
|
||||||
|
state: workspace_api::WorkerOperationState::Accepted,
|
||||||
worker: Some(worker),
|
worker: Some(worker),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
},
|
},
|
||||||
@@ -8210,7 +8346,7 @@ async fn scoped_worker_protocol_ws(
|
|||||||
async fn scoped_list_host_workers(
|
async fn scoped_list_host_workers(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedHostPath>,
|
AxumPath(path): AxumPath<ScopedHostPath>,
|
||||||
) -> ApiResult<Json<RuntimeListResponse<WorkerSummary>>> {
|
) -> ApiResult<Json<workspace_api::ListResponse<workspace_api::WorkerSummary>>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
list_host_workers(State(api), AxumPath(path.host_id)).await
|
list_host_workers(State(api), AxumPath(path.host_id)).await
|
||||||
}
|
}
|
||||||
@@ -9162,21 +9298,21 @@ async fn list_hosts(
|
|||||||
|
|
||||||
async fn list_runtimes(
|
async fn list_runtimes(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
) -> ApiResult<Json<RuntimeListResponse<RuntimeSummary>>> {
|
) -> ApiResult<Json<workspace_api::ListResponse<workspace_api::RuntimeSummary>>> {
|
||||||
let limit = api.config.max_records.min(200);
|
let limit = api.config.max_records.min(200);
|
||||||
let runtimes = api.runtime.list_runtimes(limit);
|
let runtimes = api.runtime.list_runtimes(limit);
|
||||||
Ok(Json(RuntimeListResponse {
|
Ok(Json(workspace_api::ListResponse {
|
||||||
workspace_id: api.config.workspace_id,
|
workspace_id: api.config.workspace_id,
|
||||||
limit,
|
limit,
|
||||||
items: runtimes.items,
|
items: runtimes.items.into_iter().map(Into::into).collect(),
|
||||||
source: "worker_runtime_registry".to_string(),
|
source: "worker_runtime_registry".to_string(),
|
||||||
diagnostics: runtimes.diagnostics,
|
diagnostics: runtimes.diagnostics.into_iter().map(Into::into).collect(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_workers(
|
async fn list_workers(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
) -> ApiResult<Json<RuntimeListResponse<WorkerSummary>>> {
|
) -> ApiResult<Json<workspace_api::ListResponse<workspace_api::WorkerSummary>>> {
|
||||||
workers_response(api).map(Json)
|
workers_response(api).map(Json)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9883,7 +10019,7 @@ async fn post_companion_cancel(
|
|||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct WorkerShowProjection {
|
struct WorkerShowProjection {
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
worker: WorkerSummary,
|
worker: workspace_api::WorkerSummary,
|
||||||
updated_at: String,
|
updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9929,31 +10065,18 @@ async fn get_runtime_worker(
|
|||||||
.store
|
.store
|
||||||
.list_workdir_registry(&api.config.workspace_id, 500)?;
|
.list_workdir_registry(&api.config.workspace_id, 500)?;
|
||||||
let updated_at = record.updated_at.clone();
|
let updated_at = record.updated_at.clone();
|
||||||
let mut worker = merge_worker_registry_projection(Some(&worker), &record, links, &workdirs);
|
let worker = merge_worker_registry_projection(Some(&worker), &record, links, &workdirs);
|
||||||
worker.resource_key = Some(
|
let worker = project_workspace_worker(&api, worker)?;
|
||||||
api.store
|
|
||||||
.resource_key(
|
|
||||||
&api.config.workspace_id,
|
|
||||||
WorkspaceResourceKind::Worker,
|
|
||||||
&worker_ref.worker_id,
|
|
||||||
)?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
Error::Store(format!(
|
|
||||||
"Workspace Worker `{}` has no resource key",
|
|
||||||
worker_ref.worker_id
|
|
||||||
))
|
|
||||||
})?,
|
|
||||||
);
|
|
||||||
Ok(Json(WorkerShowProjection { worker, updated_at }))
|
Ok(Json(WorkerShowProjection { worker, updated_at }))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn restore_runtime_worker(
|
async fn restore_runtime_worker(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
|
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
|
||||||
) -> ApiResult<Json<WorkerRestoreResponse>> {
|
) -> ApiResult<Json<workspace_api::WorkerRestoreResponse>> {
|
||||||
let worker = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?;
|
let worker = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?;
|
||||||
let mut result = api.restore_workspace_worker(&worker)?;
|
let result = api.restore_workspace_worker(&worker)?;
|
||||||
if let Some(worker) = result.worker.as_ref() {
|
let projected_worker = if let Some(worker) = result.worker.as_ref() {
|
||||||
let record = sync_worker_observation(&api, worker)?;
|
let record = sync_worker_observation(&api, worker)?;
|
||||||
let links = api
|
let links = api
|
||||||
.store
|
.store
|
||||||
@@ -9961,27 +10084,20 @@ async fn restore_runtime_worker(
|
|||||||
let workdirs = api
|
let workdirs = api
|
||||||
.store
|
.store
|
||||||
.list_workdir_registry(&api.config.workspace_id, 500)?;
|
.list_workdir_registry(&api.config.workspace_id, 500)?;
|
||||||
let mut summary = merge_worker_registry_projection(Some(worker), &record, links, &workdirs);
|
let summary = merge_worker_registry_projection(Some(worker), &record, links, &workdirs);
|
||||||
summary.resource_key = Some(
|
Some(project_workspace_worker(&api, summary)?)
|
||||||
api.store
|
} else {
|
||||||
.resource_key(
|
None
|
||||||
&api.config.workspace_id,
|
};
|
||||||
WorkspaceResourceKind::Worker,
|
Ok(Json(workspace_api::WorkerRestoreResponse {
|
||||||
&record.worker.worker_id,
|
|
||||||
)?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
Error::Store(format!(
|
|
||||||
"Workspace Worker `{}` has no resource key",
|
|
||||||
record.worker.worker_id
|
|
||||||
))
|
|
||||||
})?,
|
|
||||||
);
|
|
||||||
result.worker = Some(summary);
|
|
||||||
}
|
|
||||||
Ok(Json(WorkerRestoreResponse {
|
|
||||||
workspace_id: api.workspace_id().to_string(),
|
workspace_id: api.workspace_id().to_string(),
|
||||||
worker_ref: RuntimeWorkerRef::new(&runtime_id, &worker_id),
|
runtime_id: runtime_id.clone(),
|
||||||
result,
|
worker_id: worker_id.clone(),
|
||||||
|
result: workspace_api::WorkerRestoreResult {
|
||||||
|
state: result.state.into(),
|
||||||
|
worker: projected_worker,
|
||||||
|
diagnostics: result.diagnostics.into_iter().map(Into::into).collect(),
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10037,28 +10153,33 @@ async fn list_runtime_workers(
|
|||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(runtime_id): AxumPath<String>,
|
AxumPath(runtime_id): AxumPath<String>,
|
||||||
Query(query): Query<RuntimeWorkersQuery>,
|
Query(query): Query<RuntimeWorkersQuery>,
|
||||||
) -> ApiResult<Json<RuntimeListResponse<WorkerSummary>>> {
|
) -> ApiResult<Json<workspace_api::ListResponse<workspace_api::WorkerSummary>>> {
|
||||||
let limit = api.config.max_records.min(200);
|
let limit = api.config.max_records.min(200);
|
||||||
let (worker_list, source) = match query.status {
|
let (runtime_workers, source) = match query.status {
|
||||||
Some(RuntimeWorkersStatusFilter::Stopped) => (
|
Some(RuntimeWorkersStatusFilter::Stopped) => (
|
||||||
api.runtime
|
api.runtime
|
||||||
.list_stopped_workers_for_runtime(&runtime_id, limit)
|
.list_stopped_workers_for_runtime(&runtime_id, limit)
|
||||||
.map_err(|err| err.into_error())?,
|
.map_err(|error| error.into_error())?,
|
||||||
"runtime_registry_stopped",
|
"runtime_registry_stopped",
|
||||||
),
|
),
|
||||||
None => (
|
None => (
|
||||||
api.runtime
|
api.runtime
|
||||||
.list_workers_for_runtime(&runtime_id, limit)
|
.list_workers_for_runtime(&runtime_id, limit)
|
||||||
.map_err(|err| err.into_error())?,
|
.map_err(|error| error.into_error())?,
|
||||||
"runtime_registry",
|
"runtime_registry",
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
Ok(Json(RuntimeListResponse {
|
let items = project_observed_workspace_workers(&api, runtime_workers.items)?;
|
||||||
|
Ok(Json(workspace_api::ListResponse {
|
||||||
workspace_id: api.workspace_id().to_string(),
|
workspace_id: api.workspace_id().to_string(),
|
||||||
limit,
|
limit,
|
||||||
items: worker_list.items,
|
items,
|
||||||
source: source.to_string(),
|
source: source.to_string(),
|
||||||
diagnostics: worker_list.diagnostics,
|
diagnostics: runtime_workers
|
||||||
|
.diagnostics
|
||||||
|
.into_iter()
|
||||||
|
.map(Into::into)
|
||||||
|
.collect(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11000,22 +11121,70 @@ fn protocol_error_event(message: impl Into<String>) -> protocol::Event {
|
|||||||
async fn list_host_workers(
|
async fn list_host_workers(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(host_id): AxumPath<String>,
|
AxumPath(host_id): AxumPath<String>,
|
||||||
) -> ApiResult<Json<RuntimeListResponse<WorkerSummary>>> {
|
) -> ApiResult<Json<workspace_api::ListResponse<workspace_api::WorkerSummary>>> {
|
||||||
let limit = api.config.max_records.min(200);
|
let limit = api.config.max_records.min(200);
|
||||||
let runtime_workers = api
|
let runtime_workers = api
|
||||||
.runtime
|
.runtime
|
||||||
.list_workers_for_host(&host_id, limit)
|
.list_workers_for_host(&host_id, limit)
|
||||||
.map_err(|err| err.into_error())?;
|
.map_err(|err| err.into_error())?;
|
||||||
Ok(Json(RuntimeListResponse {
|
let items = project_observed_workspace_workers(&api, runtime_workers.items)?;
|
||||||
workspace_id: api.config.workspace_id,
|
Ok(Json(workspace_api::ListResponse {
|
||||||
|
workspace_id: api.workspace_id().to_string(),
|
||||||
limit,
|
limit,
|
||||||
items: runtime_workers.items,
|
items,
|
||||||
source: "worker_runtime_registry".to_string(),
|
source: "worker_runtime_registry".to_string(),
|
||||||
diagnostics: runtime_workers.diagnostics,
|
diagnostics: runtime_workers
|
||||||
|
.diagnostics
|
||||||
|
.into_iter()
|
||||||
|
.map(Into::into)
|
||||||
|
.collect(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn workers_response(api: WorkspaceApi) -> ApiResult<RuntimeListResponse<WorkerSummary>> {
|
fn project_workspace_worker(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
summary: WorkerSummary,
|
||||||
|
) -> ApiResult<workspace_api::WorkerSummary> {
|
||||||
|
let resource_key = api
|
||||||
|
.store
|
||||||
|
.resource_key(
|
||||||
|
&api.config.workspace_id,
|
||||||
|
WorkspaceResourceKind::Worker,
|
||||||
|
&summary.worker.worker_id,
|
||||||
|
)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Error::Store(format!(
|
||||||
|
"Workspace Worker `{}` has no resource key",
|
||||||
|
summary.worker.worker_id
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(workspace_worker_summary(summary, resource_key))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project_observed_workspace_workers(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
workers: Vec<WorkerSummary>,
|
||||||
|
) -> ApiResult<Vec<workspace_api::WorkerSummary>> {
|
||||||
|
let workdirs = api
|
||||||
|
.store
|
||||||
|
.list_workdir_registry(&api.config.workspace_id, 500)?;
|
||||||
|
workers
|
||||||
|
.into_iter()
|
||||||
|
.map(|worker| {
|
||||||
|
let record = sync_worker_observation(api, &worker)?;
|
||||||
|
let links = api
|
||||||
|
.store
|
||||||
|
.list_worker_workdir_links(&api.config.workspace_id, &record.worker)?;
|
||||||
|
let summary =
|
||||||
|
merge_worker_registry_projection(Some(&worker), &record, links, &workdirs);
|
||||||
|
project_workspace_worker(api, summary)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workers_response(
|
||||||
|
api: WorkspaceApi,
|
||||||
|
) -> ApiResult<workspace_api::ListResponse<workspace_api::WorkerSummary>> {
|
||||||
let limit = api.config.max_records.min(200);
|
let limit = api.config.max_records.min(200);
|
||||||
let runtime_workers = api.runtime.list_workers(limit);
|
let runtime_workers = api.runtime.list_workers(limit);
|
||||||
let mut observed = std::collections::BTreeMap::new();
|
let mut observed = std::collections::BTreeMap::new();
|
||||||
@@ -11054,34 +11223,20 @@ fn workers_response(api: WorkspaceApi) -> ApiResult<RuntimeListResponse<WorkerSu
|
|||||||
let links = api
|
let links = api
|
||||||
.store
|
.store
|
||||||
.list_worker_workdir_links(&api.config.workspace_id, &record.worker)?;
|
.list_worker_workdir_links(&api.config.workspace_id, &record.worker)?;
|
||||||
let mut summary = merge_worker_registry_projection(
|
let summary = merge_worker_registry_projection(
|
||||||
observed.get(&record.worker),
|
observed.get(&record.worker),
|
||||||
&record,
|
&record,
|
||||||
links,
|
links,
|
||||||
&workdir_records,
|
&workdir_records,
|
||||||
);
|
);
|
||||||
summary.resource_key = Some(
|
items.push(project_workspace_worker(&api, summary)?);
|
||||||
api.store
|
|
||||||
.resource_key(
|
|
||||||
&api.config.workspace_id,
|
|
||||||
WorkspaceResourceKind::Worker,
|
|
||||||
&record.worker.worker_id,
|
|
||||||
)?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
Error::Store(format!(
|
|
||||||
"Workspace Worker `{}` has no resource key",
|
|
||||||
record.worker.worker_id
|
|
||||||
))
|
|
||||||
})?,
|
|
||||||
);
|
|
||||||
items.push(summary);
|
|
||||||
}
|
}
|
||||||
Ok(RuntimeListResponse {
|
Ok(workspace_api::ListResponse {
|
||||||
workspace_id: api.config.workspace_id,
|
workspace_id: api.config.workspace_id,
|
||||||
limit,
|
limit,
|
||||||
items,
|
items,
|
||||||
source: "backend_worker_registry".to_string(),
|
source: "backend_worker_registry".to_string(),
|
||||||
diagnostics,
|
diagnostics: diagnostics.into_iter().map(Into::into).collect(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11942,7 +12097,6 @@ fn record_worker_summary(
|
|||||||
fn worker_summary_from_registry(record: &WorkerRegistryRecord) -> WorkerSummary {
|
fn worker_summary_from_registry(record: &WorkerRegistryRecord) -> WorkerSummary {
|
||||||
WorkerSummary {
|
WorkerSummary {
|
||||||
worker: record.worker.clone(),
|
worker: record.worker.clone(),
|
||||||
resource_key: None,
|
|
||||||
host_id: "backend-registry".to_string(),
|
host_id: "backend-registry".to_string(),
|
||||||
display_name: record.display_name.clone(),
|
display_name: record.display_name.clone(),
|
||||||
label: record.display_name.clone(),
|
label: record.display_name.clone(),
|
||||||
@@ -15837,11 +15991,11 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
assert_eq!(retried_restore.worker_id, first_worker.worker.worker_id);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
retried_restore.worker_ref.worker_id,
|
retried_restore.result.state,
|
||||||
first_worker.worker.worker_id
|
workspace_api::WorkerOperationState::Accepted
|
||||||
);
|
);
|
||||||
assert_eq!(retried_restore.result.state, WorkerOperationState::Accepted);
|
|
||||||
let restored_assignment = api
|
let restored_assignment = api
|
||||||
.store
|
.store
|
||||||
.get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &second_ticket.id)
|
.get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &second_ticket.id)
|
||||||
@@ -17878,6 +18032,67 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn merge_request_reads_use_first_class_workspace_resources() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let app = build_router(test_api(dir.path()).await);
|
||||||
|
|
||||||
|
let collection = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri(format!("/api/w/{TEST_WORKSPACE_ID}/merge-requests"))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(collection.status(), StatusCode::OK);
|
||||||
|
let body = to_bytes(collection.into_body(), usize::MAX).await.unwrap();
|
||||||
|
let body: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(body["items"], json!([]));
|
||||||
|
assert!(body["next_cursor"].is_null());
|
||||||
|
|
||||||
|
let missing = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri(format!("/api/w/{TEST_WORKSPACE_ID}/merge-requests/missing"))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
|
let nested = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri(format!(
|
||||||
|
"/api/w/{TEST_WORKSPACE_ID}/tickets/T-1/merge-request"
|
||||||
|
))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(nested.status(), StatusCode::METHOD_NOT_ALLOWED);
|
||||||
|
|
||||||
|
let invalid_filter = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri(format!(
|
||||||
|
"/api/w/{TEST_WORKSPACE_ID}/merge-requests?state=unknown"
|
||||||
|
))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(invalid_filter.status(), StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn ticket_rest_search_requires_worker_source_identity_and_rpc_route_is_removed() {
|
async fn ticket_rest_search_requires_worker_source_identity_and_rpc_route_is_removed() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -17956,6 +18171,23 @@ mod tests {
|
|||||||
assert_eq!(worker["profile"], "builtin:companion");
|
assert_eq!(worker["profile"], "builtin:companion");
|
||||||
assert!(worker.get("role").is_none());
|
assert!(worker.get("role").is_none());
|
||||||
assert_eq!(worker["worker_id"], created["worker_id"]);
|
assert_eq!(worker["worker_id"], created["worker_id"]);
|
||||||
|
let resource_key = worker["resource_key"]
|
||||||
|
.as_str()
|
||||||
|
.expect("Workspace Worker list must project a resource key");
|
||||||
|
assert!(resource_key.starts_with("W-"));
|
||||||
|
|
||||||
|
let runtime_workers =
|
||||||
|
get_json(app.clone(), "/api/runtimes/embedded-worker-runtime/workers").await;
|
||||||
|
let runtime_workers = serde_json::from_value::<
|
||||||
|
workspace_api::ListResponse<workspace_api::WorkerSummary>,
|
||||||
|
>(runtime_workers)
|
||||||
|
.expect("Runtime-scoped Worker list must use the shared Workspace API contract");
|
||||||
|
assert!(
|
||||||
|
runtime_workers
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.any(|worker| worker.resource_key == resource_key)
|
||||||
|
);
|
||||||
let detail_path = format!(
|
let detail_path = format!(
|
||||||
"/api/runtimes/{}/workers/{}",
|
"/api/runtimes/{}/workers/{}",
|
||||||
created["runtime_id"].as_str().unwrap(),
|
created["runtime_id"].as_str().unwrap(),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||||
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.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/tasks.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-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
|
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.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 test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.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/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
|
||||||
"build": "deno run -A npm:vite@7.2.7 build",
|
"build": "deno run -A npm:vite@7.2.7 build",
|
||||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -90,6 +90,17 @@ export type TicketMergeRequestSummary = {
|
|||||||
review_excerpt: string | null;
|
review_excerpt: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MergeRequestListItem = {
|
||||||
|
summary: TicketMergeRequestSummary;
|
||||||
|
ticket_ids: Array<string>;
|
||||||
|
thread_event_count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MergeRequestListResponse = {
|
||||||
|
items: Array<MergeRequestListItem>;
|
||||||
|
next_cursor: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type TicketEvidenceSummary = {
|
export type TicketEvidenceSummary = {
|
||||||
has_merge_request: boolean;
|
has_merge_request: boolean;
|
||||||
has_current_subject_ref: boolean;
|
has_current_subject_ref: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { MergeRequestListResponse } from "$lib/generated/ticket-api";
|
||||||
|
import { workspaceApiPath } from "./http";
|
||||||
|
|
||||||
|
export type MergeRequestState = "open" | "merged" | "closed";
|
||||||
|
|
||||||
|
export type MergeRequestActor = {
|
||||||
|
runtime_id: string;
|
||||||
|
worker_id: string;
|
||||||
|
assignment_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MergeRequestThreadEvent = {
|
||||||
|
kind: string;
|
||||||
|
sequence: number;
|
||||||
|
at: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MergeRequestRecord = {
|
||||||
|
merge_request_id: string;
|
||||||
|
workspace_id: string;
|
||||||
|
repository_id: string;
|
||||||
|
selector_from: string | null;
|
||||||
|
selector_to: string;
|
||||||
|
ticket_ids: string[];
|
||||||
|
state: MergeRequestState;
|
||||||
|
opened_by: MergeRequestActor;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
thread: MergeRequestThreadEvent[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MergeRequestRefObservation = {
|
||||||
|
status: string;
|
||||||
|
ref: string | null;
|
||||||
|
observed_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MergeRequestDetail = MergeRequestRecord & {
|
||||||
|
source: MergeRequestRefObservation;
|
||||||
|
target: MergeRequestRefObservation;
|
||||||
|
linked_tickets: Array<{ ticket_id: string; key: string | null }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MergeRequestListPage = MergeRequestListResponse;
|
||||||
|
|
||||||
|
export function mergeRequestCollectionPath(workspaceId: string): string {
|
||||||
|
return workspaceApiPath(workspaceId, "/merge-requests");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeRequestDetailPath(
|
||||||
|
workspaceId: string,
|
||||||
|
mergeRequestId: string,
|
||||||
|
): string {
|
||||||
|
return workspaceApiPath(
|
||||||
|
workspaceId,
|
||||||
|
`/merge-requests/${encodeURIComponent(mergeRequestId)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeRequestPagePath(
|
||||||
|
workspaceId: string,
|
||||||
|
mergeRequestId?: string,
|
||||||
|
): string {
|
||||||
|
const root = `/w/${encodeURIComponent(workspaceId)}/merge-requests`;
|
||||||
|
return mergeRequestId
|
||||||
|
? `${root}/${encodeURIComponent(mergeRequestId)}`
|
||||||
|
: root;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ansiSegments } from "./ansi.ts";
|
||||||
|
|
||||||
|
let { text }: { text: string } = $props();
|
||||||
|
let segments = $derived(ansiSegments(text));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#each segments as segment}
|
||||||
|
<span
|
||||||
|
class:ansi-bold={segment.bold}
|
||||||
|
class:ansi-dim={segment.dim}
|
||||||
|
class:ansi-italic={segment.italic}
|
||||||
|
class:ansi-underline={segment.underline}
|
||||||
|
class:ansi-strikethrough={segment.strikethrough}
|
||||||
|
class:ansi-concealed={segment.concealed}
|
||||||
|
style:color={segment.foreground}
|
||||||
|
style:background-color={segment.background}
|
||||||
|
>{segment.text}</span>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ansi-bold {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ansi-dim {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ansi-italic {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ansi-underline {
|
||||||
|
text-decoration-line: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ansi-strikethrough {
|
||||||
|
text-decoration-line: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ansi-underline.ansi-strikethrough {
|
||||||
|
text-decoration-line: underline line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ansi-concealed {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import AnsiText from '$lib/workspace/console/AnsiText.svelte';
|
||||||
import RichMarkdown from '$lib/workspace/console/RichMarkdown.svelte';
|
import RichMarkdown from '$lib/workspace/console/RichMarkdown.svelte';
|
||||||
import type { ConsoleLine } from '$lib/workspace/console/model';
|
import type { ConsoleLine } from '$lib/workspace/console/model';
|
||||||
|
|
||||||
@@ -18,6 +19,10 @@
|
|||||||
return [name ? `tool-${name}` : '', `tool-state-${state}`].filter(Boolean).join(' ');
|
return [name ? `tool-${name}` : '', `tool-state-${state}`].filter(Boolean).join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBashTool(line: ConsoleLine): boolean {
|
||||||
|
return line.toolCall?.name?.toLowerCase() === 'bash';
|
||||||
|
}
|
||||||
|
|
||||||
function shouldRenderHeading(line: ConsoleLine): boolean {
|
function shouldRenderHeading(line: ConsoleLine): boolean {
|
||||||
return line.kind !== 'assistant' && line.kind !== 'user' && line.kind !== 'tool' &&
|
return line.kind !== 'assistant' && line.kind !== 'user' && line.kind !== 'tool' &&
|
||||||
line.kind !== 'activity' && line.kind !== 'task_reminder' && line.kind !== 'run_stats';
|
line.kind !== 'activity' && line.kind !== 'task_reminder' && line.kind !== 'run_stats';
|
||||||
@@ -60,7 +65,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if item.kind === 'tool'}
|
{#if item.kind === 'tool'}
|
||||||
{#if bodyTextAfterToolSummary(item)}
|
{#if bodyTextAfterToolSummary(item)}
|
||||||
<p class="console-plain-text">{bodyTextAfterToolSummary(item)}</p>
|
<p class="console-plain-text">
|
||||||
|
{#if isBashTool(item)}
|
||||||
|
<AnsiText text={bodyTextAfterToolSummary(item)} />
|
||||||
|
{:else}
|
||||||
|
{bodyTextAfterToolSummary(item)}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if item.kind === 'user'}
|
{:else if item.kind === 'user'}
|
||||||
<div class="user-message">
|
<div class="user-message">
|
||||||
@@ -130,6 +141,10 @@
|
|||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.activity-summary {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.task-reminder-summary {
|
.task-reminder-summary {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -184,7 +199,10 @@
|
|||||||
display: block;
|
display: block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.1;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
}
|
}
|
||||||
@@ -193,6 +211,10 @@
|
|||||||
color: var(--tui-gray);
|
color: var(--tui-gray);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.console-line.tool.tool-bash .console-plain-text {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
.tool-summary {
|
.tool-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
|
|||||||
@@ -28,6 +28,10 @@
|
|||||||
.slice(0, 3),
|
.slice(0, 3),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function taskNoun(count: number): "task" | "tasks" {
|
||||||
|
return count === 1 ? "task" : "tasks";
|
||||||
|
}
|
||||||
|
|
||||||
function mark(status: ConsoleTask["status"]): string {
|
function mark(status: ConsoleTask["status"]): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "pending":
|
case "pending":
|
||||||
@@ -54,7 +58,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
<div class="task-summary-row">
|
<div class="task-summary-row">
|
||||||
<span class="task-summary">
|
<span class="task-summary">
|
||||||
{counts.total} task(s) — pending: {counts.pending}, inprogress: {counts.inprogress}, completed: {counts.completed}, deleted: {counts.deleted}
|
{counts.total} {taskNoun(counts.total)} — pending: {counts.pending}, inprogress: {counts.inprogress}, completed: {counts.completed}
|
||||||
</span>
|
</span>
|
||||||
{#if workerViews.length > 1}
|
{#if workerViews.length > 1}
|
||||||
<span class="worker-view-tabs" role="group" aria-label="Worker transcript view">
|
<span class="worker-view-tabs" role="group" aria-label="Worker transcript view">
|
||||||
@@ -119,7 +123,6 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.1rem;
|
gap: 0.1rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin-bottom: -0.75rem;
|
|
||||||
padding-inline: 0.75rem;
|
padding-inline: 0.75rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
export type AnsiSegment = {
|
||||||
|
text: string;
|
||||||
|
foreground?: string;
|
||||||
|
background?: string;
|
||||||
|
bold: boolean;
|
||||||
|
dim: boolean;
|
||||||
|
italic: boolean;
|
||||||
|
underline: boolean;
|
||||||
|
strikethrough: boolean;
|
||||||
|
concealed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AnsiState = Omit<AnsiSegment, "text"> & { inverse: boolean };
|
||||||
|
|
||||||
|
const ANSI_PALETTE = [
|
||||||
|
"#1e1e1e",
|
||||||
|
"#cd3131",
|
||||||
|
"#0dbc79",
|
||||||
|
"#e5e510",
|
||||||
|
"#2472c8",
|
||||||
|
"#bc3fbc",
|
||||||
|
"#11a8cd",
|
||||||
|
"#e5e5e5",
|
||||||
|
"#666666",
|
||||||
|
"#f14c4c",
|
||||||
|
"#23d18b",
|
||||||
|
"#f5f543",
|
||||||
|
"#3b8eea",
|
||||||
|
"#d670d6",
|
||||||
|
"#29b8db",
|
||||||
|
"#ffffff",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function ansiSegments(input: string): AnsiSegment[] {
|
||||||
|
const segments: AnsiSegment[] = [];
|
||||||
|
let state = defaultState();
|
||||||
|
let text = "";
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (!text) return;
|
||||||
|
segments.push(segmentFromState(text, state));
|
||||||
|
text = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < input.length;) {
|
||||||
|
const code = input.charCodeAt(index);
|
||||||
|
if (code !== 0x1b) {
|
||||||
|
if (code >= 0x20 || code === 0x09 || code === 0x0a || code === 0x0d) {
|
||||||
|
text += input[index];
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
flush();
|
||||||
|
const next = input[index + 1];
|
||||||
|
if (next === "[") {
|
||||||
|
const finalIndex = findCsiFinal(input, index + 2);
|
||||||
|
if (finalIndex < 0) break;
|
||||||
|
if (input[finalIndex] === "m") {
|
||||||
|
state = applySgr(state, input.slice(index + 2, finalIndex));
|
||||||
|
}
|
||||||
|
index = finalIndex + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (next === "]") {
|
||||||
|
const finalIndex = findOscFinal(input, index + 2);
|
||||||
|
if (finalIndex < 0) break;
|
||||||
|
index = finalIndex;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
index += next === undefined ? 1 : 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
flush();
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultState(): AnsiState {
|
||||||
|
return {
|
||||||
|
foreground: undefined,
|
||||||
|
background: undefined,
|
||||||
|
bold: false,
|
||||||
|
dim: false,
|
||||||
|
italic: false,
|
||||||
|
underline: false,
|
||||||
|
strikethrough: false,
|
||||||
|
concealed: false,
|
||||||
|
inverse: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentFromState(text: string, state: AnsiState): AnsiSegment {
|
||||||
|
const foreground = state.inverse
|
||||||
|
? state.background ?? "var(--bg)"
|
||||||
|
: state.foreground;
|
||||||
|
const background = state.inverse
|
||||||
|
? state.foreground ?? "var(--text)"
|
||||||
|
: state.background;
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
foreground,
|
||||||
|
background,
|
||||||
|
bold: state.bold,
|
||||||
|
dim: state.dim,
|
||||||
|
italic: state.italic,
|
||||||
|
underline: state.underline,
|
||||||
|
strikethrough: state.strikethrough,
|
||||||
|
concealed: state.concealed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCsiFinal(input: string, start: number): number {
|
||||||
|
for (let index = start; index < input.length; index += 1) {
|
||||||
|
const code = input.charCodeAt(index);
|
||||||
|
if (code >= 0x40 && code <= 0x7e) return index;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findOscFinal(input: string, start: number): number {
|
||||||
|
for (let index = start; index < input.length; index += 1) {
|
||||||
|
if (input.charCodeAt(index) === 0x07) return index + 1;
|
||||||
|
if (input.charCodeAt(index) === 0x1b && input[index + 1] === "\\") {
|
||||||
|
return index + 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySgr(current: AnsiState, raw: string): AnsiState {
|
||||||
|
const state = { ...current };
|
||||||
|
const values = raw === "" ? [0] : raw.split(";").map(parseSgrValue);
|
||||||
|
for (let index = 0; index < values.length; index += 1) {
|
||||||
|
const code = values[index];
|
||||||
|
if (code === null) continue;
|
||||||
|
if (code === 0) Object.assign(state, defaultState());
|
||||||
|
else if (code === 1) state.bold = true;
|
||||||
|
else if (code === 2) state.dim = true;
|
||||||
|
else if (code === 3) state.italic = true;
|
||||||
|
else if (code === 4) state.underline = true;
|
||||||
|
else if (code === 7) state.inverse = true;
|
||||||
|
else if (code === 8) state.concealed = true;
|
||||||
|
else if (code === 9) state.strikethrough = true;
|
||||||
|
else if (code === 22) {
|
||||||
|
state.bold = false;
|
||||||
|
state.dim = false;
|
||||||
|
} else if (code === 23) state.italic = false;
|
||||||
|
else if (code === 24) state.underline = false;
|
||||||
|
else if (code === 27) state.inverse = false;
|
||||||
|
else if (code === 28) state.concealed = false;
|
||||||
|
else if (code === 29) state.strikethrough = false;
|
||||||
|
else if (code >= 30 && code <= 37) {
|
||||||
|
state.foreground = ANSI_PALETTE[code - 30];
|
||||||
|
} else if (code === 38 || code === 48) {
|
||||||
|
const parsed = parseExtendedColor(values, index + 1);
|
||||||
|
if (parsed) {
|
||||||
|
if (code === 38) state.foreground = parsed.color;
|
||||||
|
else state.background = parsed.color;
|
||||||
|
index += parsed.consumed;
|
||||||
|
}
|
||||||
|
} else if (code === 39) state.foreground = undefined;
|
||||||
|
else if (code >= 40 && code <= 47) {
|
||||||
|
state.background = ANSI_PALETTE[code - 40];
|
||||||
|
} else if (code === 49) state.background = undefined;
|
||||||
|
else if (code >= 90 && code <= 97) {
|
||||||
|
state.foreground = ANSI_PALETTE[code - 82];
|
||||||
|
} else if (code >= 100 && code <= 107) {
|
||||||
|
state.background = ANSI_PALETTE[code - 92];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSgrValue(value: string): number | null {
|
||||||
|
if (!/^\d+$/.test(value)) return null;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isSafeInteger(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseExtendedColor(
|
||||||
|
values: Array<number | null>,
|
||||||
|
start: number,
|
||||||
|
): { color: string; consumed: number } | null {
|
||||||
|
const mode = values[start];
|
||||||
|
const first = values[start + 1];
|
||||||
|
if (mode === 5 && isByte(first)) {
|
||||||
|
return { color: palette256(first), consumed: 2 };
|
||||||
|
}
|
||||||
|
const second = values[start + 2];
|
||||||
|
const third = values[start + 3];
|
||||||
|
if (mode === 2 && isByte(first) && isByte(second) && isByte(third)) {
|
||||||
|
return {
|
||||||
|
color: `rgb(${first}, ${second}, ${third})`,
|
||||||
|
consumed: 4,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isByte(value: number | null | undefined): value is number {
|
||||||
|
return value !== null && value !== undefined && value >= 0 && value <= 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
function palette256(index: number): string {
|
||||||
|
if (index < 16) return ANSI_PALETTE[index];
|
||||||
|
if (index < 232) {
|
||||||
|
const offset = index - 16;
|
||||||
|
const red = Math.floor(offset / 36);
|
||||||
|
const green = Math.floor((offset % 36) / 6);
|
||||||
|
const blue = offset % 6;
|
||||||
|
return `rgb(${colorCube(red)}, ${colorCube(green)}, ${colorCube(blue)})`;
|
||||||
|
}
|
||||||
|
const gray = 8 + (index - 232) * 10;
|
||||||
|
return `rgb(${gray}, ${gray}, ${gray})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorCube(value: number): number {
|
||||||
|
return value === 0 ? 0 : 55 + value * 40;
|
||||||
|
}
|
||||||
@@ -422,9 +422,11 @@ Deno.test("projectConsole streams distinct Bash stdout and stderr through termin
|
|||||||
|
|
||||||
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||||
assert(line.body.includes("Bash — failed (exit 7)"), line.body);
|
assert(line.body.includes("Bash — failed (exit 7)"), line.body);
|
||||||
assert(line.body.includes("elapsed 300ms"), line.body);
|
assert(!line.body.includes("elapsed"), line.body);
|
||||||
assert(line.body.includes("stdout:\nready\n"), line.body);
|
assert(!line.body.includes("stdout:"), line.body);
|
||||||
|
assert(line.body.includes("ready\n"), line.body);
|
||||||
assert(line.body.includes("stderr:\nwarn\n"), line.body);
|
assert(line.body.includes("stderr:\nwarn\n"), line.body);
|
||||||
|
assert(line.detail?.includes("command: elapsed 300ms"), line.detail ?? "");
|
||||||
assertEquals(line.streaming, false);
|
assertEquals(line.streaming, false);
|
||||||
assertEquals(line.error, true);
|
assertEquals(line.error, true);
|
||||||
});
|
});
|
||||||
@@ -462,12 +464,13 @@ Deno.test("snapshot restores bounded in-flight Bash command output", () => {
|
|||||||
const projection = projectConsole([{ eventId: "snapshot-command", event: snapshot }]);
|
const projection = projectConsole([{ eventId: "snapshot-command", event: snapshot }]);
|
||||||
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||||
assert(line.body.includes("Bash — running…"), line.body);
|
assert(line.body.includes("Bash — running…"), line.body);
|
||||||
|
assert(!line.body.includes("elapsed"), line.body);
|
||||||
|
assert(!line.body.includes("stdout:"), line.body);
|
||||||
|
assert(line.body.includes("[… earlier stdout omitted]\ntail\n"), line.body);
|
||||||
assert(
|
assert(
|
||||||
line.body.includes("elapsed 250ms · last output at +200ms"),
|
line.detail?.includes("command: elapsed 250ms · last output at +200ms"),
|
||||||
line.body,
|
line.detail ?? "",
|
||||||
);
|
);
|
||||||
assert(line.body.includes("[stdout tail; earlier output omitted]"), line.body);
|
|
||||||
assert(line.body.includes("stdout:\ntail\n"), line.body);
|
|
||||||
assertEquals(line.streaming, true);
|
assertEquals(line.streaming, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1505,7 +1505,6 @@ function renderBashTool(toolCall: ToolCallView): string {
|
|||||||
return compactLines([
|
return compactLines([
|
||||||
`Bash — ${commandStateSuffix(toolCall)}`,
|
`Bash — ${commandStateSuffix(toolCall)}`,
|
||||||
command ? `$ ${command}` : argsText(toolCall),
|
command ? `$ ${command}` : argsText(toolCall),
|
||||||
commandTiming(toolCall.command),
|
|
||||||
["done", "error"].includes(toolCall.state)
|
["done", "error"].includes(toolCall.state)
|
||||||
? cappedDisplaySection(resultText(toolCall), 10)
|
? cappedDisplaySection(resultText(toolCall), 10)
|
||||||
: renderLiveCommandOutput(toolCall.command),
|
: renderLiveCommandOutput(toolCall.command),
|
||||||
@@ -1549,11 +1548,17 @@ function durationLabel(milliseconds: number): string {
|
|||||||
|
|
||||||
function renderLiveCommandOutput(command?: CommandSnapshot): string | undefined {
|
function renderLiveCommandOutput(command?: CommandSnapshot): string | undefined {
|
||||||
if (!command) return undefined;
|
if (!command) return undefined;
|
||||||
|
const stdout = compactLines([
|
||||||
|
command.stdout.truncated ? "[… earlier stdout omitted]" : undefined,
|
||||||
|
command.stdout.content,
|
||||||
|
]);
|
||||||
|
const stderr = compactLines([
|
||||||
|
command.stderr.truncated ? "[… earlier stderr omitted]" : undefined,
|
||||||
|
command.stderr.content,
|
||||||
|
]);
|
||||||
return compactLines([
|
return compactLines([
|
||||||
command.stdout.truncated ? "[stdout tail; earlier output omitted]" : undefined,
|
stdout,
|
||||||
command.stdout.content ? `stdout:\n${command.stdout.content}` : undefined,
|
stderr ? `stderr:\n${stderr}` : undefined,
|
||||||
command.stderr.truncated ? "[stderr tail; earlier output omitted]" : undefined,
|
|
||||||
command.stderr.content ? `stderr:\n${command.stderr.content}` : undefined,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1569,6 +1574,7 @@ function toolCallDetail(toolCall: ToolCallView): string {
|
|||||||
return compactLines([
|
return compactLines([
|
||||||
`id: ${toolCall.id}`,
|
`id: ${toolCall.id}`,
|
||||||
`state: ${stateSuffix(toolCall.state)}`,
|
`state: ${stateSuffix(toolCall.state)}`,
|
||||||
|
toolCall.command ? `command: ${commandTiming(toolCall.command)}` : undefined,
|
||||||
toolCall.summary
|
toolCall.summary
|
||||||
? `summary: ${
|
? `summary: ${
|
||||||
normalizeKnownToolResult(toolCall.name, toolCall.summary, toolCall.cwd)
|
normalizeKnownToolResult(toolCall.name, toolCall.summary, toolCall.cwd)
|
||||||
|
|||||||
@@ -357,6 +357,38 @@ Deno.test("Worker Console uses protocol observation events without transcript fe
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("Worker Console owns its narrower centered shell width", async () => {
|
||||||
|
const page = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const rootLayout = await Deno.readTextFile(
|
||||||
|
new URL("./../../../routes/+layout.svelte", import.meta.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
page.includes(".console-shell {") &&
|
||||||
|
page.includes("max-width: 920px;") &&
|
||||||
|
page.includes("margin-inline: auto;") &&
|
||||||
|
rootLayout.includes("max-width: 1280px;"),
|
||||||
|
"Root content should allow 1280px while Worker Console remains centered at 920px",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Worker Console overview activity summaries use 14px text", async () => {
|
||||||
|
const consoleLine = await Deno.readTextFile(
|
||||||
|
new URL("./ConsoleLineItem.svelte", import.meta.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
consoleLine.includes(".activity-summary {") &&
|
||||||
|
consoleLine.includes("font-size: 14px;"),
|
||||||
|
"Overview activity summaries such as ran command counts should render at 14px",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("Worker Console renders markdown only for message rows", async () => {
|
Deno.test("Worker Console renders markdown only for message rows", async () => {
|
||||||
const consoleLine = await Deno.readTextFile(
|
const consoleLine = await Deno.readTextFile(
|
||||||
new URL("./ConsoleLineItem.svelte", import.meta.url),
|
new URL("./ConsoleLineItem.svelte", import.meta.url),
|
||||||
@@ -365,12 +397,22 @@ Deno.test("Worker Console renders markdown only for message rows", async () => {
|
|||||||
assert(
|
assert(
|
||||||
consoleLine.includes("function shouldRenderMarkdown") &&
|
consoleLine.includes("function shouldRenderMarkdown") &&
|
||||||
consoleLine.includes("item.kind === 'tool'") &&
|
consoleLine.includes("item.kind === 'tool'") &&
|
||||||
|
consoleLine.includes("{#if isBashTool(item)}") &&
|
||||||
consoleLine.includes(
|
consoleLine.includes(
|
||||||
'<p class="console-plain-text">{bodyTextAfterToolSummary(item)}</p>',
|
"<AnsiText text={bodyTextAfterToolSummary(item)} />",
|
||||||
) &&
|
) &&
|
||||||
|
consoleLine.includes(
|
||||||
|
".console-line.tool-bash .console-plain-text",
|
||||||
|
) &&
|
||||||
|
consoleLine.includes(
|
||||||
|
".console-line.tool.tool-bash .console-plain-text",
|
||||||
|
) &&
|
||||||
|
consoleLine.includes("font-size: 12px;") &&
|
||||||
|
consoleLine.includes("line-height: 1.1;") &&
|
||||||
consoleLine.includes("{:else if shouldRenderMarkdown(item)}") &&
|
consoleLine.includes("{:else if shouldRenderMarkdown(item)}") &&
|
||||||
consoleLine.includes("<RichMarkdown text={item.body || '—'} />"),
|
consoleLine.includes("<RichMarkdown text={item.body || '—'} />") &&
|
||||||
"Console should keep markdown rendering to user/assistant/system message bodies and render tool text literally",
|
!consoleLine.includes("{@html"),
|
||||||
|
"Console should keep markdown rendering to message bodies, safely project Bash ANSI, and render other tool text literally",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -425,6 +467,35 @@ Deno.test("Worker Console exposes a foldable timeline beside the scroll body", a
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("Worker Console removes redundant chrome and uses shared alerts", async () => {
|
||||||
|
const page = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const tasks = await Deno.readTextFile(
|
||||||
|
new URL("./ConsoleTasks.svelte", import.meta.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
page.includes('<form class="console-composer"') &&
|
||||||
|
!page.includes('class="console-composer card"') &&
|
||||||
|
!page.includes(
|
||||||
|
"padding: var(--space-3) var(--space-6) var(--space-4)",
|
||||||
|
) &&
|
||||||
|
!page.includes("margin-inline: calc(-1 * var(--space-6))") &&
|
||||||
|
page.includes('import { pushWorkspaceAlert }') &&
|
||||||
|
page.includes('title: "Worker control"') &&
|
||||||
|
page.includes('title: "Rewind targets"') &&
|
||||||
|
page.includes('pushWorkspaceAlert("error"') &&
|
||||||
|
!page.includes("controlNotice") &&
|
||||||
|
!page.includes("console-notice") &&
|
||||||
|
!tasks.includes("margin-bottom: -0.75rem"),
|
||||||
|
"Console should remove redundant card/spacing chrome and route control notices through workspace alerts",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("Worker Console composer fits to content without manual resize", async () => {
|
Deno.test("Worker Console composer fits to content without manual resize", async () => {
|
||||||
const consolePage = await Deno.readTextFile(
|
const consolePage = await Deno.readTextFile(
|
||||||
new URL(
|
new URL(
|
||||||
@@ -806,9 +877,10 @@ Deno.test("Web Console renders the client-projected Worker task store", async ()
|
|||||||
tasksComponent.includes("[~]") &&
|
tasksComponent.includes("[~]") &&
|
||||||
tasksComponent.includes("[x]") &&
|
tasksComponent.includes("[x]") &&
|
||||||
tasksComponent.includes("[-]") &&
|
tasksComponent.includes("[-]") &&
|
||||||
tasksComponent.includes("task(s) — pending:") &&
|
tasksComponent.includes('return count === 1 ? "task" : "tasks"') &&
|
||||||
|
!tasksComponent.includes(", deleted: {counts.deleted}") &&
|
||||||
tasksComponent.includes("task.description"),
|
tasksComponent.includes("task.description"),
|
||||||
"Tasks UI should mirror the TUI status marks, summary, and descriptions",
|
"Tasks UI should mirror the TUI status marks, pluralize its summary, omit the deleted count, and show descriptions",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
tasksModel.includes('name === "TaskCreate"') &&
|
tasksModel.includes('name === "TaskCreate"') &&
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { mergeRequestPagePath } from "$lib/workspace/api/merge-requests";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
currentPath?: string;
|
||||||
|
workspaceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { currentPath = "/", workspaceId }: Props = $props();
|
||||||
|
let href = $derived(workspaceId ? mergeRequestPagePath(workspaceId) : "/");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="nav-section">
|
||||||
|
<header class="section-header"><span>Merge Requests</span></header>
|
||||||
|
<a class="objective-link" class:active={currentPath.startsWith(href)} {href}>
|
||||||
|
<span class="item-title">All Merge Requests</span>
|
||||||
|
<span class="item-meta">review and integration resources</span>
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { projectRepositoryNav } from './repository-nav';
|
|
||||||
import type { RepositoryListResponse } from './types';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
repositories: RepositoryListResponse | null;
|
|
||||||
repositoriesError?: string | null;
|
|
||||||
currentPath?: string;
|
|
||||||
workspaceId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
let { repositories, repositoriesError = null, currentPath = '/', workspaceId }: Props = $props();
|
|
||||||
let navigation = $derived(projectRepositoryNav(repositories, currentPath, workspaceId));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<section class="nav-section" aria-labelledby="repositories-heading">
|
|
||||||
<div class="section-heading-row">
|
|
||||||
<h2 id="repositories-heading">repositories</h2>
|
|
||||||
<span class="section-count">{navigation.count}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if repositoriesError}
|
|
||||||
<p class="nav-empty error">Repository registry unavailable.</p>
|
|
||||||
{:else if !repositories}
|
|
||||||
<p class="nav-empty">Loading repositories…</p>
|
|
||||||
{:else if navigation.items.length === 0}
|
|
||||||
<p class="nav-empty">No repositories configured.</p>
|
|
||||||
{#if navigation.diagnostics.length > 0}
|
|
||||||
<ul class="diagnostics" aria-label="Repository diagnostics">
|
|
||||||
{#each navigation.diagnostics as diagnostic}
|
|
||||||
<li><code>{diagnostic.code}</code>: {diagnostic.message}</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<ul class="nav-list" aria-label="Repositories">
|
|
||||||
{#each navigation.items as item (item.id)}
|
|
||||||
<li>
|
|
||||||
<a class="nav-item" class:active={item.active} href={item.href} aria-current={item.active ? 'page' : undefined}>
|
|
||||||
<span class="item-title">{item.title}</span>
|
|
||||||
<span class="item-meta">{item.meta}</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
@@ -1,89 +1,48 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { workspaceRoute } from '$lib/workspace/api/http';
|
|
||||||
import './sidebar.css';
|
import './sidebar.css';
|
||||||
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
|
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
|
||||||
import MemoryNavSection from './MemoryNavSection.svelte';
|
import MemoryNavSection from './MemoryNavSection.svelte';
|
||||||
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
|
import MergeRequestsNavSection from './MergeRequestsNavSection.svelte';
|
||||||
import TicketsNavSection from './TicketsNavSection.svelte';
|
import TicketsNavSection from './TicketsNavSection.svelte';
|
||||||
import WorkersNavSection from './WorkersNavSection.svelte';
|
import WorkersNavSection from './WorkersNavSection.svelte';
|
||||||
import WorkspaceSwitcher from './WorkspaceSwitcher.svelte';
|
import WorkspaceSwitcher from './WorkspaceSwitcher.svelte';
|
||||||
import type { RepositoryListResponse, WorkspaceResponse } from './types';
|
import type { WorkspaceResponse } from './types';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
workspace: WorkspaceResponse | null;
|
workspace: WorkspaceResponse | null;
|
||||||
workspaceError?: string | null;
|
workspaceError?: string | null;
|
||||||
repositories?: RepositoryListResponse | null;
|
|
||||||
repositoriesError?: string | null;
|
|
||||||
currentPath?: string;
|
currentPath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let { workspace, workspaceError = null, currentPath = '/' }: Props = $props();
|
||||||
workspace,
|
|
||||||
workspaceError = null,
|
|
||||||
repositories = null,
|
|
||||||
repositoriesError = null,
|
|
||||||
currentPath = '/'
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let workspaceId = $derived(workspace?.workspace_id ?? '');
|
let workspaceId = $derived(workspace?.workspace_id ?? '');
|
||||||
let homeHref = $derived(workspaceId ? workspaceRoute(workspaceId) : '/');
|
|
||||||
let settingsHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/settings') : '/settings');
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="workspace-sidebar">
|
<div class="workspace-sidebar">
|
||||||
<header class="sidebar-header">
|
<header class="sidebar-header">
|
||||||
<div class="sidebar-title-row">
|
{#if workspace}
|
||||||
|
<WorkspaceSwitcher
|
||||||
|
currentWorkspaceId={workspaceId}
|
||||||
|
currentWorkspaceName={workspace.display_name}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
<div class="workspace-label">
|
<div class="workspace-label">
|
||||||
{#if workspace}
|
<div class="workspace-name">Yoi workspace</div>
|
||||||
<div class="workspace-name">{workspace.display_name}</div>
|
{#if workspaceError}
|
||||||
|
<p class="workspace-status error">Workspace summary unavailable.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="workspace-name">Yoi workspace</div>
|
<p class="workspace-status">Loading workspace…</p>
|
||||||
{#if workspaceError}
|
|
||||||
<p class="workspace-status error">Workspace summary unavailable.</p>
|
|
||||||
{:else}
|
|
||||||
<p class="workspace-status">Loading workspace…</p>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
|
|
||||||
<div class="sidebar-actions-row">
|
|
||||||
<a
|
|
||||||
class="sidebar-icon-button"
|
|
||||||
href={homeHref}
|
|
||||||
aria-label="Open workspace overview"
|
|
||||||
title="Workspace overview"
|
|
||||||
>
|
|
||||||
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
|
|
||||||
<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" />
|
|
||||||
<path
|
|
||||||
d="M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
class="sidebar-icon-button"
|
|
||||||
href={settingsHref}
|
|
||||||
aria-label="Open Settings / Admin"
|
|
||||||
title="Settings / Admin"
|
|
||||||
>
|
|
||||||
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"
|
|
||||||
/>
|
|
||||||
<circle cx="12" cy="12" r="4" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if workspaceId}<WorkspaceSwitcher currentWorkspaceId={workspaceId} />{/if}
|
|
||||||
|
|
||||||
<nav class="sidebar-sections" aria-label="Workspace sections">
|
<nav class="sidebar-sections" aria-label="Workspace sections">
|
||||||
<RepositoriesNavSection {repositories} {repositoriesError} {currentPath} {workspaceId} />
|
|
||||||
<TicketsNavSection {currentPath} {workspaceId} />
|
<TicketsNavSection {currentPath} {workspaceId} />
|
||||||
<ObjectivesNavSection {currentPath} {workspaceId} />
|
<ObjectivesNavSection {currentPath} {workspaceId} />
|
||||||
<MemoryNavSection {currentPath} {workspaceId} />
|
<MemoryNavSection {currentPath} {workspaceId} />
|
||||||
|
<MergeRequestsNavSection {currentPath} {workspaceId} />
|
||||||
<WorkersNavSection {currentPath} {workspaceId} />
|
<WorkersNavSection {currentPath} {workspaceId} />
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,55 +1,196 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { onMount, tick } from "svelte";
|
||||||
import { onMount } from "svelte";
|
|
||||||
import {
|
import {
|
||||||
listWorkspaces,
|
listWorkspaces,
|
||||||
type WorkspaceCatalogRecord,
|
type WorkspaceCatalogRecord,
|
||||||
} from "$lib/workspace/api/workspace-catalog";
|
} from "$lib/workspace/api/workspace-catalog";
|
||||||
import "$lib/workspace/styles/workspace-catalog.css";
|
|
||||||
|
|
||||||
let { currentWorkspaceId } = $props<{ currentWorkspaceId: string }>();
|
let {
|
||||||
|
currentWorkspaceId,
|
||||||
|
currentWorkspaceName,
|
||||||
|
}: {
|
||||||
|
currentWorkspaceId: string;
|
||||||
|
currentWorkspaceName: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let workspaces = $state<WorkspaceCatalogRecord[]>([]);
|
let workspaces = $state<WorkspaceCatalogRecord[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state<string | null>(null);
|
let error = $state("");
|
||||||
|
let open = $state(false);
|
||||||
|
let root = $state.raw<HTMLDivElement>();
|
||||||
|
let trigger = $state.raw<HTMLButtonElement>();
|
||||||
|
let menu = $state.raw<HTMLDivElement>();
|
||||||
|
|
||||||
onMount(async () => {
|
const menuWorkspaces = $derived.by(() => {
|
||||||
|
const entries = workspaces.map((workspace) => ({
|
||||||
|
workspace_id: workspace.workspace_id,
|
||||||
|
display_name: workspace.display_name,
|
||||||
|
}));
|
||||||
|
if (!entries.some((workspace) => workspace.workspace_id === currentWorkspaceId)) {
|
||||||
|
entries.unshift({
|
||||||
|
workspace_id: currentWorkspaceId,
|
||||||
|
display_name: currentWorkspaceName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadWorkspaces() {
|
||||||
|
loading = true;
|
||||||
|
error = "";
|
||||||
try {
|
try {
|
||||||
workspaces = await listWorkspaces(fetch);
|
workspaces = await listWorkspaces(fetch);
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
error = cause instanceof Error ? cause.message : String(cause);
|
error = cause instanceof Error ? cause.message : "Failed to load Workspaces.";
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
async function switchWorkspace(event: Event) {
|
|
||||||
const workspaceId = (event.currentTarget as HTMLSelectElement).value;
|
|
||||||
if (!workspaceId || workspaceId === currentWorkspaceId) return;
|
|
||||||
await goto(`/w/${encodeURIComponent(workspaceId)}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMenu(focus: "none" | "first" | "last" = "none") {
|
||||||
|
open = true;
|
||||||
|
if (focus === "none") return;
|
||||||
|
await tick();
|
||||||
|
const items = menu?.querySelectorAll<HTMLElement>("[role='menuitem']");
|
||||||
|
if (!items?.length) return;
|
||||||
|
items[focus === "first" ? 0 : items.length - 1]?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMenu() {
|
||||||
|
if (open) {
|
||||||
|
closeMenu();
|
||||||
|
} else {
|
||||||
|
void openMenu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTriggerKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
void openMenu("first");
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
void openMenu("last");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMenuKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
closeMenu();
|
||||||
|
trigger?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key) || !menu) return;
|
||||||
|
const items = [...menu.querySelectorAll<HTMLElement>("[role='menuitem']")];
|
||||||
|
if (!items.length) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const currentIndex = items.indexOf(document.activeElement as HTMLElement);
|
||||||
|
let nextIndex = currentIndex;
|
||||||
|
if (event.key === "Home") nextIndex = 0;
|
||||||
|
if (event.key === "End") nextIndex = items.length - 1;
|
||||||
|
if (event.key === "ArrowDown") nextIndex = (currentIndex + 1) % items.length;
|
||||||
|
if (event.key === "ArrowUp") {
|
||||||
|
nextIndex = (currentIndex - 1 + items.length) % items.length;
|
||||||
|
}
|
||||||
|
items[nextIndex]?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDocumentPointerDown(event: PointerEvent) {
|
||||||
|
if (open && root && !root.contains(event.target as Node)) closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
document.addEventListener("pointerdown", handleDocumentPointerDown);
|
||||||
|
void loadWorkspaces();
|
||||||
|
return () => document.removeEventListener("pointerdown", handleDocumentPointerDown);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="workspace-switcher">
|
<div class="workspace-menu" bind:this={root}>
|
||||||
<label for="workspace-switcher-select">Workspace</label>
|
<button
|
||||||
<select
|
bind:this={trigger}
|
||||||
id="workspace-switcher-select"
|
type="button"
|
||||||
value={currentWorkspaceId}
|
class="workspace-menu-trigger"
|
||||||
onchange={switchWorkspace}
|
aria-haspopup="menu"
|
||||||
disabled={loading}
|
aria-expanded={open}
|
||||||
aria-label="Switch Workspace"
|
aria-controls="workspace-menu-popover"
|
||||||
|
onclick={toggleMenu}
|
||||||
|
onkeydown={handleTriggerKeydown}
|
||||||
>
|
>
|
||||||
{#if !workspaces.some((workspace) => workspace.workspace_id === currentWorkspaceId)}
|
<span>{currentWorkspaceName}</span>
|
||||||
<option value={currentWorkspaceId}>
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
{loading ? "Loading current Workspace…" : "Current Workspace unavailable"}
|
<path d="m8 10 4 4 4-4" />
|
||||||
</option>
|
</svg>
|
||||||
{/if}
|
</button>
|
||||||
{#each workspaces as workspace (workspace.workspace_id)}
|
|
||||||
<option value={workspace.workspace_id}>{workspace.display_name}</option>
|
{#if open}
|
||||||
{/each}
|
<div
|
||||||
</select>
|
bind:this={menu}
|
||||||
<div class="workspace-switcher-actions">
|
id="workspace-menu-popover"
|
||||||
<a href="/">All Workspaces</a>
|
class="workspace-menu-popover"
|
||||||
<a href="/#workspace-create-title">Create</a>
|
role="menu"
|
||||||
</div>
|
tabindex="-1"
|
||||||
{#if error}<span class="workspace-switcher-error">Selector unavailable: {error}</span>{/if}
|
aria-label="Workspace menu"
|
||||||
|
onkeydown={handleMenuKeydown}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="workspace-menu-item"
|
||||||
|
href={`/w/${encodeURIComponent(currentWorkspaceId)}/settings`}
|
||||||
|
role="menuitem"
|
||||||
|
onclick={closeMenu}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z" />
|
||||||
|
<path d="M19.4 15a1.8 1.8 0 0 0 .36 1.98l.06.06-2.12 2.12-.06-.06a1.8 1.8 0 0 0-1.98-.36 1.8 1.8 0 0 0-1.1 1.64v.12h-3v-.12a1.8 1.8 0 0 0-1.1-1.64 1.8 1.8 0 0 0-1.98.36l-.06.06-2.12-2.12.06-.06A1.8 1.8 0 0 0 6.6 15a1.8 1.8 0 0 0-1.64-1.1h-.12v-3h.12A1.8 1.8 0 0 0 6.6 9a1.8 1.8 0 0 0-.36-1.98l-.06-.06 2.12-2.12.06.06A1.8 1.8 0 0 0 10.34 5a1.8 1.8 0 0 0 1.1-1.64v-.12h3v.12A1.8 1.8 0 0 0 15.54 5a1.8 1.8 0 0 0 1.98-.36l.06-.06 2.12 2.12-.06.06A1.8 1.8 0 0 0 19.4 9a1.8 1.8 0 0 0 1.64 1.1h.12v3h-.12A1.8 1.8 0 0 0 19.4 15Z" />
|
||||||
|
</svg>
|
||||||
|
<span>Settings</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="workspace-menu-separator" role="separator"></div>
|
||||||
|
|
||||||
|
<div class="workspace-menu-heading">
|
||||||
|
<span>Workspaces</span>
|
||||||
|
<a
|
||||||
|
class="workspace-menu-add"
|
||||||
|
href="/#workspace-create-title"
|
||||||
|
role="menuitem"
|
||||||
|
aria-label="Create Workspace"
|
||||||
|
title="Create Workspace"
|
||||||
|
onclick={closeMenu}
|
||||||
|
>+</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="workspace-menu-list">
|
||||||
|
{#each menuWorkspaces as workspace (workspace.workspace_id)}
|
||||||
|
<a
|
||||||
|
class:current={workspace.workspace_id === currentWorkspaceId}
|
||||||
|
class="workspace-menu-item workspace-menu-workspace"
|
||||||
|
href={`/w/${encodeURIComponent(workspace.workspace_id)}`}
|
||||||
|
role="menuitem"
|
||||||
|
aria-current={workspace.workspace_id === currentWorkspaceId ? "page" : undefined}
|
||||||
|
onclick={closeMenu}
|
||||||
|
>
|
||||||
|
<span>{workspace.display_name}</span>
|
||||||
|
{#if workspace.workspace_id === currentWorkspaceId}
|
||||||
|
<svg class="workspace-menu-check" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="m6 12 4 4 8-8" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p class="workspace-menu-status">Loading Workspaces…</p>
|
||||||
|
{:else if error}
|
||||||
|
<p class="workspace-menu-status error">{error}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
import { projectRepositoryNav } from "./repository-nav.ts";
|
|
||||||
import type { RepositoryListResponse } from "./types.ts";
|
|
||||||
|
|
||||||
declare const Deno: {
|
|
||||||
test(name: string, fn: () => void): void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function assertEquals<T>(actual: T, expected: T): void {
|
|
||||||
const actualJson = JSON.stringify(actual);
|
|
||||||
const expectedJson = JSON.stringify(expected);
|
|
||||||
if (actualJson !== expectedJson) {
|
|
||||||
throw new Error(`Expected ${expectedJson}, got ${actualJson}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function repositories(
|
|
||||||
items: RepositoryListResponse["items"],
|
|
||||||
): RepositoryListResponse {
|
|
||||||
return {
|
|
||||||
workspace_id: "workspace-1",
|
|
||||||
items,
|
|
||||||
source: "workspace_backend_config",
|
|
||||||
diagnostics: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
Deno.test("repository nav does not invent main for an empty registry", () => {
|
|
||||||
const projection = projectRepositoryNav(
|
|
||||||
{
|
|
||||||
workspace_id: "workspace-1",
|
|
||||||
items: [],
|
|
||||||
source: "workspace_backend_config",
|
|
||||||
diagnostics: [
|
|
||||||
{
|
|
||||||
code: "repository_config_empty",
|
|
||||||
severity: "warning",
|
|
||||||
message: "No repositories configured",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"/w/workspace-1",
|
|
||||||
"workspace-1",
|
|
||||||
);
|
|
||||||
|
|
||||||
assertEquals(projection.count, 0);
|
|
||||||
assertEquals(projection.items, []);
|
|
||||||
assertEquals(projection.diagnostics[0].code, "repository_config_empty");
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("repository nav links configured non-main repository ids", () => {
|
|
||||||
const projection = projectRepositoryNav(
|
|
||||||
repositories([
|
|
||||||
{
|
|
||||||
id: "infra",
|
|
||||||
display_name: "Infrastructure",
|
|
||||||
kind: "git",
|
|
||||||
provider: "git",
|
|
||||||
record_authority: "workspace-backend-config",
|
|
||||||
git: null,
|
|
||||||
diagnostics: [],
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
"/w/workspace-1/repositories/infra",
|
|
||||||
"workspace-1",
|
|
||||||
);
|
|
||||||
|
|
||||||
assertEquals(projection.count, 1);
|
|
||||||
assertEquals(projection.items[0], {
|
|
||||||
id: "infra",
|
|
||||||
title: "Infrastructure",
|
|
||||||
href: "/w/workspace-1/repositories/infra",
|
|
||||||
meta: "git repository · read-only",
|
|
||||||
active: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { workspaceRoute } from "$lib/workspace/api/http";
|
|
||||||
import type { Diagnostic, RepositoryListResponse } from "./types";
|
|
||||||
|
|
||||||
export type RepositoryNavItem = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
href: string;
|
|
||||||
meta: string;
|
|
||||||
active: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RepositoryNavProjection = {
|
|
||||||
count: number;
|
|
||||||
items: RepositoryNavItem[];
|
|
||||||
diagnostics: Diagnostic[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function projectRepositoryNav(
|
|
||||||
repositories: RepositoryListResponse | null,
|
|
||||||
currentPath: string,
|
|
||||||
workspaceId: string,
|
|
||||||
): RepositoryNavProjection {
|
|
||||||
const summaries = repositories?.items ?? [];
|
|
||||||
return {
|
|
||||||
count: summaries.length,
|
|
||||||
diagnostics: repositories?.diagnostics ?? [],
|
|
||||||
items: summaries.map((repository) => {
|
|
||||||
const href = workspaceRoute(
|
|
||||||
workspaceId,
|
|
||||||
`/repositories/${encodeURIComponent(repository.id)}`,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
id: repository.id,
|
|
||||||
title: repository.display_name || repository.id,
|
|
||||||
href,
|
|
||||||
meta: `${repository.provider} repository · read-only`,
|
|
||||||
active: currentPath === href || currentPath.startsWith(`${href}/`),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -42,8 +42,7 @@
|
|||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.sidebar-control-row,
|
.sidebar-control-row {
|
||||||
.sidebar-actions-row {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
@@ -57,12 +56,6 @@
|
|||||||
padding-inline: var(--space-2);
|
padding-inline: var(--space-2);
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
}
|
}
|
||||||
.sidebar-title-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.workspace-label {
|
.workspace-label {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -80,6 +73,157 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.workspace-menu {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.workspace-menu-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-soft);
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.25;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.workspace-menu-trigger > span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.workspace-menu-trigger > svg,
|
||||||
|
.workspace-menu-item > svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.8;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
.workspace-menu-trigger > svg {
|
||||||
|
transition: transform 140ms ease;
|
||||||
|
}
|
||||||
|
.workspace-menu-trigger[aria-expanded="true"] > svg {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
.workspace-menu-trigger:hover,
|
||||||
|
.workspace-menu-trigger:focus-visible {
|
||||||
|
background: var(--interactive-hover);
|
||||||
|
}
|
||||||
|
.workspace-menu-trigger:focus-visible,
|
||||||
|
.workspace-menu-add:focus-visible,
|
||||||
|
.workspace-menu-item:focus-visible {
|
||||||
|
outline: 1px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.workspace-menu-popover {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
inset-inline: 0;
|
||||||
|
top: calc(100% + var(--space-1));
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
min-width: 0;
|
||||||
|
max-height: min(26rem, calc(100vh - 6rem));
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: calc(var(--radius-soft) + 0.2rem);
|
||||||
|
padding: var(--space-2);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
}
|
||||||
|
.workspace-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
min-width: 0;
|
||||||
|
border-radius: var(--radius-soft);
|
||||||
|
padding: var(--space-2);
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.3;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.workspace-menu-item > span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.workspace-menu-item:hover,
|
||||||
|
.workspace-menu-item:focus-visible,
|
||||||
|
.workspace-menu-item.current {
|
||||||
|
background: var(--interactive-hover);
|
||||||
|
}
|
||||||
|
.workspace-menu-item.current {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.workspace-menu-separator {
|
||||||
|
height: 1px;
|
||||||
|
margin: var(--space-1) calc(-1 * var(--space-2));
|
||||||
|
background: var(--line);
|
||||||
|
}
|
||||||
|
.workspace-menu-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-1) var(--space-2) 0;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.workspace-menu-add {
|
||||||
|
display: grid;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: var(--radius-soft);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.workspace-menu-add:hover,
|
||||||
|
.workspace-menu-add:focus-visible {
|
||||||
|
background: var(--interactive-hover);
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
.workspace-menu-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
.workspace-menu-workspace {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.workspace-menu-check {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.workspace-menu-status {
|
||||||
|
margin: var(--space-1) var(--space-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.workspace-menu-status.error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
.workspace-status {
|
.workspace-status {
|
||||||
margin: var(--space-1) 0 0;
|
margin: var(--space-1) 0 0;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -91,7 +235,6 @@
|
|||||||
.error {
|
.error {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
.sidebar-icon-button,
|
|
||||||
.sidebar-fold-button {
|
.sidebar-fold-button {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -118,8 +261,6 @@
|
|||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
stroke-linejoin: round;
|
stroke-linejoin: round;
|
||||||
}
|
}
|
||||||
.sidebar-icon-button:hover,
|
|
||||||
.sidebar-icon-button:focus-visible,
|
|
||||||
.sidebar-fold-button:hover,
|
.sidebar-fold-button:hover,
|
||||||
.sidebar-fold-button:focus-visible {
|
.sidebar-fold-button:focus-visible {
|
||||||
background: var(--interactive-hover);
|
background: var(--interactive-hover);
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
declare const Deno: {
|
||||||
|
test(name: string, fn: () => Promise<void> | void): void;
|
||||||
|
readTextFile(path: string | URL): Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function assert(condition: unknown, message: string): asserts condition {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const switcherSource = await Deno.readTextFile(
|
||||||
|
new URL("./WorkspaceSwitcher.svelte", import.meta.url),
|
||||||
|
);
|
||||||
|
const sidebarSource = await Deno.readTextFile(
|
||||||
|
new URL("./WorkspaceSidebar.svelte", import.meta.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
Deno.test("workspace name opens the settings and workspace menu", () => {
|
||||||
|
assert(
|
||||||
|
switcherSource.includes('class="workspace-menu-trigger"'),
|
||||||
|
"missing name trigger",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
switcherSource.includes('aria-haspopup="menu"'),
|
||||||
|
"trigger is not a menu button",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
switcherSource.includes("<span>Settings</span>"),
|
||||||
|
"missing Settings action",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
switcherSource.includes("<span>Workspaces</span>"),
|
||||||
|
"missing Workspaces heading",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
switcherSource.includes('aria-label="Create Workspace"'),
|
||||||
|
"missing create action",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
switcherSource.includes("currentWorkspaceName"),
|
||||||
|
"trigger does not use the name",
|
||||||
|
);
|
||||||
|
assert(!switcherSource.includes("<select"), "legacy select switcher remains");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("workspace menu lists catalog entries and marks the current workspace", () => {
|
||||||
|
assert(
|
||||||
|
switcherSource.includes("listWorkspaces(fetch)"),
|
||||||
|
"catalog is not loaded",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
switcherSource.includes("{#each menuWorkspaces as workspace"),
|
||||||
|
"Workspace catalog is not rendered",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
switcherSource.includes("aria-current="),
|
||||||
|
"current Workspace is not identified",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
switcherSource.includes("workspace.display_name"),
|
||||||
|
"Workspace name is not rendered",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("workspace sidebar uses the workspace name menu as its header", () => {
|
||||||
|
assert(
|
||||||
|
sidebarSource.includes("<WorkspaceSwitcher"),
|
||||||
|
"sidebar omits the menu",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
sidebarSource.includes("currentWorkspaceName={workspace.display_name}"),
|
||||||
|
"sidebar does not pass the current Workspace name",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!sidebarSource.includes('class="sidebar-actions-row"'),
|
||||||
|
"old action row remains",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!sidebarSource.includes("RepositoriesNavSection"),
|
||||||
|
"Repositories should not be rendered in the Workspace sidebar",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -260,6 +260,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.ticket-detail-page {
|
.ticket-detail-page {
|
||||||
|
container: ticket-detail / inline-size;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
max-width: 88rem;
|
max-width: 88rem;
|
||||||
}
|
}
|
||||||
@@ -296,9 +297,14 @@
|
|||||||
}
|
}
|
||||||
.ticket-detail-main, .ticket-control-rail {
|
.ticket-detail-main, .ticket-control-rail {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
.ticket-detail-main {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
.ticket-detail-section, .ticket-control-card, .ticket-editor {
|
.ticket-detail-section, .ticket-control-card, .ticket-editor {
|
||||||
|
min-width: 0;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 0.8rem;
|
border-radius: 0.8rem;
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
@@ -434,6 +440,15 @@
|
|||||||
.ticket-event-author {
|
.ticket-event-author {
|
||||||
margin: 0.2rem 0;
|
margin: 0.2rem 0;
|
||||||
}
|
}
|
||||||
|
@container ticket-detail (max-width: 48rem) {
|
||||||
|
.ticket-detail-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.ticket-control-rail {
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 64rem) {
|
@media (max-width: 64rem) {
|
||||||
.ticket-detail-grid {
|
.ticket-detail-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
declare const Deno: {
|
||||||
|
test(name: string, fn: () => Promise<void> | void): void;
|
||||||
|
readTextFile(path: string | URL): Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function assert(condition: unknown, message: string): asserts condition {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketLoader = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"../../../routes/w/[workspaceId]/tickets/[ticketId]/+page.ts",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const ticketPage = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"../../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const listLoader = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"../../../routes/w/[workspaceId]/merge-requests/+page.ts",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const detailLoader = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"../../../routes/w/[workspaceId]/merge-requests/[mergeRequestId]/+page.ts",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const sidebar = await Deno.readTextFile(
|
||||||
|
new URL("../sidebar/WorkspaceSidebar.svelte", import.meta.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
Deno.test("Ticket detail links to a first-class Merge Request resource", () => {
|
||||||
|
assert(
|
||||||
|
!ticketLoader.includes("`${ticketPath}/merge-request`"),
|
||||||
|
"Ticket loader still locates MR detail through a Ticket route",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
ticketPage.includes(
|
||||||
|
"mergeRequestPagePath(data.workspaceId, mergeRequest.merge_request_id)",
|
||||||
|
),
|
||||||
|
"Ticket panel does not link to the MR resource identity",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Workspace exposes Merge Request collection and detail pages", () => {
|
||||||
|
assert(
|
||||||
|
listLoader.includes("mergeRequestCollectionPath(params.workspaceId)"),
|
||||||
|
"missing MR list API",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
detailLoader.includes(
|
||||||
|
"mergeRequestDetailPath(params.workspaceId, params.mergeRequestId)",
|
||||||
|
),
|
||||||
|
"missing MR detail API",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
sidebar.includes("MergeRequestsNavSection"),
|
||||||
|
"MR resources are absent from navigation",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -11,7 +11,8 @@ import type {
|
|||||||
} from "../../generated/ticket-api.ts";
|
} from "../../generated/ticket-api.ts";
|
||||||
|
|
||||||
declare const Deno: {
|
declare const Deno: {
|
||||||
test(name: string, fn: () => void): void;
|
test(name: string, fn: () => void | Promise<void>): void;
|
||||||
|
readTextFile(path: URL): Promise<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function assertEquals<T>(actual: T, expected: T): void {
|
function assertEquals<T>(actual: T, expected: T): void {
|
||||||
@@ -108,3 +109,20 @@ Deno.test("ticket worker launch uses the common Worker route and bounded Ticket
|
|||||||
"Work on Ticket 00001KYRRDVH9 as its reviewer.",
|
"Work on Ticket 00001KYRRDVH9 as its reviewer.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("ticket detail keeps the operation rail outside main content", async () => {
|
||||||
|
const css = await Deno.readTextFile(
|
||||||
|
new URL("../styles/tickets.css", import.meta.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(css.includes("container: ticket-detail / inline-size;"), true);
|
||||||
|
assertEquals(
|
||||||
|
css.includes(".ticket-detail-main {\n overflow-wrap: anywhere;"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
css.includes("@container ticket-detail (max-width: 48rem)"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assertEquals(css.includes("min-width: 0;"), true);
|
||||||
|
});
|
||||||
|
|||||||
@@ -126,6 +126,9 @@
|
|||||||
gap: var(--space-6);
|
gap: var(--space-6);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1280px;
|
||||||
|
margin-inline: auto;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: var(--space-6);
|
padding: var(--space-6);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,6 @@
|
|||||||
<WorkspaceSidebar
|
<WorkspaceSidebar
|
||||||
workspace={data.workspace ?? null}
|
workspace={data.workspace ?? null}
|
||||||
workspaceError={data.workspaceError ?? null}
|
workspaceError={data.workspaceError ?? null}
|
||||||
repositories={data.repositories ?? null}
|
|
||||||
repositoriesError={data.repositoriesError ?? null}
|
|
||||||
currentPath={page.url.pathname}
|
currentPath={page.url.pathname}
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { mergeRequestPagePath } from "$lib/workspace/api/merge-requests";
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
function prettyDate(value: string): string {
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>Merge Requests · Yoi</title></svelte:head>
|
||||||
|
|
||||||
|
<div class="workspace-page">
|
||||||
|
<header class="workspace-page-header">
|
||||||
|
<div>
|
||||||
|
<p class="workspace-eyebrow">Workspace resources</p>
|
||||||
|
<h1>Merge Requests</h1>
|
||||||
|
</div>
|
||||||
|
<span class="workspace-count">{data.mergeRequests?.items.length ?? 0}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if data.error}
|
||||||
|
<p class="workspace-callout is-error">{data.error}</p>
|
||||||
|
{:else}
|
||||||
|
<div class="ticket-list" aria-label="Merge Requests">
|
||||||
|
{#each data.mergeRequests?.items ?? [] as item (item.summary.merge_request_id)}
|
||||||
|
{@const mergeRequest = item.summary}
|
||||||
|
<a
|
||||||
|
class="ticket-row"
|
||||||
|
href={mergeRequestPagePath(data.workspaceId, mergeRequest.merge_request_id)}
|
||||||
|
>
|
||||||
|
<div class="ticket-main">
|
||||||
|
<div class="ticket-title-row">
|
||||||
|
<span class="ticket-key">{mergeRequest.merge_request_id}</span>
|
||||||
|
<strong class="ticket-title">
|
||||||
|
{mergeRequest.selector_from ?? "Source selector requires repair"}
|
||||||
|
→ {mergeRequest.selector_to}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<p class="ticket-summary">
|
||||||
|
Repository {mergeRequest.repository_id} · {item.ticket_ids.length} linked Ticket{item.ticket_ids.length === 1 ? "" : "s"} · review {mergeRequest.review_status}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="ticket-meta">
|
||||||
|
<span class={`ticket-state state-${mergeRequest.state}`}>{mergeRequest.state}</span>
|
||||||
|
<time>{prettyDate(mergeRequest.updated_at)}</time>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<p class="workspace-empty-copy">No Merge Requests.</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { PageLoad } from "./$types";
|
||||||
|
import {
|
||||||
|
mergeRequestCollectionPath,
|
||||||
|
type MergeRequestListPage,
|
||||||
|
} from "$lib/workspace/api/merge-requests";
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ params, fetch }) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${mergeRequestCollectionPath(params.workspaceId)}?limit=100`,
|
||||||
|
);
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
workspaceId: params.workspaceId,
|
||||||
|
mergeRequests: null,
|
||||||
|
error: body?.error ?? body?.message ??
|
||||||
|
`Request failed (${response.status})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
workspaceId: params.workspaceId,
|
||||||
|
mergeRequests: body as MergeRequestListPage,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
workspaceId: params.workspaceId,
|
||||||
|
mergeRequests: null,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { mergeRequestPagePath } from "$lib/workspace/api/merge-requests";
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
function prettyDate(value: string): string {
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function textField(event: Record<string, unknown>, key: string): string | null {
|
||||||
|
const value = event[key];
|
||||||
|
return typeof value === "string" && value.length > 0 ? value : null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>Merge Request · Yoi</title></svelte:head>
|
||||||
|
|
||||||
|
<div class="workspace-page">
|
||||||
|
<header class="workspace-page-header">
|
||||||
|
<div>
|
||||||
|
<p class="workspace-eyebrow">Merge Request</p>
|
||||||
|
<h1>{data.mergeRequest?.merge_request_id ?? data.mergeRequestId}</h1>
|
||||||
|
{#if data.mergeRequest}
|
||||||
|
<p class="workspace-page-lede">
|
||||||
|
{data.mergeRequest.selector_from ?? "Source selector requires repair"}
|
||||||
|
→ {data.mergeRequest.selector_to}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<a class="workspace-secondary-button" href={mergeRequestPagePath(data.workspaceId)}>
|
||||||
|
All Merge Requests
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if data.error}
|
||||||
|
<p class="workspace-callout is-error">{data.error}</p>
|
||||||
|
{:else if data.mergeRequest}
|
||||||
|
{@const mergeRequest = data.mergeRequest}
|
||||||
|
<div class="ticket-detail-grid">
|
||||||
|
<main class="ticket-detail-main">
|
||||||
|
<section class="ticket-detail-section">
|
||||||
|
<div class="ticket-section-heading"><h2>Selectors</h2></div>
|
||||||
|
<dl class="ticket-facts">
|
||||||
|
<div><dt>Repository</dt><dd>{mergeRequest.repository_id}</dd></div>
|
||||||
|
<div><dt>State</dt><dd>{mergeRequest.state}</dd></div>
|
||||||
|
<div><dt>Source selector</dt><dd><code>{mergeRequest.selector_from ?? "requires repair"}</code></dd></div>
|
||||||
|
<div><dt>Source revision</dt><dd>{mergeRequest.source.status}{mergeRequest.source.ref ? ` · ${mergeRequest.source.ref}` : ""}</dd></div>
|
||||||
|
<div><dt>Target selector</dt><dd><code>{mergeRequest.selector_to}</code></dd></div>
|
||||||
|
<div><dt>Target revision</dt><dd>{mergeRequest.target.status}{mergeRequest.target.ref ? ` · ${mergeRequest.target.ref}` : ""}</dd></div>
|
||||||
|
<div><dt>Updated</dt><dd>{prettyDate(mergeRequest.updated_at)}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="ticket-detail-section">
|
||||||
|
<div class="ticket-section-heading">
|
||||||
|
<h2>Thread</h2><span>{mergeRequest.thread.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ticket-timeline">
|
||||||
|
{#each mergeRequest.thread as event (event.sequence)}
|
||||||
|
<article>
|
||||||
|
<div class="ticket-timeline-marker"></div>
|
||||||
|
<div>
|
||||||
|
<header>
|
||||||
|
<strong>{event.kind}</strong>
|
||||||
|
<time>{prettyDate(event.at)}</time>
|
||||||
|
</header>
|
||||||
|
{#if textField(event, "subject_ref")}
|
||||||
|
<p><code>{textField(event, "subject_ref")}</code></p>
|
||||||
|
{/if}
|
||||||
|
{#if textField(event, "decision")}<p>{textField(event, "decision")}</p>{/if}
|
||||||
|
{#if textField(event, "reason")}<p>{textField(event, "reason")}</p>{/if}
|
||||||
|
{#if textField(event, "body")}<p>{textField(event, "body")}</p>{/if}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{:else}
|
||||||
|
<p class="workspace-empty-copy">No Merge Request events.</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside class="ticket-control-rail">
|
||||||
|
<section class="ticket-control-card">
|
||||||
|
<header><h2>Linked Tickets</h2></header>
|
||||||
|
{#each mergeRequest.linked_tickets as linkedTicket}
|
||||||
|
{#if linkedTicket.key}
|
||||||
|
<p>
|
||||||
|
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(linkedTicket.key)}`}>
|
||||||
|
{linkedTicket.key}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="workspace-empty-copy">Linked Ticket key unavailable.</p>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<p class="workspace-empty-copy">No linked Tickets.</p>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { PageLoad } from "./$types";
|
||||||
|
import {
|
||||||
|
type MergeRequestDetail,
|
||||||
|
mergeRequestDetailPath,
|
||||||
|
} from "$lib/workspace/api/merge-requests";
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ params, fetch }) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${
|
||||||
|
mergeRequestDetailPath(params.workspaceId, params.mergeRequestId)
|
||||||
|
}?limit=100`,
|
||||||
|
);
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
workspaceId: params.workspaceId,
|
||||||
|
mergeRequestId: params.mergeRequestId,
|
||||||
|
mergeRequest: null,
|
||||||
|
error: body?.error ?? body?.message ??
|
||||||
|
`Request failed (${response.status})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
workspaceId: params.workspaceId,
|
||||||
|
mergeRequestId: params.mergeRequestId,
|
||||||
|
mergeRequest: body as MergeRequestDetail,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
workspaceId: params.workspaceId,
|
||||||
|
mergeRequestId: params.mergeRequestId,
|
||||||
|
mergeRequest: null,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
+27
-19
@@ -33,6 +33,7 @@
|
|||||||
type ConsoleViewScroll,
|
type ConsoleViewScroll,
|
||||||
} from "$lib/workspace/console/model";
|
} from "$lib/workspace/console/model";
|
||||||
import type { Event as ProtocolEvent, Method as ProtocolMethod, RewindTarget, Segment } from "$lib/generated/protocol";
|
import type { Event as ProtocolEvent, Method as ProtocolMethod, RewindTarget, Segment } from "$lib/generated/protocol";
|
||||||
|
import { pushWorkspaceAlert } from "$lib/workspace/alerts/store";
|
||||||
import { workspaceApiPath } from "$lib/workspace/api/http";
|
import { workspaceApiPath } from "$lib/workspace/api/http";
|
||||||
import { workspaceMultiplexer, type WorkspaceMultiplexerSubscription } from "$lib/workspace/multiplexer";
|
import { workspaceMultiplexer, type WorkspaceMultiplexerSubscription } from "$lib/workspace/multiplexer";
|
||||||
import type {
|
import type {
|
||||||
@@ -111,7 +112,6 @@
|
|||||||
let sendError = $state<string | null>(null);
|
let sendError = $state<string | null>(null);
|
||||||
let rewindTargets = $state<RewindTarget[]>([]);
|
let rewindTargets = $state<RewindTarget[]>([]);
|
||||||
let rewindHeadEntries = $state(0);
|
let rewindHeadEntries = $state(0);
|
||||||
let controlNotice = $state<string | null>(null);
|
|
||||||
let composerNotice = $state<string | null>(null);
|
let composerNotice = $state<string | null>(null);
|
||||||
let protocolState = $state<"connecting" | "open" | "closed" | "error">(
|
let protocolState = $state<"connecting" | "open" | "closed" | "error">(
|
||||||
"connecting",
|
"connecting",
|
||||||
@@ -161,6 +161,9 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const consoleTarget = $derived({ workspaceId, runtimeId, workerId });
|
const consoleTarget = $derived({ workspaceId, runtimeId, workerId });
|
||||||
|
const controlAlertId = $derived(
|
||||||
|
`worker-console-control:${runtimeId}:${workerId}`,
|
||||||
|
);
|
||||||
|
|
||||||
const workerViews = $derived(consoleWorkerViews(consoleProjection));
|
const workerViews = $derived(consoleWorkerViews(consoleProjection));
|
||||||
const selectedWorkerView = $derived(
|
const selectedWorkerView = $derived(
|
||||||
@@ -418,10 +421,18 @@
|
|||||||
function sendControl(method: ProtocolMethod, label: string) {
|
function sendControl(method: ProtocolMethod, label: string) {
|
||||||
try {
|
try {
|
||||||
sendProtocolMethod(method);
|
sendProtocolMethod(method);
|
||||||
controlNotice = `${label} sent through Worker protocol.`;
|
pushWorkspaceAlert(
|
||||||
|
"info",
|
||||||
|
`${label} sent through Worker protocol.`,
|
||||||
|
{ id: controlAlertId, title: "Worker control" },
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
controlNotice = null;
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
sendError = error instanceof Error ? error.message : String(error);
|
sendError = message;
|
||||||
|
pushWorkspaceAlert("error", message, {
|
||||||
|
id: controlAlertId,
|
||||||
|
title: "Worker control failed",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -674,10 +685,13 @@
|
|||||||
if (event.event === "rewind_targets") {
|
if (event.event === "rewind_targets") {
|
||||||
rewindHeadEntries = event.data.head_entries;
|
rewindHeadEntries = event.data.head_entries;
|
||||||
rewindTargets = event.data.targets;
|
rewindTargets = event.data.targets;
|
||||||
controlNotice =
|
pushWorkspaceAlert(
|
||||||
|
"info",
|
||||||
event.data.targets.length === 0
|
event.data.targets.length === 0
|
||||||
? "No rewind targets are available."
|
? "No rewind targets are available."
|
||||||
: `Loaded ${event.data.targets.length} rewind target(s).`;
|
: `Loaded ${event.data.targets.length} rewind target(s).`,
|
||||||
|
{ id: controlAlertId, title: "Rewind targets" },
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.event === "error") {
|
if (event.event === "error") {
|
||||||
@@ -1324,10 +1338,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if controlNotice}
|
|
||||||
<p class="console-notice">{controlNotice}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if rewindTargets.length > 0}
|
{#if rewindTargets.length > 0}
|
||||||
<section class="card rewind-targets" aria-label="Rewind targets">
|
<section class="card rewind-targets" aria-label="Rewind targets">
|
||||||
<h3>Rewind targets</h3>
|
<h3>Rewind targets</h3>
|
||||||
@@ -1509,7 +1519,7 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<form class="console-composer card" onsubmit={sendMessage}>
|
<form class="console-composer" onsubmit={sendMessage}>
|
||||||
<div class="composer-input-shell">
|
<div class="composer-input-shell">
|
||||||
<textarea
|
<textarea
|
||||||
id="worker-console-message"
|
id="worker-console-message"
|
||||||
@@ -1586,6 +1596,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.console-shell {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 920px;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.worker-console-shell {
|
.worker-console-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1653,12 +1669,6 @@
|
|||||||
color: var(--bg);
|
color: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-notice {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.86rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rewind-targets {
|
.rewind-targets {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1831,8 +1841,6 @@
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
margin-inline: calc(-1 * var(--space-6));
|
|
||||||
padding: var(--space-3) var(--space-6) var(--space-4);
|
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
workspaceApiJsonWithBody,
|
workspaceApiJsonWithBody,
|
||||||
workspaceApiPath,
|
workspaceApiPath,
|
||||||
} from "$lib/workspace/api/http";
|
} from "$lib/workspace/api/http";
|
||||||
|
import { mergeRequestPagePath } from "$lib/workspace/api/merge-requests";
|
||||||
import {
|
import {
|
||||||
relationLabel,
|
relationLabel,
|
||||||
TICKET_STATES,
|
TICKET_STATES,
|
||||||
@@ -18,54 +19,6 @@
|
|||||||
TicketDetail,
|
TicketDetail,
|
||||||
} from "$lib/workspace/sidebar/types";
|
} from "$lib/workspace/sidebar/types";
|
||||||
|
|
||||||
type MergeRequestThreadEvent =
|
|
||||||
| {
|
|
||||||
kind: "review_requested";
|
|
||||||
event_id: string;
|
|
||||||
sequence: number;
|
|
||||||
subject_ref: string;
|
|
||||||
requested_by: { runtime_id: string; worker_id: string };
|
|
||||||
reviewer: { runtime_id: string; worker_id: string };
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
kind: "review";
|
|
||||||
event_id: string;
|
|
||||||
sequence: number;
|
|
||||||
request_event_id: string;
|
|
||||||
subject_ref: string;
|
|
||||||
decision: "approve" | "request_changes";
|
|
||||||
body: string;
|
|
||||||
reviewer: { runtime_id: string; worker_id: string };
|
|
||||||
}
|
|
||||||
| { kind: "review_revoked"; sequence: number; review_event_id: string; reason: string }
|
|
||||||
| { kind: "review_cancelled"; sequence: number; request_event_id: string; reason: string }
|
|
||||||
| {
|
|
||||||
kind: "comment";
|
|
||||||
sequence: number;
|
|
||||||
body: string;
|
|
||||||
author: { runtime_id: string; worker_id: string };
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
kind: "merge";
|
|
||||||
sequence: number;
|
|
||||||
approval_event_id: string;
|
|
||||||
approved_source_ref: string;
|
|
||||||
target_ref_after: string;
|
|
||||||
strategy: "fast_forward" | "merge";
|
|
||||||
resolution: "none" | "clean" | "conflicts_resolved";
|
|
||||||
merged_by: { runtime_id: string; worker_id: string };
|
|
||||||
};
|
|
||||||
|
|
||||||
type RefProjection = { status: "known" | "unknown" | "requires_repair"; ref?: string };
|
|
||||||
type MergeRequestDetail = {
|
|
||||||
state: "open" | "closed" | "merged";
|
|
||||||
selector_from: string | null;
|
|
||||||
selector_to: string;
|
|
||||||
source: RefProjection;
|
|
||||||
target: RefProjection;
|
|
||||||
thread: MergeRequestThreadEvent[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const MUTABLE_TICKET_STATES = TICKET_STATES.filter((state) =>
|
const MUTABLE_TICKET_STATES = TICKET_STATES.filter((state) =>
|
||||||
state !== "done" && state !== "ready" && state !== "queued"
|
state !== "done" && state !== "ready" && state !== "queued"
|
||||||
);
|
);
|
||||||
@@ -77,7 +30,6 @@
|
|||||||
ticket: ApiResult<TicketDetail>;
|
ticket: ApiResult<TicketDetail>;
|
||||||
repositories: ApiResult<RepositoryListResponse>;
|
repositories: ApiResult<RepositoryListResponse>;
|
||||||
orchestrator: ApiResult<WorkspaceOrchestratorStatus>;
|
orchestrator: ApiResult<WorkspaceOrchestratorStatus>;
|
||||||
mergeRequest: ApiResult<MergeRequestDetail | null>;
|
|
||||||
};
|
};
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -88,24 +40,7 @@
|
|||||||
const orchestratorOnline = initialData.orchestrator.data?.online ?? false;
|
const orchestratorOnline = initialData.orchestrator.data?.online ?? false;
|
||||||
|
|
||||||
let ticket = $state<TicketDetail>(loadedTicket);
|
let ticket = $state<TicketDetail>(loadedTicket);
|
||||||
let mergeRequest = $state<MergeRequestDetail | null>(initialData.mergeRequest.data ?? null);
|
const mergeRequest = $derived(ticket.merge_request);
|
||||||
const currentReviewRequest = $derived(
|
|
||||||
mergeRequest?.thread.findLast((event) => event.kind === "review_requested") ?? null,
|
|
||||||
);
|
|
||||||
const currentReview = $derived.by(() => {
|
|
||||||
if (mergeRequest?.source.status !== "known") return null;
|
|
||||||
const review = mergeRequest.thread.findLast(
|
|
||||||
(event) => event.kind === "review" && event.subject_ref === mergeRequest.source.ref,
|
|
||||||
);
|
|
||||||
if (!review || review.kind !== "review") return null;
|
|
||||||
const revoked = mergeRequest.thread.some(
|
|
||||||
(event) => event.kind === "review_revoked" && event.review_event_id === review.event_id,
|
|
||||||
);
|
|
||||||
return revoked ? null : review;
|
|
||||||
});
|
|
||||||
const mergeEvent = $derived(
|
|
||||||
mergeRequest?.thread.findLast((event) => event.kind === "merge") ?? null,
|
|
||||||
);
|
|
||||||
let editing = $state(false);
|
let editing = $state(false);
|
||||||
let editTitle = $state(loadedTicket.title);
|
let editTitle = $state(loadedTicket.title);
|
||||||
let editBody = $state(loadedTicket.body);
|
let editBody = $state(loadedTicket.body);
|
||||||
@@ -473,41 +408,19 @@
|
|||||||
|
|
||||||
<section class="ticket-control-card">
|
<section class="ticket-control-card">
|
||||||
<header><h2>Merge Request</h2></header>
|
<header><h2>Merge Request</h2></header>
|
||||||
{#if data.mergeRequest.error}
|
{#if mergeRequest}
|
||||||
<p class="workspace-callout is-error">{data.mergeRequest.error}</p>
|
<p><strong>{mergeRequest.state}</strong> · review {mergeRequest.review_status}</p>
|
||||||
{:else if mergeRequest}
|
<p>
|
||||||
<p><strong>{mergeRequest.state}</strong></p>
|
From <code>{mergeRequest.selector_from ?? "requires repair"}</code>
|
||||||
<p>From <code>{mergeRequest.selector_from ?? "requires repair"}</code> · {mergeRequest.source.status}{mergeRequest.source.ref ? ` @ ${mergeRequest.source.ref}` : ""}</p>
|
to <code>{mergeRequest.selector_to}</code>
|
||||||
<p>To <code>{mergeRequest.selector_to}</code> · {mergeRequest.target.status}{mergeRequest.target.ref ? ` @ ${mergeRequest.target.ref}` : ""}</p>
|
</p>
|
||||||
{#if currentReviewRequest?.kind === "review_requested"}
|
{#if mergeRequest.current_subject_ref}
|
||||||
<p>Review requested for <code>{currentReviewRequest.subject_ref}</code></p>
|
<p>Current source <code>{mergeRequest.current_subject_ref}</code></p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if currentReview?.kind === "review"}
|
<a
|
||||||
<p><strong>{currentReview.decision}</strong> by <code>{currentReview.reviewer.runtime_id}/{currentReview.reviewer.worker_id}</code></p>
|
class="workspace-secondary-button"
|
||||||
{#if currentReview.body}<RichMarkdown text={currentReview.body} />{/if}
|
href={mergeRequestPagePath(data.workspaceId, mergeRequest.merge_request_id)}
|
||||||
{/if}
|
>Open Merge Request</a>
|
||||||
{#if mergeEvent?.kind === "merge"}
|
|
||||||
<p>Final merge · {mergeEvent.strategy} / {mergeEvent.resolution}</p>
|
|
||||||
<p>Target ref <code>{mergeEvent.target_ref_after}</code></p>
|
|
||||||
<p>Completed by <code>{mergeEvent.merged_by.runtime_id}/{mergeEvent.merged_by.worker_id}</code></p>
|
|
||||||
{/if}
|
|
||||||
<h4>Thread</h4>
|
|
||||||
{#each mergeRequest.thread as event (event.sequence)}
|
|
||||||
<p>
|
|
||||||
<code>#{event.sequence}</code> · {event.kind}
|
|
||||||
{#if event.kind === "review_requested"}
|
|
||||||
· <code>{event.subject_ref}</code> · {event.requested_by.runtime_id}/{event.requested_by.worker_id}
|
|
||||||
{:else if event.kind === "review"}
|
|
||||||
· <code>{event.subject_ref}</code> · {event.reviewer.runtime_id}/{event.reviewer.worker_id}
|
|
||||||
{:else if event.kind === "comment"}
|
|
||||||
· {event.author.runtime_id}/{event.author.worker_id} · {event.body}
|
|
||||||
{:else if event.kind === "review_cancelled" || event.kind === "review_revoked"}
|
|
||||||
· {event.reason}
|
|
||||||
{:else if event.kind === "merge"}
|
|
||||||
· approval <code>{event.approval_event_id}</code>
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
{/each}
|
|
||||||
{:else}
|
{:else}
|
||||||
<p class="workspace-empty-copy">The assigned Coder has not opened a Merge Request.</p>
|
<p class="workspace-empty-copy">The assigned Coder has not opened a Merge Request.</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -5,34 +5,48 @@ import {
|
|||||||
resourceKey,
|
resourceKey,
|
||||||
} from "$lib/workspace/resource-links";
|
} from "$lib/workspace/resource-links";
|
||||||
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
|
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
|
||||||
import type { RepositoryListResponse, TicketDetail } from "$lib/workspace/sidebar/types";
|
import type {
|
||||||
|
RepositoryListResponse,
|
||||||
|
TicketDetail,
|
||||||
|
} from "$lib/workspace/sidebar/types";
|
||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
async function loadOptionalJson<T>(fetcher: typeof fetch, path: string): Promise<{ data: T | null; error: string | null }> {
|
|
||||||
try {
|
|
||||||
const response = await fetcher(path);
|
|
||||||
if (response.status === 404) return { data: null, error: null };
|
|
||||||
if (!response.ok) return { data: null, error: await response.text() || `HTTP ${response.status}` };
|
|
||||||
return { data: await response.json() as T, error: null };
|
|
||||||
} catch (error) {
|
|
||||||
return { data: null, error: error instanceof Error ? error.message : String(error) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const load = (async ({ fetch, params }) => {
|
export const load = (async ({ fetch, params }) => {
|
||||||
const reference = resourceKey(params.ticketId);
|
const reference = resourceKey(params.ticketId);
|
||||||
const ticketPath = workspaceApiPath(params.workspaceId, `/tickets/${encodeURIComponent(reference)}`);
|
const ticketPath = workspaceApiPath(
|
||||||
const [ticket, repositories, orchestrator, mergeRequest] = await Promise.all([
|
params.workspaceId,
|
||||||
|
`/tickets/${encodeURIComponent(reference)}`,
|
||||||
|
);
|
||||||
|
const [ticket, repositories, orchestrator] = await Promise.all([
|
||||||
loadJson<TicketDetail>(fetch, ticketPath),
|
loadJson<TicketDetail>(fetch, ticketPath),
|
||||||
loadJson<RepositoryListResponse>(fetch, workspaceApiPath(params.workspaceId, "/repositories")),
|
loadJson<RepositoryListResponse>(
|
||||||
loadJson<WorkspaceOrchestratorStatus>(fetch, workspaceApiPath(params.workspaceId, "/orchestrator")),
|
fetch,
|
||||||
loadOptionalJson<Record<string, unknown>>(fetch, `${ticketPath}/merge-request`),
|
workspaceApiPath(params.workspaceId, "/repositories"),
|
||||||
|
),
|
||||||
|
loadJson<WorkspaceOrchestratorStatus>(
|
||||||
|
fetch,
|
||||||
|
workspaceApiPath(params.workspaceId, "/orchestrator"),
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
if (ticket.data) {
|
if (ticket.data) {
|
||||||
const canonical = canonicalResourceReference(ticket.data.resource_key, ticket.data.title);
|
const canonical = canonicalResourceReference(
|
||||||
|
ticket.data.resource_key,
|
||||||
|
ticket.data.title,
|
||||||
|
);
|
||||||
if (params.ticketId !== canonical) {
|
if (params.ticketId !== canonical) {
|
||||||
redirect(308, `/w/${encodeURIComponent(params.workspaceId)}/tickets/${encodeURIComponent(canonical)}`);
|
redirect(
|
||||||
|
308,
|
||||||
|
`/w/${encodeURIComponent(params.workspaceId)}/tickets/${
|
||||||
|
encodeURIComponent(canonical)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { workspaceId: params.workspaceId, ticketId: ticket.data?.id ?? reference, ticket, repositories, orchestrator, mergeRequest };
|
return {
|
||||||
|
workspaceId: params.workspaceId,
|
||||||
|
ticketId: ticket.data?.id ?? reference,
|
||||||
|
ticket,
|
||||||
|
repositories,
|
||||||
|
orchestrator,
|
||||||
|
};
|
||||||
}) satisfies PageLoad;
|
}) satisfies PageLoad;
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { ansiSegments } from "../../src/lib/workspace/console/ansi.ts";
|
||||||
|
|
||||||
|
type TestRegistrar = (name: string, body: () => void) => void;
|
||||||
|
|
||||||
|
const test =
|
||||||
|
(globalThis as unknown as { Deno: { test: TestRegistrar } }).Deno.test;
|
||||||
|
|
||||||
|
function assert(
|
||||||
|
condition: boolean,
|
||||||
|
message = "assertion failed",
|
||||||
|
): asserts condition {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEquals(actual: unknown, expected: unknown): void {
|
||||||
|
const actualJson = JSON.stringify(actual);
|
||||||
|
const expectedJson = JSON.stringify(expected);
|
||||||
|
if (actualJson !== expectedJson) {
|
||||||
|
throw new Error(
|
||||||
|
`values differ:\nactual: ${actualJson}\nexpected: ${expectedJson}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("ansiSegments projects standard colors and reset", () => {
|
||||||
|
const segments = ansiSegments("plain \x1b[31mred\x1b[0m normal");
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
segments.map(({ text, foreground }) => ({ text, foreground })),
|
||||||
|
[
|
||||||
|
{ text: "plain ", foreground: undefined },
|
||||||
|
{ text: "red", foreground: "#cd3131" },
|
||||||
|
{ text: " normal", foreground: undefined },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ansiSegments supports terminal styles, 256 colors, and truecolor", () => {
|
||||||
|
const segments = ansiSegments(
|
||||||
|
"\x1b[1;4;38;5;202mindexed\x1b[22;24;48;2;1;2;3mbackground\x1b[0m",
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(segments[0], {
|
||||||
|
text: "indexed",
|
||||||
|
foreground: "rgb(255, 95, 0)",
|
||||||
|
background: undefined,
|
||||||
|
bold: true,
|
||||||
|
dim: false,
|
||||||
|
italic: false,
|
||||||
|
underline: true,
|
||||||
|
strikethrough: false,
|
||||||
|
concealed: false,
|
||||||
|
});
|
||||||
|
assertEquals(segments[1], {
|
||||||
|
text: "background",
|
||||||
|
foreground: "rgb(255, 95, 0)",
|
||||||
|
background: "rgb(1, 2, 3)",
|
||||||
|
bold: false,
|
||||||
|
dim: false,
|
||||||
|
italic: false,
|
||||||
|
underline: false,
|
||||||
|
strikethrough: false,
|
||||||
|
concealed: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ansiSegments keeps output as text and strips terminal control sequences", () => {
|
||||||
|
const input =
|
||||||
|
"\x1b]8;;https://example.invalid\x07<script>alert(1)</script>\x1b]8;;\x07" +
|
||||||
|
"\x1b[2Ksafe\x00\x1b[31";
|
||||||
|
const segments = ansiSegments(input);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
segments.map((segment) => segment.text).join(""),
|
||||||
|
"<script>alert(1)</script>safe",
|
||||||
|
);
|
||||||
|
assert(!segments.some((segment) => segment.text.includes("\x1b")));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user