Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72cae33ea6 | ||
|
|
0cd5ca11cc | ||
|
|
ccb9d03865 | ||
|
|
41cd2d044a | ||
|
|
85d1815dcf | ||
|
|
9989aed916 | ||
|
|
3f2ba9df47 | ||
|
|
cd9f0f009e | ||
|
|
b49abce798 | ||
|
|
51b381a701 | ||
|
|
bea121ade0 | ||
|
|
18112d29a6 | ||
|
|
8bedfcda84 | ||
|
|
402543d617 | ||
|
|
a21ef31ee7 | ||
|
|
e8c159247a | ||
|
|
5dd9392575 | ||
|
|
da2296bc9c | ||
|
|
8e4f35eb3f | ||
|
|
9917c09b19 | ||
|
|
4445501f6c | ||
|
|
6fe36f3e46 | ||
|
|
cfb173c570 | ||
|
|
ad729af592 | ||
|
|
17abe1c40c | ||
|
|
4b8dc302ee | ||
|
|
cf2e74404d | ||
|
|
a0e161c653 | ||
|
|
614157424f | ||
|
|
f5f80fcd48 | ||
|
|
7dd4539f50 | ||
|
|
e66876249e | ||
|
|
d39eb43419 | ||
|
|
674b897321 | ||
|
|
d7e54ed181 | ||
|
|
3e833b5295 | ||
|
|
9194f0a1ba | ||
|
|
6945b7b3c3 | ||
|
|
560226dea2 | ||
|
|
4583b512b3 | ||
|
|
223a6ed011 | ||
|
|
a82234a75e | ||
|
|
92594488da | ||
|
|
2315c69f0a | ||
|
|
9d003a5c98 | ||
|
|
53ec914a52 | ||
|
|
d052cedc7d | ||
|
|
de72afd9a1 | ||
|
|
80ffff642f | ||
|
|
97960d4e3f | ||
|
|
a96038d79f | ||
|
|
1ca36d6b66 | ||
|
|
bb8eda379f | ||
|
|
2858e8ceba | ||
|
|
e35b5797a3 |
@@ -14,7 +14,7 @@ Workerの状態から純粋に再現可能で、且つ揮発性の無い操作
|
|||||||
|
|
||||||
**禁止**: ターンを跨ぐことができない情報に基づいて、history に記録せずに context だけにコンテンツを差し込むこと。これをやると LLM はそれに反応して生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。
|
**禁止**: ターンを跨ぐことができない情報に基づいて、history に記録せずに context だけにコンテンツを差し込むこと。これをやると LLM はそれに反応して生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。
|
||||||
|
|
||||||
新しい input を context に乗せたいなら、必ず先に `worker.history` に append して commit すること。`history.json` への永続化はそこから自動的についてくる。Notify / WorkerEvent / `<system-reminder>` 系はこの原則で扱う。
|
新しい input を context に乗せたいなら、必ず先に `worker.history` に append して commit すること。`history.json` への永続化はそこから自動的についてくる。Notify / WorkerEvent / typed `SystemItem` reminder はこの原則で扱う。
|
||||||
また、キャッシュを破壊するタイミングは正確にコントロールされる必要があり、キャッシュ破壊とトークン消費のトレードオフに基づいて慎重に設計されるべきである。
|
また、キャッシュを破壊するタイミングは正確にコントロールされる必要があり、キャッシュ破壊とトークン消費のトレードオフに基づいて慎重に設計されるべきである。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+1
-1
@@ -109,7 +109,7 @@ serde = "1.0"
|
|||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde_yaml = "0.9.34"
|
serde_yaml = "0.9.34"
|
||||||
tar = "0.4"
|
tar = "0.4"
|
||||||
rusqlite = { version = "0.37", features = ["bundled"] }
|
rusqlite = { version = "0.37", features = ["backup", "bundled"] }
|
||||||
ring = "0.17.14"
|
ring = "0.17.14"
|
||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
tempfile = "3.27"
|
tempfile = "3.27"
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ pub struct BackendRuntimeTarget {
|
|||||||
/// Workspace Backend API root URL, for example `http://127.0.0.1:8787`.
|
/// Workspace Backend API root URL, for example `http://127.0.0.1:8787`.
|
||||||
/// This is intentionally the Backend endpoint, not a Runtime endpoint.
|
/// This is intentionally the Backend endpoint, not a Runtime endpoint.
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
|
/// Workspace identity used for every Worker lifecycle and protocol operation.
|
||||||
|
pub workspace_id: String,
|
||||||
/// Backend-owned Runtime identity used as path authority.
|
/// Backend-owned Runtime identity used as path authority.
|
||||||
pub runtime_id: String,
|
pub runtime_id: String,
|
||||||
/// Backend-owned Worker identity used as path authority.
|
/// Backend-owned Worker identity used as path authority.
|
||||||
@@ -23,11 +25,13 @@ pub struct BackendRuntimeTarget {
|
|||||||
impl BackendRuntimeTarget {
|
impl BackendRuntimeTarget {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
base_url: impl Into<String>,
|
base_url: impl Into<String>,
|
||||||
|
workspace_id: impl Into<String>,
|
||||||
runtime_id: impl Into<String>,
|
runtime_id: impl Into<String>,
|
||||||
worker_id: impl Into<String>,
|
worker_id: impl Into<String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
base_url: base_url.into(),
|
base_url: base_url.into(),
|
||||||
|
workspace_id: workspace_id.into(),
|
||||||
runtime_id: runtime_id.into(),
|
runtime_id: runtime_id.into(),
|
||||||
worker_id: worker_id.into(),
|
worker_id: worker_id.into(),
|
||||||
}
|
}
|
||||||
@@ -57,6 +61,36 @@ impl BackendRuntimeListTarget {
|
|||||||
runtime_id,
|
runtime_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn select_workspace(&mut self, workspace_id: impl Into<String>) {
|
||||||
|
self.workspace_id = Some(workspace_id.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_workspace(&mut self) {
|
||||||
|
self.workspace_id = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn workspace_id(&self) -> Option<&str> {
|
||||||
|
self.workspace_id.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn runtime_target(
|
||||||
|
&self,
|
||||||
|
runtime_id: impl Into<String>,
|
||||||
|
worker_id: impl Into<String>,
|
||||||
|
) -> Result<BackendRuntimeTarget, BackendRuntimeClientError> {
|
||||||
|
let workspace_id = self.workspace_id.clone().ok_or_else(|| {
|
||||||
|
BackendRuntimeClientError::InvalidTarget(
|
||||||
|
"workspace_id is required before selecting a Backend worker".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(BackendRuntimeTarget::new(
|
||||||
|
self.base_url.clone(),
|
||||||
|
workspace_id,
|
||||||
|
runtime_id,
|
||||||
|
worker_id,
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
@@ -103,6 +137,7 @@ pub struct BackendWorkerCapabilitySummary {
|
|||||||
pub struct BackendWorkerSummary {
|
pub struct BackendWorkerSummary {
|
||||||
pub runtime_id: String,
|
pub runtime_id: String,
|
||||||
pub worker_id: String,
|
pub worker_id: String,
|
||||||
|
pub resource_key: String,
|
||||||
pub host_id: String,
|
pub host_id: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
@@ -184,7 +219,13 @@ pub async fn list_backend_workers(
|
|||||||
validate_list_target(target)?;
|
validate_list_target(target)?;
|
||||||
let http = reqwest::Client::new();
|
let http = reqwest::Client::new();
|
||||||
if let Some(runtime_id) = target.runtime_id.as_deref() {
|
if let Some(runtime_id) = target.runtime_id.as_deref() {
|
||||||
let path = backend_runtime_workers_path(target.workspace_id.as_deref(), runtime_id);
|
let path = backend_runtime_workers_path(
|
||||||
|
target
|
||||||
|
.workspace_id
|
||||||
|
.as_deref()
|
||||||
|
.expect("validated Backend Workspace scope"),
|
||||||
|
runtime_id,
|
||||||
|
);
|
||||||
let url = join_base_and_path(&target.base_url, &path);
|
let url = join_base_and_path(&target.base_url, &path);
|
||||||
return Ok(http
|
return Ok(http
|
||||||
.get(url)
|
.get(url)
|
||||||
@@ -195,7 +236,12 @@ pub async fn list_backend_workers(
|
|||||||
.await?);
|
.await?);
|
||||||
}
|
}
|
||||||
|
|
||||||
let runtime_path = backend_runtimes_path(target.workspace_id.as_deref());
|
let runtime_path = backend_runtimes_path(
|
||||||
|
target
|
||||||
|
.workspace_id
|
||||||
|
.as_deref()
|
||||||
|
.expect("validated Backend Workspace scope"),
|
||||||
|
);
|
||||||
let runtime_url = join_base_and_path(&target.base_url, &runtime_path);
|
let runtime_url = join_base_and_path(&target.base_url, &runtime_path);
|
||||||
let runtimes = http
|
let runtimes = http
|
||||||
.get(runtime_url)
|
.get(runtime_url)
|
||||||
@@ -208,8 +254,13 @@ pub async fn list_backend_workers(
|
|||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
let mut diagnostics = runtimes.diagnostics;
|
let mut diagnostics = runtimes.diagnostics;
|
||||||
for runtime in runtimes.items {
|
for runtime in runtimes.items {
|
||||||
let path =
|
let path = backend_runtime_workers_path(
|
||||||
backend_runtime_workers_path(target.workspace_id.as_deref(), &runtime.runtime_id);
|
target
|
||||||
|
.workspace_id
|
||||||
|
.as_deref()
|
||||||
|
.expect("validated Backend Workspace scope"),
|
||||||
|
&runtime.runtime_id,
|
||||||
|
);
|
||||||
let url = join_base_and_path(&target.base_url, &path);
|
let url = join_base_and_path(&target.base_url, &path);
|
||||||
match http
|
match http
|
||||||
.get(url)
|
.get(url)
|
||||||
@@ -254,7 +305,13 @@ pub async fn list_backend_stopped_workers(
|
|||||||
));
|
));
|
||||||
};
|
};
|
||||||
let http = reqwest::Client::new();
|
let http = reqwest::Client::new();
|
||||||
let path = backend_runtime_workers_path(target.workspace_id.as_deref(), runtime_id);
|
let path = backend_runtime_workers_path(
|
||||||
|
target
|
||||||
|
.workspace_id
|
||||||
|
.as_deref()
|
||||||
|
.expect("validated Backend Workspace scope"),
|
||||||
|
runtime_id,
|
||||||
|
);
|
||||||
let url = join_base_and_path(&target.base_url, &format!("{path}?status=stopped"));
|
let url = join_base_and_path(&target.base_url, &format!("{path}?status=stopped"));
|
||||||
Ok(http
|
Ok(http
|
||||||
.get(url)
|
.get(url)
|
||||||
@@ -270,7 +327,11 @@ pub async fn restore_backend_worker(
|
|||||||
) -> Result<BackendWorkerRestoreResponse, BackendRuntimeClientError> {
|
) -> Result<BackendWorkerRestoreResponse, BackendRuntimeClientError> {
|
||||||
validate_target(target)?;
|
validate_target(target)?;
|
||||||
let http = reqwest::Client::new();
|
let http = reqwest::Client::new();
|
||||||
let path = backend_runtime_worker_restore_path(None, &target.runtime_id, &target.worker_id);
|
let path = backend_runtime_worker_restore_path(
|
||||||
|
&target.workspace_id,
|
||||||
|
&target.runtime_id,
|
||||||
|
&target.worker_id,
|
||||||
|
);
|
||||||
let url = join_base_and_path(&target.base_url, &path);
|
let url = join_base_and_path(&target.base_url, &path);
|
||||||
Ok(http
|
Ok(http
|
||||||
.post(url)
|
.post(url)
|
||||||
@@ -438,6 +499,11 @@ fn validate_target(target: &BackendRuntimeTarget) -> Result<(), BackendRuntimeCl
|
|||||||
"Backend API base URL must start with http:// or https://".to_string(),
|
"Backend API base URL must start with http:// or https://".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if target.workspace_id.is_empty() {
|
||||||
|
return Err(BackendRuntimeClientError::InvalidTarget(
|
||||||
|
"workspace_id is required".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
if target.runtime_id.is_empty() {
|
if target.runtime_id.is_empty() {
|
||||||
return Err(BackendRuntimeClientError::InvalidTarget(
|
return Err(BackendRuntimeClientError::InvalidTarget(
|
||||||
"runtime_id is required".to_string(),
|
"runtime_id is required".to_string(),
|
||||||
@@ -464,10 +530,18 @@ fn validate_list_target(
|
|||||||
"Backend API base URL must start with http:// or https://".to_string(),
|
"Backend API base URL must start with http:// or https://".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if target.workspace_id.as_deref().is_some_and(str::is_empty) {
|
match target.workspace_id.as_deref() {
|
||||||
return Err(BackendRuntimeClientError::InvalidTarget(
|
Some("") => {
|
||||||
"workspace_id must not be empty when provided".to_string(),
|
return Err(BackendRuntimeClientError::InvalidTarget(
|
||||||
));
|
"workspace_id must not be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
return Err(BackendRuntimeClientError::InvalidTarget(
|
||||||
|
"workspace selection is required before listing Backend workers".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
}
|
}
|
||||||
if target.runtime_id.as_deref().is_some_and(str::is_empty) {
|
if target.runtime_id.as_deref().is_some_and(str::is_empty) {
|
||||||
return Err(BackendRuntimeClientError::InvalidTarget(
|
return Err(BackendRuntimeClientError::InvalidTarget(
|
||||||
@@ -477,47 +551,35 @@ fn validate_list_target(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn backend_runtimes_path(workspace_id: Option<&str>) -> String {
|
fn backend_runtimes_path(workspace_id: &str) -> String {
|
||||||
match workspace_id {
|
format!("/api/w/{}/runtimes", path_segment_encode(workspace_id))
|
||||||
Some(workspace_id) => format!("/api/w/{}/runtimes", path_segment_encode(workspace_id)),
|
|
||||||
None => "/api/runtimes".to_string(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn backend_runtime_workers_path(workspace_id: Option<&str>, runtime_id: &str) -> String {
|
fn backend_runtime_workers_path(workspace_id: &str, runtime_id: &str) -> String {
|
||||||
match workspace_id {
|
format!(
|
||||||
Some(workspace_id) => format!(
|
"/api/w/{}/runtimes/{}/workers",
|
||||||
"/api/w/{}/runtimes/{}/workers",
|
path_segment_encode(workspace_id),
|
||||||
path_segment_encode(workspace_id),
|
path_segment_encode(runtime_id)
|
||||||
path_segment_encode(runtime_id)
|
)
|
||||||
),
|
|
||||||
None => format!("/api/runtimes/{}/workers", path_segment_encode(runtime_id)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn backend_runtime_worker_restore_path(
|
fn backend_runtime_worker_restore_path(
|
||||||
workspace_id: Option<&str>,
|
workspace_id: &str,
|
||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
worker_id: &str,
|
worker_id: &str,
|
||||||
) -> String {
|
) -> String {
|
||||||
match workspace_id {
|
format!(
|
||||||
Some(workspace_id) => format!(
|
"/api/w/{}/runtimes/{}/workers/{}/restore",
|
||||||
"/api/w/{}/runtimes/{}/workers/{}/restore",
|
path_segment_encode(workspace_id),
|
||||||
path_segment_encode(workspace_id),
|
path_segment_encode(runtime_id),
|
||||||
path_segment_encode(runtime_id),
|
path_segment_encode(worker_id)
|
||||||
path_segment_encode(worker_id)
|
)
|
||||||
),
|
|
||||||
None => format!(
|
|
||||||
"/api/runtimes/{}/workers/{}/restore",
|
|
||||||
path_segment_encode(runtime_id),
|
|
||||||
path_segment_encode(worker_id)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn protocol_ws_url(target: &BackendRuntimeTarget) -> String {
|
fn protocol_ws_url(target: &BackendRuntimeTarget) -> String {
|
||||||
let path = format!(
|
let path = format!(
|
||||||
"/api/runtimes/{}/workers/{}/protocol/ws",
|
"/api/w/{}/runtimes/{}/workers/{}/protocol/ws",
|
||||||
|
path_segment_encode(&target.workspace_id),
|
||||||
path_segment_encode(&target.runtime_id),
|
path_segment_encode(&target.runtime_id),
|
||||||
path_segment_encode(&target.worker_id)
|
path_segment_encode(&target.worker_id)
|
||||||
);
|
);
|
||||||
@@ -571,11 +633,15 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn protocol_url_uses_backend_runtime_worker_identity() {
|
fn protocol_url_uses_backend_runtime_worker_identity() {
|
||||||
let target =
|
let target = BackendRuntimeTarget::new(
|
||||||
BackendRuntimeTarget::new("http://127.0.0.1:8787/", "runtime/one", "worker one");
|
"http://127.0.0.1:8787/",
|
||||||
|
"workspace alpha",
|
||||||
|
"runtime/one",
|
||||||
|
"worker one",
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
protocol_ws_url(&target),
|
protocol_ws_url(&target),
|
||||||
"ws://127.0.0.1:8787/api/runtimes/runtime%2Fone/workers/worker%20one/protocol/ws"
|
"ws://127.0.0.1:8787/api/w/workspace%20alpha/runtimes/runtime%2Fone/workers/worker%20one/protocol/ws"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,6 +650,7 @@ mod tests {
|
|||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"runtime_id": "arcadia",
|
"runtime_id": "arcadia",
|
||||||
"worker_id": "worker-opaque-64",
|
"worker_id": "worker-opaque-64",
|
||||||
|
"resource_key": "W-64",
|
||||||
"host_id": "host",
|
"host_id": "host",
|
||||||
"display_name": "Coder",
|
"display_name": "Coder",
|
||||||
"label": "Coder",
|
"label": "Coder",
|
||||||
@@ -620,8 +687,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workers_path_can_be_workspace_scoped_for_status_queries() {
|
fn workers_path_requires_workspace_scope_for_status_queries() {
|
||||||
let path = backend_runtime_workers_path(Some("team main"), "runtime/one");
|
let path = backend_runtime_workers_path("team main", "runtime/one");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format!("{path}?status=stopped"),
|
format!("{path}?status=stopped"),
|
||||||
"/api/w/team%20main/runtimes/runtime%2Fone/workers?status=stopped"
|
"/api/w/team%20main/runtimes/runtime%2Fone/workers?status=stopped"
|
||||||
@@ -629,10 +696,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn restore_worker_path_uses_backend_runtime_worker_identity() {
|
fn restore_worker_path_requires_workspace_scope() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
backend_runtime_worker_restore_path(None, "runtime/one", "worker one"),
|
backend_runtime_worker_restore_path("team main", "runtime/one", "worker one"),
|
||||||
"/api/runtimes/runtime%2Fone/workers/worker%20one/restore"
|
"/api/w/team%20main/runtimes/runtime%2Fone/workers/worker%20one/restore"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
const DEFAULT_WORKSPACE_LIMIT: usize = 200;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct BackendWorkspace {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub owner_account_id: Option<String>,
|
||||||
|
pub display_name: String,
|
||||||
|
pub state: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct CreateBackendWorkspaceRequest {
|
||||||
|
pub operation_key: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub repository: CreateBackendWorkspaceRepository,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct CreateBackendWorkspaceRepository {
|
||||||
|
pub uri: String,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub default_ref: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct CreateBackendWorkspaceResponse {
|
||||||
|
pub workspace: BackendWorkspace,
|
||||||
|
pub repository: CreateBackendWorkspaceRepositoryRecord,
|
||||||
|
pub config_revision: u64,
|
||||||
|
pub request_fingerprint: String,
|
||||||
|
pub replayed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct CreateBackendWorkspaceRepositoryRecord {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub repository_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub uri: String,
|
||||||
|
pub default_ref: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct BackendWorkspaceCatalogTarget {
|
||||||
|
pub base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BackendWorkspaceCatalogTarget {
|
||||||
|
pub fn new(base_url: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
base_url: base_url.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum BackendWorkspaceClientError {
|
||||||
|
InvalidTarget(String),
|
||||||
|
RequestFailed { status: u16, message: String },
|
||||||
|
Http(reqwest::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for BackendWorkspaceClientError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::InvalidTarget(message) => f.write_str(message),
|
||||||
|
Self::RequestFailed { status, message } => {
|
||||||
|
write!(f, "Backend request failed with HTTP {status}: {message}")
|
||||||
|
}
|
||||||
|
Self::Http(error) => write!(f, "{error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for BackendWorkspaceClientError {}
|
||||||
|
|
||||||
|
impl From<reqwest::Error> for BackendWorkspaceClientError {
|
||||||
|
fn from(error: reqwest::Error) -> Self {
|
||||||
|
Self::Http(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_backend_workspaces(
|
||||||
|
target: &BackendWorkspaceCatalogTarget,
|
||||||
|
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
||||||
|
validate_target(target)?;
|
||||||
|
let url = format!(
|
||||||
|
"{}/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}",
|
||||||
|
target.base_url.trim_end_matches('/')
|
||||||
|
);
|
||||||
|
let response = reqwest::Client::new().get(url).send().await?;
|
||||||
|
let response = require_success(response).await?;
|
||||||
|
Ok(response.json::<Vec<BackendWorkspace>>().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_backend_workspace(
|
||||||
|
target: &BackendWorkspaceCatalogTarget,
|
||||||
|
request: &CreateBackendWorkspaceRequest,
|
||||||
|
) -> Result<CreateBackendWorkspaceResponse, BackendWorkspaceClientError> {
|
||||||
|
validate_target(target)?;
|
||||||
|
let url = format!("{}/api/workspaces", target.base_url.trim_end_matches('/'));
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(url)
|
||||||
|
.json(request)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let response = require_success(response).await?;
|
||||||
|
Ok(response.json::<CreateBackendWorkspaceResponse>().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn require_success(
|
||||||
|
response: reqwest::Response,
|
||||||
|
) -> Result<reqwest::Response, BackendWorkspaceClientError> {
|
||||||
|
if response.status().is_success() {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let message = response.text().await.unwrap_or_default();
|
||||||
|
Err(BackendWorkspaceClientError::RequestFailed { status, message })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_target(
|
||||||
|
target: &BackendWorkspaceCatalogTarget,
|
||||||
|
) -> Result<(), BackendWorkspaceClientError> {
|
||||||
|
if !(target.base_url.starts_with("http://") || target.base_url.starts_with("https://")) {
|
||||||
|
return Err(BackendWorkspaceClientError::InvalidTarget(
|
||||||
|
"Backend API base URL must start with http:// or https://".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_request_keeps_operation_key_for_exact_retry() {
|
||||||
|
let request = CreateBackendWorkspaceRequest {
|
||||||
|
operation_key: "workspace-create-1".to_string(),
|
||||||
|
display_name: "Alpha".to_string(),
|
||||||
|
repository: CreateBackendWorkspaceRepository {
|
||||||
|
uri: "/srv/repos/alpha".to_string(),
|
||||||
|
display_name: Some("Main".to_string()),
|
||||||
|
default_ref: Some("develop".to_string()),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let retry = request.clone();
|
||||||
|
assert_eq!(retry.operation_key, "workspace-create-1");
|
||||||
|
assert_eq!(retry, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
pub mod backend_auth;
|
pub mod backend_auth;
|
||||||
pub mod backend_runtime;
|
pub mod backend_runtime;
|
||||||
|
pub mod backend_workspace;
|
||||||
pub mod runtime_command;
|
pub mod runtime_command;
|
||||||
pub mod spawn;
|
pub mod spawn;
|
||||||
pub mod target;
|
pub mod target;
|
||||||
@@ -28,6 +29,11 @@ pub use backend_runtime::{
|
|||||||
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, list_backend_stopped_workers,
|
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, list_backend_stopped_workers,
|
||||||
list_backend_workers, restore_backend_worker,
|
list_backend_workers, restore_backend_worker,
|
||||||
};
|
};
|
||||||
|
pub use backend_workspace::{
|
||||||
|
BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError,
|
||||||
|
CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest,
|
||||||
|
CreateBackendWorkspaceResponse, create_backend_workspace, list_backend_workspaces,
|
||||||
|
};
|
||||||
pub use runtime_command::WorkerRuntimeCommand;
|
pub use runtime_command::WorkerRuntimeCommand;
|
||||||
pub use target::{
|
pub use target::{
|
||||||
BackendTarget, Dashboard, LocalTarget, Target, TargetError, TargetKind, WorkerByName,
|
BackendTarget, Dashboard, LocalTarget, Target, TargetError, TargetKind, WorkerByName,
|
||||||
|
|||||||
@@ -132,6 +132,12 @@ impl TargetError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn invalid(target: TargetKind, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
message: format!("invalid {target} target: {}", message.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn local_runtime_command(error: std::io::Error) -> Self {
|
fn local_runtime_command(error: std::io::Error) -> Self {
|
||||||
Self {
|
Self {
|
||||||
message: format!("failed to resolve local Worker runtime command: {error}"),
|
message: format!("failed to resolve local Worker runtime command: {error}"),
|
||||||
@@ -260,9 +266,16 @@ impl Target for BackendTarget {
|
|||||||
&self,
|
&self,
|
||||||
selector: WorkerConnectionSelector,
|
selector: WorkerConnectionSelector,
|
||||||
) -> Result<WorkerConnection, TargetError> {
|
) -> Result<WorkerConnection, TargetError> {
|
||||||
|
let workspace_id = self.workspace_id.clone().ok_or_else(|| {
|
||||||
|
TargetError::invalid(
|
||||||
|
self.kind(),
|
||||||
|
"workspace selection is required before connecting to a Backend Worker",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
Ok(WorkerConnection {
|
Ok(WorkerConnection {
|
||||||
target: BackendRuntimeTarget::new(
|
target: BackendRuntimeTarget::new(
|
||||||
self.base_url.clone(),
|
self.base_url.clone(),
|
||||||
|
workspace_id,
|
||||||
selector.runtime_id,
|
selector.runtime_id,
|
||||||
selector.worker_id,
|
selector.worker_id,
|
||||||
),
|
),
|
||||||
@@ -313,10 +326,27 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(connection.target.base_url, "http://127.0.0.1:8787");
|
assert_eq!(connection.target.base_url, "http://127.0.0.1:8787");
|
||||||
|
assert_eq!(connection.target.workspace_id, "workspace-a");
|
||||||
assert_eq!(connection.target.runtime_id, "runtime-a");
|
assert_eq!(connection.target.runtime_id, "runtime-a");
|
||||||
assert_eq!(connection.target.worker_id, "worker-b");
|
assert_eq!(connection.target.worker_id, "worker-b");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backend_target_rejects_worker_connection_before_workspace_selection() {
|
||||||
|
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>);
|
||||||
|
let error =
|
||||||
|
match target.connect_worker(WorkerConnectionSelector::new("runtime-a", "worker-b")) {
|
||||||
|
Ok(_) => panic!("unscoped connection must fail"),
|
||||||
|
Err(error) => error,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
error
|
||||||
|
.to_string()
|
||||||
|
.contains("workspace selection is required")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backend_target_rejects_local_worker_operations() {
|
fn backend_target_rejects_local_worker_operations() {
|
||||||
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>);
|
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>);
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
use client::{
|
||||||
|
BackendTarget, CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest, Target,
|
||||||
|
WorkerConnectionSelector,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_creation_request_preserves_operation_key_for_retry() {
|
||||||
|
let request = CreateBackendWorkspaceRequest {
|
||||||
|
operation_key: "workspace-create-1".to_string(),
|
||||||
|
display_name: "Alpha".to_string(),
|
||||||
|
repository: CreateBackendWorkspaceRepository {
|
||||||
|
uri: "/srv/repos/alpha".to_string(),
|
||||||
|
display_name: Some("Main".to_string()),
|
||||||
|
default_ref: Some("develop".to_string()),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(request.clone(), request);
|
||||||
|
assert_eq!(request.operation_key, "workspace-create-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backend_worker_connection_requires_explicit_workspace_scope() {
|
||||||
|
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>);
|
||||||
|
let error = match target.connect_worker(WorkerConnectionSelector::new("runtime-a", "worker-a"))
|
||||||
|
{
|
||||||
|
Ok(_) => panic!("unscoped Backend worker connection must fail"),
|
||||||
|
Err(error) => error,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
error
|
||||||
|
.to_string()
|
||||||
|
.contains("workspace selection is required")
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,7 +24,6 @@ pub const MAX_TOTAL_BYTES: usize = 4 * 1024 * 1024;
|
|||||||
pub const MAX_PATH_BYTES: usize = 512;
|
pub const MAX_PATH_BYTES: usize = 512;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, ts_rs::TS)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, ts_rs::TS)]
|
||||||
#[serde(transparent)]
|
|
||||||
pub struct VirtualPath(String);
|
pub struct VirtualPath(String);
|
||||||
|
|
||||||
impl VirtualPath {
|
impl VirtualPath {
|
||||||
@@ -1791,6 +1790,19 @@ mod tests {
|
|||||||
assert_eq!(path("profiles/main.dcdl").as_str(), "profiles/main.dcdl");
|
assert_eq!(path("profiles/main.dcdl").as_str(), "profiles/main.dcdl");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn virtual_path_serde_shape_is_a_string() {
|
||||||
|
let path = path("profiles/main.dcdl");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&path).unwrap(),
|
||||||
|
serde_json::json!(path.as_str())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<VirtualPath>(serde_json::json!(path.as_str())).unwrap(),
|
||||||
|
path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn candidate_changes_are_atomic_ordered_and_conflict_checked() {
|
fn candidate_changes_are_atomic_ordered_and_conflict_checked() {
|
||||||
let base = ConfigTreeSnapshot::from_entries(
|
let base = ConfigTreeSnapshot::from_entries(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
+164
-5
@@ -358,7 +358,7 @@ pub enum Event {
|
|||||||
/// `Method::Run` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),
|
/// `Method::Run` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),
|
||||||
/// `Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other
|
/// `Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other
|
||||||
/// IDLE-breaking trigger. Mid-run interrupts (e.g. hook output,
|
/// IDLE-breaking trigger. Mid-run interrupts (e.g. hook output,
|
||||||
/// `<system-reminder>` injection that doesn't break IDLE) do not
|
/// typed system reminder insertion that doesn't break IDLE) do not
|
||||||
/// emit `InvokeStart` — they appear as `SystemItem` only.
|
/// emit `InvokeStart` — they appear as `SystemItem` only.
|
||||||
///
|
///
|
||||||
/// Carries `kind` only; the payload (user text / notify message /
|
/// Carries `kind` only; the payload (user text / notify message /
|
||||||
@@ -530,6 +530,15 @@ pub enum Event {
|
|||||||
revision: u64,
|
revision: u64,
|
||||||
event: Box<Event>,
|
event: Box<Event>,
|
||||||
},
|
},
|
||||||
|
/// Terminal removal fence for one parent-owned Internal Worker session.
|
||||||
|
///
|
||||||
|
/// Clients discard the matching child and descendants, then ignore later
|
||||||
|
/// nested events for this identity until an authoritative snapshot replaces
|
||||||
|
/// the projection.
|
||||||
|
InternalWorkerRemoved {
|
||||||
|
worker: InternalWorkerRef,
|
||||||
|
revision: u64,
|
||||||
|
},
|
||||||
/// Server-side segment log rotated to a fresh `SegmentStart`.
|
/// Server-side segment log rotated to a fresh `SegmentStart`.
|
||||||
///
|
///
|
||||||
/// Fires on compaction and on auto-fork when the store head drifts
|
/// Fires on compaction and on auto-fork when the store head drifts
|
||||||
@@ -547,6 +556,12 @@ pub enum Event {
|
|||||||
Status {
|
Status {
|
||||||
status: WorkerStatus,
|
status: WorkerStatus,
|
||||||
},
|
},
|
||||||
|
/// Bounded, provider-owned command telemetry for the live Console. This is
|
||||||
|
/// intentionally not a history entry and is reconstructed from
|
||||||
|
/// `Snapshot.in_flight.commands` after reconnect.
|
||||||
|
Command {
|
||||||
|
event: CommandEvent,
|
||||||
|
},
|
||||||
/// Reply to `Method::ListCompletions`. Delivered only to the
|
/// Reply to `Method::ListCompletions`. Delivered only to the
|
||||||
/// requesting socket (not broadcast). `entries` is empty when no
|
/// requesting socket (not broadcast). `entries` is empty when no
|
||||||
/// candidates match or when the requested kind has no resolver
|
/// candidates match or when the requested kind has no resolver
|
||||||
@@ -714,8 +729,79 @@ pub struct RewindSummary {
|
|||||||
pub tool_side_effect_warning: bool,
|
pub tool_side_effect_warning: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unfinished model output included in `Event::Snapshot` for clients that
|
/// Live provider-owned command status. These values are operational Console
|
||||||
/// attach while an LLM response is still streaming.
|
/// state only and are never appended to Worker history.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CommandStatus {
|
||||||
|
Running,
|
||||||
|
Completed,
|
||||||
|
Failed,
|
||||||
|
TimedOut,
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CommandStream {
|
||||||
|
Stdout,
|
||||||
|
Stderr,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub struct CommandStreamSlice {
|
||||||
|
pub start_offset: u64,
|
||||||
|
pub end_offset: u64,
|
||||||
|
pub content: String,
|
||||||
|
pub truncated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub struct CommandSnapshot {
|
||||||
|
pub command_id: String,
|
||||||
|
pub tool_call_id: Option<String>,
|
||||||
|
pub status: CommandStatus,
|
||||||
|
pub started_at_ms: u64,
|
||||||
|
pub observed_at_ms: u64,
|
||||||
|
pub last_output_at_ms: Option<u64>,
|
||||||
|
pub stdout: CommandStreamSlice,
|
||||||
|
pub stderr: CommandStreamSlice,
|
||||||
|
pub exit_code: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum CommandEvent {
|
||||||
|
Started {
|
||||||
|
command_id: String,
|
||||||
|
tool_call_id: Option<String>,
|
||||||
|
observed_at_ms: u64,
|
||||||
|
},
|
||||||
|
Output {
|
||||||
|
command_id: String,
|
||||||
|
stream: CommandStream,
|
||||||
|
start_offset: u64,
|
||||||
|
end_offset: u64,
|
||||||
|
content: String,
|
||||||
|
observed_at_ms: u64,
|
||||||
|
},
|
||||||
|
Terminal {
|
||||||
|
command_id: String,
|
||||||
|
status: CommandStatus,
|
||||||
|
exit_code: Option<i32>,
|
||||||
|
stdout_end_offset: u64,
|
||||||
|
stderr_end_offset: u64,
|
||||||
|
observed_at_ms: u64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unfinished model output and active command state included in
|
||||||
|
/// `Event::Snapshot` for clients that attach while work is still streaming.
|
||||||
///
|
///
|
||||||
/// These blocks are presentation state only: they are reconstructed from the
|
/// These blocks are presentation state only: they are reconstructed from the
|
||||||
/// active Worker controller and must not be treated as committed assistant
|
/// active Worker controller and must not be treated as committed assistant
|
||||||
@@ -726,11 +812,13 @@ pub struct RewindSummary {
|
|||||||
pub struct InFlightSnapshot {
|
pub struct InFlightSnapshot {
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub blocks: Vec<InFlightBlock>,
|
pub blocks: Vec<InFlightBlock>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub commands: Vec<CommandSnapshot>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InFlightSnapshot {
|
impl InFlightSnapshot {
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
self.blocks.is_empty()
|
self.blocks.is_empty() && self.commands.is_empty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -833,7 +921,7 @@ pub enum InvokeKind {
|
|||||||
Notify,
|
Notify,
|
||||||
/// `Method::WorkerEvent` — typed lifecycle report from a child Worker.
|
/// `Method::WorkerEvent` — typed lifecycle report from a child Worker.
|
||||||
WorkerEvent,
|
WorkerEvent,
|
||||||
/// `<system-reminder>` etc. that crosses an IDLE boundary (mid-run
|
/// A typed system reminder that crosses an IDLE boundary (mid-run
|
||||||
/// reminders that don't break IDLE are SystemItem-only and do not
|
/// reminders that don't break IDLE are SystemItem-only and do not
|
||||||
/// open a new Invoke).
|
/// open a new Invoke).
|
||||||
SystemReminder,
|
SystemReminder,
|
||||||
@@ -1375,6 +1463,22 @@ mod tests {
|
|||||||
state: InFlightToolCallState::StreamingArgs,
|
state: InFlightToolCallState::StreamingArgs,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
commands: vec![CommandSnapshot {
|
||||||
|
command_id: "command-1".into(),
|
||||||
|
tool_call_id: Some("call_1".into()),
|
||||||
|
status: CommandStatus::Running,
|
||||||
|
started_at_ms: 100,
|
||||||
|
observed_at_ms: 120,
|
||||||
|
last_output_at_ms: Some(120),
|
||||||
|
stdout: CommandStreamSlice {
|
||||||
|
start_offset: 4,
|
||||||
|
end_offset: 8,
|
||||||
|
content: "tail".into(),
|
||||||
|
truncated: true,
|
||||||
|
},
|
||||||
|
stderr: CommandStreamSlice::default(),
|
||||||
|
exit_code: None,
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
internal_workers: Vec::new(),
|
internal_workers: Vec::new(),
|
||||||
};
|
};
|
||||||
@@ -1444,6 +1548,41 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn event_command_output_roundtrip_preserves_stream_and_offsets() {
|
||||||
|
let event = Event::Command {
|
||||||
|
event: CommandEvent::Output {
|
||||||
|
command_id: "command-1".into(),
|
||||||
|
stream: CommandStream::Stderr,
|
||||||
|
start_offset: 8,
|
||||||
|
end_offset: 12,
|
||||||
|
content: "warn".into(),
|
||||||
|
observed_at_ms: 42,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed["event"], "command");
|
||||||
|
assert_eq!(parsed["data"]["event"]["kind"], "output");
|
||||||
|
assert_eq!(parsed["data"]["event"]["stream"], "stderr");
|
||||||
|
assert_eq!(parsed["data"]["event"]["start_offset"], 8);
|
||||||
|
assert_eq!(parsed["data"]["event"]["end_offset"], 12);
|
||||||
|
assert_eq!(parsed["data"]["event"]["observed_at_ms"], 42);
|
||||||
|
assert!(matches!(
|
||||||
|
serde_json::from_str::<Event>(&json).unwrap(),
|
||||||
|
Event::Command {
|
||||||
|
event: CommandEvent::Output {
|
||||||
|
command_id,
|
||||||
|
stream: CommandStream::Stderr,
|
||||||
|
start_offset: 8,
|
||||||
|
end_offset: 12,
|
||||||
|
content,
|
||||||
|
observed_at_ms: 42,
|
||||||
|
}
|
||||||
|
} if command_id == "command-1" && content == "warn"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn event_snapshot_legacy_without_status_defaults_to_idle() {
|
fn event_snapshot_legacy_without_status_defaults_to_idle() {
|
||||||
let json = r#"{"event":"snapshot","data":{"entries":[],"greeting":{"worker_name":"test","cwd":"/tmp","provider":"anthropic","model":"claude","scope_summary":"","tools":[]}}}"#;
|
let json = r#"{"event":"snapshot","data":{"entries":[],"greeting":{"worker_name":"test","cwd":"/tmp","provider":"anthropic","model":"claude","scope_summary":"","tools":[]}}}"#;
|
||||||
@@ -1802,6 +1941,26 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn internal_worker_removal_roundtrip_preserves_terminal_fence() {
|
||||||
|
let event = Event::InternalWorkerRemoved {
|
||||||
|
worker: InternalWorkerRef {
|
||||||
|
session_id: "session-1".into(),
|
||||||
|
name: "research".into(),
|
||||||
|
parent_session_id: Some("parent-session".into()),
|
||||||
|
kind: InternalWorkerKind::SubWorker,
|
||||||
|
},
|
||||||
|
revision: 8,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
|
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
decoded,
|
||||||
|
Event::InternalWorkerRemoved { worker, revision }
|
||||||
|
if worker.session_id == "session-1" && revision == 8
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn legacy_snapshot_defaults_internal_workers_to_empty() {
|
fn legacy_snapshot_defaults_internal_workers_to_empty() {
|
||||||
let snapshot: Event = serde_json::from_value(serde_json::json!({
|
let snapshot: Event = serde_json::from_value(serde_json::json!({
|
||||||
|
|||||||
@@ -551,9 +551,15 @@ pub struct SubscriptionWorker {
|
|||||||
/// Runtime producers leave this unset because the connection identifies the Runtime.
|
/// Runtime producers leave this unset because the connection identifies the Runtime.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub runtime_id: Option<String>,
|
pub runtime_id: Option<String>,
|
||||||
|
/// Workspace-scoped canonical resource key. Runtime producers leave this unset;
|
||||||
|
/// Workspace-facing projections must populate it before publishing the Worker.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub resource_key: Option<String>,
|
||||||
/// Producer-owned monotonic revision for this Worker subject.
|
/// Producer-owned monotonic revision for this Worker subject.
|
||||||
pub subject_revision: u64,
|
pub subject_revision: u64,
|
||||||
pub state: SubscriptionWorkerState,
|
pub state: SubscriptionWorkerState,
|
||||||
|
#[serde(default)]
|
||||||
|
pub has_running_internal_workers: bool,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub workspace_id: Option<String>,
|
pub workspace_id: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
@@ -572,6 +578,9 @@ impl SubscriptionWorker {
|
|||||||
if let Some(runtime_id) = &self.runtime_id {
|
if let Some(runtime_id) = &self.runtime_id {
|
||||||
validate_identifier("runtime_id", runtime_id, MAX_RESOURCE_ID_BYTES)?;
|
validate_identifier("runtime_id", runtime_id, MAX_RESOURCE_ID_BYTES)?;
|
||||||
}
|
}
|
||||||
|
if let Some(resource_key) = &self.resource_key {
|
||||||
|
validate_identifier("resource_key", resource_key, MAX_RESOURCE_ID_BYTES)?;
|
||||||
|
}
|
||||||
if let Some(repository_id) = &self.repository_id {
|
if let Some(repository_id) = &self.repository_id {
|
||||||
validate_identifier("repository_id", repository_id, MAX_RESOURCE_ID_BYTES)?;
|
validate_identifier("repository_id", repository_id, MAX_RESOURCE_ID_BYTES)?;
|
||||||
}
|
}
|
||||||
@@ -794,8 +803,10 @@ mod tests {
|
|||||||
SubscriptionWorker {
|
SubscriptionWorker {
|
||||||
worker_id: worker_id(value),
|
worker_id: worker_id(value),
|
||||||
runtime_id: None,
|
runtime_id: None,
|
||||||
|
resource_key: None,
|
||||||
subject_revision: 0,
|
subject_revision: 0,
|
||||||
state: SubscriptionWorkerState::Idle,
|
state: SubscriptionWorkerState::Idle,
|
||||||
|
has_running_internal_workers: false,
|
||||||
workspace_id: Some("workspace-1".to_string()),
|
workspace_id: Some("workspace-1".to_string()),
|
||||||
display_name: Some(format!("Worker {value}")),
|
display_name: Some(format!("Worker {value}")),
|
||||||
profile: Some("builtin:coder".to_string()),
|
profile: Some("builtin:coder".to_string()),
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ use std::path::PathBuf;
|
|||||||
use ts_rs::{Config, TS};
|
use ts_rs::{Config, TS};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
Alert, AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting,
|
Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream,
|
||||||
InFlightBlock, InFlightSnapshot, InFlightToolCallState, InternalWorkerKind, InternalWorkerRef,
|
CommandStreamSlice, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting, InFlightBlock,
|
||||||
|
InFlightSnapshot, InFlightToolCallState, InternalWorkerKind, InternalWorkerRef,
|
||||||
InternalWorkerSnapshot, InvokeKind, MemoryWorkerEvent, Method, Permission, RewindSummary,
|
InternalWorkerSnapshot, InvokeKind, MemoryWorkerEvent, Method, Permission, RewindSummary,
|
||||||
RewindTarget, RewindTargetId, RunResult, ScopeRule, Segment, TurnResult, WorkerEvent,
|
RewindTarget, RewindTargetId, RunResult, ScopeRule, Segment, TurnResult, WorkerEvent,
|
||||||
WorkerStatus,
|
WorkerStatus,
|
||||||
@@ -47,6 +48,11 @@ pub fn generated_protocol_types() -> String {
|
|||||||
push_decl::<ErrorCode>(&cfg, &mut output);
|
push_decl::<ErrorCode>(&cfg, &mut output);
|
||||||
push_decl::<Permission>(&cfg, &mut output);
|
push_decl::<Permission>(&cfg, &mut output);
|
||||||
push_decl::<InFlightToolCallState>(&cfg, &mut output);
|
push_decl::<InFlightToolCallState>(&cfg, &mut output);
|
||||||
|
push_decl::<CommandStatus>(&cfg, &mut output);
|
||||||
|
push_decl::<CommandStream>(&cfg, &mut output);
|
||||||
|
push_decl::<CommandStreamSlice>(&cfg, &mut output);
|
||||||
|
push_decl::<CommandSnapshot>(&cfg, &mut output);
|
||||||
|
push_decl::<CommandEvent>(&cfg, &mut output);
|
||||||
push_decl::<ScopeRule>(&cfg, &mut output);
|
push_decl::<ScopeRule>(&cfg, &mut output);
|
||||||
push_decl::<CompletionEntry>(&cfg, &mut output);
|
push_decl::<CompletionEntry>(&cfg, &mut output);
|
||||||
push_decl::<RewindTargetId>(&cfg, &mut output);
|
push_decl::<RewindTargetId>(&cfg, &mut output);
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
//!
|
//!
|
||||||
//! Items in worker history with `role:system` are never produced by the
|
//! Items in worker history with `role:system` are never produced by the
|
||||||
//! LLM — they are always inserted by the Worker itself (notifications,
|
//! LLM — they are always inserted by the Worker itself (notifications,
|
||||||
//! file ref resolutions, child-worker lifecycle events,
|
//! file ref resolutions, child-worker lifecycle events, reminders, …).
|
||||||
//! future `<system-reminder>` tags, …). [`SystemItem`] carries the
|
//! [`SystemItem`] carries the
|
||||||
//! typed shape of each such injection so clients can dispatch on
|
//! typed shape of each such injection so clients can dispatch on
|
||||||
//! `kind` instead of parsing text prefixes like `[Notification] …` or
|
//! `kind` instead of parsing text prefixes like `[Notification] …` or
|
||||||
//! `[File: …]`.
|
//! `[File: …]`.
|
||||||
@@ -22,10 +22,7 @@ use llm_engine::llm_client::types::Item;
|
|||||||
use protocol::WorkerEvent;
|
use protocol::WorkerEvent;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
const SYSTEM_REMINDER_OPEN: &str = "<system-reminder>";
|
/// Source policy that produced a durable system reminder input.
|
||||||
const SYSTEM_REMINDER_CLOSE: &str = "</system-reminder>";
|
|
||||||
|
|
||||||
/// Source policy that produced a durable `<system-reminder>` input.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum SystemReminderSource {
|
pub enum SystemReminderSource {
|
||||||
@@ -52,57 +49,30 @@ pub struct SystemReminder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SystemReminder {
|
impl SystemReminder {
|
||||||
/// Build a task-inactivity reminder from an unwrapped body.
|
/// Build a task-inactivity reminder from its plain system-message body.
|
||||||
pub fn task_inactivity(body: impl Into<String>) -> Self {
|
pub fn task_inactivity(body: impl Into<String>) -> Self {
|
||||||
Self::new(SystemReminderSource::TaskInactivity, body)
|
Self::new(SystemReminderSource::TaskInactivity, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a reminder from an unwrapped body. If a caller passes a body that
|
/// Build a reminder whose body is committed verbatim as a system message.
|
||||||
/// is already exactly wrapped in `<system-reminder>` tags, normalize it back
|
|
||||||
/// to the inner body so rendering still wraps exactly once.
|
|
||||||
pub fn new(source: SystemReminderSource, body: impl Into<String>) -> Self {
|
pub fn new(source: SystemReminderSource, body: impl Into<String>) -> Self {
|
||||||
let body = normalize_unwrapped_system_reminder_body(body.into());
|
Self {
|
||||||
Self { source, body }
|
source,
|
||||||
}
|
body: body.into(),
|
||||||
|
}
|
||||||
pub fn source(&self) -> SystemReminderSource {
|
|
||||||
self.source
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn body(&self) -> &str {
|
|
||||||
&self.body
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn rendered_body(&self) -> String {
|
|
||||||
render_system_reminder(&self.body)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn into_system_item(self) -> SystemItem {
|
pub fn into_system_item(self) -> SystemItem {
|
||||||
match self.source {
|
match self.source {
|
||||||
SystemReminderSource::TaskInactivity => SystemItem::TaskReminder {
|
SystemReminderSource::TaskInactivity => SystemItem::TaskReminder {
|
||||||
source: self.source,
|
source: self.source,
|
||||||
body: self.rendered_body(),
|
body: self.body,
|
||||||
prompt_provenance: None,
|
prompt_provenance: None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_unwrapped_system_reminder_body(body: String) -> String {
|
|
||||||
let trimmed = body.trim();
|
|
||||||
if let Some(inner) = trimmed
|
|
||||||
.strip_prefix(SYSTEM_REMINDER_OPEN)
|
|
||||||
.and_then(|rest| rest.strip_suffix(SYSTEM_REMINDER_CLOSE))
|
|
||||||
{
|
|
||||||
return inner.trim_matches('\n').to_string();
|
|
||||||
}
|
|
||||||
body
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_system_reminder(body: &str) -> String {
|
|
||||||
format!("{SYSTEM_REMINDER_OPEN}\n{body}\n{SYSTEM_REMINDER_CLOSE}")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct PromptRenderProvenance {
|
pub struct PromptRenderProvenance {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
@@ -178,7 +148,7 @@ pub enum SystemItem {
|
|||||||
|
|
||||||
/// Task-management inactivity reminder inserted before an LLM request.
|
/// Task-management inactivity reminder inserted before an LLM request.
|
||||||
/// `source` is the policy that produced this durable reminder; `body` is
|
/// `source` is the policy that produced this durable reminder; `body` is
|
||||||
/// the exact LLM-context text wrapped in a `<system-reminder>` block.
|
/// the exact plain system-message text committed to LLM context.
|
||||||
TaskReminder {
|
TaskReminder {
|
||||||
#[serde(default = "default_task_reminder_source")]
|
#[serde(default = "default_task_reminder_source")]
|
||||||
source: SystemReminderSource,
|
source: SystemReminderSource,
|
||||||
@@ -324,21 +294,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn system_reminder_renders_body_once() {
|
fn system_reminder_preserves_plain_body() {
|
||||||
let reminder = SystemReminder::task_inactivity("remember tasks");
|
let item = SystemReminder::task_inactivity("remember tasks").into_system_item();
|
||||||
assert_eq!(
|
assert_eq!(item.history_text(), "remember tasks");
|
||||||
reminder.rendered_body(),
|
|
||||||
"<system-reminder>\nremember tasks\n</system-reminder>"
|
|
||||||
);
|
|
||||||
|
|
||||||
let already_wrapped = SystemReminder::task_inactivity(
|
|
||||||
"<system-reminder>\nremember tasks\n</system-reminder>",
|
|
||||||
);
|
|
||||||
assert_eq!(already_wrapped.body(), "remember tasks");
|
|
||||||
assert_eq!(
|
|
||||||
already_wrapped.rendered_body(),
|
|
||||||
"<system-reminder>\nremember tasks\n</system-reminder>"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -347,10 +305,7 @@ mod tests {
|
|||||||
match item {
|
match item {
|
||||||
SystemItem::TaskReminder { source, body, .. } => {
|
SystemItem::TaskReminder { source, body, .. } => {
|
||||||
assert_eq!(source, SystemReminderSource::TaskInactivity);
|
assert_eq!(source, SystemReminderSource::TaskInactivity);
|
||||||
assert_eq!(
|
assert_eq!(body, "remember tasks");
|
||||||
body,
|
|
||||||
"<system-reminder>\nremember tasks\n</system-reminder>"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
other => panic!("unexpected: {other:?}"),
|
other => panic!("unexpected: {other:?}"),
|
||||||
}
|
}
|
||||||
@@ -358,10 +313,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn task_reminder_deserialization_defaults_legacy_source() {
|
fn task_reminder_deserialization_defaults_legacy_source() {
|
||||||
let parsed: SystemItem = serde_json::from_str(
|
let parsed: SystemItem =
|
||||||
r#"{"kind":"task_reminder","body":"<system-reminder>\nbody\n</system-reminder>"}"#,
|
serde_json::from_str(r#"{"kind":"task_reminder","body":"legacy body"}"#).unwrap();
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
match parsed {
|
match parsed {
|
||||||
SystemItem::TaskReminder { source, .. } => {
|
SystemItem::TaskReminder { source, .. } => {
|
||||||
assert_eq!(source, SystemReminderSource::TaskInactivity);
|
assert_eq!(source, SystemReminderSource::TaskInactivity);
|
||||||
|
|||||||
+152
-3
@@ -27,7 +27,9 @@ mod sqlite_schema;
|
|||||||
pub mod tool;
|
pub mod tool;
|
||||||
|
|
||||||
pub use sqlite_schema::{
|
pub use sqlite_schema::{
|
||||||
LATEST_SQLITE_TICKET_SCHEMA_VERSION, migrate_sqlite_ticket_schema, verify_sqlite_ticket_schema,
|
LATEST_SQLITE_TICKET_SCHEMA_VERSION, migrate_sqlite_ticket_resource_key_schema_in_transaction,
|
||||||
|
migrate_sqlite_ticket_schema, migrate_sqlite_ticket_schema_through,
|
||||||
|
verify_sqlite_ticket_schema,
|
||||||
};
|
};
|
||||||
|
|
||||||
const REQUIRED_FIELDS: [&str; 4] = ["title", "state", "created_at", "updated_at"];
|
const REQUIRED_FIELDS: [&str; 4] = ["title", "state", "created_at", "updated_at"];
|
||||||
@@ -124,6 +126,7 @@ fn read_ticket_summary_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<TicketSu
|
|||||||
let workflow_state = row.get::<_, String>(7)?;
|
let workflow_state = row.get::<_, String>(7)?;
|
||||||
Ok(TicketSummary {
|
Ok(TicketSummary {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
|
resource_key: None,
|
||||||
slug: row.get(1)?,
|
slug: row.get(1)?,
|
||||||
title: row.get(2)?,
|
title: row.get(2)?,
|
||||||
status: ExtensibleTicketStatus::from(row.get::<_, String>(3)?.as_str()),
|
status: ExtensibleTicketStatus::from(row.get::<_, String>(3)?.as_str()),
|
||||||
@@ -926,6 +929,8 @@ impl TicketListQuery {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct TicketRef {
|
pub struct TicketRef {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub resource_key: Option<String>,
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
pub status: TicketStatus,
|
pub status: TicketStatus,
|
||||||
}
|
}
|
||||||
@@ -1540,6 +1545,8 @@ pub struct OrchestrationPlanRecord {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct TicketMeta {
|
pub struct TicketMeta {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub resource_key: Option<String>,
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub status: ExtensibleTicketStatus,
|
pub status: ExtensibleTicketStatus,
|
||||||
@@ -1563,6 +1570,8 @@ pub struct TicketMeta {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct TicketSummary {
|
pub struct TicketSummary {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub resource_key: Option<String>,
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub status: ExtensibleTicketStatus,
|
pub status: ExtensibleTicketStatus,
|
||||||
@@ -2669,6 +2678,9 @@ impl SqliteTicketBackend {
|
|||||||
let mut summaries = rows
|
let mut summaries = rows
|
||||||
.collect::<std::result::Result<Vec<_>, _>>()
|
.collect::<std::result::Result<Vec<_>, _>>()
|
||||||
.map_err(sqlite_err)?;
|
.map_err(sqlite_err)?;
|
||||||
|
for summary in &mut summaries {
|
||||||
|
summary.resource_key = Self::resource_key_for(conn, &self.workspace_id, &summary.id)?;
|
||||||
|
}
|
||||||
let has_more = summaries.len() > query.limit;
|
let has_more = summaries.len() > query.limit;
|
||||||
summaries.truncate(query.limit);
|
summaries.truncate(query.limit);
|
||||||
let next = has_more.then(|| {
|
let next = has_more.then(|| {
|
||||||
@@ -2752,6 +2764,7 @@ impl SqliteTicketBackend {
|
|||||||
updated_at,
|
updated_at,
|
||||||
) = row.map_err(sqlite_err)?;
|
) = row.map_err(sqlite_err)?;
|
||||||
summaries.push(TicketSummary {
|
summaries.push(TicketSummary {
|
||||||
|
resource_key: Self::resource_key_for(conn, &self.workspace_id, &id)?,
|
||||||
id,
|
id,
|
||||||
slug,
|
slug,
|
||||||
title,
|
title,
|
||||||
@@ -2928,7 +2941,16 @@ impl SqliteTicketBackend {
|
|||||||
|
|
||||||
fn resolve_ticket_id(&self, conn: &Connection, id: TicketIdOrSlug) -> Result<String> {
|
fn resolve_ticket_id(&self, conn: &Connection, id: TicketIdOrSlug) -> Result<String> {
|
||||||
let query = id.as_query().to_string();
|
let query = id.as_query().to_string();
|
||||||
let mut stmt = conn.prepare("SELECT ticket_id FROM typed_tickets WHERE workspace_id = ?1 AND (ticket_id = ?2 OR slug = ?2) ORDER BY ticket_id").map_err(sqlite_err)?;
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT ticket_id FROM typed_tickets
|
||||||
|
WHERE workspace_id = ?1 AND (ticket_id = ?2 OR slug = ?2)
|
||||||
|
UNION
|
||||||
|
SELECT resource_id FROM workspace_resource_keys
|
||||||
|
WHERE workspace_id = ?1 AND resource_kind = 'ticket' AND resource_key = ?2
|
||||||
|
ORDER BY 1",
|
||||||
|
)
|
||||||
|
.map_err(sqlite_err)?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map(params![self.workspace_id, query], |row| {
|
.query_map(params![self.workspace_id, query], |row| {
|
||||||
row.get::<_, String>(0)
|
row.get::<_, String>(0)
|
||||||
@@ -2947,6 +2969,67 @@ impl SqliteTicketBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resource_key_for(
|
||||||
|
conn: &Connection,
|
||||||
|
workspace_id: &str,
|
||||||
|
ticket_id: &str,
|
||||||
|
) -> Result<Option<String>> {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT resource_key FROM workspace_resource_keys
|
||||||
|
WHERE workspace_id = ?1 AND resource_kind = 'ticket' AND resource_id = ?2",
|
||||||
|
params![workspace_id, ticket_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(sqlite_err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allocate_resource_key(
|
||||||
|
conn: &Connection,
|
||||||
|
workspace_id: &str,
|
||||||
|
ticket_id: &str,
|
||||||
|
allocated_at: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
if let Some(existing) = Self::resource_key_for(conn, workspace_id, ticket_id)? {
|
||||||
|
return Ok(existing);
|
||||||
|
}
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO workspace_resource_key_counters
|
||||||
|
(workspace_id, resource_kind, next_sequence) VALUES (?1, 'ticket', 1)",
|
||||||
|
params![workspace_id],
|
||||||
|
)
|
||||||
|
.map_err(sqlite_err)?;
|
||||||
|
let sequence: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT next_sequence FROM workspace_resource_key_counters
|
||||||
|
WHERE workspace_id = ?1 AND resource_kind = 'ticket'",
|
||||||
|
params![workspace_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.map_err(sqlite_err)?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE workspace_resource_key_counters SET next_sequence = ?3
|
||||||
|
WHERE workspace_id = ?1 AND resource_kind = 'ticket' AND next_sequence = ?2",
|
||||||
|
params![workspace_id, sequence, sequence + 1],
|
||||||
|
)
|
||||||
|
.map_err(sqlite_err)?;
|
||||||
|
let resource_key = format!("T-{sequence}");
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO workspace_resource_keys
|
||||||
|
(workspace_id, resource_kind, resource_id, sequence, resource_key, allocated_at)
|
||||||
|
VALUES (?1, 'ticket', ?2, ?3, ?4, ?5)",
|
||||||
|
params![
|
||||||
|
workspace_id,
|
||||||
|
ticket_id,
|
||||||
|
sequence,
|
||||||
|
resource_key,
|
||||||
|
allocated_at
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(sqlite_err)?;
|
||||||
|
Ok(resource_key)
|
||||||
|
}
|
||||||
|
|
||||||
fn ticket_exists(&self, conn: &Connection, id: &str) -> Result<bool> {
|
fn ticket_exists(&self, conn: &Connection, id: &str) -> Result<bool> {
|
||||||
Ok(conn
|
Ok(conn
|
||||||
.query_row(
|
.query_row(
|
||||||
@@ -3090,6 +3173,7 @@ impl SqliteTicketBackend {
|
|||||||
let state_raw: String = row.get(12)?;
|
let state_raw: String = row.get(12)?;
|
||||||
Ok(TicketMeta {
|
Ok(TicketMeta {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
|
resource_key: None,
|
||||||
slug: row.get(1)?,
|
slug: row.get(1)?,
|
||||||
title: row.get(2)?,
|
title: row.get(2)?,
|
||||||
status: ExtensibleTicketStatus::from(row.get::<_, String>(3)?.as_str()),
|
status: ExtensibleTicketStatus::from(row.get::<_, String>(3)?.as_str()),
|
||||||
@@ -3117,6 +3201,7 @@ impl SqliteTicketBackend {
|
|||||||
self.full_ticket_load_count.fetch_add(1, Ordering::SeqCst);
|
self.full_ticket_load_count.fetch_add(1, Ordering::SeqCst);
|
||||||
let (mut meta, body, resolution): (TicketMeta, String, Option<String>) = conn.query_row(r#"SELECT ticket_id, slug, title, status, kind, priority, created_at, updated_at, assignee, readiness, body, resolution, workflow_state, workflow_state_explicit, queued_by, queued_at, repository_id, ref_selector FROM typed_tickets WHERE workspace_id = ?1 AND ticket_id = ?2"#,
|
let (mut meta, body, resolution): (TicketMeta, String, Option<String>) = conn.query_row(r#"SELECT ticket_id, slug, title, status, kind, priority, created_at, updated_at, assignee, readiness, body, resolution, workflow_state, workflow_state_explicit, queued_by, queued_at, repository_id, ref_selector FROM typed_tickets WHERE workspace_id = ?1 AND ticket_id = ?2"#,
|
||||||
params![self.workspace_id, ticket_id], |row| Ok((Self::ticket_meta_from_row(row)?, row.get(10)?, row.get(11)?))).optional().map_err(sqlite_err)?.ok_or_else(|| TicketError::NotFound(ticket_id.to_string()))?;
|
params![self.workspace_id, ticket_id], |row| Ok((Self::ticket_meta_from_row(row)?, row.get(10)?, row.get(11)?))).optional().map_err(sqlite_err)?.ok_or_else(|| TicketError::NotFound(ticket_id.to_string()))?;
|
||||||
|
meta.resource_key = Self::resource_key_for(conn, &self.workspace_id, ticket_id)?;
|
||||||
meta.labels = self.load_ordered_values(conn, "typed_ticket_labels", "label", ticket_id)?;
|
meta.labels = self.load_ordered_values(conn, "typed_ticket_labels", "label", ticket_id)?;
|
||||||
meta.risk_flags =
|
meta.risk_flags =
|
||||||
self.load_ordered_values(conn, "typed_ticket_risk_flags", "risk_flag", ticket_id)?;
|
self.load_ordered_values(conn, "typed_ticket_risk_flags", "risk_flag", ticket_id)?;
|
||||||
@@ -3288,6 +3373,7 @@ impl SqliteTicketBackend {
|
|||||||
let mut summaries = Vec::new();
|
let mut summaries = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let mut meta = row.map_err(sqlite_err)?;
|
let mut meta = row.map_err(sqlite_err)?;
|
||||||
|
meta.resource_key = Self::resource_key_for(conn, &self.workspace_id, &meta.id)?;
|
||||||
if !filter.matches_state(meta.workflow_state) {
|
if !filter.matches_state(meta.workflow_state) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -3437,6 +3523,7 @@ impl TicketBackend for SqliteTicketBackend {
|
|||||||
};
|
};
|
||||||
let meta = TicketMeta {
|
let meta = TicketMeta {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
|
resource_key: None,
|
||||||
slug: input.slug.clone().unwrap_or_else(|| id.clone()),
|
slug: input.slug.clone().unwrap_or_else(|| id.clone()),
|
||||||
title: input.title,
|
title: input.title,
|
||||||
status,
|
status,
|
||||||
@@ -3473,7 +3560,7 @@ impl TicketBackend for SqliteTicketBackend {
|
|||||||
events: vec![TicketEvent {
|
events: vec![TicketEvent {
|
||||||
kind: TicketEventKind::Create,
|
kind: TicketEventKind::Create,
|
||||||
author: Some(author),
|
author: Some(author),
|
||||||
at: Some(now),
|
at: Some(now.clone()),
|
||||||
status: None,
|
status: None,
|
||||||
from: None,
|
from: None,
|
||||||
to: None,
|
to: None,
|
||||||
@@ -3488,9 +3575,11 @@ impl TicketBackend for SqliteTicketBackend {
|
|||||||
relations: TicketRelationView::default(),
|
relations: TicketRelationView::default(),
|
||||||
resolution: None,
|
resolution: None,
|
||||||
};
|
};
|
||||||
|
let resource_key = Self::allocate_resource_key(conn, &self.workspace_id, &id, &now)?;
|
||||||
self.insert_ticket(conn, &ticket)?;
|
self.insert_ticket(conn, &ticket)?;
|
||||||
Ok(TicketRef {
|
Ok(TicketRef {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
|
resource_key: Some(resource_key),
|
||||||
slug: id,
|
slug: id,
|
||||||
status: TicketStatus::Open,
|
status: TicketStatus::Open,
|
||||||
})
|
})
|
||||||
@@ -4137,6 +4226,7 @@ impl TicketBackend for LocalTicketBackend {
|
|||||||
atomic_write(&dir.join("thread.md"), thread.as_bytes())?;
|
atomic_write(&dir.join("thread.md"), thread.as_bytes())?;
|
||||||
Ok(TicketRef {
|
Ok(TicketRef {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
|
resource_key: None,
|
||||||
slug: id,
|
slug: id,
|
||||||
status: TicketStatus::Open,
|
status: TicketStatus::Open,
|
||||||
})
|
})
|
||||||
@@ -5174,6 +5264,7 @@ fn ticket_meta(frontmatter: TicketItemFrontmatter, id: String) -> TicketMeta {
|
|||||||
};
|
};
|
||||||
TicketMeta {
|
TicketMeta {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
|
resource_key: None,
|
||||||
slug: id,
|
slug: id,
|
||||||
title: frontmatter.title.unwrap_or_default(),
|
title: frontmatter.title.unwrap_or_default(),
|
||||||
status,
|
status,
|
||||||
@@ -5198,6 +5289,7 @@ fn ticket_meta(frontmatter: TicketItemFrontmatter, id: String) -> TicketMeta {
|
|||||||
fn ticket_summary_from_meta(meta: TicketMeta) -> TicketSummary {
|
fn ticket_summary_from_meta(meta: TicketMeta) -> TicketSummary {
|
||||||
TicketSummary {
|
TicketSummary {
|
||||||
id: meta.id,
|
id: meta.id,
|
||||||
|
resource_key: meta.resource_key,
|
||||||
slug: meta.slug,
|
slug: meta.slug,
|
||||||
title: meta.title,
|
title: meta.title,
|
||||||
status: meta.status,
|
status: meta.status,
|
||||||
@@ -6806,6 +6898,7 @@ mod tests {
|
|||||||
fn summary_with_state(state: TicketWorkflowState) -> TicketSummary {
|
fn summary_with_state(state: TicketWorkflowState) -> TicketSummary {
|
||||||
TicketSummary {
|
TicketSummary {
|
||||||
id: "000TEST".to_string(),
|
id: "000TEST".to_string(),
|
||||||
|
resource_key: Some("T-1".to_string()),
|
||||||
slug: "000TEST".to_string(),
|
slug: "000TEST".to_string(),
|
||||||
title: "Test Ticket".to_string(),
|
title: "Test Ticket".to_string(),
|
||||||
status: ExtensibleTicketStatus::Open,
|
status: ExtensibleTicketStatus::Open,
|
||||||
@@ -7210,6 +7303,62 @@ state: planning
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_resource_keys_are_workspace_scoped_monotonic_and_resolvable() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let db_path = tmp.path().join("workspace.db");
|
||||||
|
let backend = SqliteTicketBackend::open(&db_path, "workspace-a").unwrap();
|
||||||
|
let first = backend.create(NewTicket::new("First")).unwrap();
|
||||||
|
let second = backend.create(NewTicket::new("Second")).unwrap();
|
||||||
|
assert_eq!(first.resource_key.as_deref(), Some("T-1"));
|
||||||
|
assert_eq!(second.resource_key.as_deref(), Some("T-2"));
|
||||||
|
assert_eq!(backend.show("T-1".into()).unwrap().meta.id, first.id);
|
||||||
|
let projection = backend.list_workspace_projection(100).unwrap();
|
||||||
|
let projected_second = projection
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.summary.id == second.id)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
projected_second.summary.resource_key.as_deref(),
|
||||||
|
Some("T-2")
|
||||||
|
);
|
||||||
|
|
||||||
|
let other = SqliteTicketBackend::open(&db_path, "workspace-b").unwrap();
|
||||||
|
let other_first = other.create(NewTicket::new("Other")).unwrap();
|
||||||
|
assert_eq!(other_first.resource_key.as_deref(), Some("T-1"));
|
||||||
|
assert_eq!(other.show("T-1".into()).unwrap().meta.id, other_first.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_resource_key_allocation_is_concurrency_safe() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let db_path = tmp.path().join("workspace.db");
|
||||||
|
SqliteTicketBackend::open(&db_path, "workspace-a").unwrap();
|
||||||
|
let barrier = Arc::new(std::sync::Barrier::new(8));
|
||||||
|
let handles = (0..8)
|
||||||
|
.map(|index| {
|
||||||
|
let db_path = db_path.clone();
|
||||||
|
let barrier = barrier.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let backend = SqliteTicketBackend::open(db_path, "workspace-a").unwrap();
|
||||||
|
barrier.wait();
|
||||||
|
backend
|
||||||
|
.create(NewTicket::new(format!("Ticket {index}")))
|
||||||
|
.unwrap()
|
||||||
|
.resource_key
|
||||||
|
.unwrap()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut keys = handles
|
||||||
|
.into_iter()
|
||||||
|
.map(|handle| handle.join().unwrap())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
keys.sort_by_key(|key| key.trim_start_matches("T-").parse::<u64>().unwrap());
|
||||||
|
assert_eq!(keys, (1..=8).map(|n| format!("T-{n}")).collect::<Vec<_>>());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sqlite_backend_persists_and_edits_ticket_target() {
|
fn sqlite_backend_persists_and_edits_ticket_target() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use crate::{Result, TicketError, sqlite_err};
|
|||||||
|
|
||||||
const MIGRATION_TABLE: &str = "ticket_schema_migrations";
|
const MIGRATION_TABLE: &str = "ticket_schema_migrations";
|
||||||
const MAX_SCHEMA_DIAGNOSTICS: usize = 32;
|
const MAX_SCHEMA_DIAGNOSTICS: usize = 32;
|
||||||
pub const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 4;
|
pub const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 6;
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
struct Migration {
|
struct Migration {
|
||||||
@@ -37,6 +37,16 @@ const MIGRATIONS: &[Migration] = &[
|
|||||||
name: "add_ticket_query_indexes",
|
name: "add_ticket_query_indexes",
|
||||||
apply: add_ticket_query_indexes,
|
apply: add_ticket_query_indexes,
|
||||||
},
|
},
|
||||||
|
Migration {
|
||||||
|
version: 5,
|
||||||
|
name: "add_workspace_human_keys",
|
||||||
|
apply: add_workspace_human_keys,
|
||||||
|
},
|
||||||
|
Migration {
|
||||||
|
version: 6,
|
||||||
|
name: "rename_workspace_resource_keys",
|
||||||
|
apply: rename_workspace_resource_keys,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -66,6 +76,22 @@ const OWNED_TABLES: &[&str] = &[
|
|||||||
"typed_ticket_relations",
|
"typed_ticket_relations",
|
||||||
"typed_ticket_orchestration_plans",
|
"typed_ticket_orchestration_plans",
|
||||||
"typed_ticket_artifacts",
|
"typed_ticket_artifacts",
|
||||||
|
"workspace_resource_keys",
|
||||||
|
];
|
||||||
|
|
||||||
|
const RESOURCE_KEY_COLUMNS: &[ExpectedColumn] = &[
|
||||||
|
column("workspace_id", "TEXT", true, 1),
|
||||||
|
column("resource_kind", "TEXT", true, 2),
|
||||||
|
column("resource_id", "TEXT", true, 3),
|
||||||
|
column("sequence", "INTEGER", true, 0),
|
||||||
|
column("resource_key", "TEXT", true, 0),
|
||||||
|
column("allocated_at", "TEXT", true, 0),
|
||||||
|
];
|
||||||
|
|
||||||
|
const RESOURCE_KEY_COUNTER_COLUMNS: &[ExpectedColumn] = &[
|
||||||
|
column("workspace_id", "TEXT", true, 1),
|
||||||
|
column("resource_kind", "TEXT", true, 2),
|
||||||
|
column("next_sequence", "INTEGER", true, 0),
|
||||||
];
|
];
|
||||||
|
|
||||||
const MIGRATION_COLUMNS: &[ExpectedColumn] = &[
|
const MIGRATION_COLUMNS: &[ExpectedColumn] = &[
|
||||||
@@ -238,6 +264,24 @@ const fn column(
|
|||||||
/// use [`verify_sqlite_ticket_schema`] instead, so request paths never acquire DDL
|
/// use [`verify_sqlite_ticket_schema`] instead, so request paths never acquire DDL
|
||||||
/// authority.
|
/// authority.
|
||||||
pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
||||||
|
migrate_sqlite_ticket_schema_through(connection, LATEST_SQLITE_TICKET_SCHEMA_VERSION)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies Ticket migrations only through `target_version`.
|
||||||
|
///
|
||||||
|
/// This exists for the Workspace Server's ordered migration bridge: older Server
|
||||||
|
/// migrations must materialize the Ticket schema shape they were written against
|
||||||
|
/// before the current Ticket migration is applied at the matching Server version.
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn migrate_sqlite_ticket_schema_through(
|
||||||
|
connection: &Connection,
|
||||||
|
target_version: i64,
|
||||||
|
) -> Result<()> {
|
||||||
|
if !(1..=LATEST_SQLITE_TICKET_SCHEMA_VERSION).contains(&target_version) {
|
||||||
|
return Err(TicketError::Sqlite(format!(
|
||||||
|
"unsupported Ticket schema migration target {target_version}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
connection
|
connection
|
||||||
.busy_timeout(Duration::from_secs(5))
|
.busy_timeout(Duration::from_secs(5))
|
||||||
.map_err(sqlite_err)?;
|
.map_err(sqlite_err)?;
|
||||||
@@ -260,7 +304,20 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
|||||||
let applied = load_applied_migrations(connection)?;
|
let applied = load_applied_migrations(connection)?;
|
||||||
validate_applied_migrations(&applied)?;
|
validate_applied_migrations(&applied)?;
|
||||||
|
|
||||||
for migration in MIGRATIONS {
|
if let Some(version) = applied
|
||||||
|
.keys()
|
||||||
|
.copied()
|
||||||
|
.find(|version| *version > target_version)
|
||||||
|
{
|
||||||
|
return Err(TicketError::Sqlite(format!(
|
||||||
|
"Ticket schema version {version} is newer than requested migration target {target_version}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
for migration in MIGRATIONS
|
||||||
|
.iter()
|
||||||
|
.filter(|migration| migration.version <= target_version)
|
||||||
|
{
|
||||||
if applied.contains_key(&migration.version) {
|
if applied.contains_key(&migration.version) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -278,7 +335,22 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
|||||||
.map_err(sqlite_err)?;
|
.map_err(sqlite_err)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
verify_sqlite_ticket_schema(connection)
|
if target_version == LATEST_SQLITE_TICKET_SCHEMA_VERSION {
|
||||||
|
verify_sqlite_ticket_schema(connection)
|
||||||
|
} else {
|
||||||
|
let applied = load_applied_migrations(connection)?;
|
||||||
|
let expected = MIGRATIONS
|
||||||
|
.iter()
|
||||||
|
.filter(|migration| migration.version <= target_version)
|
||||||
|
.map(|migration| (migration.version, migration.name.to_string()))
|
||||||
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
if applied != expected {
|
||||||
|
return Err(TicketError::Sqlite(format!(
|
||||||
|
"Ticket schema migration history does not match target version {target_version}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
@@ -290,6 +362,47 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Applies the resource-key Ticket migration inside a transaction owned by the
|
||||||
|
/// Workspace Server. The caller must provide an active transaction; this function
|
||||||
|
/// deliberately does not begin or commit one so the Ticket and Server migration
|
||||||
|
/// markers can be persisted atomically.
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn migrate_sqlite_ticket_resource_key_schema_in_transaction(
|
||||||
|
connection: &Connection,
|
||||||
|
) -> Result<()> {
|
||||||
|
connection
|
||||||
|
.execute_batch(
|
||||||
|
"CREATE TABLE IF NOT EXISTS ticket_schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
applied_at TEXT NOT NULL
|
||||||
|
);",
|
||||||
|
)
|
||||||
|
.map_err(sqlite_err)?;
|
||||||
|
let applied = load_applied_migrations(connection)?;
|
||||||
|
validate_applied_migrations(&applied)?;
|
||||||
|
if applied.contains_key(&LATEST_SQLITE_TICKET_SCHEMA_VERSION) {
|
||||||
|
return verify_sqlite_ticket_schema(connection);
|
||||||
|
}
|
||||||
|
let expected_previous = LATEST_SQLITE_TICKET_SCHEMA_VERSION - 1;
|
||||||
|
if applied.len() != expected_previous as usize || !applied.contains_key(&expected_previous) {
|
||||||
|
return Err(TicketError::Sqlite(format!(
|
||||||
|
"Ticket schema must be at version {expected_previous} before the resource-key migration"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let migration = MIGRATIONS
|
||||||
|
.last()
|
||||||
|
.ok_or_else(|| TicketError::Sqlite("Ticket migration catalog is empty".to_string()))?;
|
||||||
|
(migration.apply)(connection)?;
|
||||||
|
connection
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO ticket_schema_migrations (version, name, applied_at) VALUES (?1, ?2, datetime('now'))",
|
||||||
|
params![migration.version, migration.name],
|
||||||
|
)
|
||||||
|
.map_err(sqlite_err)?;
|
||||||
|
verify_sqlite_ticket_schema(connection)
|
||||||
|
}
|
||||||
|
|
||||||
/// Verifies the current Ticket-owned SQLite schema without executing DDL.
|
/// Verifies the current Ticket-owned SQLite schema without executing DDL.
|
||||||
pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
||||||
let mut diagnostics = Vec::new();
|
let mut diagnostics = Vec::new();
|
||||||
@@ -363,6 +476,49 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
|||||||
collect_table_diagnostics(connection, table, columns, foreign_keys, &mut diagnostics);
|
collect_table_diagnostics(connection, table, columns, foreign_keys, &mut diagnostics);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
collect_column_diagnostics(
|
||||||
|
connection,
|
||||||
|
"workspace_resource_keys",
|
||||||
|
RESOURCE_KEY_COLUMNS,
|
||||||
|
&mut diagnostics,
|
||||||
|
);
|
||||||
|
collect_column_diagnostics(
|
||||||
|
connection,
|
||||||
|
"workspace_resource_key_counters",
|
||||||
|
RESOURCE_KEY_COUNTER_COLUMNS,
|
||||||
|
&mut diagnostics,
|
||||||
|
);
|
||||||
|
collect_index_diagnostics(
|
||||||
|
connection,
|
||||||
|
"workspace_resource_keys",
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
&["workspace_id", "resource_kind", "sequence"],
|
||||||
|
&mut diagnostics,
|
||||||
|
);
|
||||||
|
collect_index_diagnostics(
|
||||||
|
connection,
|
||||||
|
"workspace_resource_keys",
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
&["workspace_id", "resource_key"],
|
||||||
|
&mut diagnostics,
|
||||||
|
);
|
||||||
|
collect_index_diagnostics(
|
||||||
|
connection,
|
||||||
|
"workspace_resource_keys",
|
||||||
|
Some("idx_workspace_resource_keys_reverse"),
|
||||||
|
false,
|
||||||
|
&["workspace_id", "resource_kind", "resource_key"],
|
||||||
|
&mut diagnostics,
|
||||||
|
);
|
||||||
|
for legacy_table in [
|
||||||
|
"workspace_resource_human_keys",
|
||||||
|
"workspace_resource_human_key_counters",
|
||||||
|
] {
|
||||||
|
collect_absent_table_diagnostic(connection, legacy_table, &mut diagnostics);
|
||||||
|
}
|
||||||
|
|
||||||
for table in OWNED_TABLES {
|
for table in OWNED_TABLES {
|
||||||
collect_foreign_key_check_diagnostics(connection, table, &mut diagnostics);
|
collect_foreign_key_check_diagnostics(connection, table, &mut diagnostics);
|
||||||
}
|
}
|
||||||
@@ -535,6 +691,72 @@ fn add_ticket_query_indexes(connection: &Connection) -> Result<()> {
|
|||||||
.map_err(sqlite_err)
|
.map_err(sqlite_err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn add_workspace_human_keys(connection: &Connection) -> Result<()> {
|
||||||
|
connection
|
||||||
|
.execute_batch(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS workspace_resource_human_keys (
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')),
|
||||||
|
resource_id TEXT NOT NULL,
|
||||||
|
sequence INTEGER NOT NULL CHECK (sequence > 0),
|
||||||
|
human_key TEXT NOT NULL,
|
||||||
|
allocated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (workspace_id, resource_kind, resource_id),
|
||||||
|
UNIQUE (workspace_id, resource_kind, sequence),
|
||||||
|
UNIQUE (workspace_id, human_key)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS workspace_resource_human_key_counters (
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')),
|
||||||
|
next_sequence INTEGER NOT NULL CHECK (next_sequence > 0),
|
||||||
|
PRIMARY KEY (workspace_id, resource_kind)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO workspace_resource_human_keys (
|
||||||
|
workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at
|
||||||
|
)
|
||||||
|
SELECT workspace_id,
|
||||||
|
'ticket',
|
||||||
|
ticket_id,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY workspace_id ORDER BY created_at ASC, ticket_id ASC
|
||||||
|
),
|
||||||
|
'T-' || ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY workspace_id ORDER BY created_at ASC, ticket_id ASC
|
||||||
|
),
|
||||||
|
COALESCE(created_at, updated_at)
|
||||||
|
FROM typed_tickets;
|
||||||
|
|
||||||
|
INSERT INTO workspace_resource_human_key_counters (
|
||||||
|
workspace_id, resource_kind, next_sequence
|
||||||
|
)
|
||||||
|
SELECT workspace_id, 'ticket', MAX(sequence) + 1
|
||||||
|
FROM workspace_resource_human_keys
|
||||||
|
WHERE resource_kind = 'ticket'
|
||||||
|
GROUP BY workspace_id
|
||||||
|
ON CONFLICT(workspace_id, resource_kind) DO UPDATE SET
|
||||||
|
next_sequence = MAX(next_sequence, excluded.next_sequence);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.map_err(sqlite_err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rename_workspace_resource_keys(connection: &Connection) -> Result<()> {
|
||||||
|
connection
|
||||||
|
.execute_batch(
|
||||||
|
r#"
|
||||||
|
ALTER TABLE workspace_resource_human_keys RENAME TO workspace_resource_keys;
|
||||||
|
ALTER TABLE workspace_resource_keys RENAME COLUMN human_key TO resource_key;
|
||||||
|
ALTER TABLE workspace_resource_human_key_counters RENAME TO workspace_resource_key_counters;
|
||||||
|
DROP INDEX IF EXISTS idx_workspace_resource_human_keys_reverse;
|
||||||
|
CREATE INDEX idx_workspace_resource_keys_reverse
|
||||||
|
ON workspace_resource_keys(workspace_id, resource_kind, resource_key);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.map_err(sqlite_err)
|
||||||
|
}
|
||||||
|
|
||||||
fn add_column_if_missing(
|
fn add_column_if_missing(
|
||||||
connection: &Connection,
|
connection: &Connection,
|
||||||
table: &str,
|
table: &str,
|
||||||
@@ -663,6 +885,106 @@ fn verify_table(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn table_exists(connection: &Connection, table: &str) -> Result<bool> {
|
||||||
|
connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1",
|
||||||
|
[table],
|
||||||
|
|_| Ok(()),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map(|value| value.is_some())
|
||||||
|
.map_err(sqlite_err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_absent_table_diagnostic(
|
||||||
|
connection: &Connection,
|
||||||
|
table: &str,
|
||||||
|
diagnostics: &mut Vec<String>,
|
||||||
|
) {
|
||||||
|
match table_exists(connection, table) {
|
||||||
|
Ok(false) => {}
|
||||||
|
Ok(true) => diagnostics.push(format!("legacy table `{table}` is still present")),
|
||||||
|
Err(error) => {
|
||||||
|
diagnostics.push(format!("failed to inspect legacy table `{table}`: {error}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_index_diagnostics(
|
||||||
|
connection: &Connection,
|
||||||
|
table: &str,
|
||||||
|
expected_name: Option<&str>,
|
||||||
|
expected_unique: bool,
|
||||||
|
expected_columns: &[&str],
|
||||||
|
diagnostics: &mut Vec<String>,
|
||||||
|
) {
|
||||||
|
let sql = format!("PRAGMA index_list({table})");
|
||||||
|
let mut statement = match connection.prepare(&sql) {
|
||||||
|
Ok(statement) => statement,
|
||||||
|
Err(error) => {
|
||||||
|
diagnostics.push(format!("failed to inspect indexes for `{table}`: {error}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let rows = match statement.query_map([], |row| {
|
||||||
|
Ok((row.get::<_, String>(1)?, row.get::<_, i64>(2)? != 0))
|
||||||
|
}) {
|
||||||
|
Ok(rows) => rows,
|
||||||
|
Err(error) => {
|
||||||
|
diagnostics.push(format!("failed to read indexes for `{table}`: {error}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let indexes = match rows.collect::<std::result::Result<Vec<_>, _>>() {
|
||||||
|
Ok(indexes) => indexes,
|
||||||
|
Err(error) => {
|
||||||
|
diagnostics.push(format!("failed to decode indexes for `{table}`: {error}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (name, unique) in indexes {
|
||||||
|
if expected_name.is_some_and(|expected| expected != name) || unique != expected_unique {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let sql = format!("PRAGMA index_info({name})");
|
||||||
|
let mut statement = match connection.prepare(&sql) {
|
||||||
|
Ok(statement) => statement,
|
||||||
|
Err(error) => {
|
||||||
|
diagnostics.push(format!("failed to inspect index `{name}`: {error}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let rows = match statement.query_map([], |row| row.get::<_, String>(2)) {
|
||||||
|
Ok(rows) => rows,
|
||||||
|
Err(error) => {
|
||||||
|
diagnostics.push(format!("failed to read index `{name}`: {error}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match rows.collect::<std::result::Result<Vec<_>, _>>() {
|
||||||
|
Ok(columns) if columns == expected_columns => return,
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(error) => {
|
||||||
|
diagnostics.push(format!("failed to decode index `{name}`: {error}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let identity = expected_name
|
||||||
|
.map(|name| format!("named `{name}`"))
|
||||||
|
.unwrap_or_else(|| "unnamed".to_string());
|
||||||
|
diagnostics.push(format!(
|
||||||
|
"table `{table}` is missing {identity} {} index on ({})",
|
||||||
|
if expected_unique {
|
||||||
|
"unique"
|
||||||
|
} else {
|
||||||
|
"non-unique"
|
||||||
|
},
|
||||||
|
expected_columns.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
fn collect_table_diagnostics(
|
fn collect_table_diagnostics(
|
||||||
connection: &Connection,
|
connection: &Connection,
|
||||||
table: &str,
|
table: &str,
|
||||||
@@ -792,18 +1114,16 @@ fn collect_foreign_key_diagnostics(
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect::<BTreeSet<_>>();
|
.collect::<BTreeSet<_>>();
|
||||||
|
// The Ticket component owns its required foreign keys, while an integrated host may
|
||||||
|
// strengthen Workspace/domain boundaries with additional references to host-owned
|
||||||
|
// tables. Reject missing component constraints, but do not treat those host extensions
|
||||||
|
// as Ticket schema drift.
|
||||||
for missing in expected.difference(&actual) {
|
for missing in expected.difference(&actual) {
|
||||||
push_diagnostic(
|
push_diagnostic(
|
||||||
diagnostics,
|
diagnostics,
|
||||||
format!("table {table:?} is missing foreign key {missing:?}"),
|
format!("table {table:?} is missing foreign key {missing:?}"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for unexpected in actual.difference(&expected) {
|
|
||||||
push_diagnostic(
|
|
||||||
diagnostics,
|
|
||||||
format!("table {table:?} has unexpected foreign key {unexpected:?}"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_foreign_key_check_diagnostics(
|
fn collect_foreign_key_check_diagnostics(
|
||||||
@@ -869,10 +1189,10 @@ mod tests {
|
|||||||
verify_sqlite_ticket_schema(&connection).unwrap();
|
verify_sqlite_ticket_schema(&connection).unwrap();
|
||||||
|
|
||||||
let versions = load_applied_migrations(&connection).unwrap();
|
let versions = load_applied_migrations(&connection).unwrap();
|
||||||
assert_eq!(versions.len(), 4);
|
assert_eq!(versions.len(), 6);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
versions.get(&LATEST_SQLITE_TICKET_SCHEMA_VERSION),
|
versions.get(&LATEST_SQLITE_TICKET_SCHEMA_VERSION),
|
||||||
Some(&"add_ticket_query_indexes".to_string())
|
Some(&"rename_workspace_resource_keys".to_string())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -959,6 +1279,95 @@ mod tests {
|
|||||||
assert_eq!(preserved, (1, 1, 1, 1, 1));
|
assert_eq!(preserved, (1, 1, 1, 1, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v5_backfills_ticket_keys_and_v6_preserves_them_under_resource_key_schema() {
|
||||||
|
let connection = Connection::open_in_memory().unwrap();
|
||||||
|
migrate_sqlite_ticket_schema_through(&connection, 4).unwrap();
|
||||||
|
connection.execute_batch(
|
||||||
|
"INSERT INTO typed_tickets (
|
||||||
|
workspace_id, ticket_id, slug, title, status, kind, priority, body,
|
||||||
|
workflow_state, workflow_state_explicit, created_at, updated_at
|
||||||
|
) VALUES
|
||||||
|
('workspace-1', 'later', 'later', 'Later', 'open', 'task', 'medium', '', 'ready', 1, '2026-01-02T00:00:00Z', '2026-01-02T00:00:00Z'),
|
||||||
|
('workspace-1', 'earlier', 'earlier', 'Earlier', 'open', 'task', 'medium', '', 'ready', 1, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z');"
|
||||||
|
).unwrap();
|
||||||
|
|
||||||
|
migrate_sqlite_ticket_schema_through(&connection, 5).unwrap();
|
||||||
|
let legacy_keys = connection
|
||||||
|
.prepare(
|
||||||
|
"SELECT resource_id, human_key FROM workspace_resource_human_keys
|
||||||
|
WHERE workspace_id = 'workspace-1' AND resource_kind = 'ticket'
|
||||||
|
ORDER BY sequence",
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
legacy_keys,
|
||||||
|
vec![
|
||||||
|
("earlier".into(), "T-1".into()),
|
||||||
|
("later".into(), "T-2".into())
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let next: i64 = connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT next_sequence FROM workspace_resource_human_key_counters
|
||||||
|
WHERE workspace_id = 'workspace-1' AND resource_kind = 'ticket'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(next, 3);
|
||||||
|
|
||||||
|
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||||
|
let resource_keys = connection
|
||||||
|
.prepare(
|
||||||
|
"SELECT resource_id, resource_key FROM workspace_resource_keys
|
||||||
|
WHERE workspace_id = 'workspace-1' AND resource_kind = 'ticket'
|
||||||
|
ORDER BY sequence",
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resource_keys, legacy_keys);
|
||||||
|
assert_eq!(
|
||||||
|
connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT next_sequence FROM workspace_resource_key_counters
|
||||||
|
WHERE workspace_id = 'workspace-1' AND resource_kind = 'ticket'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
3
|
||||||
|
);
|
||||||
|
for legacy_table in [
|
||||||
|
"workspace_resource_human_keys",
|
||||||
|
"workspace_resource_human_key_counters",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?1",
|
||||||
|
[legacy_table],
|
||||||
|
|_| Ok(()),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.unwrap()
|
||||||
|
.is_none(),
|
||||||
|
"{legacy_table} still exists"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn upgrades_legacy_schema_without_repository_target_columns() {
|
fn upgrades_legacy_schema_without_repository_target_columns() {
|
||||||
let connection = Connection::open_in_memory().unwrap();
|
let connection = Connection::open_in_memory().unwrap();
|
||||||
@@ -1017,7 +1426,39 @@ mod tests {
|
|||||||
.to_string()
|
.to_string()
|
||||||
.contains("unsupported Ticket schema migration version 99")
|
.contains("unsupported Ticket schema migration version 99")
|
||||||
);
|
);
|
||||||
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 5);
|
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verifier_rejects_resource_key_schema_drift() {
|
||||||
|
for (drift, expected) in [
|
||||||
|
(
|
||||||
|
"DROP TABLE workspace_resource_key_counters",
|
||||||
|
"workspace_resource_key_counters",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ALTER TABLE workspace_resource_keys RENAME COLUMN resource_key TO human_key",
|
||||||
|
"missing column \"resource_key\"",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"DROP INDEX idx_workspace_resource_keys_reverse",
|
||||||
|
"idx_workspace_resource_keys_reverse",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"CREATE TABLE workspace_resource_human_keys (value TEXT)",
|
||||||
|
"legacy table `workspace_resource_human_keys` is still present",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let connection = Connection::open_in_memory().unwrap();
|
||||||
|
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||||
|
connection.execute_batch(drift).unwrap();
|
||||||
|
|
||||||
|
let error = verify_sqlite_ticket_schema(&connection).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
error.to_string().contains(expected),
|
||||||
|
"expected {expected:?} after {drift:?}, got {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1118,7 +1559,11 @@ mod tests {
|
|||||||
connection.execute("INSERT INTO typed_ticket_events (workspace_id,ticket_id,event_index,kind,author,at,status,heading,body) VALUES ('workspace-1','ticket-1',0,'review','reviewer','2026-08-11T00:00:00Z','approve','Review','legacy evidence')",[]).unwrap();
|
connection.execute("INSERT INTO typed_ticket_events (workspace_id,ticket_id,event_index,kind,author,at,status,heading,body) VALUES ('workspace-1','ticket-1',0,'review','reviewer','2026-08-11T00:00:00Z','approve','Review','legacy evidence')",[]).unwrap();
|
||||||
connection.execute("INSERT INTO typed_ticket_event_attributes (workspace_id,ticket_id,event_index,key,value) VALUES ('workspace-1','ticket-1',0,'result','approve')",[]).unwrap();
|
connection.execute("INSERT INTO typed_ticket_event_attributes (workspace_id,ticket_id,event_index,key,value) VALUES ('workspace-1','ticket-1',0,'result','approve')",[]).unwrap();
|
||||||
connection
|
connection
|
||||||
.execute("DELETE FROM ticket_schema_migrations WHERE version>=3", [])
|
.execute_batch(
|
||||||
|
"DROP TABLE workspace_resource_key_counters;
|
||||||
|
DROP TABLE workspace_resource_keys;
|
||||||
|
DELETE FROM ticket_schema_migrations WHERE version >= 3;",
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
migrate_sqlite_ticket_schema(&connection).unwrap();
|
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||||
let (kind,status,heading,body):(String,Option<String>,Option<String>,Option<String>)=connection.query_row("SELECT kind,status,heading,body FROM typed_ticket_events WHERE workspace_id='workspace-1' AND ticket_id='ticket-1' AND event_index=0",[],|row|Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?))).unwrap();
|
let (kind,status,heading,body):(String,Option<String>,Option<String>,Option<String>)=connection.query_row("SELECT kind,status,heading,body FROM typed_ticket_events WHERE workspace_id='workspace-1' AND ticket_id='ticket-1' AND event_index=0",[],|row|Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?))).unwrap();
|
||||||
@@ -1157,6 +1602,6 @@ mod tests {
|
|||||||
|
|
||||||
let connection = Connection::open(database).unwrap();
|
let connection = Connection::open(database).unwrap();
|
||||||
verify_sqlite_ticket_schema(&connection).unwrap();
|
verify_sqlite_ticket_schema(&connection).unwrap();
|
||||||
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 4);
|
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 6);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ impl Tool for BashTool {
|
|||||||
async fn execute(
|
async fn execute(
|
||||||
&self,
|
&self,
|
||||||
input_json: &str,
|
input_json: &str,
|
||||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
ctx: llm_engine::tool::ToolExecutionContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let params: BashParams = serde_json::from_str(input_json)
|
let params: BashParams = serde_json::from_str(input_json)
|
||||||
.map_err(|error| ToolError::InvalidArgument(format!("invalid Bash input: {error}")))?;
|
.map_err(|error| ToolError::InvalidArgument(format!("invalid Bash input: {error}")))?;
|
||||||
@@ -58,6 +58,7 @@ impl Tool for BashTool {
|
|||||||
command: params.command,
|
command: params.command,
|
||||||
timeout_secs,
|
timeout_secs,
|
||||||
output_limit: INLINE_BYTE_BUDGET,
|
output_limit: INLINE_BYTE_BUDGET,
|
||||||
|
tool_call_id: Some(ctx.call_id),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(crate::ToolsError::from)?;
|
.map_err(crate::ToolsError::from)?;
|
||||||
|
|||||||
@@ -72,13 +72,20 @@ impl Tool for EditTool {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(ToolsError::from)?;
|
.map_err(ToolsError::from)?;
|
||||||
self.tracker.record_workdir_hash(&path, result.content_hash);
|
let replacements = result.replacements;
|
||||||
|
self.tracker.record_workdir_edit(
|
||||||
|
&path,
|
||||||
|
result.content_hash,
|
||||||
|
replacements,
|
||||||
|
params.new_string.lines().count(),
|
||||||
|
params.old_string.lines().count(),
|
||||||
|
);
|
||||||
|
|
||||||
let summary = format!(
|
let summary = format!(
|
||||||
"Edited {} ({} replacement{})",
|
"Edited {} ({} replacement{})",
|
||||||
path,
|
path,
|
||||||
result.replacements,
|
replacements,
|
||||||
if result.replacements == 1 { "" } else { "s" }
|
if replacements == 1 { "" } else { "s" }
|
||||||
);
|
);
|
||||||
let preview = make_preview(¶ms.new_string, ¶ms.new_string);
|
let preview = make_preview(¶ms.new_string, ¶ms.new_string);
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ pub use error::ToolsError;
|
|||||||
pub use glob::glob_tool;
|
pub use glob::glob_tool;
|
||||||
pub use grep::grep_tool;
|
pub use grep::grep_tool;
|
||||||
pub use read::read_tool;
|
pub use read::read_tool;
|
||||||
pub use tracker::Tracker;
|
pub use tracker::{ChangeStat, Tracker};
|
||||||
pub use view_image::view_image_tool;
|
pub use view_image::view_image_tool;
|
||||||
pub use web::{web_fetch_tool, web_search_tool};
|
pub use web::{web_fetch_tool, web_search_tool};
|
||||||
pub use write::write_tool;
|
pub use write::write_tool;
|
||||||
|
|||||||
@@ -119,12 +119,22 @@ fn normalize_path_lexically(path: &Path) -> PathBuf {
|
|||||||
normalized
|
normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub struct ChangeStat {
|
||||||
|
pub added: u64,
|
||||||
|
pub deleted: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
struct Inner {
|
struct Inner {
|
||||||
/// Hash of each file's last observed contents, keyed by canonical path.
|
/// Hash of each file's last observed contents, keyed by canonical path.
|
||||||
hashes: HashMap<PathBuf, ContentHash>,
|
hashes: HashMap<PathBuf, ContentHash>,
|
||||||
|
/// Line count paired with observations that included the file content.
|
||||||
|
line_counts: HashMap<PathBuf, usize>,
|
||||||
/// LRU list of touched files. Front = most recently touched.
|
/// LRU list of touched files. Front = most recently touched.
|
||||||
recency: VecDeque<PathBuf>,
|
recency: VecDeque<PathBuf>,
|
||||||
|
/// Successful Write/Edit mutations attributed to this session's tools.
|
||||||
|
change_stat: ChangeStat,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Canonical-path keyed tracker of file observations and their recency.
|
/// Canonical-path keyed tracker of file observations and their recency.
|
||||||
@@ -187,8 +197,27 @@ impl Tracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_workdir_content(&self, path: &workdir::WorkdirPath, bytes: &[u8]) {
|
pub fn record_workdir_content(&self, path: &workdir::WorkdirPath, content: &[u8]) {
|
||||||
self.record_workdir_hash(path, hash_bytes(bytes));
|
let key = PathBuf::from(path.as_str());
|
||||||
|
let hash = hash_bytes(content);
|
||||||
|
let line_count = String::from_utf8_lossy(content).lines().count();
|
||||||
|
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
inner.line_counts.insert(key.clone(), line_count);
|
||||||
|
inner.hashes.insert(key.clone(), hash);
|
||||||
|
inner.recency.retain(|candidate| candidate != &key);
|
||||||
|
inner.recency.push_front(key);
|
||||||
|
if inner.recency.len() > RECENCY_CAPACITY {
|
||||||
|
inner.recency.pop_back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn observed_workdir_line_count(&self, path: &workdir::WorkdirPath) -> Option<usize> {
|
||||||
|
self.inner
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.line_counts
|
||||||
|
.get(Path::new(path.as_str()))
|
||||||
|
.copied()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_workdir_hash(&self, path: &workdir::WorkdirPath, hash: workdir::ContentHash) {
|
pub fn record_workdir_hash(&self, path: &workdir::WorkdirPath, hash: workdir::ContentHash) {
|
||||||
@@ -202,6 +231,50 @@ impl Tracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record a successful, session-attributable source mutation.
|
||||||
|
///
|
||||||
|
/// Callers supply line counts derived from the exact replacement accepted
|
||||||
|
/// by a Write/Edit tool. Bash and external process mutations are excluded
|
||||||
|
/// because this tracker cannot attribute them to one tool operation
|
||||||
|
/// authoritatively.
|
||||||
|
pub fn record_change(&self, added: usize, deleted: usize) {
|
||||||
|
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
inner.change_stat.added = inner.change_stat.added.saturating_add(added as u64);
|
||||||
|
inner.change_stat.deleted = inner.change_stat.deleted.saturating_add(deleted as u64);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_workdir_edit(
|
||||||
|
&self,
|
||||||
|
path: &workdir::WorkdirPath,
|
||||||
|
hash: workdir::ContentHash,
|
||||||
|
replacements: usize,
|
||||||
|
added_lines_per_replacement: usize,
|
||||||
|
deleted_lines_per_replacement: usize,
|
||||||
|
) {
|
||||||
|
let added = added_lines_per_replacement.saturating_mul(replacements);
|
||||||
|
let deleted = deleted_lines_per_replacement.saturating_mul(replacements);
|
||||||
|
let key = PathBuf::from(path.as_str());
|
||||||
|
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
inner.change_stat.added = inner.change_stat.added.saturating_add(added as u64);
|
||||||
|
inner.change_stat.deleted = inner.change_stat.deleted.saturating_add(deleted as u64);
|
||||||
|
if let Some(line_count) = inner.line_counts.get_mut(&key) {
|
||||||
|
*line_count = line_count.saturating_sub(deleted).saturating_add(added);
|
||||||
|
}
|
||||||
|
inner.hashes.insert(key.clone(), hash);
|
||||||
|
inner.recency.retain(|candidate| candidate != &key);
|
||||||
|
inner.recency.push_front(key);
|
||||||
|
if inner.recency.len() > RECENCY_CAPACITY {
|
||||||
|
inner.recency.pop_back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn change_stat(&self) -> ChangeStat {
|
||||||
|
self.inner
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.change_stat
|
||||||
|
}
|
||||||
|
|
||||||
pub fn expected_workdir_hash(
|
pub fn expected_workdir_hash(
|
||||||
&self,
|
&self,
|
||||||
path: &workdir::WorkdirPath,
|
path: &workdir::WorkdirPath,
|
||||||
@@ -458,6 +531,21 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn change_stat_saturates_and_accumulates_tracked_mutations() {
|
||||||
|
let tracker = Tracker::new();
|
||||||
|
tracker.record_change(7, 3);
|
||||||
|
tracker.record_change(5, 2);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
tracker.change_stat(),
|
||||||
|
ChangeStat {
|
||||||
|
added: 12,
|
||||||
|
deleted: 5,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn mutation_guard_blocks_equivalent_paths_until_drop() {
|
async fn mutation_guard_blocks_equivalent_paths_until_drop() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ impl Tool for WriteTool {
|
|||||||
Err(error) => return Err(ToolsError::from(error).into()),
|
Err(error) => return Err(ToolsError::from(error).into()),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let old_line_count = self.tracker.observed_workdir_line_count(&path).unwrap_or(0);
|
||||||
let outcome = self
|
let outcome = self
|
||||||
.session
|
.session
|
||||||
.write(WriteRequest {
|
.write(WriteRequest {
|
||||||
@@ -60,6 +61,8 @@ impl Tool for WriteTool {
|
|||||||
.await
|
.await
|
||||||
.map_err(ToolsError::from)?;
|
.map_err(ToolsError::from)?;
|
||||||
|
|
||||||
|
self.tracker
|
||||||
|
.record_change(params.content.lines().count(), old_line_count);
|
||||||
self.tracker
|
self.tracker
|
||||||
.record_workdir_content(&path, params.content.as_bytes());
|
.record_workdir_content(&path, params.content.as_bytes());
|
||||||
|
|
||||||
|
|||||||
+471
-9
@@ -1,4 +1,4 @@
|
|||||||
use std::collections::VecDeque;
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
@@ -233,6 +233,12 @@ pub struct InternalWorkerView {
|
|||||||
pub app: Box<App>,
|
pub app: Box<App>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct WorkerViewTab {
|
||||||
|
pub label: String,
|
||||||
|
pub selected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
pub worker_name: String,
|
pub worker_name: String,
|
||||||
pub connected: bool,
|
pub connected: bool,
|
||||||
@@ -281,8 +287,13 @@ pub struct App {
|
|||||||
/// replayable conversation rows during segment rotation.
|
/// replayable conversation rows during segment rotation.
|
||||||
run_error_messages: Vec<String>,
|
run_error_messages: Vec<String>,
|
||||||
/// Presentation-only Internal Worker projections keyed by session identity.
|
/// Presentation-only Internal Worker projections keyed by session identity.
|
||||||
/// They are rendered in separate sub-panes and never mixed into `blocks`.
|
/// They are rendered in separate selectable views and never mixed into `blocks`.
|
||||||
pub internal_workers: Vec<InternalWorkerView>,
|
pub internal_workers: Vec<InternalWorkerView>,
|
||||||
|
/// Selected Internal Worker transcript/task view. `None` is the parent (`main`)
|
||||||
|
/// view; the stable session identity survives projection reordering.
|
||||||
|
selected_internal_worker_session_id: Option<String>,
|
||||||
|
/// Terminal child-session fences, reset only by an authoritative snapshot.
|
||||||
|
removed_internal_workers: HashMap<String, u64>,
|
||||||
pub scroll: Scroll,
|
pub scroll: Scroll,
|
||||||
pub mode: Mode,
|
pub mode: Mode,
|
||||||
pub cache: FileCache,
|
pub cache: FileCache,
|
||||||
@@ -361,6 +372,8 @@ impl App {
|
|||||||
blocks: Vec::new(),
|
blocks: Vec::new(),
|
||||||
run_error_messages: Vec::new(),
|
run_error_messages: Vec::new(),
|
||||||
internal_workers: Vec::new(),
|
internal_workers: Vec::new(),
|
||||||
|
selected_internal_worker_session_id: None,
|
||||||
|
removed_internal_workers: HashMap::new(),
|
||||||
scroll: Scroll::default(),
|
scroll: Scroll::default(),
|
||||||
mode: Mode::Normal,
|
mode: Mode::Normal,
|
||||||
cache: FileCache::new(),
|
cache: FileCache::new(),
|
||||||
@@ -445,16 +458,98 @@ impl App {
|
|||||||
pub fn toggle_task_pane(&mut self) {
|
pub fn toggle_task_pane(&mut self) {
|
||||||
self.task_pane_open = !self.task_pane_open;
|
self.task_pane_open = !self.task_pane_open;
|
||||||
if !self.task_pane_open {
|
if !self.task_pane_open {
|
||||||
self.task_pane_scroll = 0;
|
self.selected_worker_view_mut().task_pane_scroll = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn worker_view_tabs(&self) -> Vec<WorkerViewTab> {
|
||||||
|
let selected = self.selected_internal_worker_session_id.as_deref();
|
||||||
|
let mut tabs = Vec::with_capacity(self.internal_workers.len().saturating_add(1));
|
||||||
|
tabs.push(WorkerViewTab {
|
||||||
|
label: "main".to_owned(),
|
||||||
|
selected: selected.is_none(),
|
||||||
|
});
|
||||||
|
tabs.extend(self.internal_workers.iter().map(|view| {
|
||||||
|
WorkerViewTab {
|
||||||
|
label: view
|
||||||
|
.worker
|
||||||
|
.name
|
||||||
|
.lines()
|
||||||
|
.next()
|
||||||
|
.filter(|name| !name.is_empty())
|
||||||
|
.unwrap_or("subworker")
|
||||||
|
.to_owned(),
|
||||||
|
selected: selected == Some(view.worker.session_id.as_str()),
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
tabs
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_internal_worker_index(&self) -> Option<usize> {
|
||||||
|
let selected = self.selected_internal_worker_session_id.as_deref()?;
|
||||||
|
self.internal_workers
|
||||||
|
.iter()
|
||||||
|
.position(|view| view.worker.session_id == selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_worker_view(&self) -> &App {
|
||||||
|
self.selected_internal_worker_index()
|
||||||
|
.map(|index| self.internal_workers[index].app.as_ref())
|
||||||
|
.unwrap_or(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_worker_view_mut(&mut self) -> &mut App {
|
||||||
|
if let Some(index) = self.selected_internal_worker_index() {
|
||||||
|
self.internal_workers[index].app.as_mut()
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cycle the presentation-only transcript/task view. Input and control
|
||||||
|
/// methods continue to target the parent Worker regardless of selection.
|
||||||
|
pub fn cycle_worker_view(&mut self) -> bool {
|
||||||
|
if self.internal_workers.is_empty() {
|
||||||
|
self.selected_internal_worker_session_id = None;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.selected_internal_worker_session_id = self
|
||||||
|
.selected_internal_worker_index()
|
||||||
|
.and_then(|index| self.internal_workers.get(index.saturating_add(1)))
|
||||||
|
.map(|view| view.worker.session_id.clone())
|
||||||
|
.or_else(|| {
|
||||||
|
if self.selected_internal_worker_session_id.is_none() {
|
||||||
|
self.internal_workers
|
||||||
|
.first()
|
||||||
|
.map(|view| view.worker.session_id.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cycle_mode(&mut self) {
|
||||||
|
let mode = self.mode.cycle();
|
||||||
|
self.set_mode_recursively(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_mode_recursively(&mut self, mode: Mode) {
|
||||||
|
self.mode = mode;
|
||||||
|
for view in &mut self.internal_workers {
|
||||||
|
view.app.set_mode_recursively(mode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn scroll_task_pane_up(&mut self, n: usize) {
|
pub fn scroll_task_pane_up(&mut self, n: usize) {
|
||||||
self.task_pane_scroll = self.task_pane_scroll.saturating_sub(n);
|
let view = self.selected_worker_view_mut();
|
||||||
|
view.task_pane_scroll = view.task_pane_scroll.saturating_sub(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn scroll_task_pane_down(&mut self, n: usize) {
|
pub fn scroll_task_pane_down(&mut self, n: usize) {
|
||||||
self.task_pane_scroll = self.task_pane_scroll.saturating_add(n);
|
let view = self.selected_worker_view_mut();
|
||||||
|
view.task_pane_scroll = view.task_pane_scroll.saturating_add(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_worker_status(&mut self, status: WorkerStatus) {
|
pub fn set_worker_status(&mut self, status: WorkerStatus) {
|
||||||
@@ -1318,10 +1413,16 @@ impl App {
|
|||||||
revision,
|
revision,
|
||||||
event,
|
event,
|
||||||
} => self.apply_internal_worker_event(worker, revision, *event),
|
} => self.apply_internal_worker_event(worker, revision, *event),
|
||||||
|
Event::InternalWorkerRemoved { worker, revision } => {
|
||||||
|
self.remove_internal_worker(worker, revision)
|
||||||
|
}
|
||||||
Event::Status { status } => {
|
Event::Status { status } => {
|
||||||
self.rewind_refresh_fence = false;
|
self.rewind_refresh_fence = false;
|
||||||
self.set_worker_status(status);
|
self.set_worker_status(status);
|
||||||
}
|
}
|
||||||
|
// Command telemetry is an operational Web Console surface. The
|
||||||
|
// TUI continues to render the final Bash ToolResult from history.
|
||||||
|
Event::Command { .. } => {}
|
||||||
Event::Completions { kind, entries } => {
|
Event::Completions { kind, entries } => {
|
||||||
// Apply only if the popup is still on the same
|
// Apply only if the popup is still on the same
|
||||||
// (kind, prefix) the request was issued for; an
|
// (kind, prefix) the request was issued for; an
|
||||||
@@ -1738,6 +1839,9 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn request_rewind_picker(&mut self) -> Option<Method> {
|
pub fn request_rewind_picker(&mut self) -> Option<Method> {
|
||||||
|
// Rewind is a parent Worker control surface. Bring the parent transcript
|
||||||
|
// back into view before presenting diagnostics or the picker.
|
||||||
|
self.selected_internal_worker_session_id = None;
|
||||||
if self.rewind_submit_pending() {
|
if self.rewind_submit_pending() {
|
||||||
self.push_command_diagnostic(
|
self.push_command_diagnostic(
|
||||||
"rewind is already applying; wait for the Worker response",
|
"rewind is already applying; wait for the Worker response",
|
||||||
@@ -1998,14 +2102,66 @@ impl App {
|
|||||||
/// produced. Followed by `Event::Entry` updates for anything
|
/// produced. Followed by `Event::Entry` updates for anything
|
||||||
/// committed after the snapshot.
|
/// committed after the snapshot.
|
||||||
fn replace_internal_worker_snapshots(&mut self, snapshots: Vec<InternalWorkerSnapshot>) {
|
fn replace_internal_worker_snapshots(&mut self, snapshots: Vec<InternalWorkerSnapshot>) {
|
||||||
|
let mode = self.mode;
|
||||||
|
let mut previous = std::mem::take(&mut self.internal_workers);
|
||||||
self.internal_workers = snapshots
|
self.internal_workers = snapshots
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(Self::internal_worker_view_from_snapshot)
|
.map(|snapshot| {
|
||||||
|
if let Some(index) = previous
|
||||||
|
.iter()
|
||||||
|
.position(|view| view.worker.session_id == snapshot.worker.session_id)
|
||||||
|
{
|
||||||
|
let view = previous.remove(index);
|
||||||
|
Self::update_internal_worker_view_from_snapshot(view, snapshot, mode)
|
||||||
|
} else {
|
||||||
|
Self::internal_worker_view_from_snapshot(snapshot, mode)
|
||||||
|
}
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
if self.selected_internal_worker_index().is_none() {
|
||||||
|
self.selected_internal_worker_session_id = None;
|
||||||
|
}
|
||||||
|
self.removed_internal_workers.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn internal_worker_view_from_snapshot(snapshot: InternalWorkerSnapshot) -> InternalWorkerView {
|
fn update_internal_worker_view_from_snapshot(
|
||||||
|
mut previous: InternalWorkerView,
|
||||||
|
snapshot: InternalWorkerSnapshot,
|
||||||
|
mode: Mode,
|
||||||
|
) -> InternalWorkerView {
|
||||||
|
let mut refreshed = Self::internal_worker_view_from_snapshot(snapshot, mode);
|
||||||
|
Self::transfer_worker_view_state(&mut previous.app, &mut refreshed.app);
|
||||||
|
refreshed
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transfer_worker_view_state(previous: &mut App, refreshed: &mut App) {
|
||||||
|
refreshed.scroll = std::mem::take(&mut previous.scroll);
|
||||||
|
refreshed.text_selection = std::mem::take(&mut previous.text_selection);
|
||||||
|
refreshed.task_pane_scroll = previous.task_pane_scroll;
|
||||||
|
refreshed.selected_internal_worker_session_id =
|
||||||
|
previous.selected_internal_worker_session_id.take();
|
||||||
|
|
||||||
|
let mut previous_children = std::mem::take(&mut previous.internal_workers);
|
||||||
|
for child in &mut refreshed.internal_workers {
|
||||||
|
if let Some(index) = previous_children
|
||||||
|
.iter()
|
||||||
|
.position(|old| old.worker.session_id == child.worker.session_id)
|
||||||
|
{
|
||||||
|
let mut old = previous_children.remove(index);
|
||||||
|
Self::transfer_worker_view_state(&mut old.app, &mut child.app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if refreshed.selected_internal_worker_index().is_none() {
|
||||||
|
refreshed.selected_internal_worker_session_id = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn internal_worker_view_from_snapshot(
|
||||||
|
snapshot: InternalWorkerSnapshot,
|
||||||
|
mode: Mode,
|
||||||
|
) -> InternalWorkerView {
|
||||||
let mut app = App::new(snapshot.worker.name.clone());
|
let mut app = App::new(snapshot.worker.name.clone());
|
||||||
|
app.mode = mode;
|
||||||
app.restore_entries(&snapshot.entries, None);
|
app.restore_entries(&snapshot.entries, None);
|
||||||
app.apply_in_flight_snapshot(snapshot.in_flight);
|
app.apply_in_flight_snapshot(snapshot.in_flight);
|
||||||
app.set_worker_status(snapshot.status);
|
app.set_worker_status(snapshot.status);
|
||||||
@@ -2029,6 +2185,12 @@ impl App {
|
|||||||
revision: u64,
|
revision: u64,
|
||||||
event: Event,
|
event: Event,
|
||||||
) {
|
) {
|
||||||
|
if self
|
||||||
|
.removed_internal_workers
|
||||||
|
.contains_key(&worker.session_id)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
let index = self
|
let index = self
|
||||||
.internal_workers
|
.internal_workers
|
||||||
.iter()
|
.iter()
|
||||||
@@ -2036,10 +2198,12 @@ impl App {
|
|||||||
let target = if let Some(index) = index {
|
let target = if let Some(index) = index {
|
||||||
&mut self.internal_workers[index]
|
&mut self.internal_workers[index]
|
||||||
} else {
|
} else {
|
||||||
|
let mut app = App::new(worker.name.clone());
|
||||||
|
app.mode = self.mode;
|
||||||
self.internal_workers.push(InternalWorkerView {
|
self.internal_workers.push(InternalWorkerView {
|
||||||
worker: worker.clone(),
|
worker: worker.clone(),
|
||||||
revision: 0,
|
revision: 0,
|
||||||
app: Box::new(App::new(worker.name.clone())),
|
app: Box::new(app),
|
||||||
});
|
});
|
||||||
self.internal_workers.last_mut().unwrap()
|
self.internal_workers.last_mut().unwrap()
|
||||||
};
|
};
|
||||||
@@ -2051,6 +2215,32 @@ impl App {
|
|||||||
let _ = target.app.handle_worker_event(event);
|
let _ = target.app.handle_worker_event(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn remove_internal_worker(&mut self, worker: InternalWorkerRef, revision: u64) {
|
||||||
|
let session_id = worker.session_id;
|
||||||
|
let Some(index) = self
|
||||||
|
.internal_workers
|
||||||
|
.iter()
|
||||||
|
.position(|candidate| candidate.worker.session_id == session_id)
|
||||||
|
else {
|
||||||
|
if self.selected_internal_worker_session_id.as_deref() == Some(session_id.as_str()) {
|
||||||
|
self.selected_internal_worker_session_id = None;
|
||||||
|
}
|
||||||
|
self.removed_internal_workers
|
||||||
|
.entry(session_id)
|
||||||
|
.and_modify(|current| *current = (*current).max(revision))
|
||||||
|
.or_insert(revision);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if revision <= self.internal_workers[index].revision {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.internal_workers.remove(index);
|
||||||
|
if self.selected_internal_worker_session_id.as_deref() == Some(session_id.as_str()) {
|
||||||
|
self.selected_internal_worker_session_id = None;
|
||||||
|
}
|
||||||
|
self.removed_internal_workers.insert(session_id, revision);
|
||||||
|
}
|
||||||
|
|
||||||
fn restore_snapshot(
|
fn restore_snapshot(
|
||||||
&mut self,
|
&mut self,
|
||||||
entries: &[serde_json::Value],
|
entries: &[serde_json::Value],
|
||||||
@@ -2260,11 +2450,14 @@ impl App {
|
|||||||
}
|
}
|
||||||
session_store::SystemItem::FileAttachment { body, .. }
|
session_store::SystemItem::FileAttachment { body, .. }
|
||||||
| session_store::SystemItem::SkillActivation { body, .. }
|
| session_store::SystemItem::SkillActivation { body, .. }
|
||||||
| session_store::SystemItem::TaskReminder { body, .. }
|
|
||||||
| session_store::SystemItem::Interrupt { body, .. } => {
|
| session_store::SystemItem::Interrupt { body, .. } => {
|
||||||
self.task_store.apply_system_message_text(&body);
|
self.task_store.apply_system_message_text(&body);
|
||||||
self.blocks.push(Block::SystemMessage { text: body });
|
self.blocks.push(Block::SystemMessage { text: body });
|
||||||
}
|
}
|
||||||
|
session_store::SystemItem::TaskReminder { body, .. } => {
|
||||||
|
self.task_store.apply_system_message_text(&body);
|
||||||
|
self.blocks.push(Block::TaskReminder { text: body });
|
||||||
|
}
|
||||||
session_store::SystemItem::LegacyIgnored { .. } => {}
|
session_store::SystemItem::LegacyIgnored { .. } => {}
|
||||||
session_store::SystemItem::LegacyKnowledgeIgnored { .. } => {}
|
session_store::SystemItem::LegacyKnowledgeIgnored { .. } => {}
|
||||||
}
|
}
|
||||||
@@ -2609,6 +2802,7 @@ mod rewind_refresh_tests {
|
|||||||
app.blocks.iter().any(|block| match block {
|
app.blocks.iter().any(|block| match block {
|
||||||
Block::AssistantText { text }
|
Block::AssistantText { text }
|
||||||
| Block::SystemMessage { text }
|
| Block::SystemMessage { text }
|
||||||
|
| Block::TaskReminder { text }
|
||||||
| Block::Alert { message: text, .. } => text.contains(needle),
|
| Block::Alert { message: text, .. } => text.contains(needle),
|
||||||
Block::UserMessage { segments } => Segment::flatten_to_text(segments).contains(needle),
|
Block::UserMessage { segments } => Segment::flatten_to_text(segments).contains(needle),
|
||||||
_ => false,
|
_ => false,
|
||||||
@@ -3470,6 +3664,7 @@ mod completion_flow_tests {
|
|||||||
finished: false,
|
finished: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
commands: Vec::new(),
|
||||||
},
|
},
|
||||||
internal_workers: Vec::new(),
|
internal_workers: Vec::new(),
|
||||||
});
|
});
|
||||||
@@ -3542,6 +3737,269 @@ mod completion_flow_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn test_internal_worker_snapshot(
|
||||||
|
session_id: &str,
|
||||||
|
name: &str,
|
||||||
|
revision: u64,
|
||||||
|
) -> InternalWorkerSnapshot {
|
||||||
|
InternalWorkerSnapshot {
|
||||||
|
worker: InternalWorkerRef {
|
||||||
|
session_id: session_id.into(),
|
||||||
|
name: name.into(),
|
||||||
|
parent_session_id: Some("parent".into()),
|
||||||
|
kind: protocol::InternalWorkerKind::SubWorker,
|
||||||
|
},
|
||||||
|
revision,
|
||||||
|
status: WorkerStatus::Idle,
|
||||||
|
entries: Vec::new(),
|
||||||
|
in_flight: protocol::InFlightSnapshot::default(),
|
||||||
|
error: None,
|
||||||
|
internal_workers: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_view_cycle_uses_stable_session_identity_and_wraps_to_main() {
|
||||||
|
let mut app = App::new("parent".into());
|
||||||
|
for (session_id, name) in [("child-a", "alpha"), ("child-b", "beta")] {
|
||||||
|
app.internal_workers.push(InternalWorkerView {
|
||||||
|
worker: InternalWorkerRef {
|
||||||
|
session_id: session_id.into(),
|
||||||
|
name: name.into(),
|
||||||
|
parent_session_id: Some("parent".into()),
|
||||||
|
kind: protocol::InternalWorkerKind::SubWorker,
|
||||||
|
},
|
||||||
|
revision: 1,
|
||||||
|
app: Box::new(App::new(name.into())),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(app.selected_worker_view().worker_name, "parent");
|
||||||
|
assert!(app.cycle_worker_view());
|
||||||
|
assert_eq!(app.selected_worker_view().worker_name, "alpha");
|
||||||
|
|
||||||
|
app.internal_workers.swap(0, 1);
|
||||||
|
assert_eq!(app.selected_worker_view().worker_name, "alpha");
|
||||||
|
assert!(app.cycle_worker_view());
|
||||||
|
assert_eq!(app.selected_worker_view().worker_name, "parent");
|
||||||
|
assert!(app.cycle_worker_view());
|
||||||
|
assert_eq!(app.selected_worker_view().worker_name, "beta");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_view_cycle_preserves_each_views_text_selection() {
|
||||||
|
use crate::text_selection::{HistoryViewport, SelectionRow};
|
||||||
|
|
||||||
|
fn select_first_row(app: &mut App, text: &str) {
|
||||||
|
app.text_selection.set_history_snapshot(
|
||||||
|
HistoryViewport {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 20,
|
||||||
|
height: 1,
|
||||||
|
top_offset: 0,
|
||||||
|
total_lines: 1,
|
||||||
|
},
|
||||||
|
vec![SelectionRow::new(text.into(), true)],
|
||||||
|
);
|
||||||
|
assert!(app.text_selection.begin_drag(0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut app = App::new("parent".into());
|
||||||
|
app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot(
|
||||||
|
"child", "child", 1,
|
||||||
|
)]);
|
||||||
|
select_first_row(&mut app, "parent selection");
|
||||||
|
select_first_row(app.internal_workers[0].app.as_mut(), "child selection");
|
||||||
|
|
||||||
|
app.cycle_worker_view();
|
||||||
|
assert!(app.selected_worker_view().text_selection.has_selection());
|
||||||
|
app.cycle_worker_view();
|
||||||
|
|
||||||
|
assert!(app.text_selection.has_selection());
|
||||||
|
assert!(app.internal_workers[0].app.text_selection.has_selection());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_removal_falls_selected_worker_view_back_to_main() {
|
||||||
|
let mut app = App::new("parent".into());
|
||||||
|
app.internal_workers.push(InternalWorkerView {
|
||||||
|
worker: InternalWorkerRef {
|
||||||
|
session_id: "old".into(),
|
||||||
|
name: "old".into(),
|
||||||
|
parent_session_id: Some("parent".into()),
|
||||||
|
kind: protocol::InternalWorkerKind::SubWorker,
|
||||||
|
},
|
||||||
|
revision: 1,
|
||||||
|
app: Box::new(App::new("old".into())),
|
||||||
|
});
|
||||||
|
app.cycle_worker_view();
|
||||||
|
assert_eq!(app.selected_worker_view().worker_name, "old");
|
||||||
|
|
||||||
|
app.replace_internal_worker_snapshots(Vec::new());
|
||||||
|
|
||||||
|
assert_eq!(app.selected_worker_view().worker_name, "parent");
|
||||||
|
assert_eq!(
|
||||||
|
app.worker_view_tabs(),
|
||||||
|
vec![WorkerViewTab {
|
||||||
|
label: "main".into(),
|
||||||
|
selected: true,
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_session_snapshot_preserves_subworker_view_local_state() {
|
||||||
|
use crate::text_selection::{HistoryViewport, SelectionRow};
|
||||||
|
|
||||||
|
let mut app = App::new("parent".into());
|
||||||
|
app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot(
|
||||||
|
"child", "child", 1,
|
||||||
|
)]);
|
||||||
|
let child = app.internal_workers[0].app.as_mut();
|
||||||
|
child.scroll.follow_tail = false;
|
||||||
|
child.scroll.top_offset = 7;
|
||||||
|
child.task_pane_scroll = 4;
|
||||||
|
child.text_selection.set_history_snapshot(
|
||||||
|
HistoryViewport {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 20,
|
||||||
|
height: 1,
|
||||||
|
top_offset: 0,
|
||||||
|
total_lines: 1,
|
||||||
|
},
|
||||||
|
vec![SelectionRow::new("selected".into(), true)],
|
||||||
|
);
|
||||||
|
assert!(child.text_selection.begin_drag(0, 0));
|
||||||
|
|
||||||
|
app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot(
|
||||||
|
"child",
|
||||||
|
"renamed-child",
|
||||||
|
2,
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let view = &app.internal_workers[0];
|
||||||
|
assert_eq!(view.revision, 2);
|
||||||
|
assert_eq!(view.app.worker_name, "renamed-child");
|
||||||
|
assert!(!view.app.scroll.follow_tail);
|
||||||
|
assert_eq!(view.app.scroll.top_offset, 7);
|
||||||
|
assert_eq!(view.app.task_pane_scroll, 4);
|
||||||
|
assert!(view.app.text_selection.has_selection());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn task_pane_scroll_is_local_to_selected_worker_view() {
|
||||||
|
let mut app = App::new("parent".into());
|
||||||
|
app.replace_internal_worker_snapshots(vec![
|
||||||
|
test_internal_worker_snapshot("child-a", "alpha", 1),
|
||||||
|
test_internal_worker_snapshot("child-b", "beta", 1),
|
||||||
|
]);
|
||||||
|
app.task_pane_scroll = 3;
|
||||||
|
|
||||||
|
app.cycle_worker_view();
|
||||||
|
app.scroll_task_pane_down(5);
|
||||||
|
assert_eq!(app.selected_worker_view().task_pane_scroll, 5);
|
||||||
|
|
||||||
|
app.cycle_worker_view();
|
||||||
|
app.scroll_task_pane_down(7);
|
||||||
|
assert_eq!(app.selected_worker_view().task_pane_scroll, 7);
|
||||||
|
|
||||||
|
app.cycle_worker_view();
|
||||||
|
assert_eq!(app.selected_worker_view().worker_name, "parent");
|
||||||
|
assert_eq!(app.task_pane_scroll, 3);
|
||||||
|
assert_eq!(app.internal_workers[0].app.task_pane_scroll, 5);
|
||||||
|
assert_eq!(app.internal_workers[1].app.task_pane_scroll, 7);
|
||||||
|
|
||||||
|
app.cycle_worker_view();
|
||||||
|
app.toggle_task_pane();
|
||||||
|
app.toggle_task_pane();
|
||||||
|
assert_eq!(app.internal_workers[0].app.task_pane_scroll, 0);
|
||||||
|
assert_eq!(app.internal_workers[1].app.task_pane_scroll, 7);
|
||||||
|
assert_eq!(app.task_pane_scroll, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terminal_internal_worker_removal_drops_descendants_and_fences_late_events() {
|
||||||
|
let mut app = App::new("parent".into());
|
||||||
|
let worker = InternalWorkerRef {
|
||||||
|
session_id: "child-session".into(),
|
||||||
|
name: "child".into(),
|
||||||
|
parent_session_id: Some("parent-session".into()),
|
||||||
|
kind: protocol::InternalWorkerKind::SubWorker,
|
||||||
|
};
|
||||||
|
let nested = InternalWorkerRef {
|
||||||
|
session_id: "grandchild-session".into(),
|
||||||
|
name: "grandchild".into(),
|
||||||
|
parent_session_id: Some("child-session".into()),
|
||||||
|
kind: protocol::InternalWorkerKind::SubWorker,
|
||||||
|
};
|
||||||
|
app.handle_worker_event(Event::InternalWorker {
|
||||||
|
worker: worker.clone(),
|
||||||
|
revision: 2,
|
||||||
|
event: Box::new(Event::InternalWorker {
|
||||||
|
worker: nested,
|
||||||
|
revision: 1,
|
||||||
|
event: Box::new(Event::TextDone {
|
||||||
|
text: "nested".into(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert_eq!(app.internal_workers.len(), 1);
|
||||||
|
assert_eq!(app.internal_workers[0].app.internal_workers.len(), 1);
|
||||||
|
app.cycle_worker_view();
|
||||||
|
assert_eq!(app.selected_worker_view().worker_name, "child");
|
||||||
|
|
||||||
|
app.handle_worker_event(Event::InternalWorkerRemoved {
|
||||||
|
worker: worker.clone(),
|
||||||
|
revision: 3,
|
||||||
|
});
|
||||||
|
app.handle_worker_event(Event::InternalWorker {
|
||||||
|
worker,
|
||||||
|
revision: 4,
|
||||||
|
event: Box::new(Event::TextDone {
|
||||||
|
text: "late".into(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(app.internal_workers.is_empty());
|
||||||
|
assert_eq!(app.selected_worker_view().worker_name, "parent");
|
||||||
|
app.handle_worker_event(Event::Snapshot {
|
||||||
|
greeting: test_greeting(),
|
||||||
|
entries: Vec::new(),
|
||||||
|
status: WorkerStatus::Idle,
|
||||||
|
in_flight: Default::default(),
|
||||||
|
internal_workers: Vec::new(),
|
||||||
|
});
|
||||||
|
assert!(app.internal_workers.is_empty());
|
||||||
|
assert!(app.removed_internal_workers.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_internal_worker_removal_keeps_newer_projection() {
|
||||||
|
let mut app = App::new("parent".into());
|
||||||
|
let worker = InternalWorkerRef {
|
||||||
|
session_id: "child-session".into(),
|
||||||
|
name: "child".into(),
|
||||||
|
parent_session_id: Some("parent-session".into()),
|
||||||
|
kind: protocol::InternalWorkerKind::SubWorker,
|
||||||
|
};
|
||||||
|
app.handle_worker_event(Event::InternalWorker {
|
||||||
|
worker: worker.clone(),
|
||||||
|
revision: 4,
|
||||||
|
event: Box::new(Event::TextDone {
|
||||||
|
text: "current".into(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
app.handle_worker_event(Event::InternalWorkerRemoved {
|
||||||
|
worker,
|
||||||
|
revision: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(app.internal_workers.len(), 1);
|
||||||
|
assert_eq!(app.internal_workers[0].revision, 4);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn snapshot_authoritatively_replaces_internal_worker_views() {
|
fn snapshot_authoritatively_replaces_internal_worker_views() {
|
||||||
let mut app = App::new("parent".into());
|
let mut app = App::new("parent".into());
|
||||||
@@ -3847,6 +4305,10 @@ mod completion_flow_tests {
|
|||||||
assert_eq!(tasks.len(), 1);
|
assert_eq!(tasks.len(), 1);
|
||||||
assert_eq!(tasks[0].taskid, 4);
|
assert_eq!(tasks[0].taskid, 4);
|
||||||
assert_eq!(tasks[0].subject, "from snapshot");
|
assert_eq!(tasks[0].subject, "from snapshot");
|
||||||
|
assert!(matches!(
|
||||||
|
app.blocks.last(),
|
||||||
|
Some(Block::TaskReminder { text }) if text == snapshot
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ use std::io;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use client::{
|
use client::{
|
||||||
BackendRuntimeListTarget, BackendRuntimeTarget, BackendWorkerSummary,
|
BackendRuntimeListTarget, BackendWorkerSummary, list_backend_stopped_workers,
|
||||||
list_backend_stopped_workers, list_backend_workers, restore_backend_worker,
|
list_backend_workers, restore_backend_worker,
|
||||||
};
|
};
|
||||||
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
@@ -14,77 +14,94 @@ use ratatui::text::{Line, Span};
|
|||||||
use ratatui::widgets::Paragraph;
|
use ratatui::widgets::Paragraph;
|
||||||
use ratatui::{Frame, Terminal, TerminalOptions, Viewport};
|
use ratatui::{Frame, Terminal, TerminalOptions, Viewport};
|
||||||
|
|
||||||
|
use crate::backend_workspace_picker::select_backend_workspace;
|
||||||
use crate::console;
|
use crate::console;
|
||||||
|
|
||||||
const MAX_ROWS: usize = 10;
|
const MAX_ROWS: usize = 10;
|
||||||
const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4;
|
const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4;
|
||||||
|
|
||||||
pub(crate) async fn run(
|
pub(crate) async fn run(
|
||||||
target: BackendRuntimeListTarget,
|
mut target: BackendRuntimeListTarget,
|
||||||
include_stopped: bool,
|
include_stopped: bool,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
let mut response = list_backend_workers(&target).await.map_err(|error| {
|
loop {
|
||||||
io::Error::other(format!(
|
if target.workspace_id().is_none() {
|
||||||
"failed to list Backend runtime workers from {}: {error}",
|
let workspace_id = select_backend_workspace(&target.base_url)
|
||||||
target.base_url
|
.await
|
||||||
))
|
.map_err(|error| io::Error::other(error.to_string()))?
|
||||||
})?;
|
.ok_or_else(|| io::Error::other("Backend workspace picker cancelled"))?;
|
||||||
if include_stopped {
|
target.select_workspace(workspace_id);
|
||||||
match list_backend_stopped_workers(&target).await {
|
}
|
||||||
Ok(stopped) => {
|
let mut response = list_backend_workers(&target).await.map_err(|error| {
|
||||||
response.items.extend(stopped.items);
|
io::Error::other(format!(
|
||||||
response.diagnostics.extend(stopped.diagnostics);
|
"failed to list Backend runtime workers from {}: {error}",
|
||||||
|
target.base_url
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if include_stopped {
|
||||||
|
match list_backend_stopped_workers(&target).await {
|
||||||
|
Ok(stopped) => {
|
||||||
|
response.items.extend(stopped.items);
|
||||||
|
response.diagnostics.extend(stopped.diagnostics);
|
||||||
|
}
|
||||||
|
Err(error) => response.diagnostics.push(client::BackendDiagnostic {
|
||||||
|
code: "backend_stopped_workers_list_failed".to_string(),
|
||||||
|
severity: Some("error".to_string()),
|
||||||
|
message: error.to_string(),
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
Err(error) => response.diagnostics.push(client::BackendDiagnostic {
|
}
|
||||||
code: "backend_stopped_workers_list_failed".to_string(),
|
dedup_workers(&mut response.items);
|
||||||
severity: Some("error".to_string()),
|
if response.items.is_empty() {
|
||||||
message: error.to_string(),
|
let diagnostics = response
|
||||||
}),
|
.diagnostics
|
||||||
|
.iter()
|
||||||
|
.map(|diagnostic| format!("{}: {}", diagnostic.code, diagnostic.message))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("; ");
|
||||||
|
let detail = if diagnostics.is_empty() {
|
||||||
|
"no backend diagnostics".to_string()
|
||||||
|
} else {
|
||||||
|
diagnostics
|
||||||
|
};
|
||||||
|
eprintln!(
|
||||||
|
"Backend returned no runtime workers for workspace {} ({detail}); choose another Workspace",
|
||||||
|
response.workspace_id
|
||||||
|
);
|
||||||
|
target.clear_workspace();
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
dedup_workers(&mut response.items);
|
|
||||||
if response.items.is_empty() {
|
|
||||||
let diagnostics = response
|
|
||||||
.diagnostics
|
|
||||||
.iter()
|
|
||||||
.map(|diagnostic| format!("{}: {}", diagnostic.code, diagnostic.message))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("; ");
|
|
||||||
let detail = if diagnostics.is_empty() {
|
|
||||||
"no backend diagnostics".to_string()
|
|
||||||
} else {
|
|
||||||
diagnostics
|
|
||||||
};
|
|
||||||
return Err(Box::new(io::Error::other(format!(
|
|
||||||
"Backend returned no runtime workers for workspace {} ({detail})",
|
|
||||||
response.workspace_id
|
|
||||||
))));
|
|
||||||
}
|
|
||||||
|
|
||||||
let selected = pick_worker(target.clone(), response.items)?;
|
let selected = match pick_worker(target.clone(), response.items)? {
|
||||||
let worker = if selected.state == "stopped" {
|
WorkerPickerResult::SwitchWorkspace => {
|
||||||
let restore_target = BackendRuntimeTarget::new(
|
target.clear_workspace();
|
||||||
target.base_url.clone(),
|
continue;
|
||||||
selected.runtime_id.clone(),
|
}
|
||||||
selected.worker_id.clone(),
|
WorkerPickerResult::Selected(selected) => selected,
|
||||||
);
|
};
|
||||||
restore_backend_worker(&restore_target)
|
let worker = if selected.state == "stopped" {
|
||||||
.await
|
let restore_target = target
|
||||||
.map_err(|error| {
|
.runtime_target(selected.runtime_id.clone(), selected.worker_id.clone())
|
||||||
io::Error::other(format!(
|
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||||
"failed to restore Backend worker {}/{}: {error}",
|
restore_backend_worker(&restore_target)
|
||||||
selected.runtime_id, selected.worker_id
|
.await
|
||||||
))
|
.map_err(|error| {
|
||||||
})?
|
io::Error::other(format!(
|
||||||
.result
|
"failed to restore Backend worker {}/{}: {error}",
|
||||||
.worker
|
selected.runtime_id, selected.worker_id
|
||||||
.unwrap_or(selected)
|
))
|
||||||
} else {
|
})?
|
||||||
selected
|
.result
|
||||||
};
|
.worker
|
||||||
let attach_target =
|
.unwrap_or(selected)
|
||||||
BackendRuntimeTarget::new(target.base_url, worker.runtime_id, worker.worker_id);
|
} else {
|
||||||
console::run_backend_runtime(attach_target).await
|
selected
|
||||||
|
};
|
||||||
|
let attach_target = target
|
||||||
|
.runtime_target(worker.runtime_id, worker.worker_id)
|
||||||
|
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||||
|
return console::run_backend_runtime(attach_target).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dedup_workers(workers: &mut Vec<BackendWorkerSummary>) {
|
fn dedup_workers(workers: &mut Vec<BackendWorkerSummary>) {
|
||||||
@@ -92,10 +109,15 @@ fn dedup_workers(workers: &mut Vec<BackendWorkerSummary>) {
|
|||||||
workers.retain(|worker| seen.insert((worker.runtime_id.clone(), worker.worker_id.clone())));
|
workers.retain(|worker| seen.insert((worker.runtime_id.clone(), worker.worker_id.clone())));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum WorkerPickerResult {
|
||||||
|
Selected(BackendWorkerSummary),
|
||||||
|
SwitchWorkspace,
|
||||||
|
}
|
||||||
|
|
||||||
fn pick_worker(
|
fn pick_worker(
|
||||||
target: BackendRuntimeListTarget,
|
target: BackendRuntimeListTarget,
|
||||||
mut workers: Vec<BackendWorkerSummary>,
|
mut workers: Vec<BackendWorkerSummary>,
|
||||||
) -> Result<BackendWorkerSummary, Box<dyn Error>> {
|
) -> Result<WorkerPickerResult, Box<dyn Error>> {
|
||||||
workers.sort_by(|a, b| {
|
workers.sort_by(|a, b| {
|
||||||
a.runtime_id
|
a.runtime_id
|
||||||
.cmp(&b.runtime_id)
|
.cmp(&b.runtime_id)
|
||||||
@@ -114,7 +136,13 @@ fn pick_worker(
|
|||||||
Some(Action::Down) => state.next(),
|
Some(Action::Down) => state.next(),
|
||||||
Some(Action::Submit) => {
|
Some(Action::Submit) => {
|
||||||
close_viewport(&mut terminal)?;
|
close_viewport(&mut terminal)?;
|
||||||
return Ok(state.selected_worker().clone());
|
return Ok(WorkerPickerResult::Selected(
|
||||||
|
state.selected_worker().clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some(Action::SwitchWorkspace) => {
|
||||||
|
close_viewport(&mut terminal)?;
|
||||||
|
return Ok(WorkerPickerResult::SwitchWorkspace);
|
||||||
}
|
}
|
||||||
Some(Action::Cancel) => {
|
Some(Action::Cancel) => {
|
||||||
close_viewport(&mut terminal)?;
|
close_viewport(&mut terminal)?;
|
||||||
@@ -181,6 +209,7 @@ enum Action {
|
|||||||
Up,
|
Up,
|
||||||
Down,
|
Down,
|
||||||
Submit,
|
Submit,
|
||||||
|
SwitchWorkspace,
|
||||||
Cancel,
|
Cancel,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +226,7 @@ fn poll_event() -> io::Result<Option<Action>> {
|
|||||||
KeyCode::Char('k') if !ctrl => Some(Action::Up),
|
KeyCode::Char('k') if !ctrl => Some(Action::Up),
|
||||||
KeyCode::Char('j') if !ctrl => Some(Action::Down),
|
KeyCode::Char('j') if !ctrl => Some(Action::Down),
|
||||||
KeyCode::Enter => Some(Action::Submit),
|
KeyCode::Enter => Some(Action::Submit),
|
||||||
|
KeyCode::Char('w') if !ctrl => Some(Action::SwitchWorkspace),
|
||||||
KeyCode::Esc => Some(Action::Cancel),
|
KeyCode::Esc => Some(Action::Cancel),
|
||||||
KeyCode::Char('c') if ctrl => Some(Action::Cancel),
|
KeyCode::Char('c') if ctrl => Some(Action::Cancel),
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -239,6 +269,8 @@ fn draw(frame: &mut Frame<'_>, state: &BackendWorkerPickerState) {
|
|||||||
Span::raw(" select "),
|
Span::raw(" select "),
|
||||||
Span::styled("[enter]", Style::default().fg(Color::Green)),
|
Span::styled("[enter]", Style::default().fg(Color::Green)),
|
||||||
Span::raw(" attach "),
|
Span::raw(" attach "),
|
||||||
|
Span::styled("[w]", Style::default().fg(Color::Cyan)),
|
||||||
|
Span::raw(" switch Workspace "),
|
||||||
Span::styled("[esc]", Style::default().fg(Color::Yellow)),
|
Span::styled("[esc]", Style::default().fg(Color::Yellow)),
|
||||||
Span::raw(" cancel"),
|
Span::raw(" cancel"),
|
||||||
])),
|
])),
|
||||||
@@ -316,14 +348,6 @@ fn state_style(state: &str) -> Style {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn short_worker_id(worker: &BackendWorkerSummary) -> String {
|
|
||||||
format!(
|
|
||||||
"{}:{}",
|
|
||||||
short_text(&worker.runtime_id),
|
|
||||||
short_text(&worker.worker_id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn short_text(text: &str) -> String {
|
fn short_text(text: &str) -> String {
|
||||||
const MAX: usize = 24;
|
const MAX: usize = 24;
|
||||||
let mut chars = text.chars();
|
let mut chars = text.chars();
|
||||||
@@ -335,6 +359,10 @@ fn short_text(text: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn short_worker_id(worker: &BackendWorkerSummary) -> String {
|
||||||
|
worker.resource_key.clone()
|
||||||
|
}
|
||||||
|
|
||||||
fn working_directory_text(worker: &BackendWorkerSummary) -> String {
|
fn working_directory_text(worker: &BackendWorkerSummary) -> String {
|
||||||
let Some(wd) = worker.working_directory.as_ref() else {
|
let Some(wd) = worker.working_directory.as_ref() else {
|
||||||
return "wd:—".to_string();
|
return "wd:—".to_string();
|
||||||
@@ -358,6 +386,7 @@ mod tests {
|
|||||||
BackendWorkerSummary {
|
BackendWorkerSummary {
|
||||||
runtime_id: runtime_id.to_string(),
|
runtime_id: runtime_id.to_string(),
|
||||||
worker_id: worker_id.to_string(),
|
worker_id: worker_id.to_string(),
|
||||||
|
resource_key: "W-1".to_string(),
|
||||||
host_id: "host".to_string(),
|
host_id: "host".to_string(),
|
||||||
label: "label".to_string(),
|
label: "label".to_string(),
|
||||||
display_name: "label".to_string(),
|
display_name: "label".to_string(),
|
||||||
@@ -393,7 +422,7 @@ mod tests {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|span| span.content)
|
.map(|span| span.content)
|
||||||
.collect::<String>();
|
.collect::<String>();
|
||||||
assert!(text.starts_with("▶ runtime-a:worker-b"));
|
assert!(text.starts_with("▶ W-1"));
|
||||||
assert!(text.contains("[running]"));
|
assert!(text.contains("[running]"));
|
||||||
assert!(text.contains("profile:default"));
|
assert!(text.contains("profile:default"));
|
||||||
assert!(text.contains("wd:—"));
|
assert!(text.contains("wd:—"));
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
use client::{
|
||||||
|
BackendWorkspace, BackendWorkspaceCatalogTarget, CreateBackendWorkspaceRepository,
|
||||||
|
CreateBackendWorkspaceRequest, create_backend_workspace, list_backend_workspaces,
|
||||||
|
};
|
||||||
|
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
||||||
|
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
|
||||||
|
use ratatui::Terminal;
|
||||||
|
use ratatui::backend::CrosstermBackend;
|
||||||
|
use ratatui::layout::{Constraint, Direction, Layout};
|
||||||
|
use ratatui::style::{Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
|
||||||
|
use std::error::Error;
|
||||||
|
use std::io::{self, IsTerminal, Write};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
type PickerResult<T> = Result<T, Box<dyn Error>>;
|
||||||
|
|
||||||
|
pub(crate) async fn select_backend_workspace(base_url: &str) -> PickerResult<Option<String>> {
|
||||||
|
let target = BackendWorkspaceCatalogTarget::new(base_url);
|
||||||
|
let mut workspaces = Vec::new();
|
||||||
|
|
||||||
|
'catalog: loop {
|
||||||
|
let error = match list_backend_workspaces(&target).await {
|
||||||
|
Ok(items) => {
|
||||||
|
workspaces = items;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(fetch_error) => Some(format!("failed to refresh workspaces: {fetch_error}")),
|
||||||
|
};
|
||||||
|
|
||||||
|
match pick_workspace(&workspaces, error.as_deref())? {
|
||||||
|
WorkspacePickerAction::Select(index) => {
|
||||||
|
return Ok(workspaces.get(index).map(|item| item.workspace_id.clone()));
|
||||||
|
}
|
||||||
|
WorkspacePickerAction::Refresh => continue,
|
||||||
|
WorkspacePickerAction::Create => {
|
||||||
|
let Some(request) = prompt_create_request()? else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
loop {
|
||||||
|
match create_backend_workspace(&target, &request).await {
|
||||||
|
Ok(response) => return Ok(Some(response.workspace.workspace_id)),
|
||||||
|
Err(create_error) => {
|
||||||
|
let creation_error =
|
||||||
|
format!("workspace creation failed: {create_error}");
|
||||||
|
match pick_workspace(&workspaces, Some(&creation_error))? {
|
||||||
|
WorkspacePickerAction::Select(index) => {
|
||||||
|
return Ok(workspaces
|
||||||
|
.get(index)
|
||||||
|
.map(|item| item.workspace_id.clone()));
|
||||||
|
}
|
||||||
|
// Retry the exact request and operation key.
|
||||||
|
WorkspacePickerAction::Create => continue,
|
||||||
|
WorkspacePickerAction::Refresh => continue 'catalog,
|
||||||
|
WorkspacePickerAction::Cancel => return Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WorkspacePickerAction::Cancel => return Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum WorkspacePickerAction {
|
||||||
|
Select(usize),
|
||||||
|
Create,
|
||||||
|
Refresh,
|
||||||
|
Cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pick_workspace(
|
||||||
|
workspaces: &[BackendWorkspace],
|
||||||
|
error: Option<&str>,
|
||||||
|
) -> PickerResult<WorkspacePickerAction> {
|
||||||
|
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
|
||||||
|
return Err(
|
||||||
|
"Backend target has no configured workspace; an interactive terminal is required to choose one"
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
|
||||||
|
let mut selected = 0usize;
|
||||||
|
loop {
|
||||||
|
terminal.draw(|frame| {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(3),
|
||||||
|
Constraint::Length(if error.is_some() { 3 } else { 1 }),
|
||||||
|
])
|
||||||
|
.split(frame.area());
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new("Choose the Workspace for this Backend session")
|
||||||
|
.block(Block::default().title("Workspace").borders(Borders::ALL)),
|
||||||
|
chunks[0],
|
||||||
|
);
|
||||||
|
let rows = workspaces
|
||||||
|
.iter()
|
||||||
|
.map(|workspace| {
|
||||||
|
ListItem::new(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
workspace.display_name.clone(),
|
||||||
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw(format!(" {} {}", workspace.workspace_id, workspace.state)),
|
||||||
|
]))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let rows = if rows.is_empty() {
|
||||||
|
vec![ListItem::new("No accessible Workspaces")]
|
||||||
|
} else {
|
||||||
|
rows
|
||||||
|
};
|
||||||
|
let mut state = ListState::default();
|
||||||
|
if !workspaces.is_empty() {
|
||||||
|
state.select(Some(selected));
|
||||||
|
}
|
||||||
|
frame.render_stateful_widget(
|
||||||
|
List::new(rows)
|
||||||
|
.block(Block::default().borders(Borders::ALL))
|
||||||
|
.highlight_symbol("▶ "),
|
||||||
|
chunks[1],
|
||||||
|
&mut state,
|
||||||
|
);
|
||||||
|
let footer = error
|
||||||
|
.map(|message| {
|
||||||
|
format!(
|
||||||
|
"{message} [n] create/retry [r] refresh [Enter] select [Esc] cancel"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
"[Enter] select [n] new [r] refresh [Esc] cancel".to_string()
|
||||||
|
});
|
||||||
|
frame.render_widget(Paragraph::new(footer), chunks[2]);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let Event::Key(key) = event::read()?
|
||||||
|
&& key.kind == KeyEventKind::Press
|
||||||
|
{
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Up if !workspaces.is_empty() => {
|
||||||
|
selected = selected.saturating_sub(1);
|
||||||
|
}
|
||||||
|
KeyCode::Down if !workspaces.is_empty() => {
|
||||||
|
selected = (selected + 1).min(workspaces.len() - 1);
|
||||||
|
}
|
||||||
|
KeyCode::Enter if !workspaces.is_empty() => {
|
||||||
|
terminal.clear()?;
|
||||||
|
return Ok(WorkspacePickerAction::Select(selected));
|
||||||
|
}
|
||||||
|
KeyCode::Char('n') => {
|
||||||
|
terminal.clear()?;
|
||||||
|
return Ok(WorkspacePickerAction::Create);
|
||||||
|
}
|
||||||
|
KeyCode::Char('r') => {
|
||||||
|
terminal.clear()?;
|
||||||
|
return Ok(WorkspacePickerAction::Refresh);
|
||||||
|
}
|
||||||
|
KeyCode::Esc | KeyCode::Char('q') => {
|
||||||
|
terminal.clear()?;
|
||||||
|
return Ok(WorkspacePickerAction::Cancel);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_create_request() -> PickerResult<Option<CreateBackendWorkspaceRequest>> {
|
||||||
|
disable_raw_mode()?;
|
||||||
|
let result = prompt_create_request_inner();
|
||||||
|
enable_raw_mode()?;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_create_request_inner() -> PickerResult<Option<CreateBackendWorkspaceRequest>> {
|
||||||
|
println!("Create Workspace (leave display name empty to cancel)");
|
||||||
|
let display_name = prompt_line("Workspace display name: ")?;
|
||||||
|
if display_name.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let uri = prompt_line("Initial repository absolute path/URI: ")?;
|
||||||
|
if uri.is_empty() {
|
||||||
|
println!("Repository path/URI is required.");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let repository_name = prompt_line("Repository display name [Main]: ")?;
|
||||||
|
let default_ref = prompt_line("Default ref [repository default]: ")?;
|
||||||
|
let operation_key = format!(
|
||||||
|
"tui-workspace-create-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_nanos()
|
||||||
|
);
|
||||||
|
Ok(Some(CreateBackendWorkspaceRequest {
|
||||||
|
operation_key,
|
||||||
|
display_name,
|
||||||
|
repository: CreateBackendWorkspaceRepository {
|
||||||
|
uri,
|
||||||
|
display_name: Some(if repository_name.is_empty() {
|
||||||
|
"Main".to_string()
|
||||||
|
} else {
|
||||||
|
repository_name
|
||||||
|
}),
|
||||||
|
default_ref: (!default_ref.is_empty()).then_some(default_ref),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_line(prompt: &str) -> PickerResult<String> {
|
||||||
|
print!("{prompt}");
|
||||||
|
io::stdout().flush()?;
|
||||||
|
let mut value = String::new();
|
||||||
|
io::stdin().read_line(&mut value)?;
|
||||||
|
Ok(value.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn picker_actions_distinguish_switch_refresh_create_and_cancel() {
|
||||||
|
assert_ne!(
|
||||||
|
WorkspacePickerAction::Create,
|
||||||
|
WorkspacePickerAction::Refresh
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
WorkspacePickerAction::Select(0),
|
||||||
|
WorkspacePickerAction::Cancel
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,11 @@ pub enum Block {
|
|||||||
SystemMessage {
|
SystemMessage {
|
||||||
text: String,
|
text: String,
|
||||||
},
|
},
|
||||||
|
/// Typed task reminder. Its presentation depends on the selected history
|
||||||
|
/// mode rather than exposing the full reminder in compact views.
|
||||||
|
TaskReminder {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
/// Echo of `Method::Notify` received by this Worker, surfaced as a log
|
/// Echo of `Method::Notify` received by this Worker, surfaced as a log
|
||||||
/// element so subscribers see the external input that drove any
|
/// element so subscribers see the external input that drove any
|
||||||
/// following auto-kicked turn.
|
/// following auto-kicked turn.
|
||||||
|
|||||||
+128
-25
@@ -96,12 +96,12 @@ fn copy_to_terminal_clipboard<W: io::Write>(out: &mut W, text: &str) -> io::Resu
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn copy_selection_to_writer<W: io::Write>(app: &mut App, out: &mut W) -> bool {
|
fn copy_selection_to_writer<W: io::Write>(app: &mut App, out: &mut W) -> bool {
|
||||||
let Some(text) = app.text_selection.copy_text() else {
|
let Some(text) = app.selected_worker_view_mut().text_selection.copy_text() else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = copy_to_terminal_clipboard(out, &text);
|
let result = copy_to_terminal_clipboard(out, &text);
|
||||||
app.text_selection.clear();
|
app.selected_worker_view_mut().text_selection.clear();
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
app.flash_actionbar_notice(
|
app.flash_actionbar_notice(
|
||||||
@@ -890,25 +890,27 @@ const WHEEL_LINES: usize = 3;
|
|||||||
const PANE_SCROLL_LINES: usize = 5;
|
const PANE_SCROLL_LINES: usize = 5;
|
||||||
|
|
||||||
fn handle_mouse(app: &mut App, mouse: MouseEvent) {
|
fn handle_mouse(app: &mut App, mouse: MouseEvent) {
|
||||||
|
let rewind_picker_open = app.rewind_picker.is_some();
|
||||||
|
let view = app.selected_worker_view_mut();
|
||||||
match mouse.kind {
|
match mouse.kind {
|
||||||
MouseEventKind::ScrollUp => {
|
MouseEventKind::ScrollUp => {
|
||||||
app.text_selection.clear();
|
view.text_selection.clear();
|
||||||
app.scroll.scroll_up(WHEEL_LINES);
|
view.scroll.scroll_up(WHEEL_LINES);
|
||||||
}
|
}
|
||||||
MouseEventKind::ScrollDown => {
|
MouseEventKind::ScrollDown => {
|
||||||
app.text_selection.clear();
|
view.text_selection.clear();
|
||||||
app.scroll.scroll_down(WHEEL_LINES);
|
view.scroll.scroll_down(WHEEL_LINES);
|
||||||
}
|
}
|
||||||
MouseEventKind::Down(MouseButton::Left) if app.rewind_picker.is_none() => {
|
MouseEventKind::Down(MouseButton::Left) if !rewind_picker_open => {
|
||||||
if !app.text_selection.begin_drag(mouse.column, mouse.row) {
|
if !view.text_selection.begin_drag(mouse.column, mouse.row) {
|
||||||
app.text_selection.clear();
|
view.text_selection.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MouseEventKind::Drag(MouseButton::Left) if app.rewind_picker.is_none() => {
|
MouseEventKind::Drag(MouseButton::Left) if !rewind_picker_open => {
|
||||||
app.text_selection.update_drag(mouse.column, mouse.row);
|
view.text_selection.update_drag(mouse.column, mouse.row);
|
||||||
}
|
}
|
||||||
MouseEventKind::Up(MouseButton::Left) if app.rewind_picker.is_none() => {
|
MouseEventKind::Up(MouseButton::Left) if !rewind_picker_open => {
|
||||||
app.text_selection.finish_drag(mouse.column, mouse.row);
|
view.text_selection.finish_drag(mouse.column, mouse.row);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -942,31 +944,31 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|||||||
// Modifier-key bindings.
|
// Modifier-key bindings.
|
||||||
if let Some(method) = match key.code {
|
if let Some(method) = match key.code {
|
||||||
KeyCode::Up if shift => {
|
KeyCode::Up if shift => {
|
||||||
app.scroll.scroll_up(1);
|
app.selected_worker_view_mut().scroll.scroll_up(1);
|
||||||
Some(None)
|
Some(None)
|
||||||
}
|
}
|
||||||
KeyCode::Down if shift => {
|
KeyCode::Down if shift => {
|
||||||
app.scroll.scroll_down(1);
|
app.selected_worker_view_mut().scroll.scroll_down(1);
|
||||||
Some(None)
|
Some(None)
|
||||||
}
|
}
|
||||||
KeyCode::Home if ctrl => {
|
KeyCode::Home if ctrl => {
|
||||||
app.scroll.to_top();
|
app.selected_worker_view_mut().scroll.to_top();
|
||||||
Some(None)
|
Some(None)
|
||||||
}
|
}
|
||||||
KeyCode::End if ctrl => {
|
KeyCode::End if ctrl => {
|
||||||
app.scroll.to_bottom();
|
app.selected_worker_view_mut().scroll.to_bottom();
|
||||||
Some(None)
|
Some(None)
|
||||||
}
|
}
|
||||||
KeyCode::Char('[') if ctrl => {
|
KeyCode::Char('[') if ctrl => {
|
||||||
app.scroll.jump_prev_turn();
|
app.selected_worker_view_mut().scroll.jump_prev_turn();
|
||||||
Some(None)
|
Some(None)
|
||||||
}
|
}
|
||||||
KeyCode::Char(']') if ctrl => {
|
KeyCode::Char(']') if ctrl => {
|
||||||
app.scroll.jump_next_turn();
|
app.selected_worker_view_mut().scroll.jump_next_turn();
|
||||||
Some(None)
|
Some(None)
|
||||||
}
|
}
|
||||||
KeyCode::Char('o') if ctrl => {
|
KeyCode::Char('o') if ctrl => {
|
||||||
app.mode = app.mode.cycle();
|
app.cycle_mode();
|
||||||
Some(None)
|
Some(None)
|
||||||
}
|
}
|
||||||
KeyCode::Char('t') if ctrl => {
|
KeyCode::Char('t') if ctrl => {
|
||||||
@@ -1047,7 +1049,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|||||||
if app.task_pane_open {
|
if app.task_pane_open {
|
||||||
app.scroll_task_pane_up(PANE_SCROLL_LINES);
|
app.scroll_task_pane_up(PANE_SCROLL_LINES);
|
||||||
} else {
|
} else {
|
||||||
app.scroll.page_up();
|
app.selected_worker_view_mut().scroll.page_up();
|
||||||
}
|
}
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -1055,7 +1057,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|||||||
if app.task_pane_open {
|
if app.task_pane_open {
|
||||||
app.scroll_task_pane_down(PANE_SCROLL_LINES);
|
app.scroll_task_pane_down(PANE_SCROLL_LINES);
|
||||||
} else {
|
} else {
|
||||||
app.scroll.page_down();
|
app.selected_worker_view_mut().scroll.page_down();
|
||||||
}
|
}
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -1130,12 +1132,17 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if key.code == KeyCode::Tab && key.modifiers.is_empty() && app.completion.is_none() {
|
||||||
|
app.cycle_worker_view();
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
if key.modifiers.is_empty() {
|
if key.modifiers.is_empty() {
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Esc if app.text_selection.clear() => return None,
|
KeyCode::Esc if app.selected_worker_view_mut().text_selection.clear() => return None,
|
||||||
KeyCode::Char('y') if app.text_selection.has_selection() => {
|
KeyCode::Char('y') if app.selected_worker_view().text_selection.has_selection() => {
|
||||||
if !copy_selection_to_terminal(app) {
|
if !copy_selection_to_terminal(app) {
|
||||||
app.text_selection.clear();
|
app.selected_worker_view_mut().text_selection.clear();
|
||||||
app.flash_actionbar_notice(
|
app.flash_actionbar_notice(
|
||||||
"Selection contains no copyable text.",
|
"Selection contains no copyable text.",
|
||||||
ActionbarNoticeLevel::Warn,
|
ActionbarNoticeLevel::Warn,
|
||||||
@@ -2170,6 +2177,18 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn command_completion_tab_applies_unambiguous_candidate() {
|
fn command_completion_tab_applies_unambiguous_candidate() {
|
||||||
let mut app = App::new("agent".to_string());
|
let mut app = App::new("agent".to_string());
|
||||||
|
app.handle_worker_event(Event::InternalWorker {
|
||||||
|
worker: protocol::InternalWorkerRef {
|
||||||
|
session_id: "child-session".into(),
|
||||||
|
name: "subworker-hoge".into(),
|
||||||
|
parent_session_id: Some("parent-session".into()),
|
||||||
|
kind: protocol::InternalWorkerKind::SubWorker,
|
||||||
|
},
|
||||||
|
revision: 1,
|
||||||
|
event: Box::new(Event::Status {
|
||||||
|
status: WorkerStatus::Running,
|
||||||
|
}),
|
||||||
|
});
|
||||||
enter_command_mode(&mut app);
|
enter_command_mode(&mut app);
|
||||||
type_keys(&mut app, "no");
|
type_keys(&mut app, "no");
|
||||||
|
|
||||||
@@ -2177,6 +2196,7 @@ mod tests {
|
|||||||
|
|
||||||
assert!(app.is_command_mode());
|
assert!(app.is_command_mode());
|
||||||
assert_eq!(app.command_text(), "noop ");
|
assert_eq!(app.command_text(), "noop ");
|
||||||
|
assert_eq!(app.selected_worker_view().worker_name, "agent");
|
||||||
assert_eq!(input_text(&app), "");
|
assert_eq!(input_text(&app), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2269,6 +2289,89 @@ mod tests {
|
|||||||
assert_eq!(input_text(&app), "");
|
assert_eq!(input_text(&app), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tab_cycles_main_and_subworker_view_without_changing_composer() {
|
||||||
|
let mut app = App::new("agent".to_string());
|
||||||
|
type_keys(&mut app, "hello");
|
||||||
|
app.handle_worker_event(Event::InternalWorker {
|
||||||
|
worker: protocol::InternalWorkerRef {
|
||||||
|
session_id: "child-session".into(),
|
||||||
|
name: "subworker-hoge".into(),
|
||||||
|
parent_session_id: Some("parent-session".into()),
|
||||||
|
kind: protocol::InternalWorkerKind::SubWorker,
|
||||||
|
},
|
||||||
|
revision: 1,
|
||||||
|
event: Box::new(Event::Status {
|
||||||
|
status: WorkerStatus::Running,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(handle_key(&mut app, key(KeyCode::Tab)).is_none());
|
||||||
|
assert_eq!(app.selected_worker_view().worker_name, "subworker-hoge");
|
||||||
|
assert_eq!(input_text(&app), "hello");
|
||||||
|
|
||||||
|
assert!(handle_key(&mut app, key(KeyCode::Tab)).is_none());
|
||||||
|
assert_eq!(app.selected_worker_view().worker_name, "agent");
|
||||||
|
assert_eq!(input_text(&app), "hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subworker_view_does_not_redirect_parent_worker_controls() {
|
||||||
|
let mut app = App::new("agent".to_string());
|
||||||
|
app.set_worker_status(WorkerStatus::Idle);
|
||||||
|
app.handle_worker_event(Event::InternalWorker {
|
||||||
|
worker: protocol::InternalWorkerRef {
|
||||||
|
session_id: "child-session".into(),
|
||||||
|
name: "subworker-hoge".into(),
|
||||||
|
parent_session_id: Some("parent-session".into()),
|
||||||
|
kind: protocol::InternalWorkerKind::SubWorker,
|
||||||
|
},
|
||||||
|
revision: 1,
|
||||||
|
event: Box::new(Event::Status {
|
||||||
|
status: WorkerStatus::Running,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
handle_key(&mut app, key(KeyCode::Tab));
|
||||||
|
assert_eq!(app.selected_worker_view().worker_name, "subworker-hoge");
|
||||||
|
|
||||||
|
let method = handle_key(
|
||||||
|
&mut app,
|
||||||
|
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(matches!(method, Some(Method::Shutdown)));
|
||||||
|
assert_eq!(app.worker_status, WorkerStatus::Idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn active_composer_completion_takes_tab_priority_over_worker_view_cycle() {
|
||||||
|
let mut app = App::new("agent".to_string());
|
||||||
|
app.insert_char('@');
|
||||||
|
app.insert_char('s');
|
||||||
|
let _ = app.refresh_completion();
|
||||||
|
app.completion.as_mut().unwrap().entries = vec![protocol::CompletionEntry {
|
||||||
|
value: "src/main.rs".into(),
|
||||||
|
is_dir: false,
|
||||||
|
}];
|
||||||
|
app.handle_worker_event(Event::InternalWorker {
|
||||||
|
worker: protocol::InternalWorkerRef {
|
||||||
|
session_id: "child-session".into(),
|
||||||
|
name: "subworker-hoge".into(),
|
||||||
|
parent_session_id: Some("parent-session".into()),
|
||||||
|
kind: protocol::InternalWorkerKind::SubWorker,
|
||||||
|
},
|
||||||
|
revision: 1,
|
||||||
|
event: Box::new(Event::Status {
|
||||||
|
status: WorkerStatus::Running,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = handle_key(&mut app, key(KeyCode::Tab));
|
||||||
|
|
||||||
|
assert_eq!(app.selected_worker_view().worker_name, "agent");
|
||||||
|
assert_eq!(input_text(&app), "@src/main.rs");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn command_completion_does_not_affect_normal_composer_without_popup() {
|
fn command_completion_does_not_affect_normal_composer_without_popup() {
|
||||||
let mut app = App::new("agent".to_string());
|
let mut app = App::new("agent".to_string());
|
||||||
|
|||||||
@@ -535,7 +535,12 @@ pub(super) fn ticket_detail_style(row: &PanelRow) -> Style {
|
|||||||
pub(super) fn panel_ticket_reference(row: &PanelRow) -> String {
|
pub(super) fn panel_ticket_reference(row: &PanelRow) -> String {
|
||||||
row.ticket
|
row.ticket
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ticket| ticket.id.clone())
|
.map(|ticket| {
|
||||||
|
ticket
|
||||||
|
.resource_key
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "resource key unavailable".to_string())
|
||||||
|
})
|
||||||
.unwrap_or_else(|| match &row.key {
|
.unwrap_or_else(|| match &row.key {
|
||||||
PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => id.clone(),
|
PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => id.clone(),
|
||||||
PanelRowKey::TicketIntakeWorker { ticket_id, .. } => ticket_id.clone(),
|
PanelRowKey::TicketIntakeWorker { ticket_id, .. } => ticket_id.clone(),
|
||||||
|
|||||||
@@ -1737,6 +1737,13 @@ fn panel_ticket_rows_render_state_title_then_detail_line() {
|
|||||||
let state_start = 2;
|
let state_start = 2;
|
||||||
let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1;
|
let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1;
|
||||||
let row_id = row.ticket.as_ref().unwrap().id.as_str();
|
let row_id = row.ticket.as_ref().unwrap().id.as_str();
|
||||||
|
let resource_key = row
|
||||||
|
.ticket
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.resource_key
|
||||||
|
.as_deref()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(title_line.starts_with("▶ "));
|
assert!(title_line.starts_with("▶ "));
|
||||||
assert!(detail_line.starts_with("│ meta "));
|
assert!(detail_line.starts_with("│ meta "));
|
||||||
@@ -1746,7 +1753,7 @@ fn panel_ticket_rows_render_state_title_then_detail_line() {
|
|||||||
display_column(&title_line, "Workspace Dashboard composer targets"),
|
display_column(&title_line, "Workspace Dashboard composer targets"),
|
||||||
title_start
|
title_start
|
||||||
);
|
);
|
||||||
assert!(detail_line.contains(row_id));
|
assert!(detail_line.contains(resource_key));
|
||||||
assert!(detail_line.contains("Gate: clear"));
|
assert!(detail_line.contains("Gate: clear"));
|
||||||
assert!(detail_line.contains("Action: Wait"));
|
assert!(detail_line.contains("Action: Wait"));
|
||||||
}
|
}
|
||||||
@@ -1769,7 +1776,7 @@ fn panel_ticket_non_selected_rows_align_with_selected_marker_space() {
|
|||||||
let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1;
|
let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1;
|
||||||
|
|
||||||
assert!(title_line.starts_with(" ready"));
|
assert!(title_line.starts_with(" ready"));
|
||||||
assert!(detail_line.starts_with(" meta 00001KTTB479X"));
|
assert!(detail_line.starts_with(" meta T-1"));
|
||||||
assert_eq!(display_column(&title_line, "ready"), state_start);
|
assert_eq!(display_column(&title_line, "ready"), state_start);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
display_column(&title_line, "Long Ticket title"),
|
display_column(&title_line, "Long Ticket title"),
|
||||||
@@ -1797,7 +1804,7 @@ fn panel_ticket_title_truncates_after_state_column() {
|
|||||||
assert_eq!(display_column(&title_line, "Very long Ticket"), title_start);
|
assert_eq!(display_column(&title_line, "Very long Ticket"), title_start);
|
||||||
assert!(title_line.ends_with('…'));
|
assert!(title_line.ends_with('…'));
|
||||||
assert_eq!(detail_line.width(), 42);
|
assert_eq!(detail_line.width(), 42);
|
||||||
assert!(detail_line.starts_with(" meta 00001KTTB479X · Gate: clear"));
|
assert!(detail_line.starts_with(" meta T-1 · Gate: clear"));
|
||||||
assert!(detail_line.ends_with('…'));
|
assert!(detail_line.ends_with('…'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2979,6 +2986,12 @@ fn idle_orchestrator_gets_bounded_attention_for_new_queued_work() {
|
|||||||
.expect("idle orchestrator should receive queued-work attention");
|
.expect("idle orchestrator should receive queued-work attention");
|
||||||
|
|
||||||
assert_eq!(request.worker_name, "test-orchestrator");
|
assert_eq!(request.worker_name, "test-orchestrator");
|
||||||
|
assert!(
|
||||||
|
request
|
||||||
|
.notice
|
||||||
|
.message
|
||||||
|
.starts_with("Workspace Dashboard observed")
|
||||||
|
);
|
||||||
assert!(request.notice.message.contains("00001QUEUE"));
|
assert!(request.notice.message.contains("00001QUEUE"));
|
||||||
assert!(request.notice.message.contains("new_queued"));
|
assert!(request.notice.message.contains("new_queued"));
|
||||||
assert!(request.notice.message.contains("queued -> inprogress"));
|
assert!(request.notice.message.contains("queued -> inprogress"));
|
||||||
@@ -3259,6 +3272,7 @@ fn panel_test_ticket_row(
|
|||||||
) -> PanelRow {
|
) -> PanelRow {
|
||||||
let ticket = crate::workspace_panel::TicketPanelEntry {
|
let ticket = crate::workspace_panel::TicketPanelEntry {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
|
resource_key: Some("T-1".to_string()),
|
||||||
title: title.to_string(),
|
title: title.to_string(),
|
||||||
priority: "P2".to_string(),
|
priority: "P2".to_string(),
|
||||||
workflow_state: TicketWorkflowState::parse(state).unwrap_or(TicketWorkflowState::Planning),
|
workflow_state: TicketWorkflowState::parse(state).unwrap_or(TicketWorkflowState::Planning),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
mod app;
|
mod app;
|
||||||
mod backend_worker_picker;
|
mod backend_worker_picker;
|
||||||
|
mod backend_workspace_picker;
|
||||||
mod block;
|
mod block;
|
||||||
mod cache;
|
mod cache;
|
||||||
mod command;
|
mod command;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
//! `Read`) consume multiple consecutive blocks to produce a single
|
//! `Read`) consume multiple consecutive blocks to produce a single
|
||||||
//! aggregate display.
|
//! aggregate display.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||||
@@ -38,6 +40,9 @@ pub fn render_tool(
|
|||||||
consumed: 1,
|
consumed: 1,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
if mode == Mode::Overview {
|
||||||
|
return render_overview_activity(blocks, start);
|
||||||
|
}
|
||||||
|
|
||||||
match tc.name.as_str() {
|
match tc.name.as_str() {
|
||||||
"Read" => render_read_aggregate(blocks, start, mode),
|
"Read" => render_read_aggregate(blocks, start, mode),
|
||||||
@@ -49,6 +54,154 @@ pub fn render_tool(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
|
struct ActivityCount {
|
||||||
|
total: usize,
|
||||||
|
active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActivityCount {
|
||||||
|
fn add(&mut self, state: &ToolCallState) {
|
||||||
|
self.total += 1;
|
||||||
|
self.active |= matches!(
|
||||||
|
state,
|
||||||
|
ToolCallState::Pending | ToolCallState::Streaming | ToolCallState::Executing
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_overview_activity(blocks: &[Block], start: usize) -> ToolRenderOutput {
|
||||||
|
let mut end = start;
|
||||||
|
let mut tools = Vec::new();
|
||||||
|
while let Some(block) = blocks.get(end) {
|
||||||
|
match block {
|
||||||
|
Block::ToolCall(tool) => tools.push(tool),
|
||||||
|
Block::Thinking(_) | Block::TaskReminder { .. } => {}
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut reads = ActivityCount::default();
|
||||||
|
let mut searches = ActivityCount::default();
|
||||||
|
let mut commands = ActivityCount::default();
|
||||||
|
let mut edits = ActivityCount::default();
|
||||||
|
let mut writes = ActivityCount::default();
|
||||||
|
let mut additions = 0;
|
||||||
|
let mut deletions = 0;
|
||||||
|
let mut failed = 0;
|
||||||
|
let mut incomplete = 0;
|
||||||
|
let mut others = BTreeMap::<String, ActivityCount>::new();
|
||||||
|
|
||||||
|
for tool in tools {
|
||||||
|
if matches!(tool.state, ToolCallState::Error { .. }) {
|
||||||
|
failed += 1;
|
||||||
|
}
|
||||||
|
if matches!(tool.state, ToolCallState::Incomplete) {
|
||||||
|
incomplete += 1;
|
||||||
|
}
|
||||||
|
match tool.name.as_str() {
|
||||||
|
"Read" => reads.add(&tool.state),
|
||||||
|
"Glob" | "Grep" | "WebSearch" | "SearchSessionEntries" => searches.add(&tool.state),
|
||||||
|
"Bash" => commands.add(&tool.state),
|
||||||
|
"Edit" => {
|
||||||
|
edits.add(&tool.state);
|
||||||
|
if matches!(tool.state, ToolCallState::Done { .. })
|
||||||
|
&& let Some(arguments) = tool.arguments.as_deref()
|
||||||
|
&& let Ok(args) = serde_json::from_str::<serde_json::Value>(arguments)
|
||||||
|
{
|
||||||
|
if let Some(old) = args.get("old_string").and_then(|value| value.as_str()) {
|
||||||
|
deletions += old.lines().count().max(1);
|
||||||
|
}
|
||||||
|
if let Some(new) = args.get("new_string").and_then(|value| value.as_str()) {
|
||||||
|
additions += new.lines().count().max(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"Write" => writes.add(&tool.state),
|
||||||
|
name => others.entry(name.to_owned()).or_default().add(&tool.state),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut primary = Vec::new();
|
||||||
|
if reads.total > 0 {
|
||||||
|
primary.push(if reads.active {
|
||||||
|
format!("reading {} file{}", reads.total, plural(reads.total))
|
||||||
|
} else {
|
||||||
|
format!("{} file{} read", reads.total, plural(reads.total))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if searches.total > 0 {
|
||||||
|
primary.push(if searches.active {
|
||||||
|
format!(
|
||||||
|
"searching {} time{}",
|
||||||
|
searches.total,
|
||||||
|
plural(searches.total)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("searched {} time{}", searches.total, plural(searches.total))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if commands.total > 0 {
|
||||||
|
primary.push(if commands.active {
|
||||||
|
format!(
|
||||||
|
"running {} command{}",
|
||||||
|
commands.total,
|
||||||
|
plural(commands.total)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("ran {} command{}", commands.total, plural(commands.total))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (name, count) in others {
|
||||||
|
primary.push(if count.total == 1 {
|
||||||
|
name
|
||||||
|
} else {
|
||||||
|
format!("{} {name}", count.total)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut summary = Vec::new();
|
||||||
|
if !primary.is_empty() {
|
||||||
|
summary.push(primary.join("・"));
|
||||||
|
}
|
||||||
|
if edits.total > 0 {
|
||||||
|
summary.push(if edits.active {
|
||||||
|
format!("editing {} file{}", edits.total, plural(edits.total))
|
||||||
|
} else if additions > 0 || deletions > 0 {
|
||||||
|
format!("edited +{additions}/-{deletions}")
|
||||||
|
} else {
|
||||||
|
format!("edited {} file{}", edits.total, plural(edits.total))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if writes.total > 0 {
|
||||||
|
summary.push(if writes.active {
|
||||||
|
format!("writing {} file{}", writes.total, plural(writes.total))
|
||||||
|
} else {
|
||||||
|
format!("wrote {} file{}", writes.total, plural(writes.total))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if failed > 0 {
|
||||||
|
summary.push(format!("{failed} failed"));
|
||||||
|
}
|
||||||
|
if incomplete > 0 {
|
||||||
|
summary.push(format!("{incomplete} incomplete"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let color = if failed > 0 {
|
||||||
|
Color::Red
|
||||||
|
} else {
|
||||||
|
Color::DarkGray
|
||||||
|
};
|
||||||
|
ToolRenderOutput {
|
||||||
|
lines: summary
|
||||||
|
.into_iter()
|
||||||
|
.map(|text| Line::from(Span::styled(text, Style::default().fg(color))))
|
||||||
|
.collect(),
|
||||||
|
consumed: end.saturating_sub(start).max(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn single(lines: Vec<Line<'static>>) -> ToolRenderOutput {
|
fn single(lines: Vec<Line<'static>>) -> ToolRenderOutput {
|
||||||
ToolRenderOutput { lines, consumed: 1 }
|
ToolRenderOutput { lines, consumed: 1 }
|
||||||
}
|
}
|
||||||
|
|||||||
+318
-39
@@ -27,7 +27,9 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
|||||||
|
|
||||||
use protocol::{AlertLevel, CompletionEntry, Greeting, Segment, WorkerEvent};
|
use protocol::{AlertLevel, CompletionEntry, Greeting, Segment, WorkerEvent};
|
||||||
|
|
||||||
use crate::app::{ActionbarNoticeLevel, App, CompletionState, alert_source_label, fmt_tokens};
|
use crate::app::{
|
||||||
|
ActionbarNoticeLevel, App, CompletionState, WorkerViewTab, alert_source_label, fmt_tokens,
|
||||||
|
};
|
||||||
use crate::block::{Block, CompactEvent, ThinkingBlock, ThinkingState};
|
use crate::block::{Block, CompactEvent, ThinkingBlock, ThinkingState};
|
||||||
use crate::command::CommandCandidate;
|
use crate::command::CommandCandidate;
|
||||||
use crate::task::{TaskCounts, TaskEntry, TaskStatus, TaskStore};
|
use crate::task::{TaskCounts, TaskEntry, TaskStatus, TaskStore};
|
||||||
@@ -52,7 +54,9 @@ pub fn draw(frame: &mut Frame, app: &mut App) {
|
|||||||
app.input
|
app.input
|
||||||
.apply_cursor_viewport(&mut input_render, input_height);
|
.apply_cursor_viewport(&mut input_render, input_height);
|
||||||
}
|
}
|
||||||
let mini_view_h = task_mini_view_height(&app.task_store);
|
let tabs = app.worker_view_tabs();
|
||||||
|
let show_tabs = tabs.len() > 1;
|
||||||
|
let mini_view_h = task_mini_view_height(&app.selected_worker_view().task_store, show_tabs);
|
||||||
// One blank row separates the history tail from the mini-view so
|
// One blank row separates the history tail from the mini-view so
|
||||||
// the latest message doesn't visually crash into the task summary.
|
// the latest message doesn't visually crash into the task summary.
|
||||||
// Folds away with the mini-view when there are no tasks.
|
// Folds away with the mini-view when there are no tasks.
|
||||||
@@ -69,11 +73,26 @@ pub fn draw(frame: &mut Frame, app: &mut App) {
|
|||||||
])
|
])
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
draw_history(frame, app, chunks[0]);
|
let selected_index = app.selected_internal_worker_index();
|
||||||
|
if let Some(index) = selected_index {
|
||||||
|
let task_pane_open = app.task_pane_open;
|
||||||
|
let view = app.internal_workers[index].app.as_mut();
|
||||||
|
view.task_pane_open = task_pane_open;
|
||||||
|
draw_history(frame, view, chunks[0]);
|
||||||
|
} else {
|
||||||
|
draw_history(frame, app, chunks[0]);
|
||||||
|
}
|
||||||
if mini_view_h > 0 {
|
if mini_view_h > 0 {
|
||||||
draw_task_mini_view(frame, &app.task_store, chunks[2]);
|
draw_task_mini_view(
|
||||||
|
frame,
|
||||||
|
&app.selected_worker_view().task_store,
|
||||||
|
&tabs,
|
||||||
|
chunks[2],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
draw_separator(frame, chunks[3]);
|
draw_separator(frame, chunks[3]);
|
||||||
|
// Status/composer/control surfaces remain parent-owned. View selection changes
|
||||||
|
// only transcript/task presentation and never implies SubWorker control.
|
||||||
draw_status(frame, app, chunks[4]);
|
draw_status(frame, app, chunks[4]);
|
||||||
draw_input(frame, app, &input_render, chunks[5]);
|
draw_input(frame, app, &input_render, chunks[5]);
|
||||||
draw_actionbar(frame, app, chunks[6]);
|
draw_actionbar(frame, app, chunks[6]);
|
||||||
@@ -89,19 +108,19 @@ pub fn draw(frame: &mut Frame, app: &mut App) {
|
|||||||
/// the summary.
|
/// the summary.
|
||||||
const MINI_VIEW_MAX_ACTIVE: usize = 3;
|
const MINI_VIEW_MAX_ACTIVE: usize = 3;
|
||||||
|
|
||||||
/// Height the mini-view section occupies. Returns 0 when there are no
|
/// Height the mini-view section occupies. Returns 0 only when there are
|
||||||
/// tasks at all, so the section collapses cleanly into surrounding
|
/// neither tasks nor Worker-view tabs, so SubWorker selection remains
|
||||||
/// layout — there's no point reserving rows for an empty store.
|
/// available even when the selected task store is empty.
|
||||||
fn task_mini_view_height(store: &TaskStore) -> u16 {
|
fn task_mini_view_height(store: &TaskStore, show_tabs: bool) -> u16 {
|
||||||
if store.is_empty() {
|
if store.is_empty() && !show_tabs {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
let active_shown = store.counts().active().min(MINI_VIEW_MAX_ACTIVE);
|
let active_shown = store.counts().active().min(MINI_VIEW_MAX_ACTIVE);
|
||||||
// active rows + 1 summary line
|
// active rows + 1 summary/tab line
|
||||||
(active_shown as u16).saturating_add(1)
|
(active_shown as u16).saturating_add(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_task_mini_view(frame: &mut Frame, store: &TaskStore, area: Rect) {
|
fn draw_task_mini_view(frame: &mut Frame, store: &TaskStore, tabs: &[WorkerViewTab], area: Rect) {
|
||||||
if area.height == 0 || area.width == 0 {
|
if area.height == 0 || area.width == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -123,7 +142,7 @@ fn draw_task_mini_view(frame: &mut Frame, store: &TaskStore, area: Rect) {
|
|||||||
lines.push(mini_view_active_line(entry, inner.width));
|
lines.push(mini_view_active_line(entry, inner.width));
|
||||||
shown += 1;
|
shown += 1;
|
||||||
}
|
}
|
||||||
lines.push(mini_view_summary_line(store.counts(), inner.width));
|
lines.push(mini_view_summary_line(store.counts(), tabs, inner.width));
|
||||||
|
|
||||||
Paragraph::new(lines)
|
Paragraph::new(lines)
|
||||||
.block(outer_block)
|
.block(outer_block)
|
||||||
@@ -146,8 +165,8 @@ fn mini_view_active_line(entry: &TaskEntry, width: u16) -> Line<'static> {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mini_view_summary_line(counts: TaskCounts, width: u16) -> Line<'static> {
|
fn mini_view_summary_line(counts: TaskCounts, tabs: &[WorkerViewTab], width: u16) -> Line<'static> {
|
||||||
let text = format!(
|
let summary = format!(
|
||||||
"{} task(s) — pending: {}, inprogress: {}, completed: {}, deleted: {}",
|
"{} task(s) — pending: {}, inprogress: {}, completed: {}, deleted: {}",
|
||||||
counts.total(),
|
counts.total(),
|
||||||
counts.pending,
|
counts.pending,
|
||||||
@@ -155,8 +174,79 @@ fn mini_view_summary_line(counts: TaskCounts, width: u16) -> Line<'static> {
|
|||||||
counts.completed,
|
counts.completed,
|
||||||
counts.deleted,
|
counts.deleted,
|
||||||
);
|
);
|
||||||
let shown = truncate_with_ellipsis(&text, width as usize);
|
if tabs.len() <= 1 {
|
||||||
Line::from(Span::styled(shown, Style::default().fg(Color::DarkGray)))
|
let shown = truncate_with_ellipsis(&summary, width as usize);
|
||||||
|
return Line::from(Span::styled(shown, Style::default().fg(Color::DarkGray)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let tabs_width = worker_view_tabs_width(tabs);
|
||||||
|
let width = width as usize;
|
||||||
|
if tabs_width >= width {
|
||||||
|
let selected = tabs.iter().find(|tab| tab.selected).unwrap_or(&tabs[0]);
|
||||||
|
if width <= 4 {
|
||||||
|
return Line::from(Span::styled(
|
||||||
|
truncate_with_ellipsis(&selected.label, width),
|
||||||
|
worker_view_selected_tab_style(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let shown = truncate_with_ellipsis(&selected.label, width.saturating_sub(4));
|
||||||
|
let selected_width = UnicodeWidthStr::width(shown.as_str());
|
||||||
|
return Line::from(vec![
|
||||||
|
Span::raw(" ".repeat(width.saturating_sub(selected_width.saturating_add(4)))),
|
||||||
|
Span::styled("[ ", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::styled(shown, worker_view_selected_tab_style()),
|
||||||
|
Span::styled(" ]", Style::default().fg(Color::DarkGray)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let summary_budget = width.saturating_sub(tabs_width + 1);
|
||||||
|
let shown = truncate_with_ellipsis(&summary, summary_budget);
|
||||||
|
let shown_width = UnicodeWidthStr::width(shown.as_str());
|
||||||
|
let padding = width.saturating_sub(shown_width + tabs_width);
|
||||||
|
let mut spans = vec![
|
||||||
|
Span::styled(shown, Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::raw(" ".repeat(padding)),
|
||||||
|
];
|
||||||
|
spans.extend(worker_view_tab_spans(tabs));
|
||||||
|
Line::from(spans)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn worker_view_tabs_text(tabs: &[WorkerViewTab]) -> String {
|
||||||
|
format!(
|
||||||
|
"[ {} ]",
|
||||||
|
tabs.iter()
|
||||||
|
.map(|tab| tab.label.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" | ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn worker_view_tabs_width(tabs: &[WorkerViewTab]) -> usize {
|
||||||
|
UnicodeWidthStr::width(worker_view_tabs_text(tabs).as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn worker_view_selected_tab_style() -> Style {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn worker_view_tab_spans(tabs: &[WorkerViewTab]) -> Vec<Span<'static>> {
|
||||||
|
let dim = Style::default().fg(Color::DarkGray);
|
||||||
|
let selected = worker_view_selected_tab_style();
|
||||||
|
let mut spans = Vec::with_capacity(tabs.len().saturating_mul(2).saturating_add(1));
|
||||||
|
spans.push(Span::styled("[ ", dim));
|
||||||
|
for (index, tab) in tabs.iter().enumerate() {
|
||||||
|
if index > 0 {
|
||||||
|
spans.push(Span::styled(" | ", dim));
|
||||||
|
}
|
||||||
|
spans.push(Span::styled(
|
||||||
|
tab.label.clone(),
|
||||||
|
if tab.selected { selected } else { dim },
|
||||||
|
));
|
||||||
|
}
|
||||||
|
spans.push(Span::styled(" ]", dim));
|
||||||
|
spans
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Two-character status marker + the style to render it with. Mirrors
|
/// Two-character status marker + the style to render it with. Mirrors
|
||||||
@@ -344,6 +434,12 @@ pub fn compute_history(app: &App, width: u16) -> HistoryLayout {
|
|||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while i < app.blocks.len() {
|
while i < app.blocks.len() {
|
||||||
let block = &app.blocks[i];
|
let block = &app.blocks[i];
|
||||||
|
if app.mode == Mode::Overview
|
||||||
|
&& matches!(block, Block::TaskReminder { .. } | Block::Thinking(_))
|
||||||
|
{
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let current_selectable = block_is_selectable_text(block);
|
let current_selectable = block_is_selectable_text(block);
|
||||||
if !first {
|
if !first {
|
||||||
// Preserve a deterministic blank-line separator when copying
|
// Preserve a deterministic blank-line separator when copying
|
||||||
@@ -381,28 +477,6 @@ pub fn compute_history(app: &App, width: u16) -> HistoryLayout {
|
|||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
for internal in &app.internal_workers {
|
|
||||||
logical.push((Line::from(""), false));
|
|
||||||
logical.push((
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled("SubWorker ", Style::default().bold()),
|
|
||||||
Span::raw(internal.worker.name.clone()),
|
|
||||||
Span::styled(
|
|
||||||
format!(" {:?}", internal.app.worker_status),
|
|
||||||
Style::default().fg(Color::DarkGray),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
false,
|
|
||||||
));
|
|
||||||
let child_width = width.saturating_sub(2).max(1);
|
|
||||||
let child_history = compute_history(&internal.app, child_width);
|
|
||||||
logical.extend(child_history.rows.into_iter().map(|row| {
|
|
||||||
let mut spans = vec![Span::raw(" ")];
|
|
||||||
spans.extend(row.line.spans);
|
|
||||||
(Line::from(spans), row.selectable)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: pre-wrap every logical line to char-based terminal rows so
|
// Step 2: pre-wrap every logical line to char-based terminal rows so
|
||||||
// scroll math is exact. Track the logical → wrapped mapping so
|
// scroll math is exact. Track the logical → wrapped mapping so
|
||||||
// turn-start indices get translated into wrapped-row coordinates.
|
// turn-start indices get translated into wrapped-row coordinates.
|
||||||
@@ -885,7 +959,10 @@ fn highlight_line_selection(line: &Line<'static>, start: usize, end: usize) -> L
|
|||||||
fn block_is_selectable_text(block: &Block) -> bool {
|
fn block_is_selectable_text(block: &Block) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
block,
|
block,
|
||||||
Block::UserMessage { .. } | Block::SystemMessage { .. } | Block::AssistantText { .. }
|
Block::UserMessage { .. }
|
||||||
|
| Block::SystemMessage { .. }
|
||||||
|
| Block::TaskReminder { .. }
|
||||||
|
| Block::AssistantText { .. }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -909,6 +986,7 @@ fn render_block_into(lines: &mut Vec<Line<'static>>, block: &Block, width: u16,
|
|||||||
}
|
}
|
||||||
Block::UserMessage { segments } => render_user_message(lines, segments, width, mode),
|
Block::UserMessage { segments } => render_user_message(lines, segments, width, mode),
|
||||||
Block::SystemMessage { text } => render_system_message(lines, text, width, mode),
|
Block::SystemMessage { text } => render_system_message(lines, text, width, mode),
|
||||||
|
Block::TaskReminder { text } => render_task_reminder(lines, text, width, mode),
|
||||||
Block::Notify { message } => {
|
Block::Notify { message } => {
|
||||||
let text = format!("[notify] {message}");
|
let text = format!("[notify] {message}");
|
||||||
match mode {
|
match mode {
|
||||||
@@ -1078,6 +1156,33 @@ fn render_system_message(lines: &mut Vec<Line<'static>>, text: &str, width: u16,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_task_reminder(lines: &mut Vec<Line<'static>>, text: &str, width: u16, mode: Mode) {
|
||||||
|
match mode {
|
||||||
|
Mode::Overview => {}
|
||||||
|
Mode::Normal => {
|
||||||
|
let first = text
|
||||||
|
.lines()
|
||||||
|
.find(|line| !line.trim().is_empty())
|
||||||
|
.unwrap_or("");
|
||||||
|
let summary = format!("task reminder: {first}");
|
||||||
|
push_overview_line(lines, &summary, width, MessageKind::System, "");
|
||||||
|
}
|
||||||
|
Mode::Detail => {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
"task reminder",
|
||||||
|
kind_style(MessageKind::System),
|
||||||
|
)));
|
||||||
|
let body_style = Style::default().fg(Color::DarkGray);
|
||||||
|
for raw in text.lines() {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(" ", body_style),
|
||||||
|
Span::styled(raw.to_owned(), body_style),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn split_system_message(text: &str) -> (&str, &str) {
|
fn split_system_message(text: &str) -> (&str, &str) {
|
||||||
match text.split_once('\n') {
|
match text.split_once('\n') {
|
||||||
Some((header, body)) => (header, body.trim_start_matches('\n')),
|
Some((header, body)) => (header, body.trim_start_matches('\n')),
|
||||||
@@ -1944,9 +2049,97 @@ fn format_worker_event(event: &WorkerEvent) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
|
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
|
||||||
|
use crate::block::{ToolCallBlock, ToolCallState};
|
||||||
use protocol::WorkerStatus;
|
use protocol::WorkerStatus;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn task_summary_right_aligns_worker_tabs_and_highlights_selection() {
|
||||||
|
let tabs = vec![
|
||||||
|
WorkerViewTab {
|
||||||
|
label: "main".into(),
|
||||||
|
selected: false,
|
||||||
|
},
|
||||||
|
WorkerViewTab {
|
||||||
|
label: "subworker-hoge".into(),
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
WorkerViewTab {
|
||||||
|
label: "subworker-fuga".into(),
|
||||||
|
selected: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let line = mini_view_summary_line(TaskCounts::default(), &tabs, 96);
|
||||||
|
let text = line
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.map(|span| span.content.as_ref())
|
||||||
|
.collect::<String>();
|
||||||
|
|
||||||
|
assert_eq!(UnicodeWidthStr::width(text.as_str()), 96);
|
||||||
|
assert!(text.ends_with("[ main | subworker-hoge | subworker-fuga ]"));
|
||||||
|
let selected = line
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.find(|span| span.content == "subworker-hoge")
|
||||||
|
.expect("selected tab span");
|
||||||
|
assert_eq!(selected.style.fg, Some(Color::Cyan));
|
||||||
|
assert!(selected.style.add_modifier.contains(Modifier::BOLD));
|
||||||
|
|
||||||
|
let narrow = mini_view_summary_line(TaskCounts::default(), &tabs, 20);
|
||||||
|
let narrow_text = narrow
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.map(|span| span.content.as_ref())
|
||||||
|
.collect::<String>();
|
||||||
|
assert_eq!(UnicodeWidthStr::width(narrow_text.as_str()), 20);
|
||||||
|
assert!(narrow_text.ends_with("[ subworker-hoge ]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selected_worker_view_history_is_not_appended_to_main_history() {
|
||||||
|
let mut app = App::new("main".into());
|
||||||
|
app.handle_worker_event(protocol::Event::TextDelta {
|
||||||
|
text: "main transcript".into(),
|
||||||
|
});
|
||||||
|
app.handle_worker_event(protocol::Event::InternalWorker {
|
||||||
|
worker: protocol::InternalWorkerRef {
|
||||||
|
session_id: "child-session".into(),
|
||||||
|
name: "subworker-hoge".into(),
|
||||||
|
parent_session_id: Some("parent-session".into()),
|
||||||
|
kind: protocol::InternalWorkerKind::SubWorker,
|
||||||
|
},
|
||||||
|
revision: 1,
|
||||||
|
event: Box::new(protocol::Event::TextDelta {
|
||||||
|
text: "child transcript".into(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
let main = compute_history(&app, 80)
|
||||||
|
.rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| row.line.to_string())
|
||||||
|
.collect::<String>();
|
||||||
|
assert!(main.contains("main transcript"));
|
||||||
|
assert!(!main.contains("child transcript"));
|
||||||
|
|
||||||
|
app.cycle_worker_view();
|
||||||
|
let child = compute_history(app.selected_worker_view(), 80)
|
||||||
|
.rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| row.line.to_string())
|
||||||
|
.collect::<String>();
|
||||||
|
assert!(!child.contains("main transcript"));
|
||||||
|
assert!(child.contains("child transcript"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_tabs_keep_mini_view_visible_without_tasks() {
|
||||||
|
assert_eq!(task_mini_view_height(&TaskStore::new(), false), 0);
|
||||||
|
assert_eq!(task_mini_view_height(&TaskStore::new(), true), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn queue_status_text_includes_count_and_preview() {
|
fn queue_status_text_includes_count_and_preview() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
@@ -2032,6 +2225,92 @@ mod tests {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overview_omits_task_reminders_without_leaving_a_gap() {
|
||||||
|
let mut app = App::new("worker".to_string());
|
||||||
|
app.mode = Mode::Overview;
|
||||||
|
app.blocks = vec![
|
||||||
|
Block::AssistantText {
|
||||||
|
text: "before".to_string(),
|
||||||
|
},
|
||||||
|
Block::TaskReminder {
|
||||||
|
text: "Current session steps are listed below.\nsecond line".to_string(),
|
||||||
|
},
|
||||||
|
Block::AssistantText {
|
||||||
|
text: "after".to_string(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
assert_eq!(row_texts(&app), ["before", "", "after"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normal_renders_task_reminder_as_one_summary_line() {
|
||||||
|
let mut app = App::new("worker".to_string());
|
||||||
|
app.mode = Mode::Normal;
|
||||||
|
app.blocks = vec![Block::TaskReminder {
|
||||||
|
text: "Current session steps are listed below.\nsecond line".to_string(),
|
||||||
|
}];
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
row_texts(&app),
|
||||||
|
["task reminder: Current session steps are listed below."]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn done_tool(id: &str, name: &str, arguments: Option<&str>) -> Block {
|
||||||
|
Block::ToolCall(ToolCallBlock {
|
||||||
|
id: id.to_string(),
|
||||||
|
name: name.to_string(),
|
||||||
|
args_stream: String::new(),
|
||||||
|
arguments: arguments.map(str::to_string),
|
||||||
|
state: ToolCallState::Done {
|
||||||
|
summary: "done".to_string(),
|
||||||
|
output: None,
|
||||||
|
},
|
||||||
|
edit_snapshot: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overview_aggregates_tools_across_hidden_thinking() {
|
||||||
|
let mut app = App::new("worker".to_string());
|
||||||
|
app.mode = Mode::Overview;
|
||||||
|
app.blocks = vec![
|
||||||
|
done_tool("read", "Read", Some(r#"{"file_path":"a.rs"}"#)),
|
||||||
|
finished_thinking("private reasoning"),
|
||||||
|
done_tool("bash", "Bash", Some(r#"{"command":"cargo check"}"#)),
|
||||||
|
done_tool(
|
||||||
|
"edit",
|
||||||
|
"Edit",
|
||||||
|
Some(r#"{"old_string":"old","new_string":"new\nnext"}"#),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
row_texts(&app),
|
||||||
|
["1 file read・ran 1 command", "edited +2/-1"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overview_starts_a_new_activity_after_visible_output() {
|
||||||
|
let mut app = App::new("worker".to_string());
|
||||||
|
app.mode = Mode::Overview;
|
||||||
|
app.blocks = vec![
|
||||||
|
done_tool("read", "Read", None),
|
||||||
|
Block::AssistantText {
|
||||||
|
text: "finding".to_string(),
|
||||||
|
},
|
||||||
|
done_tool("bash", "Bash", None),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
row_texts(&app),
|
||||||
|
["1 file read", "", "finding", "", "ran 1 command"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn finished_thinking(text: &str) -> Block {
|
fn finished_thinking(text: &str) -> Block {
|
||||||
Block::Thinking(ThinkingBlock {
|
Block::Thinking(ThinkingBlock {
|
||||||
text: text.to_string(),
|
text: text.to_string(),
|
||||||
|
|||||||
@@ -253,6 +253,7 @@ impl NextUserAction {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub(crate) struct TicketPanelEntry {
|
pub(crate) struct TicketPanelEntry {
|
||||||
pub(crate) id: String,
|
pub(crate) id: String,
|
||||||
|
pub(crate) resource_key: Option<String>,
|
||||||
pub(crate) title: String,
|
pub(crate) title: String,
|
||||||
pub(crate) priority: String,
|
pub(crate) priority: String,
|
||||||
pub(crate) workflow_state: TicketWorkflowState,
|
pub(crate) workflow_state: TicketWorkflowState,
|
||||||
@@ -1063,6 +1064,7 @@ pub(crate) fn build_current_ticket_row(
|
|||||||
fn ticket_summary_from_meta(meta: &TicketMeta) -> TicketSummary {
|
fn ticket_summary_from_meta(meta: &TicketMeta) -> TicketSummary {
|
||||||
TicketSummary {
|
TicketSummary {
|
||||||
id: meta.id.clone(),
|
id: meta.id.clone(),
|
||||||
|
resource_key: meta.resource_key.clone(),
|
||||||
slug: meta.slug.clone(),
|
slug: meta.slug.clone(),
|
||||||
title: meta.title.clone(),
|
title: meta.title.clone(),
|
||||||
status: meta.status.clone(),
|
status: meta.status.clone(),
|
||||||
@@ -1238,6 +1240,7 @@ fn ticket_row(
|
|||||||
let next_action = projection.next_action.map(next_user_action_from_workspace);
|
let next_action = projection.next_action.map(next_user_action_from_workspace);
|
||||||
let entry = TicketPanelEntry {
|
let entry = TicketPanelEntry {
|
||||||
id: summary.id.clone(),
|
id: summary.id.clone(),
|
||||||
|
resource_key: summary.resource_key.clone(),
|
||||||
title: summary.title.clone(),
|
title: summary.title.clone(),
|
||||||
priority: summary.priority.clone(),
|
priority: summary.priority.clone(),
|
||||||
workflow_state: summary.workflow_state,
|
workflow_state: summary.workflow_state,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
pub use delegation::{
|
pub use delegation::{
|
||||||
AppliedWorkdirDelegation, ReadOnlyWorkdirSession, WorkdirDelegation,
|
AppliedWorkdirDelegation, ReadOnlyWorkdirSession, WorkdirDelegation,
|
||||||
@@ -192,6 +193,19 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
|
|||||||
request: CommandOutputRequest,
|
request: CommandOutputRequest,
|
||||||
) -> Result<CommandOutput, WorkdirError>;
|
) -> Result<CommandOutput, WorkdirError>;
|
||||||
async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError>;
|
async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError>;
|
||||||
|
|
||||||
|
/// Subscribe to bounded provider-owned command telemetry. Implementations
|
||||||
|
/// that do not expose live command observation may keep the default.
|
||||||
|
fn subscribe_command_events(&self) -> Option<broadcast::Receiver<CommandEvent>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the bounded current command state used to recover from a lagged
|
||||||
|
/// provider subscription without replaying command output into history.
|
||||||
|
fn command_snapshot(&self) -> Vec<CommandSnapshot> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
/// Terminal, idempotent release of this Worker-bound operation session.
|
/// Terminal, idempotent release of this Worker-bound operation session.
|
||||||
async fn close(&self) -> Result<(), WorkdirError>;
|
async fn close(&self) -> Result<(), WorkdirError>;
|
||||||
}
|
}
|
||||||
|
|||||||
+633
-55
@@ -14,21 +14,22 @@ use std::io::Write as _;
|
|||||||
use std::io::{Read as _, Seek as _, SeekFrom};
|
use std::io::{Read as _, Seek as _, SeekFrom};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use std::time::Duration;
|
use std::sync::{Arc, Mutex as StdMutex};
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use manifest::{Permission, Scope, ScopeConfig, ScopeRule, SharedScope};
|
use manifest::{Permission, Scope, ScopeConfig, ScopeRule, SharedScope};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
use tokio::sync::{Mutex, Notify};
|
use tokio::sync::{Mutex, Notify, broadcast, watch};
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
|
CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest,
|
||||||
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
|
CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest, EditResult,
|
||||||
ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirDelegationPermission,
|
GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, ReadRequest,
|
||||||
|
ReadResult, StatRequest, StatResult, Workdir, WorkdirDelegationPermission,
|
||||||
WorkdirDelegationRequest, WorkdirError, WorkdirPath, WorkdirSession,
|
WorkdirDelegationRequest, WorkdirError, WorkdirPath, WorkdirSession,
|
||||||
WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirSessionHandle, WriteRequest,
|
WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirSessionHandle, WriteRequest,
|
||||||
WriteResult,
|
WriteResult,
|
||||||
@@ -36,15 +37,172 @@ use crate::{
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use crate::{EntryKind, WriteOutcome};
|
use crate::{EntryKind, WriteOutcome};
|
||||||
|
|
||||||
|
const COMMAND_EVENT_CHANNEL_CAPACITY: usize = 256;
|
||||||
|
const COMMAND_EVENT_CHUNK_BYTES: usize = 8 * 1024;
|
||||||
|
const COMMAND_SNAPSHOT_STREAM_BYTES: usize = 32 * 1024;
|
||||||
|
|
||||||
|
fn command_observed_at_ms() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis()
|
||||||
|
.try_into()
|
||||||
|
.unwrap_or(u64::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum LocalCommand {
|
enum LocalCommand {
|
||||||
Running {
|
Running {
|
||||||
task: JoinHandle<Result<CommandOutput, WorkdirError>>,
|
task: JoinHandle<Result<CommandOutput, WorkdirError>>,
|
||||||
completion: Arc<Notify>,
|
completion: Arc<Notify>,
|
||||||
|
cancel: watch::Sender<bool>,
|
||||||
},
|
},
|
||||||
Completed(CommandOutput),
|
Completed(CommandOutput),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct CommandTelemetry {
|
||||||
|
inner: Arc<CommandTelemetryInner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct CommandTelemetryInner {
|
||||||
|
snapshots: StdMutex<HashMap<String, CommandSnapshot>>,
|
||||||
|
events: broadcast::Sender<CommandEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandTelemetry {
|
||||||
|
fn new() -> Self {
|
||||||
|
let (events, _) = broadcast::channel(COMMAND_EVENT_CHANNEL_CAPACITY);
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(CommandTelemetryInner {
|
||||||
|
snapshots: StdMutex::new(HashMap::new()),
|
||||||
|
events,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscribe(&self) -> broadcast::Receiver<CommandEvent> {
|
||||||
|
self.inner.events.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot(&self) -> Vec<CommandSnapshot> {
|
||||||
|
let mut snapshots = self
|
||||||
|
.inner
|
||||||
|
.snapshots
|
||||||
|
.lock()
|
||||||
|
.expect("command telemetry mutex poisoned")
|
||||||
|
.values()
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
snapshots.sort_by(|left, right| left.command_id.cmp(&right.command_id));
|
||||||
|
snapshots
|
||||||
|
}
|
||||||
|
|
||||||
|
fn started(&self, command_id: &str, tool_call_id: Option<String>) {
|
||||||
|
let observed_at_ms = command_observed_at_ms();
|
||||||
|
self.inner
|
||||||
|
.snapshots
|
||||||
|
.lock()
|
||||||
|
.expect("command telemetry mutex poisoned")
|
||||||
|
.insert(
|
||||||
|
command_id.to_string(),
|
||||||
|
CommandSnapshot {
|
||||||
|
command_id: command_id.to_string(),
|
||||||
|
tool_call_id: tool_call_id.clone(),
|
||||||
|
status: CommandStatus::Running,
|
||||||
|
started_at_ms: observed_at_ms,
|
||||||
|
observed_at_ms,
|
||||||
|
last_output_at_ms: None,
|
||||||
|
stdout: CommandStreamSlice::default(),
|
||||||
|
stderr: CommandStreamSlice::default(),
|
||||||
|
exit_code: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let _ = self.inner.events.send(CommandEvent::Started {
|
||||||
|
command_id: command_id.to_string(),
|
||||||
|
tool_call_id,
|
||||||
|
observed_at_ms,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn output(&self, command_id: &str, stream: CommandStream, start_offset: u64, bytes: &[u8]) {
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let end_offset = start_offset.saturating_add(bytes.len() as u64);
|
||||||
|
let content = String::from_utf8_lossy(bytes).into_owned();
|
||||||
|
let observed_at_ms = command_observed_at_ms();
|
||||||
|
if let Some(snapshot) = self
|
||||||
|
.inner
|
||||||
|
.snapshots
|
||||||
|
.lock()
|
||||||
|
.expect("command telemetry mutex poisoned")
|
||||||
|
.get_mut(command_id)
|
||||||
|
{
|
||||||
|
snapshot.observed_at_ms = observed_at_ms;
|
||||||
|
snapshot.last_output_at_ms = Some(observed_at_ms);
|
||||||
|
let target = match stream {
|
||||||
|
CommandStream::Stdout => &mut snapshot.stdout,
|
||||||
|
CommandStream::Stderr => &mut snapshot.stderr,
|
||||||
|
};
|
||||||
|
target.end_offset = end_offset;
|
||||||
|
target.content.push_str(&content);
|
||||||
|
if target.content.len() > COMMAND_SNAPSHOT_STREAM_BYTES {
|
||||||
|
let mut cut = target.content.len() - COMMAND_SNAPSHOT_STREAM_BYTES;
|
||||||
|
while cut < target.content.len() && !target.content.is_char_boundary(cut) {
|
||||||
|
cut += 1;
|
||||||
|
}
|
||||||
|
target.content.drain(..cut);
|
||||||
|
target.start_offset = end_offset.saturating_sub(target.content.len() as u64);
|
||||||
|
target.truncated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = self.inner.events.send(CommandEvent::Output {
|
||||||
|
command_id: command_id.to_string(),
|
||||||
|
stream,
|
||||||
|
start_offset,
|
||||||
|
end_offset,
|
||||||
|
content,
|
||||||
|
observed_at_ms,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn terminal(&self, command_id: &str, status: CommandStatus, exit_code: Option<i32>) {
|
||||||
|
let observed_at_ms = command_observed_at_ms();
|
||||||
|
let (stdout_end_offset, stderr_end_offset) = if let Some(snapshot) = self
|
||||||
|
.inner
|
||||||
|
.snapshots
|
||||||
|
.lock()
|
||||||
|
.expect("command telemetry mutex poisoned")
|
||||||
|
.get_mut(command_id)
|
||||||
|
{
|
||||||
|
snapshot.status = status;
|
||||||
|
snapshot.exit_code = exit_code;
|
||||||
|
snapshot.observed_at_ms = observed_at_ms;
|
||||||
|
(snapshot.stdout.end_offset, snapshot.stderr.end_offset)
|
||||||
|
} else {
|
||||||
|
(0, 0)
|
||||||
|
};
|
||||||
|
let _ = self.inner.events.send(CommandEvent::Terminal {
|
||||||
|
command_id: command_id.to_string(),
|
||||||
|
status,
|
||||||
|
exit_code,
|
||||||
|
stdout_end_offset,
|
||||||
|
stderr_end_offset,
|
||||||
|
observed_at_ms,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove(&self, command_id: &str) {
|
||||||
|
self.inner
|
||||||
|
.snapshots
|
||||||
|
.lock()
|
||||||
|
.expect("command telemetry mutex poisoned")
|
||||||
|
.remove(command_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct ScopeAccess(Arc<Scope>);
|
struct ScopeAccess(Arc<Scope>);
|
||||||
|
|
||||||
@@ -69,6 +227,7 @@ struct LocalWorkdirSessionInner {
|
|||||||
close_lock: Mutex<()>,
|
close_lock: Mutex<()>,
|
||||||
next_command_id: AtomicU64,
|
next_command_id: AtomicU64,
|
||||||
commands: Mutex<HashMap<String, LocalCommand>>,
|
commands: Mutex<HashMap<String, LocalCommand>>,
|
||||||
|
command_telemetry: CommandTelemetry,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for LocalWorkdirSessionInner {
|
impl Drop for LocalWorkdirSessionInner {
|
||||||
@@ -171,6 +330,7 @@ impl LocalWorkdirSession {
|
|||||||
close_lock: Mutex::new(()),
|
close_lock: Mutex::new(()),
|
||||||
next_command_id: AtomicU64::new(1),
|
next_command_id: AtomicU64::new(1),
|
||||||
commands: Mutex::new(HashMap::new()),
|
commands: Mutex::new(HashMap::new()),
|
||||||
|
command_telemetry: CommandTelemetry::new(),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -502,23 +662,35 @@ impl WorkdirSession for LocalWorkdirSession {
|
|||||||
|
|
||||||
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
|
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
|
||||||
self.ensure_capability(WorkdirSessionCapability::Command)?;
|
self.ensure_capability(WorkdirSessionCapability::Command)?;
|
||||||
|
self.ensure_open()?;
|
||||||
let id = self.inner.next_command_id.fetch_add(1, Ordering::Relaxed);
|
let id = self.inner.next_command_id.fetch_add(1, Ordering::Relaxed);
|
||||||
let handle = CommandHandle(format!("command-{id}"));
|
let handle = CommandHandle(format!("command-{id}"));
|
||||||
let cwd = self.inner.cwd.clone();
|
let cwd = self.inner.cwd.clone();
|
||||||
let completion = Arc::new(Notify::new());
|
let completion = Arc::new(Notify::new());
|
||||||
let task_completion = Arc::clone(&completion);
|
let task_completion = Arc::clone(&completion);
|
||||||
|
let command_id = handle.0.clone();
|
||||||
|
let telemetry = self.inner.command_telemetry.clone();
|
||||||
|
let (cancel, cancel_rx) = watch::channel(false);
|
||||||
let task = tokio::spawn(async move {
|
let task = tokio::spawn(async move {
|
||||||
let output = run_command(cwd, request).await;
|
let output = run_command(cwd, request, command_id, telemetry, cancel_rx).await;
|
||||||
task_completion.notify_one();
|
task_completion.notify_one();
|
||||||
output
|
output
|
||||||
});
|
});
|
||||||
let mut commands = self.inner.commands.lock().await;
|
let mut commands = self.inner.commands.lock().await;
|
||||||
if let Err(error) = self.ensure_open() {
|
if let Err(error) = self.ensure_open() {
|
||||||
|
let _ = cancel.send(true);
|
||||||
task.abort();
|
task.abort();
|
||||||
completion.notify_one();
|
completion.notify_one();
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
commands.insert(handle.0.clone(), LocalCommand::Running { task, completion });
|
commands.insert(
|
||||||
|
handle.0.clone(),
|
||||||
|
LocalCommand::Running {
|
||||||
|
task,
|
||||||
|
completion,
|
||||||
|
cancel,
|
||||||
|
},
|
||||||
|
);
|
||||||
Ok(handle)
|
Ok(handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,7 +702,13 @@ impl WorkdirSession for LocalWorkdirSession {
|
|||||||
.ok_or_else(|| WorkdirError::UnknownCommand(handle.0.clone()))?;
|
.ok_or_else(|| WorkdirError::UnknownCommand(handle.0.clone()))?;
|
||||||
Ok(match command {
|
Ok(match command {
|
||||||
LocalCommand::Running { task, .. } if !task.is_finished() => CommandStatus::Running,
|
LocalCommand::Running { task, .. } if !task.is_finished() => CommandStatus::Running,
|
||||||
LocalCommand::Running { .. } => CommandStatus::Completed,
|
LocalCommand::Running { .. } => self
|
||||||
|
.inner
|
||||||
|
.command_telemetry
|
||||||
|
.snapshot()
|
||||||
|
.into_iter()
|
||||||
|
.find(|snapshot| snapshot.command_id == handle.0)
|
||||||
|
.map_or(CommandStatus::Completed, |snapshot| snapshot.status),
|
||||||
LocalCommand::Completed(output) => output.status,
|
LocalCommand::Completed(output) => output.status,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -584,36 +762,75 @@ impl WorkdirSession for LocalWorkdirSession {
|
|||||||
if !self.inner.closed.load(Ordering::Acquire) {
|
if !self.inner.closed.load(Ordering::Acquire) {
|
||||||
commands.insert(request.handle.0, LocalCommand::Completed(output));
|
commands.insert(request.handle.0, LocalCommand::Completed(output));
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
self.inner.command_telemetry.remove(&request.handle.0);
|
||||||
}
|
}
|
||||||
Ok(page)
|
Ok(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> {
|
async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> {
|
||||||
self.ensure_capability(WorkdirSessionCapability::Command)?;
|
self.ensure_capability(WorkdirSessionCapability::Command)?;
|
||||||
let command = self
|
let cancel = {
|
||||||
.inner
|
let commands = self.inner.commands.lock().await;
|
||||||
.commands
|
let command = commands
|
||||||
.lock()
|
.get(&handle.0)
|
||||||
.await
|
.ok_or_else(|| WorkdirError::UnknownCommand(handle.0.clone()))?;
|
||||||
.remove(&handle.0)
|
match command {
|
||||||
.ok_or_else(|| WorkdirError::UnknownCommand(handle.0))?;
|
LocalCommand::Running { task, cancel, .. } if !task.is_finished() => {
|
||||||
if let LocalCommand::Running { task, completion } = command {
|
Some(cancel.clone())
|
||||||
task.abort();
|
}
|
||||||
completion.notify_one();
|
_ => None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(cancel) = cancel {
|
||||||
|
let _ = cancel.send(true);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn subscribe_command_events(&self) -> Option<broadcast::Receiver<CommandEvent>> {
|
||||||
|
self.inner
|
||||||
|
.capabilities
|
||||||
|
.supports(WorkdirSessionCapability::Command)
|
||||||
|
.then(|| self.inner.command_telemetry.subscribe())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_snapshot(&self) -> Vec<CommandSnapshot> {
|
||||||
|
if self
|
||||||
|
.inner
|
||||||
|
.capabilities
|
||||||
|
.supports(WorkdirSessionCapability::Command)
|
||||||
|
{
|
||||||
|
self.inner.command_telemetry.snapshot()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn close(&self) -> Result<(), WorkdirError> {
|
async fn close(&self) -> Result<(), WorkdirError> {
|
||||||
let _close_guard = self.inner.close_lock.lock().await;
|
let _close_guard = self.inner.close_lock.lock().await;
|
||||||
if self.inner.closed.swap(true, Ordering::AcqRel) {
|
if self.inner.closed.swap(true, Ordering::AcqRel) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let mut commands = self.inner.commands.lock().await;
|
let commands = {
|
||||||
for (_, command) in commands.drain() {
|
let mut commands = self.inner.commands.lock().await;
|
||||||
if let LocalCommand::Running { task, completion } = command {
|
commands
|
||||||
task.abort();
|
.drain()
|
||||||
completion.notify_one();
|
.map(|(_, command)| command)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
for command in commands {
|
||||||
|
match command {
|
||||||
|
LocalCommand::Running {
|
||||||
|
task,
|
||||||
|
completion,
|
||||||
|
cancel,
|
||||||
|
} => {
|
||||||
|
let _ = cancel.send(true);
|
||||||
|
let _ = task.await;
|
||||||
|
completion.notify_one();
|
||||||
|
}
|
||||||
|
LocalCommand::Completed(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -680,7 +897,13 @@ fn sanitize_error(error: WorkdirError, logical: &WorkdirPath) -> WorkdirError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_command(cwd: PathBuf, request: CommandRequest) -> Result<CommandOutput, WorkdirError> {
|
async fn run_command(
|
||||||
|
cwd: PathBuf,
|
||||||
|
request: CommandRequest,
|
||||||
|
command_id: String,
|
||||||
|
telemetry: CommandTelemetry,
|
||||||
|
mut cancel: watch::Receiver<bool>,
|
||||||
|
) -> Result<CommandOutput, WorkdirError> {
|
||||||
let stdout = tempfile::NamedTempFile::new().map_err(|error| WorkdirError::io(&cwd, error))?;
|
let stdout = tempfile::NamedTempFile::new().map_err(|error| WorkdirError::io(&cwd, error))?;
|
||||||
let stderr = tempfile::NamedTempFile::new().map_err(|error| WorkdirError::io(&cwd, error))?;
|
let stderr = tempfile::NamedTempFile::new().map_err(|error| WorkdirError::io(&cwd, error))?;
|
||||||
let stdout_path = stdout.into_temp_path();
|
let stdout_path = stdout.into_temp_path();
|
||||||
@@ -690,7 +913,8 @@ async fn run_command(cwd: PathBuf, request: CommandRequest) -> Result<CommandOut
|
|||||||
let stderr_file = std::fs::File::create(&stderr_path)
|
let stderr_file = std::fs::File::create(&stderr_path)
|
||||||
.map_err(|error| WorkdirError::io(&stderr_path, error))?;
|
.map_err(|error| WorkdirError::io(&stderr_path, error))?;
|
||||||
|
|
||||||
let mut child = Command::new("bash")
|
telemetry.started(&command_id, request.tool_call_id.clone());
|
||||||
|
let mut child = match Command::new("bash")
|
||||||
.arg("-c")
|
.arg("-c")
|
||||||
.arg(&request.command)
|
.arg(&request.command)
|
||||||
.current_dir(&cwd)
|
.current_dir(&cwd)
|
||||||
@@ -699,45 +923,188 @@ async fn run_command(cwd: PathBuf, request: CommandRequest) -> Result<CommandOut
|
|||||||
.stderr(Stdio::from(stderr_file))
|
.stderr(Stdio::from(stderr_file))
|
||||||
.kill_on_drop(true)
|
.kill_on_drop(true)
|
||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|error| WorkdirError::io(&cwd, error))?;
|
|
||||||
|
|
||||||
let timed_out = match tokio::time::timeout(
|
|
||||||
Duration::from_secs(request.timeout_secs.max(1)),
|
|
||||||
child.wait(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
Ok(result) => {
|
Ok(child) => child,
|
||||||
let status = result.map_err(|error| WorkdirError::io(&cwd, error))?;
|
Err(error) => {
|
||||||
let (content, truncated) =
|
telemetry.terminal(&command_id, CommandStatus::Failed, None);
|
||||||
read_command_output_files(&stdout_path, &stderr_path, request.output_limit.max(1))?;
|
return Err(WorkdirError::io(&cwd, error));
|
||||||
return Ok(CommandOutput {
|
|
||||||
status: CommandStatus::Completed,
|
|
||||||
exit_code: status.code(),
|
|
||||||
timed_out: false,
|
|
||||||
content,
|
|
||||||
next_cursor: None,
|
|
||||||
truncated,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
let _ = child.kill().await;
|
|
||||||
true
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mut stdout_reader =
|
||||||
|
std::fs::File::open(&stdout_path).map_err(|error| WorkdirError::io(&stdout_path, error))?;
|
||||||
|
let mut stderr_reader =
|
||||||
|
std::fs::File::open(&stderr_path).map_err(|error| WorkdirError::io(&stderr_path, error))?;
|
||||||
|
let mut stdout_decoder = CommandOutputDecoder::default();
|
||||||
|
let mut stderr_decoder = CommandOutputDecoder::default();
|
||||||
|
let mut timeout = Box::pin(tokio::time::sleep(Duration::from_secs(
|
||||||
|
request.timeout_secs.max(1),
|
||||||
|
)));
|
||||||
|
let mut interval = tokio::time::interval(Duration::from_millis(50));
|
||||||
|
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
|
interval.tick().await;
|
||||||
|
|
||||||
|
let (status, exit_code) = loop {
|
||||||
|
tokio::select! {
|
||||||
|
exit = child.wait() => {
|
||||||
|
let exit = exit.map_err(|error| WorkdirError::io(&cwd, error))?;
|
||||||
|
break (
|
||||||
|
if exit.success() { CommandStatus::Completed } else { CommandStatus::Failed },
|
||||||
|
exit.code(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ = &mut timeout => {
|
||||||
|
let _ = child.start_kill();
|
||||||
|
let exit_code = child.wait().await.ok().and_then(|status| status.code());
|
||||||
|
break (CommandStatus::TimedOut, exit_code);
|
||||||
|
}
|
||||||
|
changed = cancel.changed() => {
|
||||||
|
if changed.is_err() || *cancel.borrow() {
|
||||||
|
let _ = child.start_kill();
|
||||||
|
let exit_code = child.wait().await.ok().and_then(|status| status.code());
|
||||||
|
break (CommandStatus::Cancelled, exit_code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = interval.tick() => {
|
||||||
|
publish_available_output(
|
||||||
|
&mut stdout_reader,
|
||||||
|
&mut stdout_decoder,
|
||||||
|
&telemetry,
|
||||||
|
&command_id,
|
||||||
|
CommandStream::Stdout,
|
||||||
|
&stdout_path,
|
||||||
|
false,
|
||||||
|
)?;
|
||||||
|
publish_available_output(
|
||||||
|
&mut stderr_reader,
|
||||||
|
&mut stderr_decoder,
|
||||||
|
&telemetry,
|
||||||
|
&command_id,
|
||||||
|
CommandStream::Stderr,
|
||||||
|
&stderr_path,
|
||||||
|
false,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
publish_available_output(
|
||||||
|
&mut stdout_reader,
|
||||||
|
&mut stdout_decoder,
|
||||||
|
&telemetry,
|
||||||
|
&command_id,
|
||||||
|
CommandStream::Stdout,
|
||||||
|
&stdout_path,
|
||||||
|
true,
|
||||||
|
)?;
|
||||||
|
publish_available_output(
|
||||||
|
&mut stderr_reader,
|
||||||
|
&mut stderr_decoder,
|
||||||
|
&telemetry,
|
||||||
|
&command_id,
|
||||||
|
CommandStream::Stderr,
|
||||||
|
&stderr_path,
|
||||||
|
true,
|
||||||
|
)?;
|
||||||
|
telemetry.terminal(&command_id, status, exit_code);
|
||||||
|
|
||||||
let (content, truncated) =
|
let (content, truncated) =
|
||||||
read_command_output_files(&stdout_path, &stderr_path, request.output_limit.max(1))?;
|
read_command_output_files(&stdout_path, &stderr_path, request.output_limit.max(1))?;
|
||||||
Ok(CommandOutput {
|
Ok(CommandOutput {
|
||||||
status: CommandStatus::Failed,
|
status,
|
||||||
exit_code: None,
|
exit_code,
|
||||||
timed_out,
|
timed_out: status == CommandStatus::TimedOut,
|
||||||
content,
|
content,
|
||||||
next_cursor: None,
|
next_cursor: None,
|
||||||
truncated,
|
truncated,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct CommandOutputDecoder {
|
||||||
|
read_offset: u64,
|
||||||
|
emitted_offset: u64,
|
||||||
|
pending: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish_available_output(
|
||||||
|
file: &mut std::fs::File,
|
||||||
|
decoder: &mut CommandOutputDecoder,
|
||||||
|
telemetry: &CommandTelemetry,
|
||||||
|
command_id: &str,
|
||||||
|
stream: CommandStream,
|
||||||
|
path: &Path,
|
||||||
|
flush: bool,
|
||||||
|
) -> Result<(), WorkdirError> {
|
||||||
|
file.seek(SeekFrom::Start(decoder.read_offset))
|
||||||
|
.map_err(|error| WorkdirError::io(path, error))?;
|
||||||
|
loop {
|
||||||
|
let mut buffer = vec![0; COMMAND_EVENT_CHUNK_BYTES];
|
||||||
|
let read = file
|
||||||
|
.read(&mut buffer)
|
||||||
|
.map_err(|error| WorkdirError::io(path, error))?;
|
||||||
|
if read == 0 {
|
||||||
|
publish_decoded_output(decoder, telemetry, command_id, stream, flush);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
decoder.pending.extend_from_slice(&buffer[..read]);
|
||||||
|
decoder.read_offset = decoder.read_offset.saturating_add(read as u64);
|
||||||
|
publish_decoded_output(decoder, telemetry, command_id, stream, false);
|
||||||
|
if read < COMMAND_EVENT_CHUNK_BYTES {
|
||||||
|
if flush {
|
||||||
|
publish_decoded_output(decoder, telemetry, command_id, stream, true);
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish_decoded_output(
|
||||||
|
decoder: &mut CommandOutputDecoder,
|
||||||
|
telemetry: &CommandTelemetry,
|
||||||
|
command_id: &str,
|
||||||
|
stream: CommandStream,
|
||||||
|
flush: bool,
|
||||||
|
) {
|
||||||
|
let prefix_len = if flush {
|
||||||
|
decoder.pending.len()
|
||||||
|
} else {
|
||||||
|
stable_utf8_prefix_len(&decoder.pending)
|
||||||
|
};
|
||||||
|
if prefix_len == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
telemetry.output(
|
||||||
|
command_id,
|
||||||
|
stream,
|
||||||
|
decoder.emitted_offset,
|
||||||
|
&decoder.pending[..prefix_len],
|
||||||
|
);
|
||||||
|
decoder.emitted_offset = decoder.emitted_offset.saturating_add(prefix_len as u64);
|
||||||
|
decoder.pending.drain(..prefix_len);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the byte prefix that can be decoded now without replacing a valid
|
||||||
|
/// UTF-8 scalar whose remaining bytes may arrive in a later file read. Definite
|
||||||
|
/// invalid sequences remain in the prefix and are rendered lossily, preserving
|
||||||
|
/// the existing arbitrary-byte output behavior.
|
||||||
|
fn stable_utf8_prefix_len(bytes: &[u8]) -> usize {
|
||||||
|
let mut inspected = 0;
|
||||||
|
while inspected < bytes.len() {
|
||||||
|
match std::str::from_utf8(&bytes[inspected..]) {
|
||||||
|
Ok(_) => return bytes.len(),
|
||||||
|
Err(error) => {
|
||||||
|
inspected += error.valid_up_to();
|
||||||
|
match error.error_len() {
|
||||||
|
Some(invalid_len) => inspected += invalid_len,
|
||||||
|
None => return inspected,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inspected
|
||||||
|
}
|
||||||
|
|
||||||
fn read_command_output_files(
|
fn read_command_output_files(
|
||||||
stdout_path: &Path,
|
stdout_path: &Path,
|
||||||
stderr_path: &Path,
|
stderr_path: &Path,
|
||||||
@@ -1024,6 +1391,7 @@ mod tests {
|
|||||||
command: "sleep 30".to_owned(),
|
command: "sleep 30".to_owned(),
|
||||||
timeout_secs: 60,
|
timeout_secs: 60,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1549,6 +1917,7 @@ mod tests {
|
|||||||
command: "pwd && printf provider-command".into(),
|
command: "pwd && printf provider-command".into(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 4096,
|
output_limit: 4096,
|
||||||
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1583,6 +1952,7 @@ mod tests {
|
|||||||
command: "printf 'aéz'".into(),
|
command: "printf 'aéz'".into(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1620,6 +1990,213 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_output_decoder_preserves_utf8_split_across_file_reads() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = dir.path().join("command.out");
|
||||||
|
let mut first_write = vec![b'a'; COMMAND_EVENT_CHUNK_BYTES - 1];
|
||||||
|
first_write.push(0xe2);
|
||||||
|
std::fs::write(&path, first_write).unwrap();
|
||||||
|
|
||||||
|
let telemetry = CommandTelemetry::new();
|
||||||
|
let mut events = telemetry.subscribe();
|
||||||
|
telemetry.started("command-utf8", None);
|
||||||
|
let mut decoder = CommandOutputDecoder::default();
|
||||||
|
let mut reader = std::fs::File::open(&path).unwrap();
|
||||||
|
publish_available_output(
|
||||||
|
&mut reader,
|
||||||
|
&mut decoder,
|
||||||
|
&telemetry,
|
||||||
|
"command-utf8",
|
||||||
|
CommandStream::Stdout,
|
||||||
|
&path,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(decoder.pending, vec![0xe2]);
|
||||||
|
|
||||||
|
let mut writer = std::fs::OpenOptions::new()
|
||||||
|
.append(true)
|
||||||
|
.open(&path)
|
||||||
|
.unwrap();
|
||||||
|
writer.write_all(&[0x82, 0xac]).unwrap();
|
||||||
|
writer.flush().unwrap();
|
||||||
|
publish_available_output(
|
||||||
|
&mut reader,
|
||||||
|
&mut decoder,
|
||||||
|
&telemetry,
|
||||||
|
"command-utf8",
|
||||||
|
CommandStream::Stdout,
|
||||||
|
&path,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let output = std::iter::from_fn(|| events.try_recv().ok())
|
||||||
|
.filter_map(|event| match event {
|
||||||
|
CommandEvent::Output {
|
||||||
|
stream: CommandStream::Stdout,
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
} => Some(content),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect::<String>();
|
||||||
|
assert_eq!(output.len(), COMMAND_EVENT_CHUNK_BYTES - 1 + "€".len());
|
||||||
|
assert!(output.ends_with('€'));
|
||||||
|
assert!(!output.contains('\u{fffd}'));
|
||||||
|
assert!(decoder.pending.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn provider_streams_bounded_command_lifecycle_and_distinct_output() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let workdir = make_fs(&dir);
|
||||||
|
let mut events = WorkdirSession::subscribe_command_events(&workdir)
|
||||||
|
.expect("local command observation must be available");
|
||||||
|
let handle = WorkdirSession::start_command(
|
||||||
|
&workdir,
|
||||||
|
CommandRequest {
|
||||||
|
command: "printf ready; printf warning >&2; sleep 0.2; printf done".into(),
|
||||||
|
timeout_secs: 5,
|
||||||
|
output_limit: 1024,
|
||||||
|
tool_call_id: Some("tool-7".into()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut stdout = String::new();
|
||||||
|
let mut stdout_chunks = 0;
|
||||||
|
let mut stderr = String::new();
|
||||||
|
let mut terminal = None;
|
||||||
|
while terminal.is_none() {
|
||||||
|
let event = tokio::time::timeout(Duration::from_secs(2), events.recv())
|
||||||
|
.await
|
||||||
|
.expect("command telemetry should not stall")
|
||||||
|
.unwrap();
|
||||||
|
match event {
|
||||||
|
CommandEvent::Started {
|
||||||
|
command_id,
|
||||||
|
tool_call_id,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert_eq!(command_id, handle.0);
|
||||||
|
assert_eq!(tool_call_id.as_deref(), Some("tool-7"));
|
||||||
|
}
|
||||||
|
CommandEvent::Output {
|
||||||
|
command_id,
|
||||||
|
stream,
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert_eq!(command_id, handle.0);
|
||||||
|
match stream {
|
||||||
|
CommandStream::Stdout => {
|
||||||
|
stdout_chunks += 1;
|
||||||
|
stdout.push_str(&content);
|
||||||
|
}
|
||||||
|
CommandStream::Stderr => stderr.push_str(&content),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CommandEvent::Terminal {
|
||||||
|
command_id,
|
||||||
|
status,
|
||||||
|
exit_code,
|
||||||
|
stdout_end_offset,
|
||||||
|
stderr_end_offset,
|
||||||
|
observed_at_ms,
|
||||||
|
} => {
|
||||||
|
assert_eq!(command_id, handle.0);
|
||||||
|
terminal = Some((
|
||||||
|
status,
|
||||||
|
exit_code,
|
||||||
|
stdout_end_offset,
|
||||||
|
stderr_end_offset,
|
||||||
|
observed_at_ms,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (status, exit_code, stdout_end_offset, stderr_end_offset, observed_at_ms) =
|
||||||
|
terminal.unwrap();
|
||||||
|
assert_eq!(status, CommandStatus::Completed);
|
||||||
|
assert_eq!(exit_code, Some(0));
|
||||||
|
assert_eq!(stdout_end_offset, "readydone".len() as u64);
|
||||||
|
assert_eq!(stderr_end_offset, "warning".len() as u64);
|
||||||
|
assert!(observed_at_ms > 0);
|
||||||
|
assert!(
|
||||||
|
stdout_chunks >= 2,
|
||||||
|
"long-running output should stream incrementally"
|
||||||
|
);
|
||||||
|
assert_eq!(stdout, "readydone");
|
||||||
|
assert_eq!(stderr, "warning");
|
||||||
|
let snapshot = WorkdirSession::command_snapshot(&workdir);
|
||||||
|
assert_eq!(snapshot.len(), 1);
|
||||||
|
assert_eq!(snapshot[0].status, CommandStatus::Completed);
|
||||||
|
assert_eq!(snapshot[0].stdout.content, "readydone");
|
||||||
|
assert_eq!(snapshot[0].stderr.content, "warning");
|
||||||
|
|
||||||
|
let output = WorkdirSession::command_output(
|
||||||
|
&workdir,
|
||||||
|
CommandOutputRequest {
|
||||||
|
handle,
|
||||||
|
cursor: 0,
|
||||||
|
limit: 1024,
|
||||||
|
wait: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(output.status, CommandStatus::Completed);
|
||||||
|
assert!(WorkdirSession::command_snapshot(&workdir).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn provider_distinguishes_timed_out_terminal_state() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let workdir = make_fs(&dir);
|
||||||
|
let mut events = WorkdirSession::subscribe_command_events(&workdir).unwrap();
|
||||||
|
let handle = WorkdirSession::start_command(
|
||||||
|
&workdir,
|
||||||
|
CommandRequest {
|
||||||
|
command: "sleep 30".into(),
|
||||||
|
timeout_secs: 1,
|
||||||
|
output_limit: 1024,
|
||||||
|
tool_call_id: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let output = WorkdirSession::command_output(
|
||||||
|
&workdir,
|
||||||
|
CommandOutputRequest {
|
||||||
|
handle: handle.clone(),
|
||||||
|
cursor: 0,
|
||||||
|
limit: 1024,
|
||||||
|
wait: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(output.status, CommandStatus::TimedOut);
|
||||||
|
assert!(output.timed_out);
|
||||||
|
|
||||||
|
let mut terminal = None;
|
||||||
|
while let Ok(event) = events.try_recv() {
|
||||||
|
if let CommandEvent::Terminal {
|
||||||
|
command_id,
|
||||||
|
status,
|
||||||
|
exit_code,
|
||||||
|
..
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
terminal = Some((command_id, status, exit_code));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(terminal, Some((handle.0, CommandStatus::TimedOut, None)));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn provider_cancels_active_command() {
|
async fn provider_cancels_active_command() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
@@ -1630,6 +2207,7 @@ mod tests {
|
|||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
timeout_secs: 60,
|
timeout_secs: 60,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1658,12 +2236,12 @@ mod tests {
|
|||||||
WorkdirSession::cancel_command(&workdir, handle.clone())
|
WorkdirSession::cancel_command(&workdir, handle.clone())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let waiter_error = tokio::time::timeout(Duration::from_secs(1), waiter)
|
let output = tokio::time::timeout(Duration::from_secs(1), waiter)
|
||||||
.await
|
.await
|
||||||
.expect("cancel should wake command output waiters")
|
.expect("cancel should wake command output waiters")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap_err();
|
.unwrap();
|
||||||
assert!(matches!(waiter_error, WorkdirError::UnknownCommand(_)));
|
assert_eq!(output.status, CommandStatus::Cancelled);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
WorkdirSession::command_status(&workdir, handle).await,
|
WorkdirSession::command_status(&workdir, handle).await,
|
||||||
Err(WorkdirError::UnknownCommand(_))
|
Err(WorkdirError::UnknownCommand(_))
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ pub struct CommandRequest {
|
|||||||
pub command: String,
|
pub command: String,
|
||||||
pub timeout_secs: u64,
|
pub timeout_secs: u64,
|
||||||
pub output_limit: usize,
|
pub output_limit: usize,
|
||||||
|
/// Optional caller-owned correlation id. Bash supplies its tool-call id so
|
||||||
|
/// user-facing command telemetry can update the corresponding Console row
|
||||||
|
/// without exposing provider/session handles.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tool_call_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -24,8 +29,63 @@ pub struct CommandOutputRequest {
|
|||||||
pub enum CommandStatus {
|
pub enum CommandStatus {
|
||||||
Running,
|
Running,
|
||||||
Completed,
|
Completed,
|
||||||
Cancelled,
|
|
||||||
Failed,
|
Failed,
|
||||||
|
TimedOut,
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CommandStream {
|
||||||
|
Stdout,
|
||||||
|
Stderr,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
pub struct CommandStreamSlice {
|
||||||
|
pub start_offset: u64,
|
||||||
|
pub end_offset: u64,
|
||||||
|
pub content: String,
|
||||||
|
pub truncated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct CommandSnapshot {
|
||||||
|
pub command_id: String,
|
||||||
|
pub tool_call_id: Option<String>,
|
||||||
|
pub status: CommandStatus,
|
||||||
|
pub started_at_ms: u64,
|
||||||
|
pub observed_at_ms: u64,
|
||||||
|
pub last_output_at_ms: Option<u64>,
|
||||||
|
pub stdout: CommandStreamSlice,
|
||||||
|
pub stderr: CommandStreamSlice,
|
||||||
|
pub exit_code: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum CommandEvent {
|
||||||
|
Started {
|
||||||
|
command_id: String,
|
||||||
|
tool_call_id: Option<String>,
|
||||||
|
observed_at_ms: u64,
|
||||||
|
},
|
||||||
|
Output {
|
||||||
|
command_id: String,
|
||||||
|
stream: CommandStream,
|
||||||
|
start_offset: u64,
|
||||||
|
end_offset: u64,
|
||||||
|
content: String,
|
||||||
|
observed_at_ms: u64,
|
||||||
|
},
|
||||||
|
Terminal {
|
||||||
|
command_id: String,
|
||||||
|
status: CommandStatus,
|
||||||
|
exit_code: Option<i32>,
|
||||||
|
stdout_end_offset: u64,
|
||||||
|
stderr_end_offset: u64,
|
||||||
|
observed_at_ms: u64,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ pub struct WorkingDirectoryCurrentObservation {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub cleanliness: Option<String>,
|
pub cleanliness: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub primary_worker_id: Option<u64>,
|
pub primary_worker_id: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub occupied_by: Option<WorkingDirectoryOccupancy>,
|
pub occupied_by: Option<WorkingDirectoryOccupancy>,
|
||||||
}
|
}
|
||||||
@@ -151,7 +151,7 @@ pub struct WorkingDirectorySummary {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub cleanliness: Option<String>,
|
pub cleanliness: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub primary_worker_id: Option<u64>,
|
pub primary_worker_id: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub occupied_by: Option<WorkingDirectoryOccupancy>,
|
pub occupied_by: Option<WorkingDirectoryOccupancy>,
|
||||||
}
|
}
|
||||||
@@ -177,7 +177,7 @@ impl WorkingDirectorySummary {
|
|||||||
current_ref: self.current_ref.clone(),
|
current_ref: self.current_ref.clone(),
|
||||||
status: self.status.clone(),
|
status: self.status.clone(),
|
||||||
cleanliness: self.cleanliness.clone(),
|
cleanliness: self.cleanliness.clone(),
|
||||||
primary_worker_id: self.primary_worker_id,
|
primary_worker_id: self.primary_worker_id.clone(),
|
||||||
occupied_by: self.occupied_by.clone(),
|
occupied_by: self.occupied_by.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,10 +148,10 @@ impl std::fmt::Debug for WorkspaceApiRef {
|
|||||||
/// summarized without exposing raw host paths.
|
/// summarized without exposing raw host paths.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct CreateWorkerRequest {
|
pub struct CreateWorkerRequest {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
/// Workspace-owned stable identity reserved before this request reaches a Runtime.
|
||||||
pub idempotency_key: Option<String>,
|
pub worker_id: WorkerId,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
/// Canonical create-intent fingerprint bound to `worker_id` for retry recovery.
|
||||||
pub idempotency_fingerprint: Option<String>,
|
pub create_fingerprint: String,
|
||||||
pub profile: ProfileSelector,
|
pub profile: ProfileSelector,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ use crate::catalog::{CreateWorkerRequest, WorkingDirectoryStatus};
|
|||||||
use crate::config_bundle::ConfigBundle;
|
use crate::config_bundle::ConfigBundle;
|
||||||
use crate::diagnostics::{DiagnosticSeverity, RuntimeDiagnostic};
|
use crate::diagnostics::{DiagnosticSeverity, RuntimeDiagnostic};
|
||||||
use crate::error::RuntimeError;
|
use crate::error::RuntimeError;
|
||||||
use crate::identity::{WorkerId, WorkerRef};
|
use crate::identity::{
|
||||||
|
LegacyWorkerIdentityMapping, WorkerId, WorkerRef, legacy_worker_identity_mapping_digest,
|
||||||
|
};
|
||||||
use crate::management::{RuntimeBackendKind, RuntimeStatus};
|
use crate::management::{RuntimeBackendKind, RuntimeStatus};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
@@ -11,10 +13,11 @@ use std::io::{BufReader, Write};
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
const SCHEMA_VERSION: u32 = 1;
|
const SCHEMA_VERSION: u32 = 3;
|
||||||
const RUNTIME_FILE: &str = "runtime.json";
|
const RUNTIME_FILE: &str = "runtime.json";
|
||||||
const WORKERS_DIR: &str = "workers";
|
const WORKERS_DIR: &str = "workers";
|
||||||
const WORKER_FILE: &str = "worker.json";
|
const WORKER_FILE: &str = "worker.json";
|
||||||
|
const WORKER_METADATA_FILE: &str = "metadata.json";
|
||||||
const LEGACY_OBSERVATIONS_FILE: &str = "observations.jsonl";
|
const LEGACY_OBSERVATIONS_FILE: &str = "observations.jsonl";
|
||||||
|
|
||||||
static NEXT_TMP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
|
static NEXT_TMP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
|
||||||
@@ -24,6 +27,7 @@ static NEXT_TMP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
|
|||||||
pub struct FsRuntimeStoreOptions {
|
pub struct FsRuntimeStoreOptions {
|
||||||
/// Root directory containing this Runtime's store data.
|
/// Root directory containing this Runtime's store data.
|
||||||
pub root: PathBuf,
|
pub root: PathBuf,
|
||||||
|
pub runtime_id: String,
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,14 +35,19 @@ impl FsRuntimeStoreOptions {
|
|||||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
root: root.into(),
|
root: root.into(),
|
||||||
|
runtime_id: "local".to_string(),
|
||||||
display_name: None,
|
display_name: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pub fn with_runtime_id(mut self, runtime_id: impl Into<String>) -> Self {
|
||||||
|
self.runtime_id = runtime_id.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Filesystem persistence boundary for one Worker Runtime state.
|
/// Filesystem persistence boundary for one Worker Runtime state.
|
||||||
///
|
///
|
||||||
/// Authority is Runtime-local typed Worker identity. Legacy pod paths, socket
|
/// Authority is the Workspace-owned typed Worker identity. Legacy pod paths, socket
|
||||||
/// paths, and session paths are deliberately not part of the layout or lookup API.
|
/// paths, and session paths are deliberately not part of the layout or lookup API.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct FsRuntimeStore {
|
pub struct FsRuntimeStore {
|
||||||
@@ -46,6 +55,18 @@ pub struct FsRuntimeStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl FsRuntimeStore {
|
impl FsRuntimeStore {
|
||||||
|
pub fn migration_plan(
|
||||||
|
options: &FsRuntimeStoreOptions,
|
||||||
|
) -> Result<FsRuntimeStoreMigrationPlan, RuntimeError> {
|
||||||
|
plan_runtime_store_migration(&options.root, &options.runtime_id).map(|(plan, _)| plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn migrate(
|
||||||
|
options: &FsRuntimeStoreOptions,
|
||||||
|
) -> Result<FsRuntimeStoreMigrationPlan, RuntimeError> {
|
||||||
|
migrate_runtime_store(&options.root, &options.runtime_id)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn root(&self) -> &Path {
|
pub fn root(&self) -> &Path {
|
||||||
&self.root
|
&self.root
|
||||||
}
|
}
|
||||||
@@ -54,7 +75,10 @@ impl FsRuntimeStore {
|
|||||||
&self.root
|
&self.root
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn open_or_create(root: PathBuf) -> Result<OpenedFsRuntimeStore, RuntimeError> {
|
pub(crate) fn open_or_create(
|
||||||
|
root: PathBuf,
|
||||||
|
runtime_id: &str,
|
||||||
|
) -> Result<OpenedFsRuntimeStore, RuntimeError> {
|
||||||
let existed = root.exists();
|
let existed = root.exists();
|
||||||
if existed && !root.is_dir() {
|
if existed && !root.is_dir() {
|
||||||
return Err(RuntimeError::StoreCorrupt {
|
return Err(RuntimeError::StoreCorrupt {
|
||||||
@@ -82,6 +106,9 @@ impl FsRuntimeStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if existed {
|
||||||
|
migrate_runtime_store(&root, runtime_id)?;
|
||||||
|
}
|
||||||
let store = Self { root };
|
let store = Self { root };
|
||||||
let state = if existed {
|
let state = if existed {
|
||||||
Some(store.load_runtime_state()?)
|
Some(store.load_runtime_state()?)
|
||||||
@@ -241,7 +268,6 @@ pub(crate) struct OpenedFsRuntimeStore {
|
|||||||
pub(crate) struct PersistedRuntimeState {
|
pub(crate) struct PersistedRuntimeState {
|
||||||
pub(crate) display_name: Option<String>,
|
pub(crate) display_name: Option<String>,
|
||||||
pub(crate) status: RuntimeStatus,
|
pub(crate) status: RuntimeStatus,
|
||||||
pub(crate) next_worker_sequence: u64,
|
|
||||||
pub(crate) next_diagnostic_id: u64,
|
pub(crate) next_diagnostic_id: u64,
|
||||||
pub(crate) workers: BTreeMap<WorkerId, PersistedWorkerRecord>,
|
pub(crate) workers: BTreeMap<WorkerId, PersistedWorkerRecord>,
|
||||||
pub(crate) workspace_owners: BTreeMap<String, String>,
|
pub(crate) workspace_owners: BTreeMap<String, String>,
|
||||||
@@ -259,13 +285,877 @@ pub(crate) struct PersistedWorkerRecord {
|
|||||||
pub(crate) working_directory: Option<WorkingDirectoryStatus>,
|
pub(crate) working_directory: Option<WorkingDirectoryStatus>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn runtime_io_error(operation: &'static str, path: &Path, source: std::io::Error) -> RuntimeError {
|
||||||
|
RuntimeError::StoreIo {
|
||||||
|
operation,
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_store_corrupt(path: &Path, message: String) -> RuntimeError {
|
||||||
|
RuntimeError::StoreCorrupt {
|
||||||
|
operation: "migrate Worker identity",
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct FsRuntimeStoreMigrationPlan {
|
||||||
|
pub current_schema_version: u32,
|
||||||
|
pub target_schema_version: u32,
|
||||||
|
pub migration_required: bool,
|
||||||
|
pub worker_count: usize,
|
||||||
|
pub migrated_worker_aggregate_count: usize,
|
||||||
|
pub migrated_diagnostic_worker_ref_count: usize,
|
||||||
|
pub cleared_diagnostic_worker_ref_count: usize,
|
||||||
|
pub mapping_digest: String,
|
||||||
|
pub mappings: Vec<LegacyWorkerIdentityMapping>,
|
||||||
|
pub excluded_ephemeral_paths: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct PlannedRuntimeWorkerMigration {
|
||||||
|
worker_id: WorkerId,
|
||||||
|
source_dir: PathBuf,
|
||||||
|
workspace_id: Option<String>,
|
||||||
|
legacy_mapping: Option<LegacyWorkerIdentityMapping>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plan_runtime_store_migration(
|
||||||
|
root: &Path,
|
||||||
|
runtime_id: &str,
|
||||||
|
) -> Result<
|
||||||
|
(
|
||||||
|
FsRuntimeStoreMigrationPlan,
|
||||||
|
Vec<PlannedRuntimeWorkerMigration>,
|
||||||
|
),
|
||||||
|
RuntimeError,
|
||||||
|
> {
|
||||||
|
let runtime_path = root.join(RUNTIME_FILE);
|
||||||
|
let bytes =
|
||||||
|
fs::read(&runtime_path).map_err(|error| runtime_io_error("read", &runtime_path, error))?;
|
||||||
|
let document: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| {
|
||||||
|
runtime_store_corrupt(
|
||||||
|
&runtime_path,
|
||||||
|
format!("decode Runtime state {}: {error}", runtime_path.display()),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let schema_version = document
|
||||||
|
.get("schema_version")
|
||||||
|
.and_then(serde_json::Value::as_u64)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
runtime_store_corrupt(
|
||||||
|
&runtime_path,
|
||||||
|
"Runtime state is missing schema_version".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let current_schema_version = u32::try_from(schema_version).map_err(|_| {
|
||||||
|
runtime_store_corrupt(
|
||||||
|
&runtime_path,
|
||||||
|
format!("Runtime store schema version {schema_version} is out of range"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let staging = migration_sibling(root, "schema-v3-staging")?;
|
||||||
|
let backup = migration_sibling(root, "pre-schema-v3-backup")?;
|
||||||
|
if staging.exists() || backup.exists() {
|
||||||
|
return Err(runtime_store_corrupt(
|
||||||
|
root,
|
||||||
|
format!(
|
||||||
|
"unfinished Runtime migration artifact exists (staging={}, backup={})",
|
||||||
|
staging.display(),
|
||||||
|
backup.display()
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if current_schema_version == SCHEMA_VERSION {
|
||||||
|
let plan = FsRuntimeStoreMigrationPlan {
|
||||||
|
current_schema_version,
|
||||||
|
target_schema_version: SCHEMA_VERSION,
|
||||||
|
migration_required: false,
|
||||||
|
worker_count: 0,
|
||||||
|
migrated_worker_aggregate_count: 0,
|
||||||
|
migrated_diagnostic_worker_ref_count: 0,
|
||||||
|
cleared_diagnostic_worker_ref_count: 0,
|
||||||
|
mapping_digest: legacy_worker_identity_mapping_digest(&[]),
|
||||||
|
mappings: Vec::new(),
|
||||||
|
excluded_ephemeral_paths: Vec::new(),
|
||||||
|
};
|
||||||
|
return Ok((plan, Vec::new()));
|
||||||
|
}
|
||||||
|
if !matches!(current_schema_version, 1 | 2) {
|
||||||
|
return Err(runtime_store_corrupt(
|
||||||
|
&runtime_path,
|
||||||
|
format!(
|
||||||
|
"unsupported Runtime store schema version {schema_version}; expected 1, 2, or {SCHEMA_VERSION}"
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let excluded_ephemeral_paths = runtime_tree_exclusions(root)?;
|
||||||
|
|
||||||
|
let workers_dir = root.join(WORKERS_DIR);
|
||||||
|
let mut entries = fs::read_dir(&workers_dir)
|
||||||
|
.map_err(|error| runtime_io_error("read workers", &workers_dir, error))?
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|error| runtime_io_error("read workers", &workers_dir, error))?;
|
||||||
|
entries.sort_by_key(|entry| entry.file_name());
|
||||||
|
let mut planned = Vec::with_capacity(entries.len());
|
||||||
|
let mut target_ids = std::collections::BTreeSet::new();
|
||||||
|
for entry in entries {
|
||||||
|
let source_dir = entry.path();
|
||||||
|
if !source_dir.is_dir() {
|
||||||
|
return Err(runtime_store_corrupt(
|
||||||
|
&source_dir,
|
||||||
|
"workers directory contains a non-directory entry".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let name = entry.file_name();
|
||||||
|
let name = name.to_str().ok_or_else(|| {
|
||||||
|
runtime_store_corrupt(&source_dir, "Worker directory is not UTF-8".to_string())
|
||||||
|
})?;
|
||||||
|
let snapshot_path = source_dir.join(WORKER_FILE);
|
||||||
|
let snapshot: serde_json::Value = read_json(&snapshot_path, "read Worker snapshot")?;
|
||||||
|
let (worker_id, workspace_id, legacy_mapping) = if current_schema_version == 1 {
|
||||||
|
let legacy_worker_id = name.parse::<u64>().map_err(|_| {
|
||||||
|
runtime_store_corrupt(
|
||||||
|
&source_dir,
|
||||||
|
format!("legacy Worker directory name must be numeric, found {name}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let workspace_id = snapshot
|
||||||
|
.get("workspace_id")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.filter(|workspace_id| !workspace_id.is_empty())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
runtime_store_corrupt(
|
||||||
|
&snapshot_path,
|
||||||
|
"legacy Worker snapshot is missing workspace_id; unscoped Workers require an explicit migration disposition"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.to_string();
|
||||||
|
let worker_id =
|
||||||
|
WorkerId::from_legacy_binding(&workspace_id, runtime_id, legacy_worker_id);
|
||||||
|
let mapping = LegacyWorkerIdentityMapping {
|
||||||
|
workspace_id: workspace_id.clone(),
|
||||||
|
runtime_id: runtime_id.to_string(),
|
||||||
|
legacy_worker_id,
|
||||||
|
worker_id,
|
||||||
|
};
|
||||||
|
(worker_id, Some(workspace_id), Some(mapping))
|
||||||
|
} else {
|
||||||
|
let worker_id = name.parse::<WorkerId>().map_err(|_| {
|
||||||
|
runtime_store_corrupt(
|
||||||
|
&source_dir,
|
||||||
|
format!("schema-v2 Worker directory name must be a UUIDv7, found {name}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
(worker_id, None, None)
|
||||||
|
};
|
||||||
|
if !target_ids.insert(worker_id) {
|
||||||
|
return Err(runtime_store_corrupt(
|
||||||
|
&snapshot_path,
|
||||||
|
format!("Worker identity maps to duplicate target {worker_id}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let target_dir = workers_dir.join(worker_id.to_string());
|
||||||
|
if target_dir.exists() && target_dir != source_dir {
|
||||||
|
return Err(runtime_store_corrupt(
|
||||||
|
&target_dir,
|
||||||
|
format!("target Worker directory {worker_id} already exists"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
planned.push(PlannedRuntimeWorkerMigration {
|
||||||
|
worker_id,
|
||||||
|
source_dir,
|
||||||
|
workspace_id,
|
||||||
|
legacy_mapping,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mappings = planned
|
||||||
|
.iter()
|
||||||
|
.filter_map(|worker| worker.legacy_mapping.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut migrated_worker_aggregate_count = 0;
|
||||||
|
for worker in &mut planned {
|
||||||
|
let snapshot_path = worker.source_dir.join(WORKER_FILE);
|
||||||
|
let snapshot: serde_json::Value = read_json(&snapshot_path, "read Worker snapshot")?;
|
||||||
|
let migrated = migrate_worker_document(
|
||||||
|
snapshot,
|
||||||
|
current_schema_version,
|
||||||
|
worker.legacy_mapping.as_ref(),
|
||||||
|
&snapshot_path,
|
||||||
|
)?;
|
||||||
|
let snapshot = validate_migrated_worker_document(&migrated, &snapshot_path)?;
|
||||||
|
if snapshot.worker_id != worker.worker_id {
|
||||||
|
return Err(runtime_store_corrupt(
|
||||||
|
&snapshot_path,
|
||||||
|
format!(
|
||||||
|
"Worker snapshot id {} does not match directory identity {}",
|
||||||
|
snapshot.worker_id, worker.worker_id
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
worker.workspace_id = worker
|
||||||
|
.workspace_id
|
||||||
|
.clone()
|
||||||
|
.or(snapshot.workspace_id)
|
||||||
|
.or_else(|| {
|
||||||
|
snapshot
|
||||||
|
.request
|
||||||
|
.workspace_api
|
||||||
|
.map(|workspace_api| workspace_api.workspace_id)
|
||||||
|
});
|
||||||
|
|
||||||
|
let metadata_path = worker.source_dir.join(WORKER_METADATA_FILE);
|
||||||
|
if metadata_path.is_file() {
|
||||||
|
let metadata: serde_json::Value =
|
||||||
|
read_json(&metadata_path, "read Worker aggregate metadata")?;
|
||||||
|
let (_, migrated) =
|
||||||
|
migrate_worker_aggregate_document(metadata, worker, runtime_id, &metadata_path)?;
|
||||||
|
migrated_worker_aggregate_count += usize::from(migrated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (_, diagnostic_refs) =
|
||||||
|
migrate_runtime_document(document, current_schema_version, &mappings, &runtime_path)?;
|
||||||
|
let plan = FsRuntimeStoreMigrationPlan {
|
||||||
|
current_schema_version,
|
||||||
|
target_schema_version: SCHEMA_VERSION,
|
||||||
|
migration_required: true,
|
||||||
|
worker_count: planned.len(),
|
||||||
|
migrated_worker_aggregate_count,
|
||||||
|
migrated_diagnostic_worker_ref_count: diagnostic_refs.migrated,
|
||||||
|
cleared_diagnostic_worker_ref_count: diagnostic_refs.cleared,
|
||||||
|
mapping_digest: legacy_worker_identity_mapping_digest(&mappings),
|
||||||
|
mappings,
|
||||||
|
excluded_ephemeral_paths,
|
||||||
|
};
|
||||||
|
Ok((plan, planned))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
struct DiagnosticWorkerRefMigrationCounts {
|
||||||
|
migrated: usize,
|
||||||
|
cleared: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn migrate_v1_worker_document(
|
||||||
|
mut snapshot: serde_json::Value,
|
||||||
|
mapping: &LegacyWorkerIdentityMapping,
|
||||||
|
snapshot_path: &Path,
|
||||||
|
) -> Result<serde_json::Value, RuntimeError> {
|
||||||
|
let worker_id_text = mapping.worker_id.to_string();
|
||||||
|
let snapshot_object = snapshot.as_object_mut().ok_or_else(|| {
|
||||||
|
runtime_store_corrupt(
|
||||||
|
snapshot_path,
|
||||||
|
"Worker snapshot must be an object".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
snapshot_object.insert(
|
||||||
|
"schema_version".to_string(),
|
||||||
|
serde_json::Value::from(SCHEMA_VERSION),
|
||||||
|
);
|
||||||
|
snapshot_object.insert(
|
||||||
|
"worker_id".to_string(),
|
||||||
|
serde_json::Value::String(worker_id_text.clone()),
|
||||||
|
);
|
||||||
|
snapshot_object
|
||||||
|
.get_mut("worker_ref")
|
||||||
|
.and_then(serde_json::Value::as_object_mut)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
runtime_store_corrupt(
|
||||||
|
snapshot_path,
|
||||||
|
"Worker snapshot worker_ref must be an object".to_string(),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.insert(
|
||||||
|
"worker_id".to_string(),
|
||||||
|
serde_json::Value::String(worker_id_text.clone()),
|
||||||
|
);
|
||||||
|
let request = snapshot_object
|
||||||
|
.get_mut("request")
|
||||||
|
.and_then(serde_json::Value::as_object_mut)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
runtime_store_corrupt(
|
||||||
|
snapshot_path,
|
||||||
|
"Worker snapshot request must be an object".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let fingerprint = request
|
||||||
|
.remove("idempotency_fingerprint")
|
||||||
|
.and_then(|value| value.as_str().map(ToOwned::to_owned))
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"legacy:{}:{}:{}",
|
||||||
|
mapping.workspace_id, mapping.runtime_id, mapping.legacy_worker_id
|
||||||
|
)
|
||||||
|
});
|
||||||
|
request.remove("idempotency_key");
|
||||||
|
request.insert(
|
||||||
|
"worker_id".to_string(),
|
||||||
|
serde_json::Value::String(worker_id_text),
|
||||||
|
);
|
||||||
|
request.insert(
|
||||||
|
"create_fingerprint".to_string(),
|
||||||
|
serde_json::Value::String(fingerprint),
|
||||||
|
);
|
||||||
|
Ok(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn migrate_worker_document(
|
||||||
|
mut document: serde_json::Value,
|
||||||
|
source_schema_version: u32,
|
||||||
|
mapping: Option<&LegacyWorkerIdentityMapping>,
|
||||||
|
snapshot_path: &Path,
|
||||||
|
) -> Result<serde_json::Value, RuntimeError> {
|
||||||
|
if source_schema_version == 1 {
|
||||||
|
return migrate_v1_worker_document(
|
||||||
|
document,
|
||||||
|
mapping.ok_or_else(|| {
|
||||||
|
runtime_store_corrupt(
|
||||||
|
snapshot_path,
|
||||||
|
"schema-v1 Worker migration is missing its identity mapping".to_string(),
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
snapshot_path,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let object = document.as_object_mut().ok_or_else(|| {
|
||||||
|
runtime_store_corrupt(
|
||||||
|
snapshot_path,
|
||||||
|
"Worker snapshot must be an object".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
object.insert(
|
||||||
|
"schema_version".to_string(),
|
||||||
|
serde_json::Value::from(SCHEMA_VERSION),
|
||||||
|
);
|
||||||
|
Ok(document)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_migrated_worker_document(
|
||||||
|
document: &serde_json::Value,
|
||||||
|
snapshot_path: &Path,
|
||||||
|
) -> Result<WorkerSnapshot, RuntimeError> {
|
||||||
|
let snapshot: WorkerSnapshot = serde_json::from_value(document.clone()).map_err(|error| {
|
||||||
|
runtime_store_corrupt(
|
||||||
|
snapshot_path,
|
||||||
|
format!("decode migrated Worker snapshot: {error}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
snapshot.validate(snapshot_path)?;
|
||||||
|
Ok(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_worker_name(worker_id: WorkerId) -> String {
|
||||||
|
format!("worker-runtime-{worker_id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn migrate_worker_aggregate_document(
|
||||||
|
mut document: serde_json::Value,
|
||||||
|
worker: &PlannedRuntimeWorkerMigration,
|
||||||
|
runtime_id: &str,
|
||||||
|
metadata_path: &Path,
|
||||||
|
) -> Result<(serde_json::Value, bool), RuntimeError> {
|
||||||
|
let expected_name = runtime_worker_name(worker.worker_id);
|
||||||
|
let metadata = document.as_object_mut().ok_or_else(|| {
|
||||||
|
runtime_store_corrupt(
|
||||||
|
metadata_path,
|
||||||
|
"Worker aggregate metadata must be an object".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let actual_name = metadata
|
||||||
|
.get("worker_name")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
runtime_store_corrupt(
|
||||||
|
metadata_path,
|
||||||
|
"Worker aggregate metadata is missing worker_name".to_string(),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.to_string();
|
||||||
|
let migrated = actual_name != expected_name;
|
||||||
|
if migrated {
|
||||||
|
let legacy_worker_id = actual_name
|
||||||
|
.strip_prefix("worker-runtime-")
|
||||||
|
.and_then(|worker_id| worker_id.parse::<u64>().ok())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
runtime_store_corrupt(
|
||||||
|
metadata_path,
|
||||||
|
format!(
|
||||||
|
"Worker aggregate identity {actual_name} is neither the expected UUID identity nor a legacy numeric identity"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let workspace_id = worker.workspace_id.as_deref().ok_or_else(|| {
|
||||||
|
runtime_store_corrupt(
|
||||||
|
metadata_path,
|
||||||
|
"legacy Worker aggregate identity has no Workspace binding".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let mapped = WorkerId::from_legacy_binding(workspace_id, runtime_id, legacy_worker_id);
|
||||||
|
if mapped != worker.worker_id {
|
||||||
|
return Err(runtime_store_corrupt(
|
||||||
|
metadata_path,
|
||||||
|
format!(
|
||||||
|
"legacy Worker aggregate identity {actual_name} maps to {mapped}, expected {}",
|
||||||
|
worker.worker_id
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(snapshot) = metadata
|
||||||
|
.get_mut("resolved_manifest_snapshot")
|
||||||
|
.filter(|snapshot| !snapshot.is_null())
|
||||||
|
{
|
||||||
|
let manifest: manifest::WorkerManifest =
|
||||||
|
serde_json::from_value(snapshot.clone()).map_err(|error| {
|
||||||
|
runtime_store_corrupt(
|
||||||
|
metadata_path,
|
||||||
|
format!("decode Worker aggregate resolved manifest snapshot: {error}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if manifest.worker.name != actual_name {
|
||||||
|
return Err(runtime_store_corrupt(
|
||||||
|
metadata_path,
|
||||||
|
format!(
|
||||||
|
"Worker aggregate manifest identity {} does not match metadata identity {actual_name}",
|
||||||
|
manifest.worker.name
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
snapshot
|
||||||
|
.as_object_mut()
|
||||||
|
.and_then(|manifest| manifest.get_mut("worker"))
|
||||||
|
.and_then(serde_json::Value::as_object_mut)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
runtime_store_corrupt(
|
||||||
|
metadata_path,
|
||||||
|
"Worker aggregate resolved manifest is missing worker metadata".to_string(),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.insert(
|
||||||
|
"name".to_string(),
|
||||||
|
serde_json::Value::String(expected_name.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
metadata.insert(
|
||||||
|
"worker_name".to_string(),
|
||||||
|
serde_json::Value::String(expected_name.clone()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let metadata: session_store::WorkerMetadata = serde_json::from_value(document.clone())
|
||||||
|
.map_err(|error| {
|
||||||
|
runtime_store_corrupt(
|
||||||
|
metadata_path,
|
||||||
|
format!("decode migrated Worker aggregate metadata: {error}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if metadata.worker_name != expected_name {
|
||||||
|
return Err(runtime_store_corrupt(
|
||||||
|
metadata_path,
|
||||||
|
"migrated Worker aggregate identity does not match its Worker UUID".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(snapshot) = metadata.resolved_manifest_snapshot {
|
||||||
|
let manifest: manifest::WorkerManifest =
|
||||||
|
serde_json::from_value(snapshot).map_err(|error| {
|
||||||
|
runtime_store_corrupt(
|
||||||
|
metadata_path,
|
||||||
|
format!("decode migrated Worker aggregate resolved manifest: {error}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if manifest.worker.name != expected_name {
|
||||||
|
return Err(runtime_store_corrupt(
|
||||||
|
metadata_path,
|
||||||
|
"migrated Worker aggregate manifest identity does not match its Worker UUID"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((document, migrated))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn migrate_runtime_document(
|
||||||
|
mut document: serde_json::Value,
|
||||||
|
source_schema_version: u32,
|
||||||
|
mappings: &[LegacyWorkerIdentityMapping],
|
||||||
|
runtime_path: &Path,
|
||||||
|
) -> Result<(serde_json::Value, DiagnosticWorkerRefMigrationCounts), RuntimeError> {
|
||||||
|
let mapped_worker_ids = mappings
|
||||||
|
.iter()
|
||||||
|
.map(|mapping| (mapping.legacy_worker_id, mapping.worker_id))
|
||||||
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
let mut counts = DiagnosticWorkerRefMigrationCounts::default();
|
||||||
|
if source_schema_version == 1
|
||||||
|
&& let Some(diagnostics) = document.get_mut("diagnostics")
|
||||||
|
{
|
||||||
|
let diagnostics = diagnostics.as_array_mut().ok_or_else(|| {
|
||||||
|
runtime_store_corrupt(
|
||||||
|
runtime_path,
|
||||||
|
"Runtime snapshot diagnostics must be an array".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
for (index, diagnostic) in diagnostics.iter_mut().enumerate() {
|
||||||
|
let diagnostic = diagnostic.as_object_mut().ok_or_else(|| {
|
||||||
|
runtime_store_corrupt(
|
||||||
|
runtime_path,
|
||||||
|
format!("Runtime diagnostic {index} must be an object"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let Some(worker_ref) = diagnostic.get("worker_ref") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if worker_ref.is_null() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let legacy_worker_id = worker_ref
|
||||||
|
.as_object()
|
||||||
|
.and_then(|worker_ref| worker_ref.get("worker_id"))
|
||||||
|
.and_then(serde_json::Value::as_u64)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
runtime_store_corrupt(
|
||||||
|
runtime_path,
|
||||||
|
format!(
|
||||||
|
"Runtime diagnostic {index} worker_ref.worker_id must be an unsigned legacy Worker id"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if let Some(worker_id) = mapped_worker_ids.get(&legacy_worker_id) {
|
||||||
|
diagnostic
|
||||||
|
.get_mut("worker_ref")
|
||||||
|
.and_then(serde_json::Value::as_object_mut)
|
||||||
|
.expect("validated diagnostic Worker reference")
|
||||||
|
.insert(
|
||||||
|
"worker_id".to_string(),
|
||||||
|
serde_json::Value::String(worker_id.to_string()),
|
||||||
|
);
|
||||||
|
counts.migrated += 1;
|
||||||
|
} else {
|
||||||
|
// The diagnostic remains useful historical evidence, but a deleted
|
||||||
|
// legacy Worker has no Workspace binding from which a stable UUID
|
||||||
|
// can be reconstructed.
|
||||||
|
diagnostic.remove("worker_ref");
|
||||||
|
counts.cleared += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let object = document.as_object_mut().ok_or_else(|| {
|
||||||
|
runtime_store_corrupt(
|
||||||
|
runtime_path,
|
||||||
|
"Runtime snapshot must be an object".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
object.insert(
|
||||||
|
"schema_version".to_string(),
|
||||||
|
serde_json::Value::from(SCHEMA_VERSION),
|
||||||
|
);
|
||||||
|
object.remove("workers");
|
||||||
|
object.remove("next_worker_sequence");
|
||||||
|
let snapshot: RuntimeSnapshot = serde_json::from_value(document.clone()).map_err(|error| {
|
||||||
|
runtime_store_corrupt(
|
||||||
|
runtime_path,
|
||||||
|
format!("decode migrated Runtime snapshot: {error}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
snapshot.validate(runtime_path)?;
|
||||||
|
Ok((document, counts))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn migration_sibling(root: &Path, suffix: &str) -> Result<PathBuf, RuntimeError> {
|
||||||
|
let parent = root.parent().ok_or_else(|| {
|
||||||
|
runtime_store_corrupt(
|
||||||
|
root,
|
||||||
|
"Runtime store root has no parent directory".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let name = root
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
runtime_store_corrupt(root, "Runtime store root name is not UTF-8".to_string())
|
||||||
|
})?;
|
||||||
|
Ok(parent.join(format!(".{name}.{suffix}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_ephemeral_socket(root: &Path, path: &Path) -> Result<bool, RuntimeError> {
|
||||||
|
let relative = path.strip_prefix(root).map_err(|_| {
|
||||||
|
runtime_store_corrupt(
|
||||||
|
path,
|
||||||
|
format!("Runtime migration path escaped root {}", root.display()),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let components = relative
|
||||||
|
.components()
|
||||||
|
.map(|component| component.as_os_str().to_string_lossy().into_owned())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let known_path = components.len() == 5
|
||||||
|
&& components[0] == WORKERS_DIR
|
||||||
|
&& (components[1].parse::<u64>().is_ok() || WorkerId::parse(&components[1]).is_some())
|
||||||
|
&& components[2] == "runs"
|
||||||
|
&& components[3].parse::<u64>().is_ok()
|
||||||
|
&& components[4] == "worker.sock";
|
||||||
|
if !known_path {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
match std::os::unix::net::UnixStream::connect(path) {
|
||||||
|
Ok(_) => Err(runtime_store_corrupt(
|
||||||
|
path,
|
||||||
|
"Runtime migration found an active Worker socket; stop the legacy Runtime and Worker before migrating"
|
||||||
|
.to_string(),
|
||||||
|
)),
|
||||||
|
Err(error)
|
||||||
|
if matches!(
|
||||||
|
error.kind(),
|
||||||
|
std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound
|
||||||
|
) => Ok(true),
|
||||||
|
Err(error) => Err(runtime_store_corrupt(
|
||||||
|
path,
|
||||||
|
format!("Runtime migration could not verify Worker socket liveness: {error}"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_runtime_tree_exclusions(
|
||||||
|
root: &Path,
|
||||||
|
source: &Path,
|
||||||
|
excluded: &mut Vec<String>,
|
||||||
|
) -> Result<(), RuntimeError> {
|
||||||
|
let entries = fs::read_dir(source)
|
||||||
|
.map_err(|error| runtime_io_error("read migration source", source, error))?;
|
||||||
|
for entry in entries {
|
||||||
|
let entry =
|
||||||
|
entry.map_err(|error| runtime_io_error("read migration source", source, error))?;
|
||||||
|
let source_path = entry.path();
|
||||||
|
let file_type = entry
|
||||||
|
.file_type()
|
||||||
|
.map_err(|error| runtime_io_error("inspect migration source", &source_path, error))?;
|
||||||
|
if file_type.is_dir() {
|
||||||
|
collect_runtime_tree_exclusions(root, &source_path, excluded)?;
|
||||||
|
} else if file_type.is_file() {
|
||||||
|
} else if runtime_ephemeral_socket(root, &source_path)? {
|
||||||
|
excluded.push(
|
||||||
|
source_path
|
||||||
|
.strip_prefix(root)
|
||||||
|
.expect("validated Runtime migration path")
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return Err(runtime_store_corrupt(
|
||||||
|
&source_path,
|
||||||
|
"Runtime migration refuses unknown symlinks and special files".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_tree_exclusions(root: &Path) -> Result<Vec<String>, RuntimeError> {
|
||||||
|
let mut excluded = Vec::new();
|
||||||
|
collect_runtime_tree_exclusions(root, root, &mut excluded)?;
|
||||||
|
excluded.sort();
|
||||||
|
Ok(excluded)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_runtime_tree(root: &Path, source: &Path, target: &Path) -> Result<(), RuntimeError> {
|
||||||
|
fs::create_dir(target)
|
||||||
|
.map_err(|error| runtime_io_error("create migration staging", target, error))?;
|
||||||
|
let mut entries = fs::read_dir(source)
|
||||||
|
.map_err(|error| runtime_io_error("read migration source", source, error))?
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|error| runtime_io_error("read migration source", source, error))?;
|
||||||
|
entries.sort_by_key(|entry| entry.file_name());
|
||||||
|
for entry in entries {
|
||||||
|
let source_path = entry.path();
|
||||||
|
let target_path = target.join(entry.file_name());
|
||||||
|
let file_type = entry
|
||||||
|
.file_type()
|
||||||
|
.map_err(|error| runtime_io_error("inspect migration source", &source_path, error))?;
|
||||||
|
if file_type.is_dir() {
|
||||||
|
copy_runtime_tree(root, &source_path, &target_path)?;
|
||||||
|
} else if file_type.is_file() {
|
||||||
|
fs::copy(&source_path, &target_path)
|
||||||
|
.map_err(|error| runtime_io_error("copy migration source", &source_path, error))?;
|
||||||
|
} else if runtime_ephemeral_socket(root, &source_path)? {
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
return Err(runtime_store_corrupt(
|
||||||
|
&source_path,
|
||||||
|
"Runtime migration refuses unknown symlinks and special files".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn migrate_runtime_store(
|
||||||
|
root: &Path,
|
||||||
|
runtime_id: &str,
|
||||||
|
) -> Result<FsRuntimeStoreMigrationPlan, RuntimeError> {
|
||||||
|
let (plan, _) = plan_runtime_store_migration(root, runtime_id)?;
|
||||||
|
if !plan.migration_required {
|
||||||
|
return Ok(plan);
|
||||||
|
}
|
||||||
|
let staging = migration_sibling(root, "schema-v3-staging")?;
|
||||||
|
let backup = migration_sibling(root, "pre-schema-v3-backup")?;
|
||||||
|
if staging.exists() || backup.exists() {
|
||||||
|
return Err(runtime_store_corrupt(
|
||||||
|
root,
|
||||||
|
format!(
|
||||||
|
"unfinished Runtime migration artifact exists (staging={}, backup={}); recover or remove it before retrying",
|
||||||
|
staging.display(),
|
||||||
|
backup.display()
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Err(error) = copy_runtime_tree(root, root, &staging) {
|
||||||
|
let _ = fs::remove_dir_all(&staging);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
let staged_plan = match migrate_runtime_store_in_place(&staging, runtime_id) {
|
||||||
|
Ok(plan) => plan,
|
||||||
|
Err(error) => {
|
||||||
|
let _ = fs::remove_dir_all(&staging);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let staged_store = FsRuntimeStore {
|
||||||
|
root: staging.clone(),
|
||||||
|
};
|
||||||
|
if let Err(error) = staged_store.load_runtime_state() {
|
||||||
|
let _ = fs::remove_dir_all(&staging);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
fs::rename(root, &backup)
|
||||||
|
.map_err(|error| runtime_io_error("backup runtime store", root, error))?;
|
||||||
|
if let Err(error) = fs::rename(&staging, root) {
|
||||||
|
let rollback = fs::rename(&backup, root);
|
||||||
|
return match rollback {
|
||||||
|
Ok(()) => Err(runtime_io_error(
|
||||||
|
"activate migrated runtime store",
|
||||||
|
&staging,
|
||||||
|
error,
|
||||||
|
)),
|
||||||
|
Err(rollback_error) => Err(runtime_store_corrupt(
|
||||||
|
root,
|
||||||
|
format!(
|
||||||
|
"activate migrated Runtime store failed: {error}; rollback failed: {rollback_error}; backup remains at {}",
|
||||||
|
backup.display()
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
fs::remove_dir_all(&backup)
|
||||||
|
.map_err(|error| runtime_io_error("remove runtime migration backup", &backup, error))?;
|
||||||
|
debug_assert_eq!(plan.mapping_digest, staged_plan.mapping_digest);
|
||||||
|
Ok(plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn migrate_runtime_store_in_place(
|
||||||
|
root: &Path,
|
||||||
|
runtime_id: &str,
|
||||||
|
) -> Result<FsRuntimeStoreMigrationPlan, RuntimeError> {
|
||||||
|
let (plan, planned_workers) = plan_runtime_store_migration(root, runtime_id)?;
|
||||||
|
if !plan.migration_required {
|
||||||
|
return Ok(plan);
|
||||||
|
}
|
||||||
|
let runtime_path = root.join(RUNTIME_FILE);
|
||||||
|
let bytes =
|
||||||
|
fs::read(&runtime_path).map_err(|error| runtime_io_error("read", &runtime_path, error))?;
|
||||||
|
let document: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| {
|
||||||
|
runtime_store_corrupt(
|
||||||
|
&runtime_path,
|
||||||
|
format!("decode Runtime state {}: {error}", runtime_path.display()),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
for planned_worker in &planned_workers {
|
||||||
|
let source_dir = &planned_worker.source_dir;
|
||||||
|
let source_snapshot_path = source_dir.join(WORKER_FILE);
|
||||||
|
let bytes = fs::read(&source_snapshot_path)
|
||||||
|
.map_err(|error| runtime_io_error("read", &source_snapshot_path, error))?;
|
||||||
|
let snapshot: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| {
|
||||||
|
runtime_store_corrupt(
|
||||||
|
&source_snapshot_path,
|
||||||
|
format!(
|
||||||
|
"decode Worker snapshot {}: {error}",
|
||||||
|
source_snapshot_path.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let snapshot = migrate_worker_document(
|
||||||
|
snapshot,
|
||||||
|
plan.current_schema_version,
|
||||||
|
planned_worker.legacy_mapping.as_ref(),
|
||||||
|
&source_snapshot_path,
|
||||||
|
)?;
|
||||||
|
let metadata_path = source_dir.join(WORKER_METADATA_FILE);
|
||||||
|
let metadata = if metadata_path.is_file() {
|
||||||
|
let metadata: serde_json::Value =
|
||||||
|
read_json(&metadata_path, "read Worker aggregate metadata")?;
|
||||||
|
Some(
|
||||||
|
migrate_worker_aggregate_document(
|
||||||
|
metadata,
|
||||||
|
planned_worker,
|
||||||
|
runtime_id,
|
||||||
|
&metadata_path,
|
||||||
|
)?
|
||||||
|
.0,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let worker_id_text = planned_worker.worker_id.to_string();
|
||||||
|
let migrated_dir = root.join("workers").join(&worker_id_text);
|
||||||
|
if source_dir != &migrated_dir {
|
||||||
|
fs::rename(source_dir, &migrated_dir)
|
||||||
|
.map_err(|error| runtime_io_error("rename", source_dir, error))?;
|
||||||
|
}
|
||||||
|
let migrated_snapshot_path = migrated_dir.join(WORKER_FILE);
|
||||||
|
atomic_write_json(
|
||||||
|
&migrated_snapshot_path,
|
||||||
|
&snapshot,
|
||||||
|
"migrate Worker identity",
|
||||||
|
)?;
|
||||||
|
if let Some(metadata) = metadata {
|
||||||
|
atomic_write_json(
|
||||||
|
&migrated_dir.join(WORKER_METADATA_FILE),
|
||||||
|
&metadata,
|
||||||
|
"migrate Worker aggregate identity",
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (document, _) = migrate_runtime_document(
|
||||||
|
document,
|
||||||
|
plan.current_schema_version,
|
||||||
|
&plan.mappings,
|
||||||
|
&runtime_path,
|
||||||
|
)?;
|
||||||
|
atomic_write_json(
|
||||||
|
&runtime_path,
|
||||||
|
&document,
|
||||||
|
"migrate Runtime Worker identities",
|
||||||
|
)?;
|
||||||
|
Ok(plan)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
struct RuntimeSnapshot {
|
struct RuntimeSnapshot {
|
||||||
schema_version: u32,
|
schema_version: u32,
|
||||||
display_name: Option<String>,
|
display_name: Option<String>,
|
||||||
backend: RuntimeBackendKind,
|
backend: RuntimeBackendKind,
|
||||||
status: RuntimeStatus,
|
status: RuntimeStatus,
|
||||||
next_worker_sequence: u64,
|
|
||||||
next_diagnostic_id: u64,
|
next_diagnostic_id: u64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
config_bundles: BTreeMap<String, ConfigBundle>,
|
config_bundles: BTreeMap<String, ConfigBundle>,
|
||||||
@@ -297,7 +1187,6 @@ impl RuntimeSnapshot {
|
|||||||
display_name: state.display_name.clone(),
|
display_name: state.display_name.clone(),
|
||||||
backend: RuntimeBackendKind::FsStore,
|
backend: RuntimeBackendKind::FsStore,
|
||||||
status: state.status,
|
status: state.status,
|
||||||
next_worker_sequence: state.next_worker_sequence,
|
|
||||||
next_diagnostic_id: state.next_diagnostic_id,
|
next_diagnostic_id: state.next_diagnostic_id,
|
||||||
config_bundles: BTreeMap::new(),
|
config_bundles: BTreeMap::new(),
|
||||||
workspace_owners: state.workspace_owners.clone(),
|
workspace_owners: state.workspace_owners.clone(),
|
||||||
@@ -333,7 +1222,6 @@ impl RuntimeSnapshot {
|
|||||||
PersistedRuntimeState {
|
PersistedRuntimeState {
|
||||||
display_name: self.display_name,
|
display_name: self.display_name,
|
||||||
status: self.status,
|
status: self.status,
|
||||||
next_worker_sequence: self.next_worker_sequence,
|
|
||||||
next_diagnostic_id: self.next_diagnostic_id,
|
next_diagnostic_id: self.next_diagnostic_id,
|
||||||
workers,
|
workers,
|
||||||
workspace_owners: self.workspace_owners,
|
workspace_owners: self.workspace_owners,
|
||||||
|
|||||||
@@ -2162,8 +2162,8 @@ mod tests {
|
|||||||
let profile = ProfileSelector::Builtin("builtin:coder".to_string());
|
let profile = ProfileSelector::Builtin("builtin:coder".to_string());
|
||||||
let bundle = test_bundle(profile.clone());
|
let bundle = test_bundle(profile.clone());
|
||||||
CreateWorkerRequest {
|
CreateWorkerRequest {
|
||||||
idempotency_key: None,
|
worker_id: WorkerId::now_v7(),
|
||||||
idempotency_fingerprint: None,
|
create_fingerprint: "test-create".to_string(),
|
||||||
profile,
|
profile,
|
||||||
display_name: None,
|
display_name: None,
|
||||||
profile_source: crate::catalog::ProfileSourceArchiveSource::Http {
|
profile_source: crate::catalog::ProfileSourceArchiveSource::Http {
|
||||||
@@ -2659,12 +2659,14 @@ mod tests {
|
|||||||
async fn runtime_errors_use_typed_rest_error_shape() {
|
async fn runtime_errors_use_typed_rest_error_shape() {
|
||||||
let token = "local-token";
|
let token = "local-token";
|
||||||
let app = runtime_http_router(Runtime::new_memory(), token.to_string());
|
let app = runtime_http_router(Runtime::new_memory(), token.to_string());
|
||||||
let response = authed_empty_request(app, Method::GET, "/v1/workers/999", token).await;
|
let missing = crate::identity::WorkerId::from_legacy_u64(999);
|
||||||
|
let response =
|
||||||
|
authed_empty_request(app, Method::GET, &format!("/v1/workers/{missing}"), token).await;
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
let error: RuntimeHttpErrorResponse = read_json(response).await;
|
let error: RuntimeHttpErrorResponse = read_json(response).await;
|
||||||
assert_eq!(error.error.code, "worker_not_found");
|
assert_eq!(error.error.code, "worker_not_found");
|
||||||
assert!(error.error.message.contains("999"));
|
assert!(error.error.message.contains(&missing.to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -2795,8 +2797,8 @@ mod ws_tests {
|
|||||||
fn ws_create_request() -> CreateWorkerRequest {
|
fn ws_create_request() -> CreateWorkerRequest {
|
||||||
let bundle = ws_test_bundle(ProfileSelector::Builtin("builtin:companion".to_string()));
|
let bundle = ws_test_bundle(ProfileSelector::Builtin("builtin:companion".to_string()));
|
||||||
CreateWorkerRequest {
|
CreateWorkerRequest {
|
||||||
idempotency_key: None,
|
worker_id: WorkerId::now_v7(),
|
||||||
idempotency_fingerprint: None,
|
create_fingerprint: "test-create".to_string(),
|
||||||
profile: ProfileSelector::Builtin("builtin:companion".to_string()),
|
profile: ProfileSelector::Builtin("builtin:companion".to_string()),
|
||||||
display_name: None,
|
display_name: None,
|
||||||
profile_source: crate::catalog::ProfileSourceArchiveSource::Http {
|
profile_source: crate::catalog::ProfileSourceArchiveSource::Http {
|
||||||
|
|||||||
@@ -1,50 +1,146 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
|
||||||
use std::fmt;
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::{fmt, str::FromStr};
|
||||||
|
use uuid::{Uuid, Version};
|
||||||
|
|
||||||
pub use workdir::workspace::RuntimeWorkerRef;
|
pub use workdir::workspace::RuntimeWorkerRef;
|
||||||
|
|
||||||
/// Runtime-local Worker identity.
|
/// Stable Workspace-owned Worker identity.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
///
|
||||||
#[serde(transparent)]
|
/// Runtime placement is deliberately not part of this value. New identities are
|
||||||
pub struct WorkerId(u64);
|
/// allocated by Workspace authority before a Runtime create request is sent.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
|
pub struct WorkerId(Uuid);
|
||||||
|
|
||||||
impl WorkerId {
|
impl WorkerId {
|
||||||
pub fn new(value: u64) -> Self {
|
pub fn now_v7() -> Self {
|
||||||
Self(value)
|
Self(Uuid::now_v7())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a legacy Runtime-local numeric id into a syntactically valid
|
||||||
|
/// migration-only UUIDv7 value. New Worker allocation must use `now_v7`.
|
||||||
|
pub fn from_legacy_u64(value: u64) -> Self {
|
||||||
|
let mut bytes = [0_u8; 16];
|
||||||
|
bytes[8..].copy_from_slice(&value.to_be_bytes());
|
||||||
|
bytes[6] = 0x70;
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
Self(Uuid::from_bytes(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_legacy_binding(workspace_id: &str, runtime_id: &str, value: u64) -> Self {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(b"yoi.workspace-worker-id.v1\0");
|
||||||
|
hasher.update(workspace_id.as_bytes());
|
||||||
|
hasher.update([0]);
|
||||||
|
hasher.update(runtime_id.as_bytes());
|
||||||
|
hasher.update([0]);
|
||||||
|
hasher.update(value.to_be_bytes());
|
||||||
|
let digest = hasher.finalize();
|
||||||
|
let mut bytes = [0_u8; 16];
|
||||||
|
bytes.copy_from_slice(&digest[..16]);
|
||||||
|
// Migrated ids sort before normally allocated UUIDv7 values while retaining
|
||||||
|
// deterministic collision-resistant payload bits.
|
||||||
|
bytes[..6].fill(0);
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x70;
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
Self(Uuid::from_bytes(bytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse(value: &str) -> Option<Self> {
|
pub fn parse(value: &str) -> Option<Self> {
|
||||||
value.parse::<u64>().ok().map(Self)
|
let value = Uuid::parse_str(value).ok()?;
|
||||||
|
(value.get_version() == Some(Version::SortRand)).then_some(Self(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn generated(sequence: u64) -> Self {
|
pub const fn as_uuid(self) -> Uuid {
|
||||||
Self(sequence)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn as_u64(&self) -> u64 {
|
|
||||||
self.0
|
self.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for WorkerId {
|
impl fmt::Display for WorkerId {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
self.0.fmt(f)
|
self.0.fmt(formatter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert an opaque Workspace Worker reference only at the Runtime-local boundary.
|
impl FromStr for WorkerId {
|
||||||
impl TryFrom<&RuntimeWorkerRef> for WorkerRef {
|
type Err = WorkerIdParseError;
|
||||||
type Error = std::num::ParseIntError;
|
|
||||||
|
|
||||||
fn try_from(value: &RuntimeWorkerRef) -> Result<Self, Self::Error> {
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
value
|
Self::parse(value).ok_or(WorkerIdParseError)
|
||||||
.worker_id
|
|
||||||
.parse::<u64>()
|
|
||||||
.map(WorkerId::new)
|
|
||||||
.map(Self::new)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runtime-local authority reference for Worker operations.
|
impl Serialize for WorkerId {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(&self.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for WorkerId {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let value = String::deserialize(deserializer)?;
|
||||||
|
Self::parse(&value).ok_or_else(|| de::Error::custom("Worker id must be a UUIDv7"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct WorkerIdParseError;
|
||||||
|
|
||||||
|
impl fmt::Display for WorkerIdParseError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter.write_str("Worker id must be a UUIDv7")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for WorkerIdParseError {}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct LegacyWorkerIdentityMapping {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub legacy_worker_id: u64,
|
||||||
|
pub worker_id: WorkerId,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn legacy_worker_identity_mapping_digest(mappings: &[LegacyWorkerIdentityMapping]) -> String {
|
||||||
|
let mut mappings = mappings.to_vec();
|
||||||
|
mappings.sort_by(|left, right| {
|
||||||
|
(
|
||||||
|
left.workspace_id.as_str(),
|
||||||
|
left.runtime_id.as_str(),
|
||||||
|
left.legacy_worker_id,
|
||||||
|
left.worker_id,
|
||||||
|
)
|
||||||
|
.cmp(&(
|
||||||
|
right.workspace_id.as_str(),
|
||||||
|
right.runtime_id.as_str(),
|
||||||
|
right.legacy_worker_id,
|
||||||
|
right.worker_id,
|
||||||
|
))
|
||||||
|
});
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(b"yoi.workspace-worker-migration-plan.v1\0");
|
||||||
|
for mapping in mappings {
|
||||||
|
hasher.update(mapping.workspace_id.as_bytes());
|
||||||
|
hasher.update([0]);
|
||||||
|
hasher.update(mapping.runtime_id.as_bytes());
|
||||||
|
hasher.update([0]);
|
||||||
|
hasher.update(mapping.legacy_worker_id.to_be_bytes());
|
||||||
|
hasher.update(mapping.worker_id.to_string().as_bytes());
|
||||||
|
hasher.update([b'\n']);
|
||||||
|
}
|
||||||
|
let digest = hasher.finalize();
|
||||||
|
digest.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runtime-local authority reference for Worker operations. The contained id is
|
||||||
|
/// nevertheless the Workspace-owned stable identity; the Runtime does not mint it.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
pub struct WorkerRef {
|
pub struct WorkerRef {
|
||||||
pub worker_id: WorkerId,
|
pub worker_id: WorkerId,
|
||||||
@@ -56,28 +152,43 @@ impl WorkerRef {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl TryFrom<&RuntimeWorkerRef> for WorkerRef {
|
||||||
|
type Error = WorkerIdParseError;
|
||||||
|
|
||||||
|
fn try_from(value: &RuntimeWorkerRef) -> Result<Self, Self::Error> {
|
||||||
|
value.worker_id.parse().map(Self::new)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_worker_ref_preserves_structured_identity_and_json_fields() {
|
fn worker_id_accepts_only_uuid_v7() {
|
||||||
let worker = RuntimeWorkerRef::new("arcadia", "30");
|
let worker_id = WorkerId::now_v7();
|
||||||
assert_eq!(worker.runtime_id, "arcadia");
|
assert_eq!(WorkerId::parse(&worker_id.to_string()), Some(worker_id));
|
||||||
assert_eq!(worker.worker_id, "30");
|
assert!(WorkerId::parse("30").is_none());
|
||||||
|
assert!(WorkerId::parse(&Uuid::nil().to_string()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_worker_ref_preserves_stable_worker_identity() {
|
||||||
|
let worker_id = WorkerId::now_v7();
|
||||||
|
let worker = RuntimeWorkerRef::new("arcadia", worker_id.to_string());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
WorkerRef::try_from(&worker).unwrap(),
|
WorkerRef::try_from(&worker).unwrap(),
|
||||||
WorkerRef::new(WorkerId::new(30))
|
WorkerRef::new(worker_id)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
serde_json::to_value(&worker).unwrap(),
|
serde_json::to_value(&worker).unwrap(),
|
||||||
serde_json::json!({"runtime_id": "arcadia", "worker_id": "30"})
|
serde_json::json!({"runtime_id": "arcadia", "worker_id": worker_id.to_string()})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_worker_ref_does_not_treat_composite_text_as_local_worker_id() {
|
fn runtime_worker_ref_rejects_legacy_numeric_identity() {
|
||||||
let worker = RuntimeWorkerRef::new("arcadia", "embedded-worker-runtime-5");
|
let worker = RuntimeWorkerRef::new("arcadia", "30");
|
||||||
assert!(WorkerRef::try_from(&worker).is_err());
|
assert!(WorkerRef::try_from(&worker).is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use worker_runtime::auth::{
|
|||||||
RuntimeHttpAuthConfig, RuntimeIdentityMaterial, TrustedServerKey, decode_public_key,
|
RuntimeHttpAuthConfig, RuntimeIdentityMaterial, TrustedServerKey, decode_public_key,
|
||||||
};
|
};
|
||||||
use worker_runtime::error::RuntimeError;
|
use worker_runtime::error::RuntimeError;
|
||||||
use worker_runtime::fs_store::FsRuntimeStoreOptions;
|
use worker_runtime::fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
|
||||||
use worker_runtime::http_server::{
|
use worker_runtime::http_server::{
|
||||||
RuntimeHttpServerConfig, RuntimeHttpServerError, RuntimeHttpStoreSelection,
|
RuntimeHttpServerConfig, RuntimeHttpServerError, RuntimeHttpStoreSelection,
|
||||||
};
|
};
|
||||||
@@ -44,6 +44,9 @@ fn main() -> ExitCode {
|
|||||||
|
|
||||||
fn run() -> Result<(), ProcessError> {
|
fn run() -> Result<(), ProcessError> {
|
||||||
let args = env::args().skip(1).collect::<Vec<_>>();
|
let args = env::args().skip(1).collect::<Vec<_>>();
|
||||||
|
if matches!(args.first().map(String::as_str), Some("migrate")) {
|
||||||
|
return run_migration_command(args);
|
||||||
|
}
|
||||||
if matches!(
|
if matches!(
|
||||||
args.first().map(String::as_str),
|
args.first().map(String::as_str),
|
||||||
Some("identity" | "trust-server")
|
Some("identity" | "trust-server")
|
||||||
@@ -78,6 +81,76 @@ fn run() -> Result<(), ProcessError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_migration_command(mut args: Vec<String>) -> Result<(), ProcessError> {
|
||||||
|
args.remove(0);
|
||||||
|
let dry_run_index = args
|
||||||
|
.iter()
|
||||||
|
.position(|argument| argument == "--dry-run")
|
||||||
|
.ok_or_else(|| ProcessError::usage("migrate currently requires --dry-run".to_string()))?;
|
||||||
|
args.remove(dry_run_index);
|
||||||
|
let explicit_runtime_id =
|
||||||
|
if let Some(index) = args.iter().position(|argument| argument == "--runtime-id") {
|
||||||
|
if index + 1 >= args.len() {
|
||||||
|
return Err(ProcessError::usage(
|
||||||
|
"--runtime-id requires a value".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let runtime_id = args.remove(index + 1);
|
||||||
|
args.remove(index);
|
||||||
|
Some(runtime_id)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let config = parse_args(args)?.ok_or_else(|| {
|
||||||
|
ProcessError::usage("migrate requires a Runtime store configuration".to_string())
|
||||||
|
})?;
|
||||||
|
let root = match &config.http.store {
|
||||||
|
RuntimeHttpStoreSelection::Fs { root } => root.clone(),
|
||||||
|
RuntimeHttpStoreSelection::Memory => {
|
||||||
|
return Err(ProcessError::usage(
|
||||||
|
"migration dry-run requires the fs Runtime store".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(ProcessError::usage(
|
||||||
|
"unsupported Runtime catalog store selection".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let persisted_runtime_id = read_runtime_auth_file(&runtime_auth_path(&config))?
|
||||||
|
.identity
|
||||||
|
.map(|identity| identity.identity_id);
|
||||||
|
let runtime_id = match (persisted_runtime_id, explicit_runtime_id) {
|
||||||
|
(Some(persisted), Some(explicit)) if persisted != explicit => {
|
||||||
|
return Err(ProcessError::usage(format!(
|
||||||
|
"--runtime-id {explicit} does not match persisted Runtime identity {persisted}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
(Some(persisted), _) => persisted,
|
||||||
|
(None, Some(explicit)) if !explicit.is_empty() => explicit,
|
||||||
|
(None, Some(_)) => {
|
||||||
|
return Err(ProcessError::usage(
|
||||||
|
"--runtime-id must not be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
(None, None) => {
|
||||||
|
return Err(ProcessError::usage(
|
||||||
|
"migration dry-run requires a persisted Runtime identity or explicit --runtime-id"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut options = FsRuntimeStoreOptions::new(root).with_runtime_id(runtime_id);
|
||||||
|
options.display_name = config.http.display_name.clone();
|
||||||
|
let plan = FsRuntimeStore::migration_plan(&options).map_err(ProcessError::Runtime)?;
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string_pretty(&plan)
|
||||||
|
.map_err(|error| ProcessError::Auth(format!("encode migration plan: {error}")))?
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
|
fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
|
||||||
let fs_paths = config.resolved_fs_paths();
|
let fs_paths = config.resolved_fs_paths();
|
||||||
let runtime_store_dir = match &config.http.store {
|
let runtime_store_dir = match &config.http.store {
|
||||||
@@ -112,7 +185,14 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
|
|||||||
.map_err(ProcessError::Runtime)
|
.map_err(ProcessError::Runtime)
|
||||||
}
|
}
|
||||||
RuntimeHttpStoreSelection::Fs { root } => {
|
RuntimeHttpStoreSelection::Fs { root } => {
|
||||||
let mut options = FsRuntimeStoreOptions::new(root.clone());
|
let mut options = FsRuntimeStoreOptions::new(root.clone()).with_runtime_id(
|
||||||
|
config
|
||||||
|
.http
|
||||||
|
.auth
|
||||||
|
.as_ref()
|
||||||
|
.map(|auth| auth.runtime_id.as_str())
|
||||||
|
.unwrap_or("local"),
|
||||||
|
);
|
||||||
options.display_name = config.http.display_name.clone();
|
options.display_name = config.http.display_name.clone();
|
||||||
Runtime::with_fs_store_and_execution_backend(options, backend)
|
Runtime::with_fs_store_and_execution_backend(options, backend)
|
||||||
.map_err(ProcessError::Runtime)
|
.map_err(ProcessError::Runtime)
|
||||||
@@ -784,6 +864,7 @@ fn run_trust_server_command(mut args: VecDeque<String>) -> Result<(), ProcessErr
|
|||||||
|
|
||||||
fn usage() -> &'static str {
|
fn usage() -> &'static str {
|
||||||
r#"Usage: yoi-runtime [OPTIONS]
|
r#"Usage: yoi-runtime [OPTIONS]
|
||||||
|
yoi-runtime migrate --dry-run [--runtime-id <ID>] [OPTIONS]
|
||||||
|
|
||||||
Starts a worker-backed Runtime REST command API for a trusted backend/proxy.
|
Starts a worker-backed Runtime REST command API for a trusted backend/proxy.
|
||||||
Browsers must not connect to this Runtime process directly.
|
Browsers must not connect to this Runtime process directly.
|
||||||
@@ -904,6 +985,87 @@ mod tests {
|
|||||||
assert_eq!(paths.workdir_target, PathBuf::from("/tmp/yoi-workdirs"));
|
assert_eq!(paths.workdir_target, PathBuf::from("/tmp/yoi-workdirs"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_dry_run_accepts_real_v1_document_without_workers_field() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let root = temp.path().join("runtime");
|
||||||
|
std::fs::create_dir_all(root.join("workers")).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
root.join("runtime.json"),
|
||||||
|
serde_json::to_vec_pretty(&serde_json::json!({
|
||||||
|
"schema_version": 1,
|
||||||
|
"display_name": "local",
|
||||||
|
"backend": "fs_store",
|
||||||
|
"status": "running",
|
||||||
|
"next_diagnostic_id": 1,
|
||||||
|
"config_bundles": {},
|
||||||
|
"workspace_owners": {},
|
||||||
|
"assignments": [],
|
||||||
|
"execution": [],
|
||||||
|
"diagnostics": []
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let before = std::fs::read(root.join("runtime.json")).unwrap();
|
||||||
|
run_migration_command(vec![
|
||||||
|
"migrate".to_string(),
|
||||||
|
"--dry-run".to_string(),
|
||||||
|
"--runtime-id".to_string(),
|
||||||
|
"local".to_string(),
|
||||||
|
"--store".to_string(),
|
||||||
|
"fs".to_string(),
|
||||||
|
"--fs-root".to_string(),
|
||||||
|
temp.path().display().to_string(),
|
||||||
|
"--fs-runtime-dir".to_string(),
|
||||||
|
root.display().to_string(),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(std::fs::read(root.join("runtime.json")).unwrap(), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_dry_run_rejects_v1_document_that_cannot_decode_as_v3() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let root = temp.path().join("runtime");
|
||||||
|
std::fs::create_dir_all(root.join("workers")).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
root.join("runtime.json"),
|
||||||
|
serde_json::to_vec_pretty(&serde_json::json!({
|
||||||
|
"schema_version": 1,
|
||||||
|
"display_name": "local",
|
||||||
|
"backend": "fs_store",
|
||||||
|
"status": 3,
|
||||||
|
"next_diagnostic_id": 1,
|
||||||
|
"config_bundles": {},
|
||||||
|
"workspace_owners": {},
|
||||||
|
"diagnostics": []
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let before = std::fs::read(root.join("runtime.json")).unwrap();
|
||||||
|
let error = run_migration_command(vec![
|
||||||
|
"migrate".to_string(),
|
||||||
|
"--dry-run".to_string(),
|
||||||
|
"--runtime-id".to_string(),
|
||||||
|
"local".to_string(),
|
||||||
|
"--store".to_string(),
|
||||||
|
"fs".to_string(),
|
||||||
|
"--fs-root".to_string(),
|
||||||
|
temp.path().display().to_string(),
|
||||||
|
"--fs-runtime-dir".to_string(),
|
||||||
|
root.display().to_string(),
|
||||||
|
])
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
error
|
||||||
|
.to_string()
|
||||||
|
.contains("decode migrated Runtime snapshot")
|
||||||
|
);
|
||||||
|
assert_eq!(std::fs::read(root.join("runtime.json")).unwrap(), before);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_store_disables_runtime_catalog_persistence() {
|
fn no_store_disables_runtime_catalog_persistence() {
|
||||||
let config = parse_args(["--no-store"]).unwrap().unwrap();
|
let config = parse_args(["--no-store"]).unwrap().unwrap();
|
||||||
|
|||||||
@@ -275,14 +275,13 @@ impl FsWorkerRetentionProvider {
|
|||||||
));
|
));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Ok(worker_number) = raw_id.parse::<u64>() else {
|
let Ok(worker_id) = raw_id.parse::<WorkerId>() else {
|
||||||
diagnostics.push(runtime_aggregate_diagnostic(
|
diagnostics.push(runtime_aggregate_diagnostic(
|
||||||
&bounded_id,
|
&bounded_id,
|
||||||
"aggregate_worker_id_invalid",
|
"aggregate_worker_id_invalid",
|
||||||
));
|
));
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let worker_id = WorkerId::new(worker_number);
|
|
||||||
let worker_dir = self.worker_dir(worker_id);
|
let worker_dir = self.worker_dir(worker_id);
|
||||||
let snapshot: WorkerGenerationSnapshot = match read_json(
|
let snapshot: WorkerGenerationSnapshot = match read_json(
|
||||||
&worker_dir.join("worker.json"),
|
&worker_dir.join("worker.json"),
|
||||||
@@ -1315,7 +1314,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn archive_is_verified_before_source_removal_and_retry_converges() {
|
fn archive_is_verified_before_source_removal_and_retry_converges() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let worker_id = WorkerId::new(7);
|
let worker_id = WorkerId::from_legacy_u64(7);
|
||||||
source(temp.path(), worker_id, 4);
|
source(temp.path(), worker_id, 4);
|
||||||
let provider = FsWorkerRetentionProvider::new(temp.path());
|
let provider = FsWorkerRetentionProvider::new(temp.path());
|
||||||
let request = request(worker_id, 4, SessionDisposition::Archive);
|
let request = request(worker_id, 4, SessionDisposition::Archive);
|
||||||
@@ -1325,7 +1324,7 @@ mod tests {
|
|||||||
let archive = first.archive.as_ref().unwrap();
|
let archive = first.archive.as_ref().unwrap();
|
||||||
assert_eq!(archive.source_session_id, "session-a");
|
assert_eq!(archive.source_session_id, "session-a");
|
||||||
assert_eq!(archive.segment_ids, vec!["segment-a"]);
|
assert_eq!(archive.segment_ids, vec!["segment-a"]);
|
||||||
assert!(!temp.path().join("workers/7").exists());
|
assert!(!temp.path().join(format!("workers/{worker_id}")).exists());
|
||||||
assert!(
|
assert!(
|
||||||
temp.path()
|
temp.path()
|
||||||
.join("archives/workers/archive-a/session/segments/segment-a.jsonl")
|
.join("archives/workers/archive-a/session/segments/segment-a.jsonl")
|
||||||
@@ -1346,7 +1345,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn archive_failure_keeps_live_source_for_retry() {
|
fn archive_failure_keeps_live_source_for_retry() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let worker_id = WorkerId::new(8);
|
let worker_id = WorkerId::from_legacy_u64(8);
|
||||||
source(temp.path(), worker_id, 2);
|
source(temp.path(), worker_id, 2);
|
||||||
let collision = temp.path().join("archives/workers/archive-a");
|
let collision = temp.path().join("archives/workers/archive-a");
|
||||||
fs::create_dir_all(&collision).unwrap();
|
fs::create_dir_all(&collision).unwrap();
|
||||||
@@ -1358,7 +1357,11 @@ mod tests {
|
|||||||
.execute(&request(worker_id, 2, SessionDisposition::Archive))
|
.execute(&request(worker_id, 2, SessionDisposition::Archive))
|
||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
assert!(temp.path().join("workers/8/session").is_dir());
|
assert!(
|
||||||
|
temp.path()
|
||||||
|
.join(format!("workers/{worker_id}/session"))
|
||||||
|
.is_dir()
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!temp
|
!temp
|
||||||
.path()
|
.path()
|
||||||
@@ -1370,7 +1373,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn target_inventory_and_execute_reject_cross_workspace_aggregate() {
|
fn target_inventory_and_execute_reject_cross_workspace_aggregate() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let worker_id = WorkerId::new(16);
|
let worker_id = WorkerId::from_legacy_u64(16);
|
||||||
source(temp.path(), worker_id, 3);
|
source(temp.path(), worker_id, 3);
|
||||||
let provider = FsWorkerRetentionProvider::new(temp.path());
|
let provider = FsWorkerRetentionProvider::new(temp.path());
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
@@ -1383,7 +1386,11 @@ mod tests {
|
|||||||
provider.execute(&request),
|
provider.execute(&request),
|
||||||
Err(RuntimeError::WorkerNotFound { .. })
|
Err(RuntimeError::WorkerNotFound { .. })
|
||||||
));
|
));
|
||||||
assert!(temp.path().join("workers/16/session").is_dir());
|
assert!(
|
||||||
|
temp.path()
|
||||||
|
.join(format!("workers/{worker_id}/session"))
|
||||||
|
.is_dir()
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!temp
|
!temp
|
||||||
.path()
|
.path()
|
||||||
@@ -1401,18 +1408,22 @@ mod tests {
|
|||||||
fn purge_removes_aggregate_and_rejects_stale_generation() {
|
fn purge_removes_aggregate_and_rejects_stale_generation() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let provider = FsWorkerRetentionProvider::new(temp.path());
|
let provider = FsWorkerRetentionProvider::new(temp.path());
|
||||||
let worker_id = WorkerId::new(9);
|
let worker_id = WorkerId::from_legacy_u64(9);
|
||||||
source(temp.path(), worker_id, 5);
|
source(temp.path(), worker_id, 5);
|
||||||
let stale = request(worker_id, 4, SessionDisposition::Purge);
|
let stale = request(worker_id, 4, SessionDisposition::Purge);
|
||||||
assert!(provider.execute(&stale).is_err());
|
assert!(provider.execute(&stale).is_err());
|
||||||
assert!(temp.path().join("workers/9/session").is_dir());
|
assert!(
|
||||||
|
temp.path()
|
||||||
|
.join(format!("workers/{worker_id}/session"))
|
||||||
|
.is_dir()
|
||||||
|
);
|
||||||
|
|
||||||
let mut current = request(worker_id, 5, SessionDisposition::Purge);
|
let mut current = request(worker_id, 5, SessionDisposition::Purge);
|
||||||
current.operation_id = "operation-current".to_string();
|
current.operation_id = "operation-current".to_string();
|
||||||
current.input_fingerprint = "fingerprint-current".to_string();
|
current.input_fingerprint = "fingerprint-current".to_string();
|
||||||
let result = provider.execute(¤t).unwrap();
|
let result = provider.execute(¤t).unwrap();
|
||||||
assert!(result.archive.is_none());
|
assert!(result.archive.is_none());
|
||||||
assert!(!temp.path().join("workers/9").exists());
|
assert!(!temp.path().join(format!("workers/{worker_id}")).exists());
|
||||||
assert!(
|
assert!(
|
||||||
temp.path()
|
temp.path()
|
||||||
.join("retention/operations/operation-current.json")
|
.join("retention/operations/operation-current.json")
|
||||||
@@ -1423,7 +1434,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn pending_receipt_recovers_delete_to_receipt_crash_window() {
|
fn pending_receipt_recovers_delete_to_receipt_crash_window() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let worker_id = WorkerId::new(11);
|
let worker_id = WorkerId::from_legacy_u64(11);
|
||||||
source(temp.path(), worker_id, 1);
|
source(temp.path(), worker_id, 1);
|
||||||
let provider = FsWorkerRetentionProvider::new(temp.path());
|
let provider = FsWorkerRetentionProvider::new(temp.path());
|
||||||
let request = request(worker_id, 1, SessionDisposition::Archive);
|
let request = request(worker_id, 1, SessionDisposition::Archive);
|
||||||
@@ -1442,10 +1453,15 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn provider_snapshot_scans_aggregate_storage_independent_of_runtime_catalog() {
|
fn provider_snapshot_scans_aggregate_storage_independent_of_runtime_catalog() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
source(temp.path(), WorkerId::new(13), 2);
|
source(temp.path(), WorkerId::from_legacy_u64(13), 2);
|
||||||
source(temp.path(), WorkerId::new(14), 1);
|
let other_worker = WorkerId::from_legacy_u64(14);
|
||||||
|
source(temp.path(), other_worker, 1);
|
||||||
write_json(
|
write_json(
|
||||||
&temp.path().join("workers/14/worker.json"),
|
&temp
|
||||||
|
.path()
|
||||||
|
.join("workers")
|
||||||
|
.join(other_worker.to_string())
|
||||||
|
.join("worker.json"),
|
||||||
&serde_json::json!({"workspace_id": "other-workspace", "run_generation": 1}),
|
&serde_json::json!({"workspace_id": "other-workspace", "run_generation": 1}),
|
||||||
);
|
);
|
||||||
fs::create_dir_all(temp.path().join("workers/not-a-worker")).unwrap();
|
fs::create_dir_all(temp.path().join("workers/not-a-worker")).unwrap();
|
||||||
@@ -1454,15 +1470,20 @@ mod tests {
|
|||||||
b"not-json",
|
b"not-json",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
fs::create_dir_all(temp.path().join("workers/15")).unwrap();
|
let corrupt_worker = WorkerId::from_legacy_u64(15);
|
||||||
fs::write(temp.path().join("workers/15/worker.json"), b"not-json").unwrap();
|
let corrupt_worker_dir = temp.path().join("workers").join(corrupt_worker.to_string());
|
||||||
|
fs::create_dir_all(&corrupt_worker_dir).unwrap();
|
||||||
|
fs::write(corrupt_worker_dir.join("worker.json"), b"not-json").unwrap();
|
||||||
let provider = FsWorkerRetentionProvider::new(temp.path());
|
let provider = FsWorkerRetentionProvider::new(temp.path());
|
||||||
|
|
||||||
let snapshot = provider.snapshot("workspace-a", "runtime-a").unwrap();
|
let snapshot = provider.snapshot("workspace-a", "runtime-a").unwrap();
|
||||||
assert_eq!(snapshot.workers().len(), 1);
|
assert_eq!(snapshot.workers().len(), 1);
|
||||||
assert_eq!(snapshot.workers()[0].worker_id, WorkerId::new(13));
|
assert_eq!(
|
||||||
|
snapshot.workers()[0].worker_id,
|
||||||
|
WorkerId::from_legacy_u64(13)
|
||||||
|
);
|
||||||
assert!(snapshot.diagnostics().iter().any(|diagnostic| {
|
assert!(snapshot.diagnostics().iter().any(|diagnostic| {
|
||||||
diagnostic.worker_id() == "14"
|
diagnostic.worker_id() == other_worker.to_string()
|
||||||
&& diagnostic.category() == "aggregate_workspace_mismatch"
|
&& diagnostic.category() == "aggregate_workspace_mismatch"
|
||||||
}));
|
}));
|
||||||
assert!(snapshot.diagnostics().iter().any(|diagnostic| {
|
assert!(snapshot.diagnostics().iter().any(|diagnostic| {
|
||||||
@@ -1470,7 +1491,7 @@ mod tests {
|
|||||||
&& diagnostic.category() == "aggregate_worker_id_invalid"
|
&& diagnostic.category() == "aggregate_worker_id_invalid"
|
||||||
}));
|
}));
|
||||||
assert!(snapshot.diagnostics().iter().any(|diagnostic| {
|
assert!(snapshot.diagnostics().iter().any(|diagnostic| {
|
||||||
diagnostic.worker_id() == "15"
|
diagnostic.worker_id() == corrupt_worker.to_string()
|
||||||
&& diagnostic.category() == "aggregate_worker_record_corrupt"
|
&& diagnostic.category() == "aggregate_worker_record_corrupt"
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -1478,7 +1499,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn diagnostics_retry_rejects_corrupt_existing_archive_before_source_delete() {
|
fn diagnostics_retry_rejects_corrupt_existing_archive_before_source_delete() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let worker_id = WorkerId::new(12);
|
let worker_id = WorkerId::from_legacy_u64(12);
|
||||||
source(temp.path(), worker_id, 1);
|
source(temp.path(), worker_id, 1);
|
||||||
let provider = FsWorkerRetentionProvider::new(temp.path());
|
let provider = FsWorkerRetentionProvider::new(temp.path());
|
||||||
let mut request = request(worker_id, 1, SessionDisposition::Archive);
|
let mut request = request(worker_id, 1, SessionDisposition::Archive);
|
||||||
@@ -1499,14 +1520,18 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(provider.execute(&request).is_err());
|
assert!(provider.execute(&request).is_err());
|
||||||
assert!(temp.path().join("workers/12/session").is_dir());
|
assert!(
|
||||||
|
temp.path()
|
||||||
|
.join(format!("workers/{worker_id}/session"))
|
||||||
|
.is_dir()
|
||||||
|
);
|
||||||
assert!(provider.completed_for(&request).unwrap().is_none());
|
assert!(provider.completed_for(&request).unwrap().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn concurrent_retry_produces_one_archive() {
|
fn concurrent_retry_produces_one_archive() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let worker_id = WorkerId::new(10);
|
let worker_id = WorkerId::from_legacy_u64(10);
|
||||||
source(temp.path(), worker_id, 1);
|
source(temp.path(), worker_id, 1);
|
||||||
let provider = Arc::new(FsWorkerRetentionProvider::new(temp.path()));
|
let provider = Arc::new(FsWorkerRetentionProvider::new(temp.path()));
|
||||||
let request = Arc::new(request(worker_id, 1, SessionDisposition::Archive));
|
let request = Arc::new(request(worker_id, 1, SessionDisposition::Archive));
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ impl Runtime {
|
|||||||
options: FsRuntimeStoreOptions,
|
options: FsRuntimeStoreOptions,
|
||||||
execution_backend: Option<WorkerExecutionBackendRef>,
|
execution_backend: Option<WorkerExecutionBackendRef>,
|
||||||
) -> Result<Self, RuntimeError> {
|
) -> Result<Self, RuntimeError> {
|
||||||
let opened = FsRuntimeStore::open_or_create(options.root)?;
|
let opened = FsRuntimeStore::open_or_create(options.root, &options.runtime_id)?;
|
||||||
let mut state = if let Some(persisted) = opened.state {
|
let mut state = if let Some(persisted) = opened.state {
|
||||||
RuntimeState::from_persisted(persisted, opened.store)?
|
RuntimeState::from_persisted(persisted, opened.store)?
|
||||||
} else {
|
} else {
|
||||||
@@ -335,6 +335,7 @@ impl Runtime {
|
|||||||
for (worker_id, worker) in &mut state.workers {
|
for (worker_id, worker) in &mut state.workers {
|
||||||
if worker.status.is_active() {
|
if worker.status.is_active() {
|
||||||
worker.status = WorkerStatus::Stopped;
|
worker.status = WorkerStatus::Stopped;
|
||||||
|
worker.internal_workers.clear();
|
||||||
stopped.push(*worker_id);
|
stopped.push(*worker_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -492,7 +493,7 @@ impl Runtime {
|
|||||||
let state = self.lock()?;
|
let state = self.lock()?;
|
||||||
status.summary.primary_worker_id = state
|
status.summary.primary_worker_id = state
|
||||||
.primary_worker_id_for_workdir(status.summary.working_directory_id.as_str())
|
.primary_worker_id_for_workdir(status.summary.working_directory_id.as_str())
|
||||||
.map(|worker_id| worker_id.as_u64());
|
.map(|worker_id| worker_id.to_string());
|
||||||
Ok(status)
|
Ok(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,11 +519,6 @@ impl Runtime {
|
|||||||
request: CreateWorkerRequest,
|
request: CreateWorkerRequest,
|
||||||
scope: Option<&RuntimeWorkspaceScope>,
|
scope: Option<&RuntimeWorkspaceScope>,
|
||||||
) -> Result<WorkerDetail, RuntimeError> {
|
) -> Result<WorkerDetail, RuntimeError> {
|
||||||
if request.idempotency_key.is_some() != request.idempotency_fingerprint.is_some() {
|
|
||||||
return Err(RuntimeError::InvalidRequest(
|
|
||||||
"idempotency_key and idempotency_fingerprint must be provided together".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let (backend, worker_ref, spawn_request) = {
|
let (backend, worker_ref, spawn_request) = {
|
||||||
let mut state = self.lock()?;
|
let mut state = self.lock()?;
|
||||||
state.ensure_running()?;
|
state.ensure_running()?;
|
||||||
@@ -534,19 +530,21 @@ impl Runtime {
|
|||||||
if let Some(scope) = scope {
|
if let Some(scope) = scope {
|
||||||
state.ensure_workspace_owner(scope, true)?;
|
state.ensure_workspace_owner(scope, true)?;
|
||||||
};
|
};
|
||||||
if let Some(idempotency_key) = request.idempotency_key.as_deref() {
|
let workspace_id = scope.map(|scope| scope.workspace_id.as_str());
|
||||||
let workspace_id = scope.map(|scope| scope.workspace_id.as_str());
|
if let Some(existing) = state.workers.get(&request.worker_id) {
|
||||||
if let Some(existing) = state.workers.values().find(|record| {
|
if existing.workspace_id.as_deref() != workspace_id {
|
||||||
record.workspace_id.as_deref() == workspace_id
|
return Err(RuntimeError::InvalidRequest(format!(
|
||||||
&& record.request.idempotency_key.as_deref() == Some(idempotency_key)
|
"worker {} already belongs to another Workspace scope",
|
||||||
}) {
|
request.worker_id
|
||||||
if existing.request.idempotency_fingerprint != request.idempotency_fingerprint {
|
)));
|
||||||
return Err(RuntimeError::InvalidRequest(format!(
|
|
||||||
"worker creation idempotency key {idempotency_key} was already used with different input"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
return Ok(existing.detail());
|
|
||||||
}
|
}
|
||||||
|
if existing.request.create_fingerprint != request.create_fingerprint {
|
||||||
|
return Err(RuntimeError::InvalidRequest(format!(
|
||||||
|
"worker {} was already created with a different fingerprint",
|
||||||
|
request.worker_id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
return Ok(existing.detail());
|
||||||
}
|
}
|
||||||
state.validate_worker_config_boundary(&request)?;
|
state.validate_worker_config_boundary(&request)?;
|
||||||
if let Some(working_directory_id) = requested_primary_workdir_id(&request) {
|
if let Some(working_directory_id) = requested_primary_workdir_id(&request) {
|
||||||
@@ -565,9 +563,8 @@ impl Runtime {
|
|||||||
})?;
|
})?;
|
||||||
let config_bundle = state.resolve_config_bundle_ref(request.config_bundle.as_ref())?;
|
let config_bundle = state.resolve_config_bundle_ref(request.config_bundle.as_ref())?;
|
||||||
|
|
||||||
let worker_id = WorkerId::generated(state.next_worker_sequence);
|
let worker_id = request.worker_id;
|
||||||
state.next_worker_sequence += 1;
|
let worker_ref = WorkerRef::new(worker_id);
|
||||||
let worker_ref = WorkerRef::new(worker_id.clone());
|
|
||||||
|
|
||||||
let record = WorkerRecord {
|
let record = WorkerRecord {
|
||||||
worker_ref: worker_ref.clone(),
|
worker_ref: worker_ref.clone(),
|
||||||
@@ -578,6 +575,7 @@ impl Runtime {
|
|||||||
run_generation: 1,
|
run_generation: 1,
|
||||||
working_directory: None,
|
working_directory: None,
|
||||||
execution_handle: None,
|
execution_handle: None,
|
||||||
|
internal_workers: BTreeMap::new(),
|
||||||
};
|
};
|
||||||
state.workers.insert(worker_id, record);
|
state.workers.insert(worker_id, record);
|
||||||
state.persist_runtime_snapshot()?;
|
state.persist_runtime_snapshot()?;
|
||||||
@@ -1434,7 +1432,10 @@ impl Runtime {
|
|||||||
context_tokens: 0,
|
context_tokens: 0,
|
||||||
},
|
},
|
||||||
status: protocol::WorkerStatus::Idle,
|
status: protocol::WorkerStatus::Idle,
|
||||||
in_flight: protocol::InFlightSnapshot { blocks: Vec::new() },
|
in_flight: protocol::InFlightSnapshot {
|
||||||
|
blocks: Vec::new(),
|
||||||
|
commands: Vec::new(),
|
||||||
|
},
|
||||||
internal_workers: Vec::new(),
|
internal_workers: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1475,7 +1476,8 @@ impl Runtime {
|
|||||||
let mut state = self.lock()?;
|
let mut state = self.lock()?;
|
||||||
state.ensure_worker_ref(worker_ref)?;
|
state.ensure_worker_ref(worker_ref)?;
|
||||||
let status_changed = state.project_protocol_event_to_status(worker_ref, &payload);
|
let status_changed = state.project_protocol_event_to_status(worker_ref, &payload);
|
||||||
if status_changed {
|
let activity_changed = state.project_internal_worker_activity(worker_ref, &payload);
|
||||||
|
if status_changed || activity_changed {
|
||||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||||
}
|
}
|
||||||
let event = state.push_worker_observation_event(worker_ref.clone(), payload);
|
let event = state.push_worker_observation_event(worker_ref.clone(), payload);
|
||||||
@@ -1532,6 +1534,7 @@ impl Runtime {
|
|||||||
let worker = state.worker_mut(worker_ref)?;
|
let worker = state.worker_mut(worker_ref)?;
|
||||||
worker.status = status;
|
worker.status = status;
|
||||||
worker.execution_handle = None;
|
worker.execution_handle = None;
|
||||||
|
worker.internal_workers.clear();
|
||||||
let status = worker.status;
|
let status = worker.status;
|
||||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||||
state.persist_runtime_snapshot()?;
|
state.persist_runtime_snapshot()?;
|
||||||
@@ -1850,7 +1853,6 @@ struct RuntimeState {
|
|||||||
persistence: RuntimePersistence,
|
persistence: RuntimePersistence,
|
||||||
status: RuntimeStatus,
|
status: RuntimeStatus,
|
||||||
execution_backend: Option<WorkerExecutionBackendRef>,
|
execution_backend: Option<WorkerExecutionBackendRef>,
|
||||||
next_worker_sequence: u64,
|
|
||||||
#[cfg(feature = "fs-store")]
|
#[cfg(feature = "fs-store")]
|
||||||
next_diagnostic_id: u64,
|
next_diagnostic_id: u64,
|
||||||
workers: BTreeMap<WorkerId, WorkerRecord>,
|
workers: BTreeMap<WorkerId, WorkerRecord>,
|
||||||
@@ -1878,7 +1880,6 @@ impl RuntimeState {
|
|||||||
persistence: RuntimePersistence::Memory,
|
persistence: RuntimePersistence::Memory,
|
||||||
status: RuntimeStatus::Running,
|
status: RuntimeStatus::Running,
|
||||||
execution_backend: None,
|
execution_backend: None,
|
||||||
next_worker_sequence: 1,
|
|
||||||
#[cfg(feature = "fs-store")]
|
#[cfg(feature = "fs-store")]
|
||||||
next_diagnostic_id: 1,
|
next_diagnostic_id: 1,
|
||||||
workers: BTreeMap::new(),
|
workers: BTreeMap::new(),
|
||||||
@@ -1907,7 +1908,6 @@ impl RuntimeState {
|
|||||||
persistence: RuntimePersistence::Fs(store),
|
persistence: RuntimePersistence::Fs(store),
|
||||||
status: RuntimeStatus::Running,
|
status: RuntimeStatus::Running,
|
||||||
execution_backend: None,
|
execution_backend: None,
|
||||||
next_worker_sequence: 1,
|
|
||||||
#[cfg(feature = "fs-store")]
|
#[cfg(feature = "fs-store")]
|
||||||
next_diagnostic_id: 1,
|
next_diagnostic_id: 1,
|
||||||
workers: BTreeMap::new(),
|
workers: BTreeMap::new(),
|
||||||
@@ -1947,6 +1947,7 @@ impl RuntimeState {
|
|||||||
run_generation: worker.run_generation,
|
run_generation: worker.run_generation,
|
||||||
working_directory: worker.working_directory,
|
working_directory: worker.working_directory,
|
||||||
execution_handle: None,
|
execution_handle: None,
|
||||||
|
internal_workers: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1958,7 +1959,6 @@ impl RuntimeState {
|
|||||||
persistence: RuntimePersistence::Fs(store),
|
persistence: RuntimePersistence::Fs(store),
|
||||||
status: persisted.status,
|
status: persisted.status,
|
||||||
execution_backend: None,
|
execution_backend: None,
|
||||||
next_worker_sequence: persisted.next_worker_sequence,
|
|
||||||
next_diagnostic_id,
|
next_diagnostic_id,
|
||||||
workers,
|
workers,
|
||||||
config_bundles: BTreeMap::new(),
|
config_bundles: BTreeMap::new(),
|
||||||
@@ -1982,7 +1982,6 @@ impl RuntimeState {
|
|||||||
PersistedRuntimeState {
|
PersistedRuntimeState {
|
||||||
display_name: self.display_name.clone(),
|
display_name: self.display_name.clone(),
|
||||||
status: self.status,
|
status: self.status,
|
||||||
next_worker_sequence: self.next_worker_sequence,
|
|
||||||
next_diagnostic_id: self.next_diagnostic_id,
|
next_diagnostic_id: self.next_diagnostic_id,
|
||||||
workers: self
|
workers: self
|
||||||
.workers
|
.workers
|
||||||
@@ -2263,12 +2262,17 @@ impl RuntimeState {
|
|||||||
Ok(SubscriptionWorker {
|
Ok(SubscriptionWorker {
|
||||||
worker_id,
|
worker_id,
|
||||||
runtime_id: None,
|
runtime_id: None,
|
||||||
|
resource_key: None,
|
||||||
subject_revision: self
|
subject_revision: self
|
||||||
.worker_subject_revisions
|
.worker_subject_revisions
|
||||||
.get(&worker.worker_id)
|
.get(&worker.worker_id)
|
||||||
.copied()
|
.copied()
|
||||||
.unwrap_or(0),
|
.unwrap_or(0),
|
||||||
state: subscription_worker_state(worker.status),
|
state: subscription_worker_state(worker.status),
|
||||||
|
has_running_internal_workers: worker
|
||||||
|
.internal_workers
|
||||||
|
.values()
|
||||||
|
.any(|worker| worker.status == protocol::WorkerStatus::Running),
|
||||||
workspace_id: worker.workspace_id.clone(),
|
workspace_id: worker.workspace_id.clone(),
|
||||||
display_name: worker.request.display_name.clone(),
|
display_name: worker.request.display_name.clone(),
|
||||||
profile,
|
profile,
|
||||||
@@ -2411,6 +2415,7 @@ impl RuntimeState {
|
|||||||
let worker = self.worker_mut(worker_ref)?;
|
let worker = self.worker_mut(worker_ref)?;
|
||||||
worker.execution_handle = None;
|
worker.execution_handle = None;
|
||||||
worker.status = WorkerStatus::Stopped;
|
worker.status = WorkerStatus::Stopped;
|
||||||
|
worker.internal_workers.clear();
|
||||||
self.publish_worker_upsert(worker_ref.worker_id)?;
|
self.publish_worker_upsert(worker_ref.worker_id)?;
|
||||||
self.persist_runtime_snapshot()?;
|
self.persist_runtime_snapshot()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -2464,7 +2469,134 @@ impl RuntimeState {
|
|||||||
event
|
event
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "ws-server")]
|
fn internal_worker_snapshot_statuses(
|
||||||
|
statuses: &mut BTreeMap<String, InternalWorkerActivity>,
|
||||||
|
snapshot: &protocol::InternalWorkerSnapshot,
|
||||||
|
) {
|
||||||
|
statuses.insert(
|
||||||
|
snapshot.worker.session_id.clone(),
|
||||||
|
InternalWorkerActivity {
|
||||||
|
status: snapshot.status,
|
||||||
|
parent_session_id: snapshot.worker.parent_session_id.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
for child in &snapshot.internal_workers {
|
||||||
|
Self::internal_worker_snapshot_statuses(statuses, child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_internal_worker_subtree(
|
||||||
|
statuses: &mut BTreeMap<String, InternalWorkerActivity>,
|
||||||
|
root_session_id: &str,
|
||||||
|
) {
|
||||||
|
let mut removed = vec![root_session_id.to_string()];
|
||||||
|
while let Some(parent_session_id) = removed.pop() {
|
||||||
|
let children = statuses
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(session_id, worker)| {
|
||||||
|
(worker.parent_session_id.as_deref() == Some(parent_session_id.as_str()))
|
||||||
|
.then(|| session_id.clone())
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
statuses.remove(&parent_session_id);
|
||||||
|
removed.extend(children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project_internal_worker_event(
|
||||||
|
statuses: &mut BTreeMap<String, InternalWorkerActivity>,
|
||||||
|
worker: &protocol::InternalWorkerRef,
|
||||||
|
event: &protocol::Event,
|
||||||
|
) {
|
||||||
|
match event {
|
||||||
|
protocol::Event::Snapshot {
|
||||||
|
status,
|
||||||
|
internal_workers,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
Self::remove_internal_worker_subtree(statuses, &worker.session_id);
|
||||||
|
statuses.insert(
|
||||||
|
worker.session_id.clone(),
|
||||||
|
InternalWorkerActivity {
|
||||||
|
status: *status,
|
||||||
|
parent_session_id: worker.parent_session_id.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
for child in internal_workers {
|
||||||
|
Self::internal_worker_snapshot_statuses(statuses, child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
protocol::Event::InternalWorker {
|
||||||
|
worker: nested_worker,
|
||||||
|
event,
|
||||||
|
..
|
||||||
|
} => Self::project_internal_worker_event(statuses, nested_worker, event),
|
||||||
|
protocol::Event::Status { status } => {
|
||||||
|
statuses.insert(
|
||||||
|
worker.session_id.clone(),
|
||||||
|
InternalWorkerActivity {
|
||||||
|
status: *status,
|
||||||
|
parent_session_id: worker.parent_session_id.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
protocol::Event::RunEnd { result } => {
|
||||||
|
let status = match result {
|
||||||
|
protocol::RunResult::Paused => protocol::WorkerStatus::Paused,
|
||||||
|
protocol::RunResult::Finished
|
||||||
|
| protocol::RunResult::LimitReached
|
||||||
|
| protocol::RunResult::RolledBack => protocol::WorkerStatus::Idle,
|
||||||
|
};
|
||||||
|
statuses.insert(
|
||||||
|
worker.session_id.clone(),
|
||||||
|
InternalWorkerActivity {
|
||||||
|
status,
|
||||||
|
parent_session_id: worker.parent_session_id.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_internal_worker_activity(
|
||||||
|
statuses: &mut BTreeMap<String, InternalWorkerActivity>,
|
||||||
|
event: &protocol::Event,
|
||||||
|
) -> bool {
|
||||||
|
let was_running = statuses
|
||||||
|
.values()
|
||||||
|
.any(|worker| worker.status == protocol::WorkerStatus::Running);
|
||||||
|
match event {
|
||||||
|
protocol::Event::Snapshot {
|
||||||
|
internal_workers, ..
|
||||||
|
} => {
|
||||||
|
statuses.clear();
|
||||||
|
for child in internal_workers {
|
||||||
|
Self::internal_worker_snapshot_statuses(statuses, child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
protocol::Event::InternalWorker { worker, event, .. } => {
|
||||||
|
Self::project_internal_worker_event(statuses, worker, event);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
let is_running = statuses
|
||||||
|
.values()
|
||||||
|
.any(|worker| worker.status == protocol::WorkerStatus::Running);
|
||||||
|
was_running != is_running
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project_internal_worker_activity(
|
||||||
|
&mut self,
|
||||||
|
worker_ref: &WorkerRef,
|
||||||
|
event: &protocol::Event,
|
||||||
|
) -> bool {
|
||||||
|
let Some(worker) = self.workers.get_mut(&worker_ref.worker_id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
Self::update_internal_worker_activity(&mut worker.internal_workers, event)
|
||||||
|
}
|
||||||
|
|
||||||
fn project_protocol_event_to_status(
|
fn project_protocol_event_to_status(
|
||||||
&mut self,
|
&mut self,
|
||||||
worker_ref: &WorkerRef,
|
worker_ref: &WorkerRef,
|
||||||
@@ -2507,6 +2639,12 @@ impl RuntimeState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct InternalWorkerActivity {
|
||||||
|
status: protocol::WorkerStatus,
|
||||||
|
parent_session_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct WorkerRecord {
|
struct WorkerRecord {
|
||||||
worker_ref: WorkerRef,
|
worker_ref: WorkerRef,
|
||||||
@@ -2517,6 +2655,7 @@ struct WorkerRecord {
|
|||||||
run_generation: u64,
|
run_generation: u64,
|
||||||
working_directory: Option<CatalogWorkingDirectoryStatus>,
|
working_directory: Option<CatalogWorkingDirectoryStatus>,
|
||||||
execution_handle: Option<WorkerExecutionHandle>,
|
execution_handle: Option<WorkerExecutionHandle>,
|
||||||
|
internal_workers: BTreeMap<String, InternalWorkerActivity>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkerRecord {
|
impl WorkerRecord {
|
||||||
@@ -2589,6 +2728,11 @@ fn requested_primary_workdir_id(request: &CreateWorkerRequest) -> Option<&str> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn validate_create_worker_request(request: &CreateWorkerRequest) -> Result<(), RuntimeError> {
|
fn validate_create_worker_request(request: &CreateWorkerRequest) -> Result<(), RuntimeError> {
|
||||||
|
if request.create_fingerprint.trim().is_empty() {
|
||||||
|
return Err(RuntimeError::InvalidRequest(
|
||||||
|
"create_fingerprint must not be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
match &request.profile_source {
|
match &request.profile_source {
|
||||||
crate::catalog::ProfileSourceArchiveSource::Embedded { archive } => {
|
crate::catalog::ProfileSourceArchiveSource::Embedded { archive } => {
|
||||||
archive.verify().map_err(|err| {
|
archive.verify().map_err(|err| {
|
||||||
@@ -2734,6 +2878,126 @@ mod tests {
|
|||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
fn internal_worker_ref(
|
||||||
|
session_id: &str,
|
||||||
|
parent_session_id: Option<&str>,
|
||||||
|
) -> protocol::InternalWorkerRef {
|
||||||
|
protocol::InternalWorkerRef {
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
parent_session_id: parent_session_id.map(str::to_string),
|
||||||
|
name: session_id.to_string(),
|
||||||
|
kind: protocol::InternalWorkerKind::SubWorker,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn internal_worker_status_event(
|
||||||
|
worker: protocol::InternalWorkerRef,
|
||||||
|
status: protocol::WorkerStatus,
|
||||||
|
) -> protocol::Event {
|
||||||
|
protocol::Event::InternalWorker {
|
||||||
|
worker,
|
||||||
|
revision: 1,
|
||||||
|
event: Box::new(protocol::Event::Status { status }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn internal_worker_activity_tracks_running_children_independently() {
|
||||||
|
let mut activity = BTreeMap::new();
|
||||||
|
assert!(RuntimeState::update_internal_worker_activity(
|
||||||
|
&mut activity,
|
||||||
|
&internal_worker_status_event(
|
||||||
|
internal_worker_ref("child-a", None),
|
||||||
|
protocol::WorkerStatus::Running,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
assert!(!RuntimeState::update_internal_worker_activity(
|
||||||
|
&mut activity,
|
||||||
|
&internal_worker_status_event(
|
||||||
|
internal_worker_ref("child-b", None),
|
||||||
|
protocol::WorkerStatus::Running,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
assert!(!RuntimeState::update_internal_worker_activity(
|
||||||
|
&mut activity,
|
||||||
|
&internal_worker_status_event(
|
||||||
|
internal_worker_ref("child-a", None),
|
||||||
|
protocol::WorkerStatus::Idle,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
assert!(RuntimeState::update_internal_worker_activity(
|
||||||
|
&mut activity,
|
||||||
|
&internal_worker_status_event(
|
||||||
|
internal_worker_ref("child-b", None),
|
||||||
|
protocol::WorkerStatus::Idle,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_internal_worker_activity_reaches_the_parent_projection() {
|
||||||
|
let mut activity = BTreeMap::new();
|
||||||
|
let direct_child = internal_worker_ref("child", None);
|
||||||
|
let nested_running = protocol::Event::InternalWorker {
|
||||||
|
worker: direct_child.clone(),
|
||||||
|
revision: 1,
|
||||||
|
event: Box::new(internal_worker_status_event(
|
||||||
|
internal_worker_ref("grandchild", Some("child")),
|
||||||
|
protocol::WorkerStatus::Running,
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
assert!(RuntimeState::update_internal_worker_activity(
|
||||||
|
&mut activity,
|
||||||
|
&nested_running,
|
||||||
|
));
|
||||||
|
|
||||||
|
let nested_idle = protocol::Event::InternalWorker {
|
||||||
|
worker: direct_child,
|
||||||
|
revision: 2,
|
||||||
|
event: Box::new(internal_worker_status_event(
|
||||||
|
internal_worker_ref("grandchild", Some("child")),
|
||||||
|
protocol::WorkerStatus::Idle,
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
assert!(RuntimeState::update_internal_worker_activity(
|
||||||
|
&mut activity,
|
||||||
|
&nested_idle,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parent_snapshot_replaces_stale_internal_worker_activity() {
|
||||||
|
let mut activity = BTreeMap::new();
|
||||||
|
RuntimeState::update_internal_worker_activity(
|
||||||
|
&mut activity,
|
||||||
|
&internal_worker_status_event(
|
||||||
|
internal_worker_ref("child-a", None),
|
||||||
|
protocol::WorkerStatus::Running,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let snapshot = protocol::Event::Snapshot {
|
||||||
|
entries: Vec::new(),
|
||||||
|
greeting: protocol::Greeting {
|
||||||
|
worker_name: "parent".to_string(),
|
||||||
|
cwd: "/tmp".to_string(),
|
||||||
|
provider: "test".to_string(),
|
||||||
|
model: "test".to_string(),
|
||||||
|
scope_summary: String::new(),
|
||||||
|
tools: Vec::new(),
|
||||||
|
context_window: 0,
|
||||||
|
context_tokens: 0,
|
||||||
|
},
|
||||||
|
status: protocol::WorkerStatus::Idle,
|
||||||
|
in_flight: protocol::InFlightSnapshot::default(),
|
||||||
|
internal_workers: Vec::new(),
|
||||||
|
};
|
||||||
|
assert!(RuntimeState::update_internal_worker_activity(
|
||||||
|
&mut activity,
|
||||||
|
&snapshot,
|
||||||
|
));
|
||||||
|
assert!(activity.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_identity_binding_is_immutable_and_host_owned() {
|
fn runtime_identity_binding_is_immutable_and_host_owned() {
|
||||||
let runtime = Runtime::new_memory();
|
let runtime = Runtime::new_memory();
|
||||||
@@ -2791,8 +3055,8 @@ mod tests {
|
|||||||
let profile = ProfileSelector::Builtin("builtin:coder".to_string());
|
let profile = ProfileSelector::Builtin("builtin:coder".to_string());
|
||||||
let bundle = test_bundle_for_profile(profile.clone());
|
let bundle = test_bundle_for_profile(profile.clone());
|
||||||
CreateWorkerRequest {
|
CreateWorkerRequest {
|
||||||
idempotency_key: None,
|
worker_id: WorkerId::now_v7(),
|
||||||
idempotency_fingerprint: None,
|
create_fingerprint: "test-create".to_string(),
|
||||||
profile,
|
profile,
|
||||||
display_name: None,
|
display_name: None,
|
||||||
profile_source: crate::catalog::ProfileSourceArchiveSource::Http {
|
profile_source: crate::catalog::ProfileSourceArchiveSource::Http {
|
||||||
@@ -3087,16 +3351,75 @@ mod tests {
|
|||||||
payload => panic!("unexpected subscription payload: {payload:?}"),
|
payload => panic!("unexpected subscription payload: {payload:?}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime.stop_runtime().unwrap();
|
runtime
|
||||||
|
.observe_worker_event(
|
||||||
|
&created.worker_ref,
|
||||||
|
internal_worker_status_event(
|
||||||
|
internal_worker_ref("child-live", None),
|
||||||
|
protocol::WorkerStatus::Running,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let update = receive_subscription_update(&mut subscription).unwrap();
|
let update = receive_subscription_update(&mut subscription).unwrap();
|
||||||
assert_eq!(update.subject_revision, 2);
|
assert_eq!(update.subject_revision, 2);
|
||||||
match update.payload {
|
match update.payload {
|
||||||
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
||||||
assert_eq!(worker.worker_id.as_str(), created.worker_id.to_string());
|
assert_eq!(worker.state, SubscriptionWorkerState::Idle);
|
||||||
assert_eq!(worker.state, SubscriptionWorkerState::Stopped);
|
assert!(worker.has_running_internal_workers);
|
||||||
}
|
}
|
||||||
payload => panic!("unexpected subscription payload: {payload:?}"),
|
payload => panic!("unexpected subscription payload: {payload:?}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runtime
|
||||||
|
.observe_worker_event(
|
||||||
|
&created.worker_ref,
|
||||||
|
internal_worker_status_event(
|
||||||
|
internal_worker_ref("child-live", None),
|
||||||
|
protocol::WorkerStatus::Idle,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let update = receive_subscription_update(&mut subscription).unwrap();
|
||||||
|
assert_eq!(update.subject_revision, 3);
|
||||||
|
match update.payload {
|
||||||
|
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
||||||
|
assert_eq!(worker.state, SubscriptionWorkerState::Idle);
|
||||||
|
assert!(!worker.has_running_internal_workers);
|
||||||
|
}
|
||||||
|
payload => panic!("unexpected subscription payload: {payload:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime
|
||||||
|
.observe_worker_event(
|
||||||
|
&created.worker_ref,
|
||||||
|
internal_worker_status_event(
|
||||||
|
internal_worker_ref("child-live", None),
|
||||||
|
protocol::WorkerStatus::Running,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let update = receive_subscription_update(&mut subscription).unwrap();
|
||||||
|
assert_eq!(update.subject_revision, 4);
|
||||||
|
match update.payload {
|
||||||
|
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
||||||
|
assert_eq!(worker.state, SubscriptionWorkerState::Idle);
|
||||||
|
assert!(worker.has_running_internal_workers);
|
||||||
|
}
|
||||||
|
payload => panic!("unexpected subscription payload: {payload:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.stop_worker(&created.worker_ref, None).unwrap();
|
||||||
|
let update = receive_subscription_update(&mut subscription).unwrap();
|
||||||
|
assert_eq!(update.subject_revision, 5);
|
||||||
|
match update.payload {
|
||||||
|
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
||||||
|
assert_eq!(worker.worker_id.as_str(), created.worker_id.to_string());
|
||||||
|
assert_eq!(worker.state, SubscriptionWorkerState::Stopped);
|
||||||
|
assert!(!worker.has_running_internal_workers);
|
||||||
|
}
|
||||||
|
payload => panic!("unexpected subscription payload: {payload:?}"),
|
||||||
|
}
|
||||||
|
runtime.stop_runtime().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3570,8 +3893,7 @@ mod tests {
|
|||||||
fn create_worker_idempotency_reuses_worker_and_rejects_different_input() {
|
fn create_worker_idempotency_reuses_worker_and_rejects_different_input() {
|
||||||
let runtime = runtime_with_backend();
|
let runtime = runtime_with_backend();
|
||||||
let mut request = task_request("idempotent");
|
let mut request = task_request("idempotent");
|
||||||
request.idempotency_key = Some("operation-1".to_string());
|
request.create_fingerprint = "sha256:input-1".to_string();
|
||||||
request.idempotency_fingerprint = Some("sha256:input-1".to_string());
|
|
||||||
request.working_directory = Some(WorkingDirectoryClaim {
|
request.working_directory = Some(WorkingDirectoryClaim {
|
||||||
working_directory_id: "workdir-idempotent".to_string(),
|
working_directory_id: "workdir-idempotent".to_string(),
|
||||||
relative_cwd: None,
|
relative_cwd: None,
|
||||||
@@ -3587,7 +3909,7 @@ mod tests {
|
|||||||
workdir_count_after_first
|
workdir_count_after_first
|
||||||
);
|
);
|
||||||
|
|
||||||
request.idempotency_fingerprint = Some("sha256:different".to_string());
|
request.create_fingerprint = "sha256:different".to_string();
|
||||||
let error = runtime.create_worker(request).unwrap_err();
|
let error = runtime.create_worker(request).unwrap_err();
|
||||||
assert!(matches!(error, RuntimeError::InvalidRequest(_)));
|
assert!(matches!(error, RuntimeError::InvalidRequest(_)));
|
||||||
assert_eq!(runtime.list_workers().unwrap().len(), 1);
|
assert_eq!(runtime.list_workers().unwrap().len(), 1);
|
||||||
@@ -3770,7 +4092,10 @@ mod tests {
|
|||||||
context_tokens: 64,
|
context_tokens: 64,
|
||||||
},
|
},
|
||||||
status: protocol::WorkerStatus::Running,
|
status: protocol::WorkerStatus::Running,
|
||||||
in_flight: protocol::InFlightSnapshot { blocks: Vec::new() },
|
in_flight: protocol::InFlightSnapshot {
|
||||||
|
blocks: Vec::new(),
|
||||||
|
commands: Vec::new(),
|
||||||
|
},
|
||||||
internal_workers: Vec::new(),
|
internal_workers: Vec::new(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -4141,6 +4466,244 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "fs-store")]
|
||||||
|
#[test]
|
||||||
|
fn fs_store_migrates_legacy_numeric_worker_identity_to_workspace_uuid() {
|
||||||
|
let root = fs_store_root("worker-id-v1");
|
||||||
|
let runtime_id = "arcadia";
|
||||||
|
let runtime = Runtime::with_fs_store_and_execution_backend(
|
||||||
|
crate::fs_store::FsRuntimeStoreOptions {
|
||||||
|
root: root.clone(),
|
||||||
|
runtime_id: runtime_id.to_string(),
|
||||||
|
display_name: None,
|
||||||
|
},
|
||||||
|
Arc::new(TestExecutionBackend::default()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
runtime.store_config_bundle(test_bundle()).unwrap();
|
||||||
|
let worker = runtime
|
||||||
|
.create_worker_scoped(
|
||||||
|
&RuntimeWorkspaceScope::new("workspace-a", "server"),
|
||||||
|
task_request("legacy"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
drop(runtime);
|
||||||
|
|
||||||
|
let current_dir = root.join("workers").join(worker.worker_id.to_string());
|
||||||
|
let legacy_dir = root.join("workers").join("7");
|
||||||
|
std::fs::rename(¤t_dir, &legacy_dir).unwrap();
|
||||||
|
let worker_path = legacy_dir.join("worker.json");
|
||||||
|
let mut worker_json: serde_json::Value =
|
||||||
|
serde_json::from_slice(&std::fs::read(&worker_path).unwrap()).unwrap();
|
||||||
|
worker_json["schema_version"] = serde_json::json!(1);
|
||||||
|
worker_json["worker_id"] = serde_json::json!(7);
|
||||||
|
worker_json["worker_ref"]["worker_id"] = serde_json::json!(7);
|
||||||
|
let request = worker_json["request"].as_object_mut().unwrap();
|
||||||
|
request.remove("worker_id");
|
||||||
|
request.remove("create_fingerprint");
|
||||||
|
request.insert("idempotency_key".to_string(), serde_json::Value::Null);
|
||||||
|
request.insert(
|
||||||
|
"idempotency_fingerprint".to_string(),
|
||||||
|
serde_json::Value::Null,
|
||||||
|
);
|
||||||
|
std::fs::write(
|
||||||
|
&worker_path,
|
||||||
|
serde_json::to_vec_pretty(&worker_json).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let legacy_worker_name = "worker-runtime-7";
|
||||||
|
let legacy_manifest = manifest::WorkerManifest::from_toml(&format!(
|
||||||
|
r#"
|
||||||
|
[worker]
|
||||||
|
name = "{legacy_worker_name}"
|
||||||
|
|
||||||
|
[model]
|
||||||
|
scheme = "anthropic"
|
||||||
|
model_id = "test-model"
|
||||||
|
|
||||||
|
[engine]
|
||||||
|
|
||||||
|
[[scope.allow]]
|
||||||
|
target = "/tmp"
|
||||||
|
permission = "write"
|
||||||
|
"#,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
legacy_dir.join("metadata.json"),
|
||||||
|
serde_json::to_vec_pretty(&serde_json::json!({
|
||||||
|
"worker_name": legacy_worker_name,
|
||||||
|
"workspace_id": "workspace-a",
|
||||||
|
"resolved_manifest_snapshot": legacy_manifest
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let runtime_path = root.join("runtime.json");
|
||||||
|
let mut runtime_json: serde_json::Value =
|
||||||
|
serde_json::from_slice(&std::fs::read(&runtime_path).unwrap()).unwrap();
|
||||||
|
runtime_json["schema_version"] = serde_json::json!(1);
|
||||||
|
runtime_json["workers"] = serde_json::json!({"legacy": "ignored"});
|
||||||
|
runtime_json["next_worker_sequence"] = serde_json::json!(8);
|
||||||
|
runtime_json["next_diagnostic_id"] = serde_json::json!(3);
|
||||||
|
runtime_json["diagnostics"] = serde_json::json!([
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"severity": "warning",
|
||||||
|
"code": "mapped_legacy_worker",
|
||||||
|
"message": "mapped diagnostic",
|
||||||
|
"worker_ref": {"worker_id": 7}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"severity": "warning",
|
||||||
|
"code": "deleted_legacy_worker",
|
||||||
|
"message": "unmapped diagnostic",
|
||||||
|
"worker_ref": {"worker_id": 6}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
std::fs::write(
|
||||||
|
&runtime_path,
|
||||||
|
serde_json::to_vec_pretty(&runtime_json).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
let run_dir = legacy_dir.join("runs").join("6");
|
||||||
|
std::fs::create_dir_all(&run_dir).unwrap();
|
||||||
|
let socket =
|
||||||
|
std::os::unix::net::UnixListener::bind(run_dir.join("worker.sock")).unwrap();
|
||||||
|
drop(socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
let runtime_options = crate::fs_store::FsRuntimeStoreOptions {
|
||||||
|
root: root.clone(),
|
||||||
|
runtime_id: runtime_id.to_string(),
|
||||||
|
display_name: None,
|
||||||
|
};
|
||||||
|
let runtime_before_dry_run = std::fs::read(&runtime_path).unwrap();
|
||||||
|
let plan = crate::fs_store::FsRuntimeStore::migration_plan(&runtime_options).unwrap();
|
||||||
|
assert!(plan.migration_required);
|
||||||
|
assert_eq!(plan.worker_count, 1);
|
||||||
|
assert_eq!(plan.migrated_worker_aggregate_count, 1);
|
||||||
|
assert_eq!(plan.migrated_diagnostic_worker_ref_count, 1);
|
||||||
|
assert_eq!(plan.cleared_diagnostic_worker_ref_count, 1);
|
||||||
|
assert_eq!(plan.mappings[0].legacy_worker_id, 7);
|
||||||
|
#[cfg(unix)]
|
||||||
|
assert_eq!(
|
||||||
|
plan.excluded_ephemeral_paths,
|
||||||
|
vec!["workers/7/runs/6/worker.sock"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(&runtime_path).unwrap(),
|
||||||
|
runtime_before_dry_run
|
||||||
|
);
|
||||||
|
assert!(legacy_dir.exists());
|
||||||
|
|
||||||
|
let restored = Runtime::with_fs_store(runtime_options.clone()).unwrap();
|
||||||
|
let expected = WorkerId::from_legacy_binding("workspace-a", runtime_id, 7);
|
||||||
|
let detail = restored.worker_detail(&WorkerRef::new(expected)).unwrap();
|
||||||
|
assert_eq!(detail.worker_id, expected);
|
||||||
|
assert_eq!(detail.worker_ref.worker_id, expected);
|
||||||
|
let expected_worker_dir = root.join("workers").join(expected.to_string());
|
||||||
|
assert!(expected_worker_dir.exists());
|
||||||
|
#[cfg(unix)]
|
||||||
|
assert!(!expected_worker_dir.join("runs/6/worker.sock").exists());
|
||||||
|
assert!(!legacy_dir.exists());
|
||||||
|
let migrated_runtime: serde_json::Value =
|
||||||
|
serde_json::from_slice(&std::fs::read(&runtime_path).unwrap()).unwrap();
|
||||||
|
assert_eq!(migrated_runtime["schema_version"], serde_json::json!(3));
|
||||||
|
assert!(migrated_runtime.get("workers").is_none());
|
||||||
|
assert!(migrated_runtime.get("next_worker_sequence").is_none());
|
||||||
|
assert_eq!(
|
||||||
|
migrated_runtime["diagnostics"][0]["worker_ref"]["worker_id"],
|
||||||
|
serde_json::json!(expected.to_string())
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
migrated_runtime["diagnostics"][1]
|
||||||
|
.get("worker_ref")
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
let diagnostics = restored.diagnostics().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
diagnostics
|
||||||
|
.iter()
|
||||||
|
.find(|diagnostic| diagnostic.code == "mapped_legacy_worker")
|
||||||
|
.and_then(|diagnostic| diagnostic.worker_ref.as_ref()),
|
||||||
|
Some(&WorkerRef::new(expected))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
diagnostics
|
||||||
|
.iter()
|
||||||
|
.find(|diagnostic| diagnostic.code == "deleted_legacy_worker")
|
||||||
|
.is_some_and(|diagnostic| diagnostic.worker_ref.is_none())
|
||||||
|
);
|
||||||
|
let metadata_path = expected_worker_dir.join("metadata.json");
|
||||||
|
let mut migrated_metadata: serde_json::Value =
|
||||||
|
serde_json::from_slice(&std::fs::read(&metadata_path).unwrap()).unwrap();
|
||||||
|
let expected_worker_name = format!("worker-runtime-{expected}");
|
||||||
|
assert_eq!(
|
||||||
|
migrated_metadata["worker_name"],
|
||||||
|
serde_json::json!(expected_worker_name)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
migrated_metadata["resolved_manifest_snapshot"]["worker"]["name"],
|
||||||
|
serde_json::json!(expected_worker_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(restored);
|
||||||
|
|
||||||
|
let mut schema_v2_runtime: serde_json::Value =
|
||||||
|
serde_json::from_slice(&std::fs::read(&runtime_path).unwrap()).unwrap();
|
||||||
|
schema_v2_runtime["schema_version"] = serde_json::json!(2);
|
||||||
|
std::fs::write(
|
||||||
|
&runtime_path,
|
||||||
|
serde_json::to_vec_pretty(&schema_v2_runtime).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let migrated_worker_path = expected_worker_dir.join("worker.json");
|
||||||
|
let mut schema_v2_worker: serde_json::Value =
|
||||||
|
serde_json::from_slice(&std::fs::read(&migrated_worker_path).unwrap()).unwrap();
|
||||||
|
schema_v2_worker["schema_version"] = serde_json::json!(2);
|
||||||
|
std::fs::write(
|
||||||
|
&migrated_worker_path,
|
||||||
|
serde_json::to_vec_pretty(&schema_v2_worker).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
migrated_metadata["worker_name"] = serde_json::json!(legacy_worker_name);
|
||||||
|
migrated_metadata["resolved_manifest_snapshot"]["worker"]["name"] =
|
||||||
|
serde_json::json!(legacy_worker_name);
|
||||||
|
std::fs::write(
|
||||||
|
&metadata_path,
|
||||||
|
serde_json::to_vec_pretty(&migrated_metadata).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let recovery_plan =
|
||||||
|
crate::fs_store::FsRuntimeStore::migration_plan(&runtime_options).unwrap();
|
||||||
|
assert_eq!(recovery_plan.current_schema_version, 2);
|
||||||
|
assert_eq!(recovery_plan.target_schema_version, 3);
|
||||||
|
assert!(recovery_plan.migration_required);
|
||||||
|
assert_eq!(recovery_plan.worker_count, 1);
|
||||||
|
assert_eq!(recovery_plan.migrated_worker_aggregate_count, 1);
|
||||||
|
assert!(recovery_plan.mappings.is_empty());
|
||||||
|
|
||||||
|
let recovered = Runtime::with_fs_store(runtime_options).unwrap();
|
||||||
|
let recovered_metadata: serde_json::Value =
|
||||||
|
serde_json::from_slice(&std::fs::read(metadata_path).unwrap()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
recovered_metadata["worker_name"],
|
||||||
|
serde_json::json!(expected_worker_name)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
recovered_metadata["resolved_manifest_snapshot"]["worker"]["name"],
|
||||||
|
serde_json::json!(expected_worker_name)
|
||||||
|
);
|
||||||
|
drop(recovered);
|
||||||
|
let _ = std::fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "fs-store")]
|
#[cfg(feature = "fs-store")]
|
||||||
#[test]
|
#[test]
|
||||||
fn fs_store_restores_workers_without_legacy_event_or_protocol_observation_logs() {
|
fn fs_store_restores_workers_without_legacy_event_or_protocol_observation_logs() {
|
||||||
@@ -4148,6 +4711,7 @@ mod tests {
|
|||||||
let runtime = Runtime::with_fs_store_and_execution_backend(
|
let runtime = Runtime::with_fs_store_and_execution_backend(
|
||||||
crate::fs_store::FsRuntimeStoreOptions {
|
crate::fs_store::FsRuntimeStoreOptions {
|
||||||
root: root.clone(),
|
root: root.clone(),
|
||||||
|
runtime_id: "test-runtime".to_string(),
|
||||||
display_name: Some("filesystem runtime".to_string()),
|
display_name: Some("filesystem runtime".to_string()),
|
||||||
},
|
},
|
||||||
Arc::new(TestExecutionBackend::default()),
|
Arc::new(TestExecutionBackend::default()),
|
||||||
@@ -4189,6 +4753,7 @@ mod tests {
|
|||||||
|
|
||||||
let restored = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions {
|
let restored = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions {
|
||||||
root: root.clone(),
|
root: root.clone(),
|
||||||
|
runtime_id: "test-runtime".to_string(),
|
||||||
display_name: None,
|
display_name: None,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -4239,6 +4804,7 @@ mod tests {
|
|||||||
let runtime = Runtime::with_fs_store_and_execution_backend(
|
let runtime = Runtime::with_fs_store_and_execution_backend(
|
||||||
crate::fs_store::FsRuntimeStoreOptions {
|
crate::fs_store::FsRuntimeStoreOptions {
|
||||||
root: root.clone(),
|
root: root.clone(),
|
||||||
|
runtime_id: "test-runtime".to_string(),
|
||||||
display_name: None,
|
display_name: None,
|
||||||
},
|
},
|
||||||
Arc::new(TestExecutionBackend::default()),
|
Arc::new(TestExecutionBackend::default()),
|
||||||
@@ -4264,6 +4830,7 @@ mod tests {
|
|||||||
|
|
||||||
let restored = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions {
|
let restored = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions {
|
||||||
root: root.clone(),
|
root: root.clone(),
|
||||||
|
runtime_id: "test-runtime".to_string(),
|
||||||
display_name: None,
|
display_name: None,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -4313,6 +4880,7 @@ mod tests {
|
|||||||
let runtime = Runtime::with_fs_store_and_execution_backend(
|
let runtime = Runtime::with_fs_store_and_execution_backend(
|
||||||
crate::fs_store::FsRuntimeStoreOptions {
|
crate::fs_store::FsRuntimeStoreOptions {
|
||||||
root: root.clone(),
|
root: root.clone(),
|
||||||
|
runtime_id: "test-runtime".to_string(),
|
||||||
display_name: None,
|
display_name: None,
|
||||||
},
|
},
|
||||||
Arc::new(TestExecutionBackend::default()),
|
Arc::new(TestExecutionBackend::default()),
|
||||||
@@ -4326,6 +4894,7 @@ mod tests {
|
|||||||
|
|
||||||
let backendless = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions {
|
let backendless = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions {
|
||||||
root: root.clone(),
|
root: root.clone(),
|
||||||
|
runtime_id: "test-runtime".to_string(),
|
||||||
display_name: None,
|
display_name: None,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -4337,6 +4906,7 @@ mod tests {
|
|||||||
let restored = Runtime::with_fs_store_and_execution_backend(
|
let restored = Runtime::with_fs_store_and_execution_backend(
|
||||||
crate::fs_store::FsRuntimeStoreOptions {
|
crate::fs_store::FsRuntimeStoreOptions {
|
||||||
root: root.clone(),
|
root: root.clone(),
|
||||||
|
runtime_id: "test-runtime".to_string(),
|
||||||
display_name: None,
|
display_name: None,
|
||||||
},
|
},
|
||||||
restoring_backend.clone(),
|
restoring_backend.clone(),
|
||||||
@@ -4360,6 +4930,7 @@ mod tests {
|
|||||||
let runtime = Runtime::with_fs_store_and_execution_backend(
|
let runtime = Runtime::with_fs_store_and_execution_backend(
|
||||||
crate::fs_store::FsRuntimeStoreOptions {
|
crate::fs_store::FsRuntimeStoreOptions {
|
||||||
root: root.clone(),
|
root: root.clone(),
|
||||||
|
runtime_id: "test-runtime".to_string(),
|
||||||
display_name: None,
|
display_name: None,
|
||||||
},
|
},
|
||||||
Arc::new(TestExecutionBackend::default()),
|
Arc::new(TestExecutionBackend::default()),
|
||||||
@@ -4379,6 +4950,7 @@ mod tests {
|
|||||||
let restored = Runtime::with_fs_store_and_execution_backend(
|
let restored = Runtime::with_fs_store_and_execution_backend(
|
||||||
crate::fs_store::FsRuntimeStoreOptions {
|
crate::fs_store::FsRuntimeStoreOptions {
|
||||||
root: root.clone(),
|
root: root.clone(),
|
||||||
|
runtime_id: "test-runtime".to_string(),
|
||||||
display_name: None,
|
display_name: None,
|
||||||
},
|
},
|
||||||
restoring_backend.clone(),
|
restoring_backend.clone(),
|
||||||
@@ -4415,6 +4987,7 @@ mod tests {
|
|||||||
let corrupt_root = fs_store_root("corrupt");
|
let corrupt_root = fs_store_root("corrupt");
|
||||||
let corrupt_runtime = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions {
|
let corrupt_runtime = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions {
|
||||||
root: corrupt_root.clone(),
|
root: corrupt_root.clone(),
|
||||||
|
runtime_id: "test-runtime".to_string(),
|
||||||
display_name: None,
|
display_name: None,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -4427,6 +5000,7 @@ mod tests {
|
|||||||
drop(corrupt_runtime);
|
drop(corrupt_runtime);
|
||||||
let err = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions {
|
let err = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions {
|
||||||
root: corrupt_root.clone(),
|
root: corrupt_root.clone(),
|
||||||
|
runtime_id: "test-runtime".to_string(),
|
||||||
display_name: None,
|
display_name: None,
|
||||||
})
|
})
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
@@ -4437,6 +5011,7 @@ mod tests {
|
|||||||
let missing_runtime = Runtime::with_fs_store_and_execution_backend(
|
let missing_runtime = Runtime::with_fs_store_and_execution_backend(
|
||||||
crate::fs_store::FsRuntimeStoreOptions {
|
crate::fs_store::FsRuntimeStoreOptions {
|
||||||
root: missing_root.clone(),
|
root: missing_root.clone(),
|
||||||
|
runtime_id: "test-runtime".to_string(),
|
||||||
display_name: None,
|
display_name: None,
|
||||||
},
|
},
|
||||||
Arc::new(TestExecutionBackend::default()),
|
Arc::new(TestExecutionBackend::default()),
|
||||||
@@ -4456,6 +5031,7 @@ mod tests {
|
|||||||
drop(missing_runtime);
|
drop(missing_runtime);
|
||||||
let loaded = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions {
|
let loaded = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions {
|
||||||
root: missing_root.clone(),
|
root: missing_root.clone(),
|
||||||
|
runtime_id: "test-runtime".to_string(),
|
||||||
display_name: None,
|
display_name: None,
|
||||||
})
|
})
|
||||||
.expect("invalid worker snapshot should not make runtime store unreadable");
|
.expect("invalid worker snapshot should not make runtime store unreadable");
|
||||||
|
|||||||
@@ -1998,6 +1998,7 @@ mod tests {
|
|||||||
WorkingDirectoryRequest,
|
WorkingDirectoryRequest,
|
||||||
};
|
};
|
||||||
use crate::execution::WorkerExecutionContext;
|
use crate::execution::WorkerExecutionContext;
|
||||||
|
use crate::identity::WorkerId;
|
||||||
use crate::identity::WorkerRef;
|
use crate::identity::WorkerRef;
|
||||||
use crate::management::RuntimeOptions;
|
use crate::management::RuntimeOptions;
|
||||||
use crate::observation::WorkerObservationCursor;
|
use crate::observation::WorkerObservationCursor;
|
||||||
@@ -2119,7 +2120,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn restart_restore_reconstructs_runtime_owned_worker_mutation_client() {
|
fn restart_restore_reconstructs_runtime_owned_worker_mutation_client() {
|
||||||
let identity = RuntimeIdentityMaterial::generate("runtime-source").unwrap();
|
let identity = RuntimeIdentityMaterial::generate("runtime-source").unwrap();
|
||||||
let worker_ref = WorkerRef::new(crate::identity::WorkerId::new(17));
|
let worker_ref = WorkerRef::new(crate::identity::WorkerId::from_legacy_u64(17));
|
||||||
let backend = RuntimeWorkspaceBackendRef::Http {
|
let backend = RuntimeWorkspaceBackendRef::Http {
|
||||||
workspace_id: "workspace-a".to_string(),
|
workspace_id: "workspace-a".to_string(),
|
||||||
base_url: "https://server.invalid".to_string(),
|
base_url: "https://server.invalid".to_string(),
|
||||||
@@ -2486,8 +2487,8 @@ mod tests {
|
|||||||
fn create_request(_name: &str) -> CreateWorkerRequest {
|
fn create_request(_name: &str) -> CreateWorkerRequest {
|
||||||
let bundle = test_bundle();
|
let bundle = test_bundle();
|
||||||
CreateWorkerRequest {
|
CreateWorkerRequest {
|
||||||
idempotency_key: None,
|
worker_id: WorkerId::now_v7(),
|
||||||
idempotency_fingerprint: None,
|
create_fingerprint: "test-create".to_string(),
|
||||||
profile: ProfileSelector::Builtin("builtin:companion".to_string()),
|
profile: ProfileSelector::Builtin("builtin:companion".to_string()),
|
||||||
display_name: None,
|
display_name: None,
|
||||||
profile_source: crate::catalog::ProfileSourceArchiveSource::Embedded {
|
profile_source: crate::catalog::ProfileSourceArchiveSource::Embedded {
|
||||||
@@ -2590,7 +2591,8 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn runtime_provider_projects_only_explicit_live_canonical_grants() {
|
async fn runtime_provider_projects_only_explicit_live_canonical_grants() {
|
||||||
let hub = Arc::new(RuntimeWorkerObservationHub::default());
|
let hub = Arc::new(RuntimeWorkerObservationHub::default());
|
||||||
let worker_ref = WorkerRef::new(crate::identity::WorkerId::new(7));
|
let worker_id = crate::identity::WorkerId::from_legacy_u64(7);
|
||||||
|
let worker_ref = WorkerRef::new(worker_id);
|
||||||
let shared_state = Arc::new(WorkerSharedState::new(
|
let shared_state = Arc::new(WorkerSharedState::new(
|
||||||
"peer-worker".to_string(),
|
"peer-worker".to_string(),
|
||||||
session_store::new_segment_id(),
|
session_store::new_segment_id(),
|
||||||
@@ -2614,7 +2616,7 @@ mod tests {
|
|||||||
sink: SegmentLogSink::new(),
|
sink: SegmentLogSink::new(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let grant = crate::identity::RuntimeWorkerRef::new("runtime-1", "7");
|
let grant = crate::identity::RuntimeWorkerRef::new("runtime-1", worker_id.to_string());
|
||||||
let provider = RuntimeGrantedWorkerObservationProvider {
|
let provider = RuntimeGrantedWorkerObservationProvider {
|
||||||
runtime_id: "runtime-1".to_string(),
|
runtime_id: "runtime-1".to_string(),
|
||||||
workspace_id: "workspace-1".to_string(),
|
workspace_id: "workspace-1".to_string(),
|
||||||
@@ -2628,7 +2630,7 @@ mod tests {
|
|||||||
listed[0].subject,
|
listed[0].subject,
|
||||||
WorkerObservationSubjectRef::RuntimeWorker {
|
WorkerObservationSubjectRef::RuntimeWorker {
|
||||||
runtime_id: "runtime-1".to_string(),
|
runtime_id: "runtime-1".to_string(),
|
||||||
worker_id: "7".to_string(),
|
worker_id: worker_id.to_string(),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
provider
|
provider
|
||||||
@@ -2668,8 +2670,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_worker_name_is_runtime_local() {
|
fn runtime_worker_name_uses_workspace_worker_identity() {
|
||||||
let worker_ref = crate::identity::WorkerRef::new(crate::identity::WorkerId::new(1));
|
let worker_ref =
|
||||||
|
crate::identity::WorkerRef::new(crate::identity::WorkerId::from_legacy_u64(1));
|
||||||
let request = WorkerExecutionSpawnRequest {
|
let request = WorkerExecutionSpawnRequest {
|
||||||
worker_ref: worker_ref.clone(),
|
worker_ref: worker_ref.clone(),
|
||||||
run_generation: 1,
|
run_generation: 1,
|
||||||
@@ -2682,7 +2685,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ProfileRuntimeWorkerFactory::runtime_worker_name(&request),
|
ProfileRuntimeWorkerFactory::runtime_worker_name(&request),
|
||||||
"worker-runtime-1"
|
format!("worker-runtime-{}", request.worker_ref.worker_id)
|
||||||
);
|
);
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
ProfileRuntimeWorkerFactory::runtime_worker_name(&request),
|
ProfileRuntimeWorkerFactory::runtime_worker_name(&request),
|
||||||
@@ -2765,8 +2768,10 @@ mod tests {
|
|||||||
async fn restore_pending_workspace_worker_without_system_prompt_fails_closed() {
|
async fn restore_pending_workspace_worker_without_system_prompt_fails_closed() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
let runtime_store_dir = root.path().join("runtime");
|
let runtime_store_dir = root.path().join("runtime");
|
||||||
let worker_ref = WorkerRef::new(crate::identity::WorkerId::new(1));
|
let worker_ref = WorkerRef::new(crate::identity::WorkerId::from_legacy_u64(1));
|
||||||
let worker_aggregate_dir = runtime_store_dir.join("workers/1");
|
let worker_aggregate_dir = runtime_store_dir
|
||||||
|
.join("workers")
|
||||||
|
.join(worker_ref.worker_id.to_string());
|
||||||
let worker_name = ProfileRuntimeWorkerFactory::runtime_worker_name_for_ref(&worker_ref);
|
let worker_name = ProfileRuntimeWorkerFactory::runtime_worker_name_for_ref(&worker_ref);
|
||||||
let session_id = session_store::new_session_id();
|
let session_id = session_store::new_session_id();
|
||||||
let manifest = manifest::WorkerManifest::from_toml(&format!(
|
let manifest = manifest::WorkerManifest::from_toml(&format!(
|
||||||
@@ -2842,8 +2847,10 @@ mod tests {
|
|||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
let long_component = "embedded-workspace-store-segment".repeat(4);
|
let long_component = "embedded-workspace-store-segment".repeat(4);
|
||||||
let runtime_store_dir = root.path().join(long_component);
|
let runtime_store_dir = root.path().join(long_component);
|
||||||
let worker_ref = WorkerRef::new(crate::identity::WorkerId::new(1));
|
let worker_ref = WorkerRef::new(crate::identity::WorkerId::from_legacy_u64(1));
|
||||||
let worker_aggregate_dir = runtime_store_dir.join("workers/1");
|
let worker_aggregate_dir = runtime_store_dir
|
||||||
|
.join("workers")
|
||||||
|
.join(worker_ref.worker_id.to_string());
|
||||||
let worker_name = ProfileRuntimeWorkerFactory::runtime_worker_name_for_ref(&worker_ref);
|
let worker_name = ProfileRuntimeWorkerFactory::runtime_worker_name_for_ref(&worker_ref);
|
||||||
let session_id = session_store::new_session_id();
|
let session_id = session_store::new_session_id();
|
||||||
let manifest = manifest::WorkerManifest::from_toml(&format!(
|
let manifest = manifest::WorkerManifest::from_toml(&format!(
|
||||||
@@ -2880,7 +2887,10 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let run_dir = runtime_store_dir.join("workers/1/runs/2");
|
let run_dir = runtime_store_dir
|
||||||
|
.join("workers")
|
||||||
|
.join(worker_ref.worker_id.to_string())
|
||||||
|
.join("runs/2");
|
||||||
let socket_path = run_dir.join("worker.sock");
|
let socket_path = run_dir.join("worker.sock");
|
||||||
assert!(
|
assert!(
|
||||||
socket_path.as_os_str().as_encoded_bytes().len() > 107,
|
socket_path.as_os_str().as_encoded_bytes().len() > 107,
|
||||||
@@ -2926,6 +2936,7 @@ mod tests {
|
|||||||
let runtime_store_dir = root.path().join(long_component);
|
let runtime_store_dir = root.path().join(long_component);
|
||||||
let runtime_options = crate::fs_store::FsRuntimeStoreOptions {
|
let runtime_options = crate::fs_store::FsRuntimeStoreOptions {
|
||||||
root: runtime_store_dir.clone(),
|
root: runtime_store_dir.clone(),
|
||||||
|
runtime_id: "test-runtime".to_string(),
|
||||||
display_name: Some("embedded".to_string()),
|
display_name: Some("embedded".to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2946,7 +2957,10 @@ mod tests {
|
|||||||
let mut request = create_request("embedded singleton");
|
let mut request = create_request("embedded singleton");
|
||||||
request.profile = ProfileSelector::Builtin("default".to_string());
|
request.profile = ProfileSelector::Builtin("default".to_string());
|
||||||
let worker = runtime.create_worker(request).unwrap();
|
let worker = runtime.create_worker(request).unwrap();
|
||||||
let first_run_socket = runtime_store_dir.join("workers/1/runs/1/worker.sock");
|
let first_run_socket = runtime_store_dir
|
||||||
|
.join("workers")
|
||||||
|
.join(worker.worker_id.to_string())
|
||||||
|
.join("runs/1/worker.sock");
|
||||||
assert!(
|
assert!(
|
||||||
first_run_socket.as_os_str().as_encoded_bytes().len() > 107,
|
first_run_socket.as_os_str().as_encoded_bytes().len() > 107,
|
||||||
"test path must exceed Linux sockaddr_un.sun_path capacity: {}",
|
"test path must exceed Linux sockaddr_un.sun_path capacity: {}",
|
||||||
@@ -2998,7 +3012,10 @@ mod tests {
|
|||||||
diagnostic.code == "worker_execution_restore_failed"
|
diagnostic.code == "worker_execution_restore_failed"
|
||||||
&& diagnostic.worker_ref.as_ref() == Some(&worker.worker_ref)
|
&& diagnostic.worker_ref.as_ref() == Some(&worker.worker_ref)
|
||||||
}));
|
}));
|
||||||
let restored_run = runtime_store_dir.join("workers/1/runs/2");
|
let restored_run = runtime_store_dir
|
||||||
|
.join("workers")
|
||||||
|
.join(worker.worker_id.to_string())
|
||||||
|
.join("runs/2");
|
||||||
assert!(!restored_run.join("worker.sock").exists());
|
assert!(!restored_run.join("worker.sock").exists());
|
||||||
assert!(restored_run.join("worker.out.log").is_file());
|
assert!(restored_run.join("worker.out.log").is_file());
|
||||||
assert!(restored_run.join("worker.err.log").is_file());
|
assert!(restored_run.join("worker.err.log").is_file());
|
||||||
|
|||||||
@@ -125,7 +125,6 @@ pub trait EmbeddedWorkerMutationDispatcher: Send + Sync {
|
|||||||
proof: InProcessWorkerMutationProof,
|
proof: InProcessWorkerMutationProof,
|
||||||
target_runtime_id: &str,
|
target_runtime_id: &str,
|
||||||
target_worker_id: &str,
|
target_worker_id: &str,
|
||||||
expected_worker_revision: &str,
|
|
||||||
reason: &str,
|
reason: &str,
|
||||||
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError>;
|
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError>;
|
||||||
}
|
}
|
||||||
@@ -187,7 +186,6 @@ impl RuntimeWorkerMutationForwarder {
|
|||||||
&self,
|
&self,
|
||||||
target_runtime_id: &str,
|
target_runtime_id: &str,
|
||||||
target_worker_id: &str,
|
target_worker_id: &str,
|
||||||
expected_worker_revision: &str,
|
|
||||||
reason: &str,
|
reason: &str,
|
||||||
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> {
|
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> {
|
||||||
let proof = self.authority.issue_worker_remove(
|
let proof = self.authority.issue_worker_remove(
|
||||||
@@ -206,7 +204,6 @@ impl RuntimeWorkerMutationForwarder {
|
|||||||
token,
|
token,
|
||||||
target_runtime_id: target_runtime_id.to_string(),
|
target_runtime_id: target_runtime_id.to_string(),
|
||||||
target_worker_id: target_worker_id.to_string(),
|
target_worker_id: target_worker_id.to_string(),
|
||||||
expected_worker_revision: expected_worker_revision.to_string(),
|
|
||||||
reason: reason.to_string(),
|
reason: reason.to_string(),
|
||||||
}),
|
}),
|
||||||
(
|
(
|
||||||
@@ -216,7 +213,6 @@ impl RuntimeWorkerMutationForwarder {
|
|||||||
claims,
|
claims,
|
||||||
target_runtime_id,
|
target_runtime_id,
|
||||||
target_worker_id,
|
target_worker_id,
|
||||||
expected_worker_revision,
|
|
||||||
reason,
|
reason,
|
||||||
),
|
),
|
||||||
_ => Err(RuntimeWorkerMutationForwardError::AuthorityTransportMismatch),
|
_ => Err(RuntimeWorkerMutationForwardError::AuthorityTransportMismatch),
|
||||||
@@ -230,7 +226,6 @@ struct RemoteWorkerRemoveHttpRequest {
|
|||||||
token: String,
|
token: String,
|
||||||
target_runtime_id: String,
|
target_runtime_id: String,
|
||||||
target_worker_id: String,
|
target_worker_id: String,
|
||||||
expected_worker_revision: String,
|
|
||||||
reason: String,
|
reason: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +262,6 @@ fn execute_remote_worker_remove_http_blocking(
|
|||||||
let body = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
"target_runtime_id": request.target_runtime_id,
|
"target_runtime_id": request.target_runtime_id,
|
||||||
"target_worker_id": request.target_worker_id,
|
"target_worker_id": request.target_worker_id,
|
||||||
"expected_worker_revision": request.expected_worker_revision,
|
|
||||||
"reason": request.reason,
|
"reason": request.reason,
|
||||||
});
|
});
|
||||||
let client = reqwest::blocking::Client::new();
|
let client = reqwest::blocking::Client::new();
|
||||||
@@ -474,7 +468,6 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
|||||||
&self,
|
&self,
|
||||||
target_runtime_id: &str,
|
target_runtime_id: &str,
|
||||||
target_worker_id: &str,
|
target_worker_id: &str,
|
||||||
expected_worker_revision: &str,
|
|
||||||
reason: &str,
|
reason: &str,
|
||||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||||
self.worker_remove
|
self.worker_remove
|
||||||
@@ -484,12 +477,7 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
|||||||
"Runtime-owned WorkerRemove forwarding is unavailable".to_string(),
|
"Runtime-owned WorkerRemove forwarding is unavailable".to_string(),
|
||||||
)
|
)
|
||||||
})?
|
})?
|
||||||
.execute_worker_remove(
|
.execute_worker_remove(target_runtime_id, target_worker_id, reason)
|
||||||
target_runtime_id,
|
|
||||||
target_worker_id,
|
|
||||||
expected_worker_revision,
|
|
||||||
reason,
|
|
||||||
)
|
|
||||||
.map_err(|error| WorkspaceClientError::Request(error.to_string()))
|
.map_err(|error| WorkspaceClientError::Request(error.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -915,12 +903,7 @@ mod tests {
|
|||||||
format!("http://{address}"),
|
format!("http://{address}"),
|
||||||
);
|
);
|
||||||
let response = forwarder
|
let response = forwarder
|
||||||
.execute_worker_remove(
|
.execute_worker_remove("runtime-target", "worker-target", "retire obsolete Worker")
|
||||||
"runtime-target",
|
|
||||||
"worker-target",
|
|
||||||
"revision-7",
|
|
||||||
"retire obsolete Worker",
|
|
||||||
)
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(response.status, 204);
|
assert_eq!(response.status, 204);
|
||||||
server.join().unwrap();
|
server.join().unwrap();
|
||||||
@@ -929,7 +912,7 @@ mod tests {
|
|||||||
assert!(request.starts_with("POST /api/w/workspace-a/workers/remove HTTP/1.1"));
|
assert!(request.starts_with("POST /api/w/workspace-a/workers/remove HTTP/1.1"));
|
||||||
assert!(request.contains("\"target_runtime_id\":\"runtime-target\""));
|
assert!(request.contains("\"target_runtime_id\":\"runtime-target\""));
|
||||||
assert!(request.contains("\"target_worker_id\":\"worker-target\""));
|
assert!(request.contains("\"target_worker_id\":\"worker-target\""));
|
||||||
assert!(request.contains("\"expected_worker_revision\":\"revision-7\""));
|
assert!(!request.contains("expected_worker_revision"));
|
||||||
assert!(request.contains("\"reason\":\"retire obsolete Worker\""));
|
assert!(request.contains("\"reason\":\"retire obsolete Worker\""));
|
||||||
let token = request
|
let token = request
|
||||||
.lines()
|
.lines()
|
||||||
@@ -962,7 +945,7 @@ mod tests {
|
|||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct RecordingDispatcher {
|
struct RecordingDispatcher {
|
||||||
seen: Mutex<Option<(WorkerMutationSourceClaims, String, String, String, String)>>,
|
seen: Mutex<Option<(WorkerMutationSourceClaims, String, String, String)>>,
|
||||||
}
|
}
|
||||||
impl EmbeddedWorkerMutationDispatcher for RecordingDispatcher {
|
impl EmbeddedWorkerMutationDispatcher for RecordingDispatcher {
|
||||||
fn execute_worker_remove(
|
fn execute_worker_remove(
|
||||||
@@ -970,14 +953,12 @@ mod tests {
|
|||||||
proof: InProcessWorkerMutationProof,
|
proof: InProcessWorkerMutationProof,
|
||||||
target_runtime_id: &str,
|
target_runtime_id: &str,
|
||||||
target_worker_id: &str,
|
target_worker_id: &str,
|
||||||
expected_worker_revision: &str,
|
|
||||||
reason: &str,
|
reason: &str,
|
||||||
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> {
|
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> {
|
||||||
*self.seen.lock().unwrap() = Some((
|
*self.seen.lock().unwrap() = Some((
|
||||||
proof.into_claims(),
|
proof.into_claims(),
|
||||||
target_runtime_id.to_string(),
|
target_runtime_id.to_string(),
|
||||||
target_worker_id.to_string(),
|
target_worker_id.to_string(),
|
||||||
expected_worker_revision.to_string(),
|
|
||||||
reason.to_string(),
|
reason.to_string(),
|
||||||
));
|
));
|
||||||
Ok(WorkspaceResponse {
|
Ok(WorkspaceResponse {
|
||||||
@@ -996,15 +977,10 @@ mod tests {
|
|||||||
dispatcher.clone(),
|
dispatcher.clone(),
|
||||||
);
|
);
|
||||||
let response = forwarder
|
let response = forwarder
|
||||||
.execute_worker_remove(
|
.execute_worker_remove("runtime-target", "worker-target", "retire obsolete Worker")
|
||||||
"runtime-target",
|
|
||||||
"worker-target",
|
|
||||||
"revision-7",
|
|
||||||
"retire obsolete Worker",
|
|
||||||
)
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(response.status, 202);
|
assert_eq!(response.status, 202);
|
||||||
let (claims, target_runtime_id, target_worker_id, expected_revision, reason) =
|
let (claims, target_runtime_id, target_worker_id, reason) =
|
||||||
dispatcher.seen.lock().unwrap().take().unwrap();
|
dispatcher.seen.lock().unwrap().take().unwrap();
|
||||||
assert_eq!(claims.iss, "runtime-embedded");
|
assert_eq!(claims.iss, "runtime-embedded");
|
||||||
assert_eq!(claims.worker_id, "worker-source");
|
assert_eq!(claims.worker_id, "worker-source");
|
||||||
@@ -1012,7 +988,6 @@ mod tests {
|
|||||||
assert_eq!(claims.target_worker_id, "worker-target");
|
assert_eq!(claims.target_worker_id, "worker-target");
|
||||||
assert_eq!(target_runtime_id, "runtime-target");
|
assert_eq!(target_runtime_id, "runtime-target");
|
||||||
assert_eq!(target_worker_id, "worker-target");
|
assert_eq!(target_worker_id, "worker-target");
|
||||||
assert_eq!(expected_revision, "revision-7");
|
|
||||||
assert_eq!(reason, "retire obsolete Worker");
|
assert_eq!(reason, "retire obsolete Worker");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -803,7 +803,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn worker_ref(sequence: u64) -> WorkerRef {
|
fn worker_ref(sequence: u64) -> WorkerRef {
|
||||||
WorkerRef::new(WorkerId::generated(sequence))
|
WorkerRef::new(WorkerId::from_legacy_u64(sequence))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -28,8 +28,14 @@ use crate::worker::{
|
|||||||
WorkerRunResult,
|
WorkerRunResult,
|
||||||
};
|
};
|
||||||
use protocol::{
|
use protocol::{
|
||||||
AlertLevel, AlertSource, ErrorCode, Event, Method, RewindTargetId, RunResult, Segment,
|
AlertLevel, AlertSource, CommandEvent as ProtocolCommandEvent,
|
||||||
TurnResult, WorkerStatus,
|
CommandSnapshot as ProtocolCommandSnapshot, CommandStatus as ProtocolCommandStatus,
|
||||||
|
CommandStream as ProtocolCommandStream, CommandStreamSlice as ProtocolCommandStreamSlice,
|
||||||
|
ErrorCode, Event, Method, RewindTargetId, RunResult, Segment, TurnResult, WorkerStatus,
|
||||||
|
};
|
||||||
|
use workdir::{
|
||||||
|
CommandEvent as WorkdirCommandEvent, CommandSnapshot as WorkdirCommandSnapshot,
|
||||||
|
CommandStatus as WorkdirCommandStatus, CommandStream as WorkdirCommandStream, WorkdirSession,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -424,6 +430,9 @@ impl WorkerController {
|
|||||||
Some(method_tx.downgrade()),
|
Some(method_tx.downgrade()),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
if let Some(session) = fs_for_view.as_ref() {
|
||||||
|
wire_workdir_command_events(session, &in_flight);
|
||||||
|
}
|
||||||
|
|
||||||
// Intake role Workers self-terminate only after a successful
|
// Intake role Workers self-terminate only after a successful
|
||||||
// TicketIntakeReady turn has fully settled back to Idle. The request
|
// TicketIntakeReady turn has fully settled back to Idle. The request
|
||||||
@@ -498,6 +507,125 @@ impl WorkerController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn wire_workdir_command_events(
|
||||||
|
session: &Arc<dyn WorkdirSession>,
|
||||||
|
in_flight: &InFlightEvents,
|
||||||
|
) {
|
||||||
|
in_flight.replace_command_snapshot(protocol_command_snapshots(session.as_ref()));
|
||||||
|
let Some(mut events) = session.subscribe_command_events() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Keep only a weak reference in the observer task. Holding the session
|
||||||
|
// strongly here would keep its broadcast sender alive forever and prevent
|
||||||
|
// the receiver from observing closure during Worker teardown.
|
||||||
|
let session = Arc::downgrade(session);
|
||||||
|
let in_flight = in_flight.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match events.recv().await {
|
||||||
|
Ok(event) => in_flight.publish_command_event(protocol_command_event(event)),
|
||||||
|
Err(broadcast::error::RecvError::Lagged(_)) => {
|
||||||
|
let Some(session) = session.upgrade() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
in_flight
|
||||||
|
.replace_command_snapshot(protocol_command_snapshots(session.as_ref()));
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn protocol_command_snapshots(session: &dyn WorkdirSession) -> Vec<ProtocolCommandSnapshot> {
|
||||||
|
session
|
||||||
|
.command_snapshot()
|
||||||
|
.into_iter()
|
||||||
|
.map(protocol_command_snapshot)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn protocol_command_snapshot(snapshot: WorkdirCommandSnapshot) -> ProtocolCommandSnapshot {
|
||||||
|
ProtocolCommandSnapshot {
|
||||||
|
command_id: snapshot.command_id,
|
||||||
|
tool_call_id: snapshot.tool_call_id,
|
||||||
|
status: protocol_command_status(snapshot.status),
|
||||||
|
started_at_ms: snapshot.started_at_ms,
|
||||||
|
observed_at_ms: snapshot.observed_at_ms,
|
||||||
|
last_output_at_ms: snapshot.last_output_at_ms,
|
||||||
|
stdout: ProtocolCommandStreamSlice {
|
||||||
|
start_offset: snapshot.stdout.start_offset,
|
||||||
|
end_offset: snapshot.stdout.end_offset,
|
||||||
|
content: snapshot.stdout.content,
|
||||||
|
truncated: snapshot.stdout.truncated,
|
||||||
|
},
|
||||||
|
stderr: ProtocolCommandStreamSlice {
|
||||||
|
start_offset: snapshot.stderr.start_offset,
|
||||||
|
end_offset: snapshot.stderr.end_offset,
|
||||||
|
content: snapshot.stderr.content,
|
||||||
|
truncated: snapshot.stderr.truncated,
|
||||||
|
},
|
||||||
|
exit_code: snapshot.exit_code,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn protocol_command_event(event: WorkdirCommandEvent) -> ProtocolCommandEvent {
|
||||||
|
match event {
|
||||||
|
WorkdirCommandEvent::Started {
|
||||||
|
command_id,
|
||||||
|
tool_call_id,
|
||||||
|
observed_at_ms,
|
||||||
|
} => ProtocolCommandEvent::Started {
|
||||||
|
command_id,
|
||||||
|
tool_call_id,
|
||||||
|
observed_at_ms,
|
||||||
|
},
|
||||||
|
WorkdirCommandEvent::Output {
|
||||||
|
command_id,
|
||||||
|
stream,
|
||||||
|
start_offset,
|
||||||
|
end_offset,
|
||||||
|
content,
|
||||||
|
observed_at_ms,
|
||||||
|
} => ProtocolCommandEvent::Output {
|
||||||
|
command_id,
|
||||||
|
stream: match stream {
|
||||||
|
WorkdirCommandStream::Stdout => ProtocolCommandStream::Stdout,
|
||||||
|
WorkdirCommandStream::Stderr => ProtocolCommandStream::Stderr,
|
||||||
|
},
|
||||||
|
start_offset,
|
||||||
|
end_offset,
|
||||||
|
content,
|
||||||
|
observed_at_ms,
|
||||||
|
},
|
||||||
|
WorkdirCommandEvent::Terminal {
|
||||||
|
command_id,
|
||||||
|
status,
|
||||||
|
exit_code,
|
||||||
|
stdout_end_offset,
|
||||||
|
stderr_end_offset,
|
||||||
|
observed_at_ms,
|
||||||
|
} => ProtocolCommandEvent::Terminal {
|
||||||
|
command_id,
|
||||||
|
status: protocol_command_status(status),
|
||||||
|
exit_code,
|
||||||
|
stdout_end_offset,
|
||||||
|
stderr_end_offset,
|
||||||
|
observed_at_ms,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn protocol_command_status(status: WorkdirCommandStatus) -> ProtocolCommandStatus {
|
||||||
|
match status {
|
||||||
|
WorkdirCommandStatus::Running => ProtocolCommandStatus::Running,
|
||||||
|
WorkdirCommandStatus::Completed => ProtocolCommandStatus::Completed,
|
||||||
|
WorkdirCommandStatus::Failed => ProtocolCommandStatus::Failed,
|
||||||
|
WorkdirCommandStatus::TimedOut => ProtocolCommandStatus::TimedOut,
|
||||||
|
WorkdirCommandStatus::Cancelled => ProtocolCommandStatus::Cancelled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Wire the per-event broadcast bridges on the Worker's Engine. Each callback
|
/// Wire the per-event broadcast bridges on the Worker's Engine. Each callback
|
||||||
/// re-publishes a worker-level signal as a `protocol::Event` on `event_tx`
|
/// re-publishes a worker-level signal as a `protocol::Event` on `event_tx`
|
||||||
/// so subscribers (TUI, socket clients) get a single typed stream.
|
/// so subscribers (TUI, socket clients) get a single typed stream.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use crate::feature::{
|
|||||||
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule,
|
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule,
|
||||||
ServiceDeclaration, ServiceId, ToolContribution, ToolDeclaration,
|
ServiceDeclaration, ServiceId, ToolContribution, ToolDeclaration,
|
||||||
};
|
};
|
||||||
use crate::spawn::registry::SpawnedWorkerRegistry;
|
use crate::spawn::registry::{SpawnedWorkerRegistry, SubWorkerStopSummary};
|
||||||
use crate::worker::{
|
use crate::worker::{
|
||||||
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
|
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
|
||||||
WorkspaceResponse,
|
WorkspaceResponse,
|
||||||
@@ -48,7 +48,6 @@ pub trait WorkerControlService: Send + Sync {
|
|||||||
&self,
|
&self,
|
||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
worker_id: &str,
|
worker_id: &str,
|
||||||
expected_worker_revision: &str,
|
|
||||||
reason: &str,
|
reason: &str,
|
||||||
) -> Result<WorkspaceResponse, WorkspaceClientError>;
|
) -> Result<WorkspaceResponse, WorkspaceClientError>;
|
||||||
async fn execute_runtime(
|
async fn execute_runtime(
|
||||||
@@ -138,14 +137,19 @@ impl WorkerControlService for WorkspaceWorkerControlService {
|
|||||||
let registry = self.registry.as_ref().ok_or_else(|| {
|
let registry = self.registry.as_ref().ok_or_else(|| {
|
||||||
WorkspaceClientError::Request("unknown Worker or permission not granted".to_string())
|
WorkspaceClientError::Request("unknown Worker or permission not granted".to_string())
|
||||||
})?;
|
})?;
|
||||||
registry
|
let summary = registry
|
||||||
.remove_internal(name)
|
.remove_internal(name)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
|
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
WorkspaceClientError::Request(
|
||||||
|
"unknown Worker or permission not granted".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
Ok(WorkspaceResponse {
|
Ok(WorkspaceResponse {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: serde_json::json!({ "subject": { "kind": "sub_worker", "name": name } })
|
body: serde_json::to_string(&summary)
|
||||||
.to_string(),
|
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,11 +169,10 @@ impl WorkerControlService for WorkspaceWorkerControlService {
|
|||||||
&self,
|
&self,
|
||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
worker_id: &str,
|
worker_id: &str,
|
||||||
expected_worker_revision: &str,
|
|
||||||
reason: &str,
|
reason: &str,
|
||||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||||
self.client
|
self.client
|
||||||
.execute_worker_remove(runtime_id, worker_id, expected_worker_revision, reason)
|
.execute_worker_remove(runtime_id, worker_id, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute_runtime(
|
async fn execute_runtime(
|
||||||
@@ -530,7 +533,6 @@ struct WorkerStopInput {
|
|||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
struct WorkerRemoveInput {
|
struct WorkerRemoveInput {
|
||||||
subject: WorkerSubjectInput,
|
subject: WorkerSubjectInput,
|
||||||
expected_worker_revision: String,
|
|
||||||
reason: String,
|
reason: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,7 +594,7 @@ impl WorkerOperation {
|
|||||||
"Restore a stopped Backend/Runtime Worker session in the current Workspace."
|
"Restore a stopped Backend/Runtime Worker session in the current Workspace."
|
||||||
}
|
}
|
||||||
Self::Remove => {
|
Self::Remove => {
|
||||||
"Remove an eligible stopped, unassigned, non-internal Worker. Supply the current Worker revision and a bounded reason; Backend validation and retention are authoritative."
|
"Remove an eligible stopped, unassigned, non-internal Worker. Supply a bounded reason; Backend validation and retention are authoritative."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -742,8 +744,6 @@ impl Tool for WorkspaceWorkerTool {
|
|||||||
WorkerOperation::Remove => {
|
WorkerOperation::Remove => {
|
||||||
let input = parse::<WorkerRemoveInput>(input_json, "WorkerRemove")?;
|
let input = parse::<WorkerRemoveInput>(input_json, "WorkerRemove")?;
|
||||||
let (runtime_id, worker_id) = runtime_subject_ids(&input.subject, self.operation)?;
|
let (runtime_id, worker_id) = runtime_subject_ids(&input.subject, self.operation)?;
|
||||||
let expected_worker_revision =
|
|
||||||
non_empty(input.expected_worker_revision, "expected_worker_revision")?;
|
|
||||||
let reason = non_empty(input.reason, "reason")?;
|
let reason = non_empty(input.reason, "reason")?;
|
||||||
if reason.len() > 512 {
|
if reason.len() > 512 {
|
||||||
return Err(ToolError::ExecutionFailed(
|
return Err(ToolError::ExecutionFailed(
|
||||||
@@ -751,12 +751,7 @@ impl Tool for WorkspaceWorkerTool {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
self.control
|
self.control
|
||||||
.remove_runtime_worker(
|
.remove_runtime_worker(&runtime_id, &worker_id, &reason)
|
||||||
&runtime_id,
|
|
||||||
&worker_id,
|
|
||||||
&expected_worker_revision,
|
|
||||||
&reason,
|
|
||||||
)
|
|
||||||
.map_err(control_tool_error)?
|
.map_err(control_tool_error)?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -839,6 +834,15 @@ fn tool_output(
|
|||||||
response.status, response.body
|
response.status, response.body
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
if operation == WorkerOperation::Stop
|
||||||
|
&& let Ok(summary) = serde_json::from_str::<SubWorkerStopSummary>(&response.body)
|
||||||
|
{
|
||||||
|
return Ok(ToolOutput {
|
||||||
|
summary: render_subworker_stop_summary(&summary),
|
||||||
|
content: Some(response.body),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
Ok(ToolOutput {
|
Ok(ToolOutput {
|
||||||
summary: format!("{} completed", operation.tool_name()),
|
summary: format!("{} completed", operation.tool_name()),
|
||||||
content: Some(response.body),
|
content: Some(response.body),
|
||||||
@@ -846,6 +850,37 @@ fn tool_output(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_subworker_stop_summary(summary: &SubWorkerStopSummary) -> String {
|
||||||
|
let tools = if summary.tool_counts.is_empty() {
|
||||||
|
"No tool calls".to_string()
|
||||||
|
} else {
|
||||||
|
summary
|
||||||
|
.tool_counts
|
||||||
|
.iter()
|
||||||
|
.map(|tool| format!("{} {}", tool.count, tool.name))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
};
|
||||||
|
let elapsed = format_elapsed(summary.elapsed_ms);
|
||||||
|
let changes = summary
|
||||||
|
.change_stat
|
||||||
|
.as_ref()
|
||||||
|
.map(|stat| format!("+{}/-{} Changes · ", stat.added, stat.deleted))
|
||||||
|
.unwrap_or_default();
|
||||||
|
format!("SubWorkerStop - done\n {tools}\n {changes}{elapsed}",)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_elapsed(elapsed_ms: u64) -> String {
|
||||||
|
let seconds = elapsed_ms / 1_000;
|
||||||
|
let minutes = seconds / 60;
|
||||||
|
let seconds = seconds % 60;
|
||||||
|
if minutes > 0 {
|
||||||
|
format!("{minutes}m {seconds}s")
|
||||||
|
} else {
|
||||||
|
format!("{seconds}s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn definition<I: JsonSchema + 'static>(
|
fn definition<I: JsonSchema + 'static>(
|
||||||
operation: WorkerOperation,
|
operation: WorkerOperation,
|
||||||
control: Arc<dyn WorkerControlService>,
|
control: Arc<dyn WorkerControlService>,
|
||||||
@@ -912,7 +947,7 @@ mod tests {
|
|||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
struct RecordingWorkspaceClient {
|
struct RecordingWorkspaceClient {
|
||||||
requests: Mutex<Vec<WorkspaceRequest>>,
|
requests: Mutex<Vec<WorkspaceRequest>>,
|
||||||
removals: Mutex<Vec<(String, String, String, String)>>,
|
removals: Mutex<Vec<(String, String, String)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkspaceClient for RecordingWorkspaceClient {
|
impl WorkspaceClient for RecordingWorkspaceClient {
|
||||||
@@ -943,13 +978,11 @@ mod tests {
|
|||||||
&self,
|
&self,
|
||||||
target_runtime_id: &str,
|
target_runtime_id: &str,
|
||||||
target_worker_id: &str,
|
target_worker_id: &str,
|
||||||
expected_worker_revision: &str,
|
|
||||||
reason: &str,
|
reason: &str,
|
||||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||||
self.removals.lock().unwrap().push((
|
self.removals.lock().unwrap().push((
|
||||||
target_runtime_id.to_string(),
|
target_runtime_id.to_string(),
|
||||||
target_worker_id.to_string(),
|
target_worker_id.to_string(),
|
||||||
expected_worker_revision.to_string(),
|
|
||||||
reason.to_string(),
|
reason.to_string(),
|
||||||
));
|
));
|
||||||
Ok(WorkspaceResponse {
|
Ok(WorkspaceResponse {
|
||||||
@@ -1160,7 +1193,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn worker_remove_forwards_only_target_revision_and_bounded_reason() {
|
async fn worker_remove_forwards_only_target_and_bounded_reason() {
|
||||||
let client = Arc::new(RecordingWorkspaceClient::default());
|
let client = Arc::new(RecordingWorkspaceClient::default());
|
||||||
let tool = WorkspaceWorkerTool {
|
let tool = WorkspaceWorkerTool {
|
||||||
operation: WorkerOperation::Remove,
|
operation: WorkerOperation::Remove,
|
||||||
@@ -1173,7 +1206,6 @@ mod tests {
|
|||||||
"runtime_id": "runtime-1",
|
"runtime_id": "runtime-1",
|
||||||
"worker_id": "worker-7",
|
"worker_id": "worker-7",
|
||||||
},
|
},
|
||||||
"expected_worker_revision": "2026-08-11T20:00:00Z",
|
|
||||||
"reason": " retire completed Worker "
|
"reason": " retire completed Worker "
|
||||||
})
|
})
|
||||||
.to_string(),
|
.to_string(),
|
||||||
@@ -1186,7 +1218,6 @@ mod tests {
|
|||||||
[(
|
[(
|
||||||
"runtime-1".to_string(),
|
"runtime-1".to_string(),
|
||||||
"worker-7".to_string(),
|
"worker-7".to_string(),
|
||||||
"2026-08-11T20:00:00Z".to_string(),
|
|
||||||
"retire completed Worker".to_string(),
|
"retire completed Worker".to_string(),
|
||||||
)]
|
)]
|
||||||
);
|
);
|
||||||
@@ -1194,15 +1225,18 @@ mod tests {
|
|||||||
let schema = serde_json::to_value(schemars::schema_for!(WorkerRemoveInput))
|
let schema = serde_json::to_value(schemars::schema_for!(WorkerRemoveInput))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.to_string();
|
.to_string();
|
||||||
for field in [
|
for field in ["runtime_id", "worker_id", "reason"] {
|
||||||
"runtime_id",
|
|
||||||
"worker_id",
|
|
||||||
"expected_worker_revision",
|
|
||||||
"reason",
|
|
||||||
] {
|
|
||||||
assert!(schema.contains(field));
|
assert!(schema.contains(field));
|
||||||
}
|
}
|
||||||
for forbidden in ["proof", "actor", "workspace_id", "policy", "plan", "stage"] {
|
for forbidden in [
|
||||||
|
"expected_worker_revision",
|
||||||
|
"proof",
|
||||||
|
"actor",
|
||||||
|
"workspace_id",
|
||||||
|
"policy",
|
||||||
|
"plan",
|
||||||
|
"stage",
|
||||||
|
] {
|
||||||
assert!(!schema.contains(forbidden), "schema leaked {forbidden}");
|
assert!(!schema.contains(forbidden), "schema leaked {forbidden}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1223,7 +1257,6 @@ mod tests {
|
|||||||
"runtime_id": "runtime-1",
|
"runtime_id": "runtime-1",
|
||||||
"worker_id": "worker-7",
|
"worker_id": "worker-7",
|
||||||
},
|
},
|
||||||
"expected_worker_revision": "revision-1",
|
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
})
|
})
|
||||||
.to_string(),
|
.to_string(),
|
||||||
@@ -1252,6 +1285,47 @@ mod tests {
|
|||||||
assert!(client.removals.lock().unwrap().is_empty());
|
assert!(client.removals.lock().unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subworker_stop_output_is_compact_and_keeps_typed_evidence() {
|
||||||
|
let summary = SubWorkerStopSummary {
|
||||||
|
session_id: "session-1".to_string(),
|
||||||
|
display_name: "research".to_string(),
|
||||||
|
outcome: crate::spawn::registry::SubWorkerFinalOutcome::Done,
|
||||||
|
elapsed_ms: 78_000,
|
||||||
|
tool_counts: vec![
|
||||||
|
crate::spawn::registry::SubWorkerToolCount {
|
||||||
|
name: "Read".to_string(),
|
||||||
|
count: 26,
|
||||||
|
},
|
||||||
|
crate::spawn::registry::SubWorkerToolCount {
|
||||||
|
name: "Grep".to_string(),
|
||||||
|
count: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
change_stat: Some(crate::spawn::registry::SubWorkerChangeStat {
|
||||||
|
added: 215,
|
||||||
|
deleted: 148,
|
||||||
|
source: "tracked_write_edit_tools".to_string(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let response = WorkspaceResponse {
|
||||||
|
status: 200,
|
||||||
|
body: serde_json::to_string(&summary).unwrap(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let output = tool_output(WorkerOperation::Stop, response).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
output.summary,
|
||||||
|
"SubWorkerStop - done\n 26 Read, 5 Grep\n +215/-148 Changes · 1m 18s"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<SubWorkerStopSummary>(output.content.as_deref().unwrap())
|
||||||
|
.unwrap(),
|
||||||
|
summary
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn worker_inputs_reject_paths_and_parent_traversal() {
|
fn worker_inputs_reject_paths_and_parent_traversal() {
|
||||||
assert!(authority_id("https://runtime.example", "runtime_id").is_err());
|
assert!(authority_id("https://runtime.example", "runtime_id").is_err());
|
||||||
|
|||||||
@@ -312,8 +312,7 @@ mod tests {
|
|||||||
let SystemItem::TaskReminder { body, .. } = &queued[0] else {
|
let SystemItem::TaskReminder { body, .. } = &queued[0] else {
|
||||||
panic!("unexpected system item: {:?}", queued[0]);
|
panic!("unexpected system item: {:?}", queued[0]);
|
||||||
};
|
};
|
||||||
assert_eq!(body.matches("<system-reminder>").count(), 1);
|
assert!(body.starts_with("Current session steps are listed below."));
|
||||||
assert_eq!(body.matches("</system-reminder>").count(), 1);
|
|
||||||
assert!(body.contains("taskid 1"));
|
assert!(body.contains("taskid 1"));
|
||||||
assert!(body.contains("pending"));
|
assert!(body.contains("pending"));
|
||||||
assert!(body.contains("keep going"));
|
assert!(body.contains("keep going"));
|
||||||
@@ -338,19 +337,17 @@ mod tests {
|
|||||||
panic!("unexpected system item: {:?}", queued[0]);
|
panic!("unexpected system item: {:?}", queued[0]);
|
||||||
};
|
};
|
||||||
assert_eq!(*source, SystemReminderSource::TaskInactivity);
|
assert_eq!(*source, SystemReminderSource::TaskInactivity);
|
||||||
assert_eq!(body.matches("<system-reminder>").count(), 1);
|
assert!(body.starts_with("Current session steps are listed below."));
|
||||||
assert_eq!(body.matches("</system-reminder>").count(), 1);
|
|
||||||
assert!(body.contains("typed"));
|
assert!(body.contains("typed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn render_task_reminder_body_is_unwrapped_for_system_reminder_helper() {
|
fn render_task_reminder_body_is_plain_system_text() {
|
||||||
let feature = TaskFeature::new();
|
let feature = TaskFeature::new();
|
||||||
let task = feature.task_store().create("body".into(), String::new());
|
let task = feature.task_store().create("body".into(), String::new());
|
||||||
let body = render_task_reminder_body(&[task]);
|
let body = render_task_reminder_body(&[task]);
|
||||||
|
|
||||||
assert!(!body.contains("<system-reminder>"));
|
assert!(body.starts_with("Current session steps are listed below."));
|
||||||
assert!(!body.contains("</system-reminder>"));
|
|
||||||
assert!(body.contains("TaskUpdate"));
|
assert!(body.contains("TaskUpdate"));
|
||||||
assert!(body.contains("taskid 1"));
|
assert!(body.contains("taskid 1"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1781,6 +1781,7 @@ provider = "github"
|
|||||||
assert!(request.contains("\"title\":\"HTTP ticket\""));
|
assert!(request.contains("\"title\":\"HTTP ticket\""));
|
||||||
let response_body = serde_json::to_string(&TicketRef {
|
let response_body = serde_json::to_string(&TicketRef {
|
||||||
id: "01TEST".to_string(),
|
id: "01TEST".to_string(),
|
||||||
|
resource_key: None,
|
||||||
slug: "http-ticket".to_string(),
|
slug: "http-ticket".to_string(),
|
||||||
status: ticket::TicketStatus::Open,
|
status: ticket::TicketStatus::Open,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -176,9 +176,7 @@ impl SystemItemAppendHandle {
|
|||||||
|
|
||||||
/// Queue a task-inactivity reminder for durable model-visible append.
|
/// Queue a task-inactivity reminder for durable model-visible append.
|
||||||
///
|
///
|
||||||
/// The body should be the unwrapped reminder text; the host-side
|
/// The body is committed verbatim as the typed item's system-message text.
|
||||||
/// `SystemReminder` renderer wraps it exactly once in `<system-reminder>`
|
|
||||||
/// tags before commit.
|
|
||||||
pub fn append_task_reminder(&self, body: impl Into<String>) {
|
pub fn append_task_reminder(&self, body: impl Into<String>) {
|
||||||
let item = SystemReminder::task_inactivity(body).into_system_item();
|
let item = SystemReminder::task_inactivity(body).into_system_item();
|
||||||
self.pending
|
self.pending
|
||||||
@@ -452,8 +450,7 @@ mod tests {
|
|||||||
assert_eq!(queued.len(), 1);
|
assert_eq!(queued.len(), 1);
|
||||||
match &queued[0] {
|
match &queued[0] {
|
||||||
SystemItem::TaskReminder { body, .. } => {
|
SystemItem::TaskReminder { body, .. } => {
|
||||||
assert_eq!(body.matches("<system-reminder>").count(), 1);
|
assert_eq!(body, "remember tasks");
|
||||||
assert!(body.contains("remember tasks"));
|
|
||||||
}
|
}
|
||||||
other => panic!("unexpected system item: {other:?}"),
|
other => panic!("unexpected system item: {other:?}"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
use std::sync::{Arc, Mutex, MutexGuard};
|
use std::sync::{Arc, Mutex, MutexGuard};
|
||||||
|
|
||||||
use protocol::{Event, InFlightBlock, InFlightSnapshot, InFlightToolCallState};
|
use protocol::{
|
||||||
|
CommandEvent, CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, Event,
|
||||||
|
InFlightBlock, InFlightSnapshot, InFlightToolCallState,
|
||||||
|
};
|
||||||
use session_store::{LoggedContentPart, LoggedItem};
|
use session_store::{LoggedContentPart, LoggedItem};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
|
const COMMAND_SNAPSHOT_STREAM_BYTES: usize = 32 * 1024;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct InFlightBlockId(u64);
|
pub struct InFlightBlockId(u64);
|
||||||
|
|
||||||
@@ -17,6 +22,7 @@ pub struct InFlightEvents {
|
|||||||
pub(crate) struct InFlightInner {
|
pub(crate) struct InFlightInner {
|
||||||
next_block_id: u64,
|
next_block_id: u64,
|
||||||
blocks: Vec<TrackedBlock>,
|
blocks: Vec<TrackedBlock>,
|
||||||
|
commands: Vec<CommandSnapshot>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -46,6 +52,7 @@ impl InFlightEvents {
|
|||||||
inner: Arc::new(Mutex::new(InFlightInner {
|
inner: Arc::new(Mutex::new(InFlightInner {
|
||||||
next_block_id: 1,
|
next_block_id: 1,
|
||||||
blocks: Vec::new(),
|
blocks: Vec::new(),
|
||||||
|
commands: Vec::new(),
|
||||||
})),
|
})),
|
||||||
event_tx,
|
event_tx,
|
||||||
}
|
}
|
||||||
@@ -201,6 +208,15 @@ impl InFlightEvents {
|
|||||||
f()
|
f()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn publish_command_event(&self, event: CommandEvent) {
|
||||||
|
self.lock().apply_command_event(&event);
|
||||||
|
let _ = self.event_tx.send(Event::Command { event });
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn replace_command_snapshot(&self, commands: Vec<CommandSnapshot>) {
|
||||||
|
self.lock().commands = commands;
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn clear(&self) {
|
pub(crate) fn clear(&self) {
|
||||||
let mut inner = self.lock();
|
let mut inner = self.lock();
|
||||||
inner.clear();
|
inner.clear();
|
||||||
@@ -224,6 +240,92 @@ impl InFlightInner {
|
|||||||
.find(|block| block.block_id() == block_id)
|
.find(|block| block.block_id() == block_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_command_event(&mut self, event: &CommandEvent) {
|
||||||
|
match event {
|
||||||
|
CommandEvent::Started {
|
||||||
|
command_id,
|
||||||
|
tool_call_id,
|
||||||
|
observed_at_ms,
|
||||||
|
} => {
|
||||||
|
self.commands
|
||||||
|
.retain(|command| command.command_id != *command_id);
|
||||||
|
self.commands.push(CommandSnapshot {
|
||||||
|
command_id: command_id.clone(),
|
||||||
|
tool_call_id: tool_call_id.clone(),
|
||||||
|
status: CommandStatus::Running,
|
||||||
|
started_at_ms: *observed_at_ms,
|
||||||
|
observed_at_ms: *observed_at_ms,
|
||||||
|
last_output_at_ms: None,
|
||||||
|
stdout: CommandStreamSlice::default(),
|
||||||
|
stderr: CommandStreamSlice::default(),
|
||||||
|
exit_code: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
CommandEvent::Output {
|
||||||
|
command_id,
|
||||||
|
stream,
|
||||||
|
start_offset,
|
||||||
|
end_offset,
|
||||||
|
content,
|
||||||
|
observed_at_ms,
|
||||||
|
} => {
|
||||||
|
let command = match self
|
||||||
|
.commands
|
||||||
|
.iter_mut()
|
||||||
|
.find(|command| command.command_id == *command_id)
|
||||||
|
{
|
||||||
|
Some(command) => command,
|
||||||
|
None => {
|
||||||
|
self.commands.push(CommandSnapshot {
|
||||||
|
command_id: command_id.clone(),
|
||||||
|
tool_call_id: None,
|
||||||
|
status: CommandStatus::Running,
|
||||||
|
started_at_ms: *observed_at_ms,
|
||||||
|
observed_at_ms: *observed_at_ms,
|
||||||
|
last_output_at_ms: Some(*observed_at_ms),
|
||||||
|
stdout: CommandStreamSlice::default(),
|
||||||
|
stderr: CommandStreamSlice::default(),
|
||||||
|
exit_code: None,
|
||||||
|
});
|
||||||
|
self.commands.last_mut().expect("command was inserted")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
command.observed_at_ms = *observed_at_ms;
|
||||||
|
command.last_output_at_ms = Some(*observed_at_ms);
|
||||||
|
let target = match stream {
|
||||||
|
CommandStream::Stdout => &mut command.stdout,
|
||||||
|
CommandStream::Stderr => &mut command.stderr,
|
||||||
|
};
|
||||||
|
if target.end_offset != *start_offset {
|
||||||
|
target.content.clear();
|
||||||
|
target.start_offset = *start_offset;
|
||||||
|
target.truncated = *start_offset > 0;
|
||||||
|
}
|
||||||
|
target.content.push_str(content);
|
||||||
|
target.end_offset = *end_offset;
|
||||||
|
if target.content.len() > COMMAND_SNAPSHOT_STREAM_BYTES {
|
||||||
|
let mut cut = target.content.len() - COMMAND_SNAPSHOT_STREAM_BYTES;
|
||||||
|
while cut < target.content.len() && !target.content.is_char_boundary(cut) {
|
||||||
|
cut += 1;
|
||||||
|
}
|
||||||
|
target.content.drain(..cut);
|
||||||
|
target.start_offset = target
|
||||||
|
.end_offset
|
||||||
|
.saturating_sub(target.content.len() as u64);
|
||||||
|
target.truncated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CommandEvent::Terminal { command_id, .. } => {
|
||||||
|
// Terminal state is delivered as a live protocol event. It is
|
||||||
|
// no longer in-flight snapshot state, and removing it here
|
||||||
|
// also prevents queued output from an aborted turn from
|
||||||
|
// surviving the subsequent terminal event after `clear()`.
|
||||||
|
self.commands
|
||||||
|
.retain(|command| command.command_id != *command_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn clear_for_committed_item(&mut self, item: &LoggedItem) {
|
fn clear_for_committed_item(&mut self, item: &LoggedItem) {
|
||||||
match item {
|
match item {
|
||||||
LoggedItem::Message { role, content }
|
LoggedItem::Message { role, content }
|
||||||
@@ -273,14 +375,16 @@ impl InFlightInner {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(TrackedBlock::to_snapshot_block)
|
.filter_map(TrackedBlock::to_snapshot_block)
|
||||||
.collect(),
|
.collect(),
|
||||||
|
commands: self.commands.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clear(&mut self) -> bool {
|
fn clear(&mut self) -> bool {
|
||||||
if self.blocks.is_empty() {
|
if self.blocks.is_empty() && self.commands.is_empty() {
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
self.blocks.clear();
|
self.blocks.clear();
|
||||||
|
self.commands.clear();
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,6 +687,57 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_events_are_bounded_and_recoverable_from_snapshot() {
|
||||||
|
let (event_tx, _) = broadcast::channel(16);
|
||||||
|
let mut rx = event_tx.subscribe();
|
||||||
|
let in_flight = InFlightEvents::new(event_tx);
|
||||||
|
in_flight.publish_command_event(CommandEvent::Started {
|
||||||
|
command_id: "command-1".into(),
|
||||||
|
tool_call_id: Some("tool-1".into()),
|
||||||
|
observed_at_ms: 100,
|
||||||
|
});
|
||||||
|
in_flight.publish_command_event(CommandEvent::Output {
|
||||||
|
command_id: "command-1".into(),
|
||||||
|
stream: CommandStream::Stdout,
|
||||||
|
start_offset: 0,
|
||||||
|
end_offset: 5,
|
||||||
|
content: "ready".into(),
|
||||||
|
observed_at_ms: 110,
|
||||||
|
});
|
||||||
|
|
||||||
|
let guard = in_flight.snapshot_guard();
|
||||||
|
let snapshot = snapshot_from_guard(&guard);
|
||||||
|
assert_eq!(snapshot.commands.len(), 1);
|
||||||
|
assert_eq!(snapshot.commands[0].tool_call_id.as_deref(), Some("tool-1"));
|
||||||
|
assert_eq!(snapshot.commands[0].stdout.content, "ready");
|
||||||
|
assert_eq!(snapshot.commands[0].status, CommandStatus::Running);
|
||||||
|
drop(guard);
|
||||||
|
assert!(matches!(
|
||||||
|
rx.try_recv().unwrap(),
|
||||||
|
Event::Command {
|
||||||
|
event: CommandEvent::Started { .. }
|
||||||
|
}
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
rx.try_recv().unwrap(),
|
||||||
|
Event::Command {
|
||||||
|
event: CommandEvent::Output { .. }
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
in_flight.publish_command_event(CommandEvent::Terminal {
|
||||||
|
command_id: "command-1".into(),
|
||||||
|
status: CommandStatus::TimedOut,
|
||||||
|
exit_code: None,
|
||||||
|
stdout_end_offset: 5,
|
||||||
|
stderr_end_offset: 0,
|
||||||
|
observed_at_ms: 200,
|
||||||
|
});
|
||||||
|
let guard = in_flight.snapshot_guard();
|
||||||
|
assert!(snapshot_from_guard(&guard).commands.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clear_discards_uncommitted_blocks_without_protocol_event() {
|
fn clear_discards_uncommitted_blocks_without_protocol_event() {
|
||||||
let (event_tx, _) = broadcast::channel(16);
|
let (event_tx, _) = broadcast::channel(16);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use session_store::{LogEntry, SegmentId, SessionId, Store, StoreError, TraceEntr
|
|||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::controller::wire_event_bridges_on_engine;
|
use crate::controller::{wire_event_bridges_on_engine, wire_workdir_command_events};
|
||||||
use crate::feature::FeatureRegistryBuilder;
|
use crate::feature::FeatureRegistryBuilder;
|
||||||
use crate::in_flight::{InFlightEvents, snapshot_from_guard};
|
use crate::in_flight::{InFlightEvents, snapshot_from_guard};
|
||||||
use crate::ipc::alerter::Alerter;
|
use crate::ipc::alerter::Alerter;
|
||||||
@@ -294,6 +294,8 @@ pub(crate) struct InternalWorkerSessionHandle {
|
|||||||
last_error: Arc<Mutex<Option<String>>>,
|
last_error: Arc<Mutex<Option<String>>>,
|
||||||
child_registry: Option<Arc<SpawnedWorkerRegistry>>,
|
child_registry: Option<Arc<SpawnedWorkerRegistry>>,
|
||||||
sink: SegmentLogSink,
|
sink: SegmentLogSink,
|
||||||
|
#[cfg(test)]
|
||||||
|
fail_stop: Arc<std::sync::atomic::AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InternalWorkerSessionHandle {
|
impl InternalWorkerSessionHandle {
|
||||||
@@ -319,6 +321,9 @@ impl InternalWorkerSessionHandle {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn publish_test_entry(&self, entry: LogEntry) {
|
pub(crate) fn publish_test_entry(&self, entry: LogEntry) {
|
||||||
|
self.store
|
||||||
|
.append(self.session_id, self.segment_id, &entry)
|
||||||
|
.expect("append test Internal Worker entry");
|
||||||
self.sink.publish(entry);
|
self.sink.publish(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,7 +424,23 @@ impl InternalWorkerSessionHandle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn force_status(&self, status: InternalWorkerSessionStatus) {
|
||||||
|
self.status
|
||||||
|
.store(status.encode(), std::sync::atomic::Ordering::Release);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn force_stop_failure(&self) {
|
||||||
|
self.fail_stop
|
||||||
|
.store(true, std::sync::atomic::Ordering::Release);
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn stop(&self) -> Result<(), InternalWorkerSessionError> {
|
pub(crate) async fn stop(&self) -> Result<(), InternalWorkerSessionError> {
|
||||||
|
#[cfg(test)]
|
||||||
|
if self.fail_stop.load(std::sync::atomic::Ordering::Acquire) {
|
||||||
|
return Err(InternalWorkerSessionError::Unavailable);
|
||||||
|
}
|
||||||
let prior = self.status.swap(
|
let prior = self.status.swap(
|
||||||
InternalWorkerSessionStatus::Stopping.encode(),
|
InternalWorkerSessionStatus::Stopping.encode(),
|
||||||
std::sync::atomic::Ordering::AcqRel,
|
std::sync::atomic::Ordering::AcqRel,
|
||||||
@@ -555,6 +576,10 @@ pub(crate) async fn prepare_internal_worker_session(
|
|||||||
spawn_internal_log_event_bridge(sink.clone(), event_tx.clone());
|
spawn_internal_log_event_bridge(sink.clone(), event_tx.clone());
|
||||||
let alerter = Alerter::new(event_tx.clone());
|
let alerter = Alerter::new(event_tx.clone());
|
||||||
let in_flight = InFlightEvents::new(event_tx.clone());
|
let in_flight = InFlightEvents::new(event_tx.clone());
|
||||||
|
if let Some(session) = worker.workdir_session() {
|
||||||
|
wire_workdir_command_events(session, &in_flight);
|
||||||
|
}
|
||||||
|
let actor_in_flight = in_flight.clone();
|
||||||
worker.attach_alerter(alerter.clone());
|
worker.attach_alerter(alerter.clone());
|
||||||
worker.attach_event_tx(event_tx.clone());
|
worker.attach_event_tx(event_tx.clone());
|
||||||
worker.attach_in_flight_events(in_flight.clone());
|
worker.attach_in_flight_events(in_flight.clone());
|
||||||
@@ -581,12 +606,15 @@ pub(crate) async fn prepare_internal_worker_session(
|
|||||||
last_error: last_error.clone(),
|
last_error: last_error.clone(),
|
||||||
child_registry,
|
child_registry,
|
||||||
sink,
|
sink,
|
||||||
|
#[cfg(test)]
|
||||||
|
fail_stop: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
};
|
};
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Some(command) = command_rx.recv().await {
|
while let Some(command) = command_rx.recv().await {
|
||||||
match command {
|
match command {
|
||||||
InternalWorkerSessionCommand::Run(input) => {
|
InternalWorkerSessionCommand::Run(input) => {
|
||||||
|
actor_in_flight.clear();
|
||||||
let cancel_sender = worker.engine_mut().cancel_sender();
|
let cancel_sender = worker.engine_mut().cancel_sender();
|
||||||
let mut run = std::pin::pin!(worker.run_text(&input));
|
let mut run = std::pin::pin!(worker.run_text(&input));
|
||||||
loop {
|
loop {
|
||||||
@@ -599,6 +627,7 @@ pub(crate) async fn prepare_internal_worker_session(
|
|||||||
Some(error.to_string()),
|
Some(error.to_string()),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
actor_in_flight.clear();
|
||||||
status.store(turn_status.encode(), std::sync::atomic::Ordering::Release);
|
status.store(turn_status.encode(), std::sync::atomic::Ordering::Release);
|
||||||
if let Some(message) = error {
|
if let Some(message) = error {
|
||||||
*last_error.lock().unwrap() = Some(message.clone());
|
*last_error.lock().unwrap() = Some(message.clone());
|
||||||
@@ -622,6 +651,7 @@ pub(crate) async fn prepare_internal_worker_session(
|
|||||||
Some(InternalWorkerSessionCommand::Stop(done)) => {
|
Some(InternalWorkerSessionCommand::Stop(done)) => {
|
||||||
let _ = cancel_sender.send(()).await;
|
let _ = cancel_sender.send(()).await;
|
||||||
let _ = (&mut run).await;
|
let _ = (&mut run).await;
|
||||||
|
actor_in_flight.clear();
|
||||||
status.store(InternalWorkerSessionStatus::Stopped.encode(), std::sync::atomic::Ordering::Release);
|
status.store(InternalWorkerSessionStatus::Stopped.encode(), std::sync::atomic::Ordering::Release);
|
||||||
let _ = event_tx.send(Event::Status { status: WorkerStatus::Paused });
|
let _ = event_tx.send(Event::Status { status: WorkerStatus::Paused });
|
||||||
let _ = event_tx.send(Event::Shutdown);
|
let _ = event_tx.send(Event::Shutdown);
|
||||||
@@ -634,6 +664,7 @@ pub(crate) async fn prepare_internal_worker_session(
|
|||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
let _ = cancel_sender.send(()).await;
|
let _ = cancel_sender.send(()).await;
|
||||||
|
actor_in_flight.clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -642,6 +673,7 @@ pub(crate) async fn prepare_internal_worker_session(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
InternalWorkerSessionCommand::Stop(done) => {
|
InternalWorkerSessionCommand::Stop(done) => {
|
||||||
|
actor_in_flight.clear();
|
||||||
status.store(
|
status.store(
|
||||||
InternalWorkerSessionStatus::Stopped.encode(),
|
InternalWorkerSessionStatus::Stopped.encode(),
|
||||||
std::sync::atomic::Ordering::Release,
|
std::sync::atomic::Ordering::Release,
|
||||||
@@ -656,6 +688,7 @@ pub(crate) async fn prepare_internal_worker_session(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
actor_in_flight.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(handle)
|
Ok(handle)
|
||||||
@@ -883,8 +916,17 @@ pub(crate) fn test_internal_worker_session(
|
|||||||
let session_id = session_store::new_session_id();
|
let session_id = session_store::new_session_id();
|
||||||
let segment_id = session_store::new_segment_id();
|
let segment_id = session_store::new_segment_id();
|
||||||
let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(1);
|
let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(1);
|
||||||
tokio::spawn(async move { while command_rx.recv().await.is_some() {} });
|
|
||||||
let (event_tx, _) = broadcast::channel(256);
|
let (event_tx, _) = broadcast::channel(256);
|
||||||
|
let command_event_tx = event_tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(command) = command_rx.recv().await {
|
||||||
|
if let InternalWorkerSessionCommand::Stop(done_tx) = command {
|
||||||
|
let _ = command_event_tx.send(Event::Shutdown);
|
||||||
|
let _ = done_tx.send(());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
let sink = SegmentLogSink::new();
|
let sink = SegmentLogSink::new();
|
||||||
spawn_internal_log_event_bridge(sink.clone(), event_tx.clone());
|
spawn_internal_log_event_bridge(sink.clone(), event_tx.clone());
|
||||||
let handle = InternalWorkerSessionHandle {
|
let handle = InternalWorkerSessionHandle {
|
||||||
@@ -902,6 +944,7 @@ pub(crate) fn test_internal_worker_session(
|
|||||||
last_error: Arc::new(Mutex::new(None)),
|
last_error: Arc::new(Mutex::new(None)),
|
||||||
child_registry: None,
|
child_registry: None,
|
||||||
sink,
|
sink,
|
||||||
|
fail_stop: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
};
|
};
|
||||||
(handle, event_tx)
|
(handle, event_tx)
|
||||||
}
|
}
|
||||||
@@ -1068,6 +1111,10 @@ permission = "write"
|
|||||||
handle.wait_until_idle().await,
|
handle.wait_until_idle().await,
|
||||||
InternalWorkerSessionStatus::Idle
|
InternalWorkerSessionStatus::Idle
|
||||||
);
|
);
|
||||||
|
handle
|
||||||
|
.in_flight
|
||||||
|
.tool_call_start("stale-call".to_string(), "Read".to_string());
|
||||||
|
assert_eq!(handle.protocol_snapshot().in_flight.blocks.len(), 1);
|
||||||
let entries_after_first = handle.entries().len();
|
let entries_after_first = handle.entries().len();
|
||||||
assert!(entries_after_first >= 4);
|
assert!(entries_after_first >= 4);
|
||||||
handle.send("follow-up").await.expect("send follow-up turn");
|
handle.send("follow-up").await.expect("send follow-up turn");
|
||||||
@@ -1075,6 +1122,7 @@ permission = "write"
|
|||||||
handle.wait_until_idle().await,
|
handle.wait_until_idle().await,
|
||||||
InternalWorkerSessionStatus::Idle
|
InternalWorkerSessionStatus::Idle
|
||||||
);
|
);
|
||||||
|
assert!(handle.protocol_snapshot().in_flight.blocks.is_empty());
|
||||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||||
assert!(handle.entries().len() > entries_after_first);
|
assert!(handle.entries().len() > entries_after_first);
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
//!
|
//!
|
||||||
//! This is the **single lane** for "system messages produced by Worker
|
//! This is the **single lane** for "system messages produced by Worker
|
||||||
//! state that should land in the next LLM request": Notify,
|
//! state that should land in the next LLM request": Notify,
|
||||||
//! agent-visible WorkerEvent variants, and any future `<system-reminder>`
|
//! agent-visible WorkerEvent variants, and any future typed system reminder
|
||||||
//! injection all ride this queue.
|
//! insertion all ride this queue.
|
||||||
//! Per `tickets/notify-history-persist.md` and `AGENTS.md` (LLM
|
//! Per `tickets/notify-history-persist.md` and `AGENTS.md` (LLM
|
||||||
//! context の加工原則), there is **no** "transient, history-skipping"
|
//! context の加工原則), there is **no** "transient, history-skipping"
|
||||||
//! lane — everything injected into a request is also committed to
|
//! lane — everything injected into a request is also committed to
|
||||||
|
|||||||
@@ -170,20 +170,27 @@ impl Tool for SubWorkerStopTool {
|
|||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let input: NameInput = serde_json::from_str(input_json)
|
let input: NameInput = serde_json::from_str(input_json)
|
||||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid SubWorkerStop input: {e}")))?;
|
.map_err(|e| ToolError::InvalidArgument(format!("invalid SubWorkerStop input: {e}")))?;
|
||||||
if let Some(record) = self.registry.get_internal(&input.name) {
|
if let Some(summary) = self
|
||||||
record.session.stop().await.map_err(|error| {
|
.registry
|
||||||
ToolError::ExecutionFailed(format!("stop `{}`: {error}", input.name))
|
.remove_internal(&input.name)
|
||||||
})?;
|
.await
|
||||||
self.registry
|
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?
|
||||||
.remove_internal(&input.name)
|
{
|
||||||
.await
|
|
||||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
|
||||||
return Ok(ToolOutput {
|
return Ok(ToolOutput {
|
||||||
summary: format!(
|
summary: format!(
|
||||||
"stopped worker `{}` and reclaimed delegated scope",
|
"SubWorkerStop - done\n {} tool kind{}\n {}ms",
|
||||||
input.name
|
summary.tool_counts.len(),
|
||||||
|
if summary.tool_counts.len() == 1 {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
"s"
|
||||||
|
},
|
||||||
|
summary.elapsed_ms,
|
||||||
|
),
|
||||||
|
content: Some(
|
||||||
|
serde_json::to_string(&summary)
|
||||||
|
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?,
|
||||||
),
|
),
|
||||||
content: None,
|
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,17 +7,20 @@
|
|||||||
//! Parent registry drop closes all session handles and synchronously returns delegated Write deny
|
//! Parent registry drop closes all session handles and synchronously returns delegated Write deny
|
||||||
//! rules to the parent scope.
|
//! rules to the parent scope.
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::{BTreeMap, HashSet};
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
Arc, Mutex,
|
Arc, Mutex,
|
||||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||||
};
|
};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use manifest::{Permission, ScopeRule, SharedScope};
|
use manifest::{Permission, ScopeRule, SharedScope};
|
||||||
use protocol::{Event, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot};
|
use protocol::{Event, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot};
|
||||||
use session_store::{
|
use session_store::{
|
||||||
WorkerMetadataStore, WorkerReclaimedChild, WorkerSpawnedChild, WorkerStoreError,
|
LoggedItem, WorkerMetadataStore, WorkerReclaimedChild, WorkerSpawnedChild, WorkerStoreError,
|
||||||
};
|
};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
@@ -27,6 +30,39 @@ use crate::internal_worker::{InternalWorkerSessionHandle, InternalWorkerVisibili
|
|||||||
use crate::runtime::dir::{RuntimeDir, SpawnedWorkerRecord};
|
use crate::runtime::dir::{RuntimeDir, SpawnedWorkerRecord};
|
||||||
use crate::runtime::worker_allocation;
|
use crate::runtime::worker_allocation;
|
||||||
|
|
||||||
|
const STOP_SUMMARY_TOOL_LIMIT: usize = 16;
|
||||||
|
const STOP_SUMMARY_TOOL_NAME_LIMIT: usize = 64;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub(crate) enum SubWorkerFinalOutcome {
|
||||||
|
Done,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub(crate) struct SubWorkerToolCount {
|
||||||
|
pub name: String,
|
||||||
|
pub count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub(crate) struct SubWorkerChangeStat {
|
||||||
|
pub added: u64,
|
||||||
|
pub deleted: u64,
|
||||||
|
pub source: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub(crate) struct SubWorkerStopSummary {
|
||||||
|
pub session_id: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub outcome: SubWorkerFinalOutcome,
|
||||||
|
pub elapsed_ms: u64,
|
||||||
|
pub tool_counts: Vec<SubWorkerToolCount>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub change_stat: Option<SubWorkerChangeStat>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub(crate) struct InternalSpawnedWorkerRecord {
|
pub(crate) struct InternalSpawnedWorkerRecord {
|
||||||
pub worker_name: String,
|
pub worker_name: String,
|
||||||
@@ -35,8 +71,13 @@ pub(crate) struct InternalSpawnedWorkerRecord {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub installed_tools: Arc<[String]>,
|
pub installed_tools: Arc<[String]>,
|
||||||
pub session: InternalWorkerSessionHandle,
|
pub session: InternalWorkerSessionHandle,
|
||||||
|
change_tracker: Option<tools::Tracker>,
|
||||||
|
started_at: Instant,
|
||||||
|
stop_lock: Arc<tokio::sync::Mutex<()>>,
|
||||||
scope_reclaimed: Arc<AtomicBool>,
|
scope_reclaimed: Arc<AtomicBool>,
|
||||||
protocol_revision: Arc<AtomicU64>,
|
protocol_revision: Arc<AtomicU64>,
|
||||||
|
protocol_emit_lock: Arc<Mutex<()>>,
|
||||||
|
protocol_terminal: Arc<AtomicBool>,
|
||||||
forwarding_started: Arc<AtomicBool>,
|
forwarding_started: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +88,7 @@ impl InternalSpawnedWorkerRecord {
|
|||||||
workdir_delegation: WorkdirDelegation,
|
workdir_delegation: WorkdirDelegation,
|
||||||
#[cfg(test)] installed_tools: Vec<String>,
|
#[cfg(test)] installed_tools: Vec<String>,
|
||||||
session: InternalWorkerSessionHandle,
|
session: InternalWorkerSessionHandle,
|
||||||
|
change_tracker: Option<tools::Tracker>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
worker_name,
|
worker_name,
|
||||||
@@ -55,12 +97,64 @@ impl InternalSpawnedWorkerRecord {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
installed_tools: installed_tools.into(),
|
installed_tools: installed_tools.into(),
|
||||||
session,
|
session,
|
||||||
|
change_tracker,
|
||||||
|
started_at: Instant::now(),
|
||||||
|
stop_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||||
scope_reclaimed: Arc::new(AtomicBool::new(false)),
|
scope_reclaimed: Arc::new(AtomicBool::new(false)),
|
||||||
protocol_revision: Arc::new(AtomicU64::new(0)),
|
protocol_revision: Arc::new(AtomicU64::new(0)),
|
||||||
|
protocol_emit_lock: Arc::new(Mutex::new(())),
|
||||||
|
protocol_terminal: Arc::new(AtomicBool::new(false)),
|
||||||
forwarding_started: Arc::new(AtomicBool::new(false)),
|
forwarding_started: Arc::new(AtomicBool::new(false)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn stop_summary(&self) -> SubWorkerStopSummary {
|
||||||
|
let mut counts = BTreeMap::<String, u64>::new();
|
||||||
|
for entry in self.session.entries() {
|
||||||
|
if let session_store::LogEntry::AssistantItem {
|
||||||
|
item: LoggedItem::ToolCall { name, .. },
|
||||||
|
..
|
||||||
|
} = entry
|
||||||
|
{
|
||||||
|
let count = counts.entry(bounded_tool_name(&name)).or_default();
|
||||||
|
*count = count.saturating_add(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut tool_counts = counts
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, count)| SubWorkerToolCount { name, count })
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
tool_counts.sort_by(|left, right| {
|
||||||
|
right
|
||||||
|
.count
|
||||||
|
.cmp(&left.count)
|
||||||
|
.then_with(|| left.name.cmp(&right.name))
|
||||||
|
});
|
||||||
|
tool_counts.truncate(STOP_SUMMARY_TOOL_LIMIT);
|
||||||
|
|
||||||
|
let change_stat = self.change_tracker.as_ref().and_then(|tracker| {
|
||||||
|
let stat = tracker.change_stat();
|
||||||
|
(stat.added > 0 || stat.deleted > 0).then(|| SubWorkerChangeStat {
|
||||||
|
added: stat.added,
|
||||||
|
deleted: stat.deleted,
|
||||||
|
source: "tracked_write_edit_tools".to_string(),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
SubWorkerStopSummary {
|
||||||
|
session_id: self.session.session_id_string(),
|
||||||
|
display_name: self.worker_name.clone(),
|
||||||
|
outcome: SubWorkerFinalOutcome::Done,
|
||||||
|
elapsed_ms: self
|
||||||
|
.started_at
|
||||||
|
.elapsed()
|
||||||
|
.as_millis()
|
||||||
|
.min(u128::from(u64::MAX)) as u64,
|
||||||
|
tool_counts,
|
||||||
|
change_stat,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn claim_scope_reclaim(&self) -> bool {
|
fn claim_scope_reclaim(&self) -> bool {
|
||||||
!self.scope_reclaimed.swap(true, Ordering::AcqRel)
|
!self.scope_reclaimed.swap(true, Ordering::AcqRel)
|
||||||
}
|
}
|
||||||
@@ -277,12 +371,20 @@ impl SpawnedWorkerRegistry {
|
|||||||
};
|
};
|
||||||
let worker = record.protocol_ref(Some(parent_session_id));
|
let worker = record.protocol_ref(Some(parent_session_id));
|
||||||
let protocol_revision = record.protocol_revision.clone();
|
let protocol_revision = record.protocol_revision.clone();
|
||||||
|
let protocol_emit_lock = record.protocol_emit_lock.clone();
|
||||||
|
let protocol_terminal = record.protocol_terminal.clone();
|
||||||
let mut child_rx = record.session.subscribe_events();
|
let mut child_rx = record.session.subscribe_events();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
match child_rx.recv().await {
|
match child_rx.recv().await {
|
||||||
Ok(event) => {
|
Ok(event) => {
|
||||||
let shutdown = matches!(event, Event::Shutdown);
|
let shutdown = matches!(event, Event::Shutdown);
|
||||||
|
let _emit_guard = protocol_emit_lock
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|error| error.into_inner());
|
||||||
|
if protocol_terminal.load(Ordering::Acquire) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1;
|
let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1;
|
||||||
let _ = parent_tx.send(Event::InternalWorker {
|
let _ = parent_tx.send(Event::InternalWorker {
|
||||||
worker: worker.clone(),
|
worker: worker.clone(),
|
||||||
@@ -294,6 +396,12 @@ impl SpawnedWorkerRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
||||||
|
let _emit_guard = protocol_emit_lock
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|error| error.into_inner());
|
||||||
|
if protocol_terminal.load(Ordering::Acquire) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1;
|
let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1;
|
||||||
let _ = parent_tx.send(Event::InternalWorker {
|
let _ = parent_tx.send(Event::InternalWorker {
|
||||||
worker: worker.clone(),
|
worker: worker.clone(),
|
||||||
@@ -385,13 +493,34 @@ impl SpawnedWorkerRegistry {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stop one direct Internal SubWorker and discard its registry/scope state.
|
||||||
|
///
|
||||||
|
/// The child actor must acknowledge its stop before the registry is removed.
|
||||||
|
/// After scope reclamation and removal, `InternalWorkerRemoved` is published
|
||||||
|
/// exactly once as the parent-stream terminal fence. Callers only receive
|
||||||
|
/// `Done` after all authoritative cleanup succeeds.
|
||||||
pub(crate) async fn remove_internal(
|
pub(crate) async fn remove_internal(
|
||||||
&self,
|
&self,
|
||||||
worker_name: &str,
|
worker_name: &str,
|
||||||
) -> io::Result<Option<InternalSpawnedWorkerRecord>> {
|
) -> io::Result<Option<SubWorkerStopSummary>> {
|
||||||
if let Some(record) = self.get_internal(worker_name) {
|
let Some(record) = self.get_internal(worker_name) else {
|
||||||
self.reclaim_record_scope(&record)?;
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let _stop_guard = record.stop_lock.lock().await;
|
||||||
|
let still_registered = self.get_internal(worker_name).is_some_and(|current| {
|
||||||
|
current.session.session_id_string() == record.session.session_id_string()
|
||||||
|
});
|
||||||
|
if !still_registered {
|
||||||
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
record
|
||||||
|
.session
|
||||||
|
.stop()
|
||||||
|
.await
|
||||||
|
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||||
|
let summary = record.stop_summary();
|
||||||
|
self.reclaim_record_scope(&record)?;
|
||||||
let removed =
|
let removed =
|
||||||
{
|
{
|
||||||
let mut records = self.internal_records.lock().map_err(|_| {
|
let mut records = self.internal_records.lock().map_err(|_| {
|
||||||
@@ -402,14 +531,41 @@ impl SpawnedWorkerRegistry {
|
|||||||
})?;
|
})?;
|
||||||
let removed = records
|
let removed = records
|
||||||
.iter()
|
.iter()
|
||||||
.position(|record| record.worker_name == worker_name)
|
.position(|candidate| {
|
||||||
|
candidate.worker_name == worker_name
|
||||||
|
&& candidate.session.session_id_string()
|
||||||
|
== record.session.session_id_string()
|
||||||
|
})
|
||||||
.map(|index| records.remove(index));
|
.map(|index| records.remove(index));
|
||||||
if removed.is_some() {
|
if removed.is_some() {
|
||||||
names.remove(worker_name);
|
names.remove(worker_name);
|
||||||
}
|
}
|
||||||
removed
|
removed
|
||||||
};
|
};
|
||||||
Ok(removed)
|
if removed.is_some() {
|
||||||
|
self.publish_internal_removal(&record);
|
||||||
|
}
|
||||||
|
Ok(removed.map(|_| summary))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish_internal_removal(&self, record: &InternalSpawnedWorkerRecord) {
|
||||||
|
if record.session.visibility() != InternalWorkerVisibility::ParentClient {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some((parent_tx, parent_session_id)) = self.parent_protocol.lock().unwrap().clone()
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _emit_guard = record
|
||||||
|
.protocol_emit_lock
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|error| error.into_inner());
|
||||||
|
record.protocol_terminal.store(true, Ordering::Release);
|
||||||
|
let revision = record.protocol_revision.fetch_add(1, Ordering::AcqRel) + 1;
|
||||||
|
let _ = parent_tx.send(Event::InternalWorkerRemoved {
|
||||||
|
worker: record.protocol_ref(Some(parent_session_id)),
|
||||||
|
revision,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,6 +664,17 @@ fn record_from_worker_state(child: &WorkerSpawnedChild) -> io::Result<SpawnedWor
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn bounded_tool_name(name: &str) -> String {
|
||||||
|
let mut bounded = name
|
||||||
|
.chars()
|
||||||
|
.take(STOP_SUMMARY_TOOL_NAME_LIMIT)
|
||||||
|
.collect::<String>();
|
||||||
|
if name.chars().count() > STOP_SUMMARY_TOOL_NAME_LIMIT {
|
||||||
|
bounded.push('…');
|
||||||
|
}
|
||||||
|
bounded
|
||||||
|
}
|
||||||
|
|
||||||
fn store_error_to_io(error: WorkerStoreError) -> io::Error {
|
fn store_error_to_io(error: WorkerStoreError) -> io::Error {
|
||||||
io::Error::other(error)
|
io::Error::other(error)
|
||||||
}
|
}
|
||||||
@@ -520,7 +687,7 @@ mod tests {
|
|||||||
use session_store::LogEntry;
|
use session_store::LogEntry;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::internal_worker::test_internal_worker_session;
|
use crate::internal_worker::{InternalWorkerSessionStatus, test_internal_worker_session};
|
||||||
|
|
||||||
fn registry() -> Arc<SpawnedWorkerRegistry> {
|
fn registry() -> Arc<SpawnedWorkerRegistry> {
|
||||||
let scope = Scope::from_config(&ScopeConfig {
|
let scope = Scope::from_config(&ScopeConfig {
|
||||||
@@ -577,6 +744,7 @@ mod tests {
|
|||||||
delegation,
|
delegation,
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
session,
|
session,
|
||||||
|
None,
|
||||||
),
|
),
|
||||||
sender,
|
sender,
|
||||||
)
|
)
|
||||||
@@ -669,4 +837,124 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(registry.internal_worker_snapshots().is_empty());
|
assert!(registry.internal_worker_snapshots().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn install_record(registry: &SpawnedWorkerRegistry, record: InternalSpawnedWorkerRecord) {
|
||||||
|
registry
|
||||||
|
.internal_names
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(record.worker_name.clone());
|
||||||
|
registry.internal_records.lock().unwrap().push(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stop_removes_internal_worker_and_returns_bounded_summary() {
|
||||||
|
let registry = registry();
|
||||||
|
let (parent_tx, mut parent_rx) = broadcast::channel(32);
|
||||||
|
registry.attach_parent_protocol(parent_tx, "parent-session".into());
|
||||||
|
let tracker = tools::Tracker::new();
|
||||||
|
tracker.record_change(12, 4);
|
||||||
|
let (mut record, _events) = record("child", InternalWorkerVisibility::ParentClient).await;
|
||||||
|
record.change_tracker = Some(tracker);
|
||||||
|
for (index, name) in ["Read", "Read", "Grep"].into_iter().enumerate() {
|
||||||
|
record.session.publish_test_entry(LogEntry::AssistantItem {
|
||||||
|
ts: index as u64,
|
||||||
|
item: LoggedItem::ToolCall {
|
||||||
|
call_id: format!("call-{index}"),
|
||||||
|
name: name.to_string(),
|
||||||
|
arguments: "{}".to_string(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
registry.start_protocol_forwarding(record.clone());
|
||||||
|
install_record(®istry, record);
|
||||||
|
|
||||||
|
let summary = registry.remove_internal("child").await.unwrap().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(summary.display_name, "child");
|
||||||
|
assert_eq!(summary.outcome, SubWorkerFinalOutcome::Done);
|
||||||
|
assert_eq!(
|
||||||
|
summary.tool_counts,
|
||||||
|
vec![
|
||||||
|
SubWorkerToolCount {
|
||||||
|
name: "Read".to_string(),
|
||||||
|
count: 2,
|
||||||
|
},
|
||||||
|
SubWorkerToolCount {
|
||||||
|
name: "Grep".to_string(),
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
summary.change_stat,
|
||||||
|
Some(SubWorkerChangeStat {
|
||||||
|
added: 12,
|
||||||
|
deleted: 4,
|
||||||
|
source: "tracked_write_edit_tools".to_string(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert!(registry.get_internal("child").is_none());
|
||||||
|
let terminal_revision = loop {
|
||||||
|
if let Event::InternalWorkerRemoved { worker, revision } =
|
||||||
|
parent_rx.recv().await.unwrap()
|
||||||
|
{
|
||||||
|
assert_eq!(worker.session_id, summary.session_id);
|
||||||
|
assert!(revision > 0);
|
||||||
|
break revision;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert!(registry.remove_internal("child").await.unwrap().is_none());
|
||||||
|
while let Ok(Ok(event)) =
|
||||||
|
tokio::time::timeout(Duration::from_millis(20), parent_rx.recv()).await
|
||||||
|
{
|
||||||
|
assert!(!matches!(event, Event::InternalWorkerRemoved { .. }));
|
||||||
|
if let Event::InternalWorker { revision, .. } = event {
|
||||||
|
assert!(revision > terminal_revision);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn running_worker_is_stopped_before_removal() {
|
||||||
|
let registry = registry();
|
||||||
|
let (record, _events) = record("running", InternalWorkerVisibility::ParentClient).await;
|
||||||
|
record
|
||||||
|
.session
|
||||||
|
.force_status(InternalWorkerSessionStatus::Running);
|
||||||
|
install_record(®istry, record);
|
||||||
|
|
||||||
|
let summary = registry.remove_internal("running").await.unwrap().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(summary.outcome, SubWorkerFinalOutcome::Done);
|
||||||
|
assert!(registry.get_internal("running").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stop_failure_keeps_registry_and_emits_no_removal() {
|
||||||
|
let registry = registry();
|
||||||
|
let (parent_tx, mut parent_rx) = broadcast::channel(8);
|
||||||
|
registry.attach_parent_protocol(parent_tx, "parent-session".into());
|
||||||
|
let (record, _events) = record("child", InternalWorkerVisibility::ParentClient).await;
|
||||||
|
record.session.force_stop_failure();
|
||||||
|
install_record(®istry, record);
|
||||||
|
|
||||||
|
let error = registry.remove_internal("child").await.unwrap_err();
|
||||||
|
|
||||||
|
assert!(error.to_string().contains("unavailable"));
|
||||||
|
assert!(registry.get_internal("child").is_some());
|
||||||
|
assert!(matches!(
|
||||||
|
parent_rx.try_recv(),
|
||||||
|
Err(broadcast::error::TryRecvError::Empty)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_only_summary_omits_unavailable_change_stat() {
|
||||||
|
let tracker = tools::Tracker::new();
|
||||||
|
let (mut record, _events) = record("reader", InternalWorkerVisibility::ParentClient).await;
|
||||||
|
record.change_tracker = Some(tracker);
|
||||||
|
|
||||||
|
assert_eq!(record.stop_summary().change_stat, None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -481,6 +481,7 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
ToolError::ExecutionFailed(format!("install Internal Worker features: {error}"))
|
ToolError::ExecutionFailed(format!("install Internal Worker features: {error}"))
|
||||||
})?;
|
})?;
|
||||||
|
let child_change_tracker = child.tracker().cloned();
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
let installed_tools = child
|
let installed_tools = child
|
||||||
.engine()
|
.engine()
|
||||||
@@ -587,6 +588,7 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
installed_tools,
|
installed_tools,
|
||||||
session.clone(),
|
session.clone(),
|
||||||
|
child_change_tracker,
|
||||||
);
|
);
|
||||||
if let Err(error) = name_reservation.commit(record) {
|
if let Err(error) = name_reservation.commit(record) {
|
||||||
let _ = session.stop().await;
|
let _ = session.stop().await;
|
||||||
|
|||||||
@@ -272,7 +272,6 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync {
|
|||||||
&self,
|
&self,
|
||||||
_target_runtime_id: &str,
|
_target_runtime_id: &str,
|
||||||
_target_worker_id: &str,
|
_target_worker_id: &str,
|
||||||
_expected_worker_revision: &str,
|
|
||||||
_reason: &str,
|
_reason: &str,
|
||||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||||
Err(WorkspaceClientError::Unavailable(
|
Err(WorkspaceClientError::Unavailable(
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
|||||||
use session_store::{CombinedStore, FsWorkerStore};
|
use session_store::{CombinedStore, FsWorkerStore};
|
||||||
use session_store::{FsStore, LogEntry};
|
use session_store::{FsStore, LogEntry};
|
||||||
use workdir::{
|
use workdir::{
|
||||||
CommandRequest, LocalWorkdirSession, Workdir, WorkdirError, WorkdirSessionCapabilities,
|
CommandOutputRequest, CommandRequest, LocalWorkdirSession, Workdir, WorkdirError,
|
||||||
WorkdirSessionHandle,
|
WorkdirSessionCapabilities, WorkdirSessionHandle,
|
||||||
};
|
};
|
||||||
|
|
||||||
use worker::{
|
use worker::{
|
||||||
@@ -232,6 +232,7 @@ async fn shutdown_closes_bound_workdir_session() {
|
|||||||
command: "sleep 30".to_owned(),
|
command: "sleep 30".to_owned(),
|
||||||
timeout_secs: 60,
|
timeout_secs: 60,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
tool_call_id: None,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -253,6 +254,180 @@ async fn shutdown_closes_bound_workdir_session() {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn controller_projects_workdir_command_events_and_snapshot_state() {
|
||||||
|
let (mut worker, pwd) = make_worker_with_pwd(MockClient::new(simple_text_events())).await;
|
||||||
|
let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::materialized_bound(
|
||||||
|
Workdir::new("controller-command-observation-workdir"),
|
||||||
|
pwd.clone(),
|
||||||
|
pwd,
|
||||||
|
worker.scope().clone(),
|
||||||
|
WorkdirSessionCapabilities::ALL,
|
||||||
|
));
|
||||||
|
worker.bind_workdir_session(Some(Arc::clone(&session)));
|
||||||
|
let handle = spawn_controller(worker).await;
|
||||||
|
let mut events = handle.subscribe();
|
||||||
|
|
||||||
|
let command = session
|
||||||
|
.start_command(CommandRequest {
|
||||||
|
command: "printf ready; sleep 0.3; printf done".to_owned(),
|
||||||
|
timeout_secs: 5,
|
||||||
|
output_limit: 1024,
|
||||||
|
tool_call_id: Some("tool-command-1".into()),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut saw_started = false;
|
||||||
|
let mut saw_output = false;
|
||||||
|
while !saw_output {
|
||||||
|
let event = tokio::time::timeout(std::time::Duration::from_secs(2), events.recv())
|
||||||
|
.await
|
||||||
|
.expect("command event should arrive")
|
||||||
|
.unwrap();
|
||||||
|
match event {
|
||||||
|
Event::Command {
|
||||||
|
event:
|
||||||
|
protocol::CommandEvent::Started {
|
||||||
|
command_id,
|
||||||
|
tool_call_id,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
} => {
|
||||||
|
assert_eq!(command_id, command.0);
|
||||||
|
assert_eq!(tool_call_id.as_deref(), Some("tool-command-1"));
|
||||||
|
saw_started = true;
|
||||||
|
}
|
||||||
|
Event::Command {
|
||||||
|
event:
|
||||||
|
protocol::CommandEvent::Output {
|
||||||
|
command_id,
|
||||||
|
stream: protocol::CommandStream::Stdout,
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
} if command_id == command.0 && content.contains("ready") => saw_output = true,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(saw_started);
|
||||||
|
|
||||||
|
let Event::Snapshot { in_flight, .. } = handle.snapshot_event() else {
|
||||||
|
panic!("worker snapshot expected");
|
||||||
|
};
|
||||||
|
assert_eq!(in_flight.commands.len(), 1);
|
||||||
|
assert_eq!(in_flight.commands[0].command_id, command.0);
|
||||||
|
assert_eq!(in_flight.commands[0].stdout.content, "ready");
|
||||||
|
assert_eq!(
|
||||||
|
in_flight.commands[0].status,
|
||||||
|
protocol::CommandStatus::Running
|
||||||
|
);
|
||||||
|
|
||||||
|
let saw_terminal = drain_until(&mut events, std::time::Duration::from_secs(2), |event| {
|
||||||
|
matches!(
|
||||||
|
event,
|
||||||
|
Event::Command {
|
||||||
|
event: protocol::CommandEvent::Terminal {
|
||||||
|
command_id,
|
||||||
|
status: protocol::CommandStatus::Completed,
|
||||||
|
exit_code: Some(0),
|
||||||
|
..
|
||||||
|
}
|
||||||
|
} if command_id == &command.0
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
assert!(saw_terminal, "completed command event should arrive");
|
||||||
|
|
||||||
|
let output = session
|
||||||
|
.command_output(CommandOutputRequest {
|
||||||
|
handle: command,
|
||||||
|
cursor: 0,
|
||||||
|
limit: 1024,
|
||||||
|
wait: true,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(output.status, workdir::CommandStatus::Completed);
|
||||||
|
let (entries, _) = handle.sink.subscribe_with_snapshot();
|
||||||
|
let durable_history = serde_json::to_string(&entries).unwrap();
|
||||||
|
assert!(
|
||||||
|
!durable_history.contains("ready") && !durable_history.contains("done"),
|
||||||
|
"operational command chunks must not be appended to Worker history: {durable_history}"
|
||||||
|
);
|
||||||
|
handle.send(Method::Shutdown).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn controller_refreshes_command_snapshot_after_high_output_provider_lag() {
|
||||||
|
let (mut worker, pwd) = make_worker_with_pwd(MockClient::new(simple_text_events())).await;
|
||||||
|
let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::materialized_bound(
|
||||||
|
Workdir::new("controller-command-lag-recovery-workdir"),
|
||||||
|
pwd.clone(),
|
||||||
|
pwd,
|
||||||
|
worker.scope().clone(),
|
||||||
|
WorkdirSessionCapabilities::ALL,
|
||||||
|
));
|
||||||
|
worker.bind_workdir_session(Some(Arc::clone(&session)));
|
||||||
|
let handle = spawn_controller(worker).await;
|
||||||
|
|
||||||
|
// Local command telemetry uses 8 KiB chunks and a 256-event channel. One
|
||||||
|
// synchronous file-poll burst with 300 chunks deterministically makes the
|
||||||
|
// worker-side receiver observe `Lagged` before this command terminates.
|
||||||
|
let command = session
|
||||||
|
.start_command(CommandRequest {
|
||||||
|
command: "dd if=/dev/zero bs=8192 count=300 2>/dev/null | tr '\\0' x; sleep 5"
|
||||||
|
.to_owned(),
|
||||||
|
timeout_secs: 10,
|
||||||
|
output_limit: 1024,
|
||||||
|
tool_call_id: Some("tool-high-output".into()),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let expected_end_offset = 300_u64 * 8192;
|
||||||
|
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(3);
|
||||||
|
let recovered = loop {
|
||||||
|
let Event::Snapshot { in_flight, .. } = handle.snapshot_event() else {
|
||||||
|
panic!("worker snapshot expected");
|
||||||
|
};
|
||||||
|
if let Some(snapshot) = in_flight
|
||||||
|
.commands
|
||||||
|
.iter()
|
||||||
|
.find(|snapshot| snapshot.command_id == command.0)
|
||||||
|
&& snapshot.stdout.end_offset >= expected_end_offset
|
||||||
|
{
|
||||||
|
break snapshot.clone();
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
tokio::time::Instant::now() < deadline,
|
||||||
|
"timed out waiting for lag recovery snapshot"
|
||||||
|
);
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(recovered.tool_call_id.as_deref(), Some("tool-high-output"));
|
||||||
|
assert_eq!(recovered.status, protocol::CommandStatus::Running);
|
||||||
|
assert!(recovered.stdout.truncated);
|
||||||
|
assert!(recovered.stdout.start_offset > 0);
|
||||||
|
assert_eq!(recovered.stdout.end_offset, expected_end_offset);
|
||||||
|
assert!(recovered.stdout.content.len() <= 32 * 1024);
|
||||||
|
assert!(recovered.stdout.content.bytes().all(|byte| byte == b'x'));
|
||||||
|
|
||||||
|
session.cancel_command(command.clone()).await.unwrap();
|
||||||
|
let output = session
|
||||||
|
.command_output(CommandOutputRequest {
|
||||||
|
handle: command,
|
||||||
|
cursor: 0,
|
||||||
|
limit: 1024,
|
||||||
|
wait: true,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(output.status, workdir::CommandStatus::Cancelled);
|
||||||
|
handle.send(Method::Shutdown).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn controller_startup_failure_closes_bound_workdir_session() {
|
async fn controller_startup_failure_closes_bound_workdir_session() {
|
||||||
let (mut worker, pwd) = make_worker_with_pwd(MockClient::new(simple_text_events())).await;
|
let (mut worker, pwd) = make_worker_with_pwd(MockClient::new(simple_text_events())).await;
|
||||||
@@ -279,6 +454,7 @@ async fn controller_startup_failure_closes_bound_workdir_session() {
|
|||||||
command: "printf unreachable".to_owned(),
|
command: "printf unreachable".to_owned(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
tool_call_id: None,
|
||||||
})
|
})
|
||||||
.await,
|
.await,
|
||||||
Err(WorkdirError::Unavailable(_))
|
Err(WorkdirError::Unavailable(_))
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ ticket.workspace = true
|
|||||||
memory.workspace = true
|
memory.workspace = true
|
||||||
merge-request.workspace = true
|
merge-request.workspace = true
|
||||||
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync", "time"] }
|
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync", "time"] }
|
||||||
|
tower.workspace = true
|
||||||
tokio-tungstenite.workspace = true
|
tokio-tungstenite.workspace = true
|
||||||
worker.workspace = true
|
worker.workspace = true
|
||||||
workdir = { workspace = true, features = ["http-client"] }
|
workdir = { workspace = true, features = ["http-client"] }
|
||||||
|
|||||||
@@ -20,12 +20,13 @@ use crate::records::{
|
|||||||
ObjectiveShowRequest, ObjectiveSummary, ProjectRecordList, QueryPage, TicketAssignmentSummary,
|
ObjectiveShowRequest, ObjectiveSummary, ProjectRecordList, QueryPage, TicketAssignmentSummary,
|
||||||
TicketDetail, TicketEventDetail, TicketEvidenceEvent, TicketEvidenceSummary,
|
TicketDetail, TicketEventDetail, TicketEvidenceEvent, TicketEvidenceSummary,
|
||||||
TicketListPageRequest, TicketMergeRequestSummary, TicketQueryItem, TicketQueryRequest,
|
TicketListPageRequest, TicketMergeRequestSummary, TicketQueryItem, TicketQueryRequest,
|
||||||
TicketQueryResponse, TicketShowRequest, TicketSummary, TicketSummaryPage, summarize_body,
|
TicketQueryResponse, TicketRelationView, TicketShowRequest, TicketSummary, TicketSummaryPage,
|
||||||
truncate_body, validate_project_id,
|
summarize_body, truncate_body, validate_project_id,
|
||||||
};
|
};
|
||||||
use crate::store::{
|
use crate::store::{
|
||||||
ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord,
|
ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord,
|
||||||
ObjectiveEventRecord, ObjectiveRecord, ObjectiveTicketLinkRecord, SqliteWorkspaceStore,
|
ObjectiveEventRecord, ObjectiveRecord, ObjectiveTicketLinkRecord, SqliteWorkspaceStore,
|
||||||
|
WorkspaceResourceKind,
|
||||||
};
|
};
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
|
|
||||||
@@ -227,10 +228,24 @@ impl SqliteWorkspaceAuthority {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn objective_record(&self, id: &str) -> Result<ObjectiveRecord> {
|
fn resource_key(&self, kind: WorkspaceResourceKind, resource_id: &str) -> Result<String> {
|
||||||
self.store
|
self.store
|
||||||
.get_objective(&self.workspace_id, id)?
|
.resource_key(&self.workspace_id, kind, resource_id)?
|
||||||
.ok_or_else(|| unknown_objective_error(id))
|
.ok_or_else(|| Error::Store(format!("missing resource key for {resource_id}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn objective_record(&self, reference: &str) -> Result<ObjectiveRecord> {
|
||||||
|
let id = self
|
||||||
|
.store
|
||||||
|
.resolve_resource_reference(
|
||||||
|
&self.workspace_id,
|
||||||
|
WorkspaceResourceKind::Objective,
|
||||||
|
reference,
|
||||||
|
)?
|
||||||
|
.ok_or_else(|| unknown_objective_error(reference))?;
|
||||||
|
self.store
|
||||||
|
.get_objective(&self.workspace_id, &id)?
|
||||||
|
.ok_or_else(|| unknown_objective_error(reference))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn objective_detail_from_record(&self, record: ObjectiveRecord) -> Result<ObjectiveDetail> {
|
fn objective_detail_from_record(&self, record: ObjectiveRecord) -> Result<ObjectiveDetail> {
|
||||||
@@ -247,6 +262,7 @@ impl SqliteWorkspaceAuthority {
|
|||||||
.filter(|ticket| linked_tickets.iter().any(|id| id == &ticket.id))
|
.filter(|ticket| linked_tickets.iter().any(|id| id == &ticket.id))
|
||||||
.map(|ticket| ObjectiveLinkedTicketSummary {
|
.map(|ticket| ObjectiveLinkedTicketSummary {
|
||||||
id: ticket.id,
|
id: ticket.id,
|
||||||
|
resource_key: ticket.resource_key,
|
||||||
title: ticket.title,
|
title: ticket.title,
|
||||||
state: ticket.state,
|
state: ticket.state,
|
||||||
})
|
})
|
||||||
@@ -288,6 +304,8 @@ impl SqliteWorkspaceAuthority {
|
|||||||
.unwrap_or("none")
|
.unwrap_or("none")
|
||||||
);
|
);
|
||||||
Ok(ObjectiveDetail {
|
Ok(ObjectiveDetail {
|
||||||
|
resource_key: self
|
||||||
|
.resource_key(WorkspaceResourceKind::Objective, &record.objective_id)?,
|
||||||
id: record.objective_id,
|
id: record.objective_id,
|
||||||
title: record.title,
|
title: record.title,
|
||||||
state: record.state,
|
state: record.state,
|
||||||
@@ -681,11 +699,20 @@ impl SqliteWorkspaceAuthority {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_ticket_detail(&self, id: &str, request: TicketShowRequest) -> Result<TicketDetail> {
|
fn read_ticket_detail(
|
||||||
validate_project_id(id)?;
|
&self,
|
||||||
let ticket = self
|
reference: &str,
|
||||||
.ticket_backend
|
request: TicketShowRequest,
|
||||||
.show(TicketIdOrSlug::Id(id.to_string()))?;
|
) -> Result<TicketDetail> {
|
||||||
|
let id = self
|
||||||
|
.store
|
||||||
|
.resolve_resource_reference(
|
||||||
|
&self.workspace_id,
|
||||||
|
WorkspaceResourceKind::Ticket,
|
||||||
|
reference,
|
||||||
|
)?
|
||||||
|
.ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(reference.to_string())))?;
|
||||||
|
let ticket = self.ticket_backend.show(TicketIdOrSlug::Id(id))?;
|
||||||
self.ticket_detail_from_ticket(ticket, request)
|
self.ticket_detail_from_ticket(ticket, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -718,12 +745,16 @@ impl SqliteWorkspaceAuthority {
|
|||||||
.store
|
.store
|
||||||
.list_objectives_for_ticket(&self.workspace_id, id, 1_000)?
|
.list_objectives_for_ticket(&self.workspace_id, id, 1_000)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|objective| ObjectiveLinkSummary {
|
.map(|objective| {
|
||||||
id: objective.objective_id,
|
Ok::<_, Error>(ObjectiveLinkSummary {
|
||||||
title: objective.title,
|
resource_key: self
|
||||||
state: objective.state,
|
.resource_key(WorkspaceResourceKind::Objective, &objective.objective_id)?,
|
||||||
|
id: objective.objective_id,
|
||||||
|
title: objective.title,
|
||||||
|
state: objective.state,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Result<Vec<_>>>()?;
|
||||||
let implementation_reports = ticket
|
let implementation_reports = ticket
|
||||||
.events
|
.events
|
||||||
.iter()
|
.iter()
|
||||||
@@ -734,11 +765,20 @@ impl SqliteWorkspaceAuthority {
|
|||||||
let current_assignment = self
|
let current_assignment = self
|
||||||
.store
|
.store
|
||||||
.get_current_ticket_worker_assignment(&self.workspace_id, id)?
|
.get_current_ticket_worker_assignment(&self.workspace_id, id)?
|
||||||
.map(|assignment| TicketAssignmentSummary {
|
.map(|assignment| {
|
||||||
assignment_id: assignment.assignment_id,
|
let worker_resource_key = self.store.resource_key(
|
||||||
runtime_id: assignment.worker.runtime_id,
|
&self.workspace_id,
|
||||||
worker_id: assignment.worker.worker_id,
|
WorkspaceResourceKind::Worker,
|
||||||
});
|
&assignment.worker.worker_id,
|
||||||
|
)?;
|
||||||
|
Ok::<_, Error>(TicketAssignmentSummary {
|
||||||
|
assignment_id: assignment.assignment_id,
|
||||||
|
runtime_id: assignment.worker.runtime_id,
|
||||||
|
worker_id: assignment.worker.worker_id,
|
||||||
|
worker_resource_key,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.transpose()?;
|
||||||
let merge_request = match self.merge_request_store.get(&self.workspace_id, id) {
|
let merge_request = match self.merge_request_store.get(&self.workspace_id, id) {
|
||||||
Ok(request) => {
|
Ok(request) => {
|
||||||
let current_subject_ref = request.selector_from.as_deref().and_then(|selector| {
|
let current_subject_ref = request.selector_from.as_deref().and_then(|selector| {
|
||||||
@@ -763,8 +803,41 @@ impl SqliteWorkspaceAuthority {
|
|||||||
.and_then(|event| event.attributes.get("event_id").cloned())
|
.and_then(|event| event.attributes.get("event_id").cloned())
|
||||||
.or_else(|| ticket.meta.updated_at.clone())
|
.or_else(|| ticket.meta.updated_at.clone())
|
||||||
.unwrap_or_else(|| format!("{}:0", ticket.meta.id));
|
.unwrap_or_else(|| format!("{}:0", ticket.meta.id));
|
||||||
|
let resource_key = ticket
|
||||||
|
.meta
|
||||||
|
.resource_key
|
||||||
|
.clone()
|
||||||
|
.or(self.store.resource_key(
|
||||||
|
&self.workspace_id,
|
||||||
|
WorkspaceResourceKind::Ticket,
|
||||||
|
&ticket.meta.id,
|
||||||
|
)?)
|
||||||
|
.ok_or_else(|| Error::Store(format!("missing resource key for {}", ticket.meta.id)))?;
|
||||||
|
let mut relations: TicketRelationView = ticket.relations.into();
|
||||||
|
for relation in &mut relations.outgoing {
|
||||||
|
relation.target_resource_key = self.store.resource_key(
|
||||||
|
&self.workspace_id,
|
||||||
|
WorkspaceResourceKind::Ticket,
|
||||||
|
&relation.target,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
for relation in &mut relations.incoming {
|
||||||
|
relation.source_resource_key = self.store.resource_key(
|
||||||
|
&self.workspace_id,
|
||||||
|
WorkspaceResourceKind::Ticket,
|
||||||
|
&relation.source_ticket,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
for blocker in &mut relations.blockers {
|
||||||
|
blocker.blocking_resource_key = self.store.resource_key(
|
||||||
|
&self.workspace_id,
|
||||||
|
WorkspaceResourceKind::Ticket,
|
||||||
|
&blocker.blocking_ticket,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
Ok(TicketDetail {
|
Ok(TicketDetail {
|
||||||
id: ticket.meta.id,
|
id: ticket.meta.id,
|
||||||
|
resource_key,
|
||||||
title: ticket.meta.title,
|
title: ticket.meta.title,
|
||||||
state: ticket.meta.workflow_state.as_str().to_string(),
|
state: ticket.meta.workflow_state.as_str().to_string(),
|
||||||
readiness: ticket.meta.readiness,
|
readiness: ticket.meta.readiness,
|
||||||
@@ -797,7 +870,7 @@ impl SqliteWorkspaceAuthority {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|artifact| artifact.relative_path.display().to_string())
|
.map(|artifact| artifact.relative_path.display().to_string())
|
||||||
.collect(),
|
.collect(),
|
||||||
relations: ticket.relations.into(),
|
relations,
|
||||||
linked_objectives,
|
linked_objectives,
|
||||||
implementation_reports,
|
implementation_reports,
|
||||||
current_assignment,
|
current_assignment,
|
||||||
@@ -820,7 +893,11 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
|
|||||||
.map(|item| {
|
.map(|item| {
|
||||||
let projection =
|
let projection =
|
||||||
project_ticket_workspace_item(&item.summary, &item.relation_blockers, None);
|
project_ticket_workspace_item(&item.summary, &item.relation_blockers, None);
|
||||||
TicketSummary {
|
let resource_key = item.summary.resource_key.clone().ok_or_else(|| {
|
||||||
|
Error::Store(format!("missing resource key for {}", item.summary.id))
|
||||||
|
})?;
|
||||||
|
Ok::<_, Error>(TicketSummary {
|
||||||
|
resource_key,
|
||||||
id: item.summary.id,
|
id: item.summary.id,
|
||||||
title: item.summary.title,
|
title: item.summary.title,
|
||||||
state: item.summary.workflow_state.as_str().to_string(),
|
state: item.summary.workflow_state.as_str().to_string(),
|
||||||
@@ -831,9 +908,9 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
|
|||||||
workspace_action_priority: workspace_action_priority_name(projection.priority)
|
workspace_action_priority: workspace_action_priority_name(projection.priority)
|
||||||
.to_string(),
|
.to_string(),
|
||||||
record_source: "sqlite_yoi_ticket".to_string(),
|
record_source: "sqlite_yoi_ticket".to_string(),
|
||||||
}
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect::<Result<Vec<_>>>()?;
|
||||||
Ok(ProjectRecordList {
|
Ok(ProjectRecordList {
|
||||||
items,
|
items,
|
||||||
invalid_records: Vec::new(),
|
invalid_records: Vec::new(),
|
||||||
@@ -874,7 +951,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
|
|||||||
.items
|
.items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(ticket_summary_from_sqlite_item)
|
.map(ticket_summary_from_sqlite_item)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Result<Vec<_>>>()?;
|
||||||
let next_cursor = page
|
let next_cursor = page
|
||||||
.next
|
.next
|
||||||
.map(|position| make_ticket_summary_cursor(&fingerprint, position));
|
.map(|position| make_ticket_summary_cursor(&fingerprint, position));
|
||||||
@@ -913,7 +990,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
|
|||||||
let authoritative = self
|
let authoritative = self
|
||||||
.ticket_backend
|
.ticket_backend
|
||||||
.show(TicketIdOrSlug::Id(ticket_id.clone()))?;
|
.show(TicketIdOrSlug::Id(ticket_id.clone()))?;
|
||||||
let summary = ticket_summary_from_ticket(&authoritative);
|
let summary = ticket_summary_from_ticket(&authoritative)?;
|
||||||
let authoritative_body = authoritative.document.body.clone();
|
let authoritative_body = authoritative.document.body.clone();
|
||||||
let authoritative_events = authoritative.events.clone();
|
let authoritative_events = authoritative.events.clone();
|
||||||
let detail = self.ticket_detail_from_ticket(
|
let detail = self.ticket_detail_from_ticket(
|
||||||
@@ -987,6 +1064,8 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
|
|||||||
.map(|link| link.ticket_id)
|
.map(|link| link.ticket_id)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
items.push(ObjectiveSummary {
|
items.push(ObjectiveSummary {
|
||||||
|
resource_key: self
|
||||||
|
.resource_key(WorkspaceResourceKind::Objective, &record.objective_id)?,
|
||||||
id: record.objective_id,
|
id: record.objective_id,
|
||||||
title: record.title,
|
title: record.title,
|
||||||
state: record.state,
|
state: record.state,
|
||||||
@@ -1032,6 +1111,8 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
|
|||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let body_md = record.body_md.clone();
|
let body_md = record.body_md.clone();
|
||||||
let objective = ObjectiveSummary {
|
let objective = ObjectiveSummary {
|
||||||
|
resource_key: self
|
||||||
|
.resource_key(WorkspaceResourceKind::Objective, &record.objective_id)?,
|
||||||
id: record.objective_id,
|
id: record.objective_id,
|
||||||
title: record.title,
|
title: record.title,
|
||||||
state: record.state,
|
state: record.state,
|
||||||
@@ -1076,15 +1157,21 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn objective(&self, id: &str) -> Result<ObjectiveDetail> {
|
fn objective(&self, reference: &str) -> Result<ObjectiveDetail> {
|
||||||
validate_project_id(id)?;
|
let record = self.objective_record(reference)?;
|
||||||
let record = self.objective_record(id)?;
|
|
||||||
self.objective_detail_from_record(record)
|
self.objective_detail_from_record(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn show_objective(&self, id: &str, query: ObjectiveShowRequest) -> Result<ObjectiveDetail> {
|
fn show_objective(
|
||||||
let mut detail = self.objective(id)?;
|
&self,
|
||||||
let all_events = self.store.list_objective_events(&self.workspace_id, id)?;
|
reference: &str,
|
||||||
|
query: ObjectiveShowRequest,
|
||||||
|
) -> Result<ObjectiveDetail> {
|
||||||
|
let mut detail = self.objective(reference)?;
|
||||||
|
let objective_id = detail.id.clone();
|
||||||
|
let all_events = self
|
||||||
|
.store
|
||||||
|
.list_objective_events(&self.workspace_id, &objective_id)?;
|
||||||
let event_limit = query
|
let event_limit = query
|
||||||
.event_limit
|
.event_limit
|
||||||
.unwrap_or(TICKET_EVENT_LIMIT)
|
.unwrap_or(TICKET_EVENT_LIMIT)
|
||||||
@@ -1169,9 +1256,12 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
|
|||||||
self.objective(&objective_id)
|
self.objective(&objective_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn edit_objective(&self, id: &str, input: ObjectiveEditInput) -> Result<ObjectiveDetail> {
|
fn edit_objective(
|
||||||
validate_project_id(id)?;
|
&self,
|
||||||
let mut record = self.objective_record(id)?;
|
reference: &str,
|
||||||
|
input: ObjectiveEditInput,
|
||||||
|
) -> Result<ObjectiveDetail> {
|
||||||
|
let mut record = self.objective_record(reference)?;
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
if let Some(title) = input.title {
|
if let Some(title) = input.title {
|
||||||
validate_objective_title(&title)?;
|
validate_objective_title(&title)?;
|
||||||
@@ -1217,60 +1307,85 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
record.updated_at = now_rfc3339();
|
record.updated_at = now_rfc3339();
|
||||||
|
let objective_id = record.objective_id.clone();
|
||||||
self.store.upsert_objective(&record)?;
|
self.store.upsert_objective(&record)?;
|
||||||
self.insert_objective_event(id, "edit", None)?;
|
self.insert_objective_event(&objective_id, "edit", None)?;
|
||||||
self.objective(id)
|
self.objective(&objective_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_objective_state(&self, id: &str, state: &str) -> Result<ObjectiveDetail> {
|
fn set_objective_state(&self, reference: &str, state: &str) -> Result<ObjectiveDetail> {
|
||||||
validate_project_id(id)?;
|
|
||||||
validate_objective_state(state)?;
|
validate_objective_state(state)?;
|
||||||
let mut record = self.objective_record(id)?;
|
let mut record = self.objective_record(reference)?;
|
||||||
record.state = state.trim().to_string();
|
record.state = state.trim().to_string();
|
||||||
record.updated_at = now_rfc3339();
|
record.updated_at = now_rfc3339();
|
||||||
|
let objective_id = record.objective_id.clone();
|
||||||
self.store.upsert_objective(&record)?;
|
self.store.upsert_objective(&record)?;
|
||||||
self.insert_objective_event(id, "state", Some(&record.state))?;
|
self.insert_objective_event(&objective_id, "state", Some(&record.state))?;
|
||||||
self.objective(id)
|
self.objective(&objective_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn link_objective_ticket(&self, id: &str, ticket_id: &str) -> Result<ObjectiveDetail> {
|
fn link_objective_ticket(
|
||||||
validate_project_id(id)?;
|
&self,
|
||||||
validate_project_id(ticket_id)?;
|
objective_reference: &str,
|
||||||
let _record = self.objective_record(id)?;
|
ticket_reference: &str,
|
||||||
|
) -> Result<ObjectiveDetail> {
|
||||||
|
let objective_id = self.objective_record(objective_reference)?.objective_id;
|
||||||
|
let ticket_id = self
|
||||||
|
.store
|
||||||
|
.resolve_resource_reference(
|
||||||
|
&self.workspace_id,
|
||||||
|
WorkspaceResourceKind::Ticket,
|
||||||
|
ticket_reference,
|
||||||
|
)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Error::Ticket(ticket::TicketError::NotFound(ticket_reference.to_string()))
|
||||||
|
})?;
|
||||||
let now = now_rfc3339();
|
let now = now_rfc3339();
|
||||||
let mut links = self
|
let mut links = self
|
||||||
.store
|
.store
|
||||||
.list_objective_ticket_links(&self.workspace_id, id)?;
|
.list_objective_ticket_links(&self.workspace_id, &objective_id)?;
|
||||||
if !links.iter().any(|link| link.ticket_id == ticket_id) {
|
if !links.iter().any(|link| link.ticket_id == ticket_id) {
|
||||||
links.push(ObjectiveTicketLinkRecord {
|
links.push(ObjectiveTicketLinkRecord {
|
||||||
workspace_id: self.workspace_id.clone(),
|
workspace_id: self.workspace_id.clone(),
|
||||||
objective_id: id.to_string(),
|
objective_id: objective_id.clone(),
|
||||||
ticket_id: ticket_id.to_string(),
|
ticket_id: ticket_id.clone(),
|
||||||
kind: "linked".to_string(),
|
kind: "linked".to_string(),
|
||||||
created_at: now,
|
created_at: now,
|
||||||
});
|
});
|
||||||
self.store
|
self.store
|
||||||
.replace_objective_ticket_links(&self.workspace_id, id, &links)?;
|
.replace_objective_ticket_links(&self.workspace_id, &objective_id, &links)?;
|
||||||
self.insert_objective_event(id, "link_ticket", Some(ticket_id))?;
|
self.insert_objective_event(&objective_id, "link_ticket", Some(&ticket_id))?;
|
||||||
}
|
}
|
||||||
self.objective(id)
|
self.objective(&objective_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn unlink_objective_ticket(&self, id: &str, ticket_id: &str) -> Result<ObjectiveDetail> {
|
fn unlink_objective_ticket(
|
||||||
validate_project_id(id)?;
|
&self,
|
||||||
validate_project_id(ticket_id)?;
|
objective_reference: &str,
|
||||||
let _record = self.objective_record(id)?;
|
ticket_reference: &str,
|
||||||
|
) -> Result<ObjectiveDetail> {
|
||||||
|
let objective_id = self.objective_record(objective_reference)?.objective_id;
|
||||||
|
let ticket_id = self
|
||||||
|
.store
|
||||||
|
.resolve_resource_reference(
|
||||||
|
&self.workspace_id,
|
||||||
|
WorkspaceResourceKind::Ticket,
|
||||||
|
ticket_reference,
|
||||||
|
)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Error::Ticket(ticket::TicketError::NotFound(ticket_reference.to_string()))
|
||||||
|
})?;
|
||||||
let mut links = self
|
let mut links = self
|
||||||
.store
|
.store
|
||||||
.list_objective_ticket_links(&self.workspace_id, id)?;
|
.list_objective_ticket_links(&self.workspace_id, &objective_id)?;
|
||||||
let original_len = links.len();
|
let original_len = links.len();
|
||||||
links.retain(|link| link.ticket_id != ticket_id);
|
links.retain(|link| link.ticket_id != ticket_id);
|
||||||
if links.len() != original_len {
|
if links.len() != original_len {
|
||||||
self.store
|
self.store
|
||||||
.replace_objective_ticket_links(&self.workspace_id, id, &links)?;
|
.replace_objective_ticket_links(&self.workspace_id, &objective_id, &links)?;
|
||||||
self.insert_objective_event(id, "unlink_ticket", Some(ticket_id))?;
|
self.insert_objective_event(&objective_id, "unlink_ticket", Some(&ticket_id))?;
|
||||||
}
|
}
|
||||||
self.objective(id)
|
self.objective(&objective_id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1475,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 {
|
||||||
@@ -2023,6 +2138,7 @@ fn ticket_query_item(
|
|||||||
}
|
}
|
||||||
TicketQueryItem {
|
TicketQueryItem {
|
||||||
id: summary.id,
|
id: summary.id,
|
||||||
|
resource_key: summary.resource_key,
|
||||||
title: summary.title,
|
title: summary.title,
|
||||||
state: summary.state,
|
state: summary.state,
|
||||||
readiness: detail.readiness.clone(),
|
readiness: detail.readiness.clone(),
|
||||||
@@ -2173,6 +2289,7 @@ fn objective_query_item(
|
|||||||
}
|
}
|
||||||
ObjectiveQueryItem {
|
ObjectiveQueryItem {
|
||||||
id: objective.id,
|
id: objective.id,
|
||||||
|
resource_key: objective.resource_key,
|
||||||
title: objective.title,
|
title: objective.title,
|
||||||
state: objective.state,
|
state: objective.state,
|
||||||
created_at: objective.created_at,
|
created_at: objective.created_at,
|
||||||
@@ -2362,9 +2479,10 @@ fn memory_resolution_from_record(record: MemoryStagingResolutionRecord) -> Memor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ticket_summary_from_ticket(ticket: &ticket::Ticket) -> TicketSummary {
|
fn ticket_summary_from_ticket(ticket: &ticket::Ticket) -> Result<TicketSummary> {
|
||||||
let summary = ticket::TicketSummary {
|
let summary = ticket::TicketSummary {
|
||||||
id: ticket.meta.id.clone(),
|
id: ticket.meta.id.clone(),
|
||||||
|
resource_key: ticket.meta.resource_key.clone(),
|
||||||
slug: ticket.meta.slug.clone(),
|
slug: ticket.meta.slug.clone(),
|
||||||
title: ticket.meta.title.clone(),
|
title: ticket.meta.title.clone(),
|
||||||
status: ticket.meta.status.clone(),
|
status: ticket.meta.status.clone(),
|
||||||
@@ -2384,9 +2502,15 @@ fn ticket_summary_from_ticket(ticket: &ticket::Ticket) -> TicketSummary {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ticket_summary_from_sqlite_item(item: SqliteTicketListItem) -> TicketSummary {
|
fn ticket_summary_from_sqlite_item(item: SqliteTicketListItem) -> Result<TicketSummary> {
|
||||||
let projection = project_ticket_workspace_item(&item.summary, &item.relation_blockers, None);
|
let projection = project_ticket_workspace_item(&item.summary, &item.relation_blockers, None);
|
||||||
TicketSummary {
|
let resource_key = item
|
||||||
|
.summary
|
||||||
|
.resource_key
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| Error::Store(format!("missing resource key for {}", item.summary.id)))?;
|
||||||
|
Ok(TicketSummary {
|
||||||
|
resource_key,
|
||||||
id: item.summary.id,
|
id: item.summary.id,
|
||||||
title: item.summary.title,
|
title: item.summary.title,
|
||||||
state: item.summary.workflow_state.as_str().to_string(),
|
state: item.summary.workflow_state.as_str().to_string(),
|
||||||
@@ -2396,7 +2520,7 @@ fn ticket_summary_from_sqlite_item(item: SqliteTicketListItem) -> TicketSummary
|
|||||||
queued_at: item.summary.queued_at,
|
queued_at: item.summary.queued_at,
|
||||||
workspace_action_priority: workspace_action_priority_name(projection.priority).to_string(),
|
workspace_action_priority: workspace_action_priority_name(projection.priority).to_string(),
|
||||||
record_source: "sqlite_yoi_ticket".to_string(),
|
record_source: "sqlite_yoi_ticket".to_string(),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
@@ -2721,12 +2845,6 @@ mod tests {
|
|||||||
write_ticket(dir.path(), "00000000001J5", "Second ticket", "planning");
|
write_ticket(dir.path(), "00000000001J5", "Second ticket", "planning");
|
||||||
write_ticket(dir.path(), "00000000001J6", "Third ticket", "planning");
|
write_ticket(dir.path(), "00000000001J6", "Third ticket", "planning");
|
||||||
let db_path = dir.path().join("workspace.db");
|
let db_path = dir.path().join("workspace.db");
|
||||||
SqliteTicketBackend::open(&db_path, "workspace-test")
|
|
||||||
.unwrap()
|
|
||||||
.import_from_local_backend(&ticket::LocalTicketBackend::new(
|
|
||||||
dir.path().join(".yoi/tickets"),
|
|
||||||
))
|
|
||||||
.unwrap();
|
|
||||||
let store = SqliteWorkspaceStore::open(&db_path).unwrap();
|
let store = SqliteWorkspaceStore::open(&db_path).unwrap();
|
||||||
store
|
store
|
||||||
.upsert_workspace(&WorkspaceRecord {
|
.upsert_workspace(&WorkspaceRecord {
|
||||||
@@ -2739,6 +2857,27 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
SqliteTicketBackend::open(&db_path, "workspace-test")
|
||||||
|
.unwrap()
|
||||||
|
.import_from_local_backend(&ticket::LocalTicketBackend::new(
|
||||||
|
dir.path().join(".yoi/tickets"),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
rusqlite::Connection::open(&db_path)
|
||||||
|
.unwrap()
|
||||||
|
.execute_batch(
|
||||||
|
r#"
|
||||||
|
INSERT INTO workspace_resource_keys (
|
||||||
|
workspace_id, resource_kind, resource_id, sequence, resource_key, allocated_at
|
||||||
|
) VALUES
|
||||||
|
('workspace-test', 'ticket', '00000000001J2', 1, 'T-1', '2026-01-01T00:00:00Z'),
|
||||||
|
('workspace-test', 'ticket', '00000000001J5', 2, 'T-2', '2026-01-01T00:00:00Z'),
|
||||||
|
('workspace-test', 'ticket', '00000000001J6', 3, 'T-3', '2026-01-01T00:00:00Z');
|
||||||
|
INSERT INTO workspace_resource_key_counters (workspace_id, resource_kind, next_sequence)
|
||||||
|
VALUES ('workspace-test', 'ticket', 4);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
store
|
store
|
||||||
.upsert_objective(&ObjectiveRecord {
|
.upsert_objective(&ObjectiveRecord {
|
||||||
workspace_id: "workspace-test".to_string(),
|
workspace_id: "workspace-test".to_string(),
|
||||||
@@ -2828,6 +2967,8 @@ mod tests {
|
|||||||
assert_eq!(tickets.items[0].id, "00000000001J2");
|
assert_eq!(tickets.items[0].id, "00000000001J2");
|
||||||
assert_eq!(tickets.items[0].state, "ready");
|
assert_eq!(tickets.items[0].state, "ready");
|
||||||
assert_eq!(tickets.items[0].workspace_action_priority, "background");
|
assert_eq!(tickets.items[0].workspace_action_priority, "background");
|
||||||
|
let ticket_by_key = authority.ticket(&tickets.items[0].resource_key).unwrap();
|
||||||
|
assert_eq!(ticket_by_key.id, tickets.items[0].id);
|
||||||
|
|
||||||
let ticket = authority.ticket("00000000001J2").unwrap();
|
let ticket = authority.ticket("00000000001J2").unwrap();
|
||||||
assert!(ticket.body.contains("Ticket body"));
|
assert!(ticket.body.contains("Ticket body"));
|
||||||
@@ -2999,6 +3140,20 @@ mod tests {
|
|||||||
assert_eq!(objectives.items.len(), 1);
|
assert_eq!(objectives.items.len(), 1);
|
||||||
assert_eq!(objectives.items[0].id, "00000000001J3");
|
assert_eq!(objectives.items[0].id, "00000000001J3");
|
||||||
assert_eq!(objectives.items[0].linked_tickets, vec!["00000000001J2"]);
|
assert_eq!(objectives.items[0].linked_tickets, vec!["00000000001J2"]);
|
||||||
|
let objective_by_key = authority
|
||||||
|
.objective(&objectives.items[0].resource_key)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(objective_by_key.id, objectives.items[0].id);
|
||||||
|
assert_eq!(
|
||||||
|
authority
|
||||||
|
.show_objective(
|
||||||
|
&objectives.items[0].resource_key,
|
||||||
|
ObjectiveShowRequest::default(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.id,
|
||||||
|
objectives.items[0].id
|
||||||
|
);
|
||||||
|
|
||||||
let objective = authority.objective("00000000001J3").unwrap();
|
let objective = authority.objective("00000000001J3").unwrap();
|
||||||
assert!(objective.body.contains("Objective body"));
|
assert!(objective.body.contains("Objective body"));
|
||||||
@@ -3080,6 +3235,26 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
rusqlite::Connection::open(&db_path)
|
||||||
|
.unwrap()
|
||||||
|
.execute_batch(
|
||||||
|
r#"
|
||||||
|
INSERT INTO typed_tickets (
|
||||||
|
workspace_id, ticket_id, slug, title, status, kind, priority, body,
|
||||||
|
workflow_state, workflow_state_explicit
|
||||||
|
) VALUES
|
||||||
|
('workspace-test', '00000000001J2', 'ticket-j2', 'Ticket J2', 'open', 'task', 'normal', '', 'planning', 1),
|
||||||
|
('workspace-test', '00000000001J3', 'ticket-j3', 'Ticket J3', 'open', 'task', 'normal', '', 'planning', 1);
|
||||||
|
INSERT INTO workspace_resource_keys (
|
||||||
|
workspace_id, resource_kind, resource_id, sequence, resource_key, allocated_at
|
||||||
|
) VALUES
|
||||||
|
('workspace-test', 'ticket', '00000000001J2', 1, 'T-1', '2026-01-01T00:00:00Z'),
|
||||||
|
('workspace-test', 'ticket', '00000000001J3', 2, 'T-2', '2026-01-01T00:00:00Z');
|
||||||
|
INSERT INTO workspace_resource_key_counters (workspace_id, resource_kind, next_sequence)
|
||||||
|
VALUES ('workspace-test', 'ticket', 3);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let authority = SqliteWorkspaceAuthority::new(&db_path, "workspace-test").unwrap();
|
let authority = SqliteWorkspaceAuthority::new(&db_path, "workspace-test").unwrap();
|
||||||
|
|
||||||
let created = authority
|
let created = authority
|
||||||
|
|||||||
@@ -246,6 +246,8 @@ 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,
|
||||||
@@ -340,6 +342,14 @@ pub struct WorkerTicketAssignmentRequest {
|
|||||||
pub operation_id: String,
|
pub operation_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn worker_spawn_create_fingerprint(
|
||||||
|
request: &WorkerSpawnRequest,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let encoded = serde_json::to_vec(request)
|
||||||
|
.map_err(|error| format!("serialize Worker create input: {error}"))?;
|
||||||
|
Ok(format!("sha256:{}", digest_hex(&encoded, 64)))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn worker_spawn_idempotency(
|
pub(crate) fn worker_spawn_idempotency(
|
||||||
request: &WorkerSpawnRequest,
|
request: &WorkerSpawnRequest,
|
||||||
) -> Result<Option<(String, String)>, String> {
|
) -> Result<Option<(String, String)>, String> {
|
||||||
@@ -366,6 +376,12 @@ pub struct WorkerControlOperation {
|
|||||||
pub input_fingerprint: String,
|
pub input_fingerprint: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct WorkerCreateBinding {
|
||||||
|
pub worker_id: EmbeddedWorkerId,
|
||||||
|
pub create_fingerprint: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct WorkerSpawnRequest {
|
pub struct WorkerSpawnRequest {
|
||||||
@@ -763,7 +779,11 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_worker(&self, request: WorkerSpawnRequest) -> WorkerSpawnResult {
|
fn spawn_worker(
|
||||||
|
&self,
|
||||||
|
_binding: WorkerCreateBinding,
|
||||||
|
request: WorkerSpawnRequest,
|
||||||
|
) -> WorkerSpawnResult {
|
||||||
WorkerSpawnResult {
|
WorkerSpawnResult {
|
||||||
state: WorkerOperationState::Unsupported,
|
state: WorkerOperationState::Unsupported,
|
||||||
worker: None,
|
worker: None,
|
||||||
@@ -1226,6 +1246,7 @@ impl RuntimeRegistry {
|
|||||||
pub fn spawn_worker(
|
pub fn spawn_worker(
|
||||||
&self,
|
&self,
|
||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
|
binding: WorkerCreateBinding,
|
||||||
request: WorkerSpawnRequest,
|
request: WorkerSpawnRequest,
|
||||||
) -> Result<WorkerSpawnResult, RuntimeRegistryError> {
|
) -> Result<WorkerSpawnResult, RuntimeRegistryError> {
|
||||||
validate_backend_identifier("runtime_id", runtime_id)?;
|
validate_backend_identifier("runtime_id", runtime_id)?;
|
||||||
@@ -1269,7 +1290,7 @@ impl RuntimeRegistry {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(runtime.spawn_worker(request))
|
Ok(runtime.spawn_worker(binding, request))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_working_directory(
|
pub fn create_working_directory(
|
||||||
@@ -1606,6 +1627,7 @@ impl EmbeddedWorkerRuntime {
|
|||||||
let runtime = worker_runtime::Runtime::with_fs_store_and_execution_backend(
|
let runtime = worker_runtime::Runtime::with_fs_store_and_execution_backend(
|
||||||
FsRuntimeStoreOptions {
|
FsRuntimeStoreOptions {
|
||||||
root: store_root.into(),
|
root: store_root.into(),
|
||||||
|
runtime_id: EMBEDDED_RUNTIME_ID.to_string(),
|
||||||
display_name: Some("embedded".to_string()),
|
display_name: Some("embedded".to_string()),
|
||||||
},
|
},
|
||||||
backend,
|
backend,
|
||||||
@@ -1658,6 +1680,7 @@ 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,
|
||||||
@@ -1697,6 +1720,7 @@ 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,
|
||||||
@@ -1948,7 +1972,11 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_worker(&self, request: WorkerSpawnRequest) -> WorkerSpawnResult {
|
fn spawn_worker(
|
||||||
|
&self,
|
||||||
|
binding: WorkerCreateBinding,
|
||||||
|
request: WorkerSpawnRequest,
|
||||||
|
) -> WorkerSpawnResult {
|
||||||
let mut diagnostics = Vec::new();
|
let mut diagnostics = Vec::new();
|
||||||
if request.resolved_working_directory_request.is_some()
|
if request.resolved_working_directory_request.is_some()
|
||||||
|| request.resolved_working_directory.is_some()
|
|| request.resolved_working_directory.is_some()
|
||||||
@@ -1981,7 +2009,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||||||
diagnostics.push(diagnostic(
|
diagnostics.push(diagnostic(
|
||||||
"embedded_worker_name_display_only",
|
"embedded_worker_name_display_only",
|
||||||
DiagnosticSeverity::Info,
|
DiagnosticSeverity::Info,
|
||||||
"requested_worker_name is used only as display_name; embedded Runtime allocates opaque runtime-local worker ids".to_string(),
|
"requested_worker_name is used only as display_name; Worker identity is allocated by Workspace authority".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if matches!(request.acceptance, WorkerSpawnAcceptanceRequirement::RunAccepted { expected_segments } if expected_segments > 0)
|
if matches!(request.acceptance, WorkerSpawnAcceptanceRequirement::RunAccepted { expected_segments } if expected_segments > 0)
|
||||||
@@ -2010,11 +2038,6 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let (idempotency_key, idempotency_fingerprint) = worker_spawn_idempotency(&request)
|
|
||||||
.expect("WorkerSpawnRequest serialization is infallible")
|
|
||||||
.map_or((None, None), |(key, fingerprint)| {
|
|
||||||
(Some(key), Some(fingerprint))
|
|
||||||
});
|
|
||||||
let workspace_api = match required_worker_workspace_api(&request) {
|
let workspace_api = match required_worker_workspace_api(&request) {
|
||||||
Ok(workspace_api) => workspace_api,
|
Ok(workspace_api) => workspace_api,
|
||||||
Err(diagnostic) => {
|
Err(diagnostic) => {
|
||||||
@@ -2030,8 +2053,8 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||||||
let workspace_id = workspace_api.workspace_id.clone();
|
let workspace_id = workspace_api.workspace_id.clone();
|
||||||
let config_bundle = spawn_config_bundle_ref(&request);
|
let config_bundle = spawn_config_bundle_ref(&request);
|
||||||
let create_request = CreateWorkerRequest {
|
let create_request = CreateWorkerRequest {
|
||||||
idempotency_key,
|
worker_id: binding.worker_id,
|
||||||
idempotency_fingerprint,
|
create_fingerprint: binding.create_fingerprint,
|
||||||
profile,
|
profile,
|
||||||
display_name: request.requested_worker_name.clone(),
|
display_name: request.requested_worker_name.clone(),
|
||||||
config_bundle,
|
config_bundle,
|
||||||
@@ -2424,6 +2447,8 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct RemoteRuntimeConfig {
|
pub struct RemoteRuntimeConfig {
|
||||||
pub runtime_id: String,
|
pub runtime_id: String,
|
||||||
|
/// Explicit Workspace assignment granted by Server authority.
|
||||||
|
pub workspace_id: Option<String>,
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
pub bearer_token: Option<String>,
|
pub bearer_token: Option<String>,
|
||||||
@@ -2466,6 +2491,7 @@ impl RemoteRuntimeConfig {
|
|||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
runtime_id: runtime_id.into(),
|
runtime_id: runtime_id.into(),
|
||||||
|
workspace_id: None,
|
||||||
display_name: display_name.into(),
|
display_name: display_name.into(),
|
||||||
base_url: base_url.into(),
|
base_url: base_url.into(),
|
||||||
bearer_token,
|
bearer_token,
|
||||||
@@ -2478,6 +2504,11 @@ impl RemoteRuntimeConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_workspace_id(mut self, workspace_id: impl Into<String>) -> Self {
|
||||||
|
self.workspace_id = Some(workspace_id.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_cached_capabilities(mut self, capabilities: RuntimeCapabilitySummary) -> Self {
|
pub fn with_cached_capabilities(mut self, capabilities: RuntimeCapabilitySummary) -> Self {
|
||||||
self.cached_capabilities = capabilities;
|
self.cached_capabilities = capabilities;
|
||||||
self
|
self
|
||||||
@@ -2775,6 +2806,7 @@ 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,
|
||||||
@@ -2818,6 +2850,7 @@ 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,
|
||||||
@@ -3113,7 +3146,11 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_worker(&self, request: WorkerSpawnRequest) -> WorkerSpawnResult {
|
fn spawn_worker(
|
||||||
|
&self,
|
||||||
|
binding: WorkerCreateBinding,
|
||||||
|
request: WorkerSpawnRequest,
|
||||||
|
) -> WorkerSpawnResult {
|
||||||
if matches!(
|
if matches!(
|
||||||
request.acceptance,
|
request.acceptance,
|
||||||
WorkerSpawnAcceptanceRequirement::SocketReady
|
WorkerSpawnAcceptanceRequirement::SocketReady
|
||||||
@@ -3152,11 +3189,6 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let (idempotency_key, idempotency_fingerprint) = worker_spawn_idempotency(&request)
|
|
||||||
.expect("WorkerSpawnRequest serialization is infallible")
|
|
||||||
.map_or((None, None), |(key, fingerprint)| {
|
|
||||||
(Some(key), Some(fingerprint))
|
|
||||||
});
|
|
||||||
let workspace_api = match required_worker_workspace_api(&request) {
|
let workspace_api = match required_worker_workspace_api(&request) {
|
||||||
Ok(workspace_api) => workspace_api,
|
Ok(workspace_api) => workspace_api,
|
||||||
Err(diagnostic) => {
|
Err(diagnostic) => {
|
||||||
@@ -3170,8 +3202,8 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
};
|
};
|
||||||
let config_bundle = spawn_config_bundle_ref(&request);
|
let config_bundle = spawn_config_bundle_ref(&request);
|
||||||
let create = CreateWorkerRequest {
|
let create = CreateWorkerRequest {
|
||||||
idempotency_key,
|
worker_id: binding.worker_id,
|
||||||
idempotency_fingerprint,
|
create_fingerprint: binding.create_fingerprint,
|
||||||
profile,
|
profile,
|
||||||
display_name: request.requested_worker_name.clone(),
|
display_name: request.requested_worker_name.clone(),
|
||||||
config_bundle,
|
config_bundle,
|
||||||
@@ -4190,6 +4222,7 @@ 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(),
|
||||||
@@ -4245,6 +4278,13 @@ mod tests {
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
|
|
||||||
|
fn test_create_binding() -> WorkerCreateBinding {
|
||||||
|
WorkerCreateBinding {
|
||||||
|
worker_id: EmbeddedWorkerId::now_v7(),
|
||||||
|
create_fingerprint: "sha256:test-create".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn test_workspace_api() -> WorkspaceApiRef {
|
fn test_workspace_api() -> WorkspaceApiRef {
|
||||||
WorkspaceApiRef {
|
WorkspaceApiRef {
|
||||||
workspace_id: "workspace-test".to_string(),
|
workspace_id: "workspace-test".to_string(),
|
||||||
@@ -4576,6 +4616,7 @@ 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(),
|
||||||
@@ -4899,11 +4940,16 @@ mod tests {
|
|||||||
digest: bundle.metadata.digest.clone(),
|
digest: bundle.metadata.digest.clone(),
|
||||||
};
|
};
|
||||||
request.resolved_config_bundle = Some(bundle);
|
request.resolved_config_bundle = Some(bundle);
|
||||||
|
let binding = test_create_binding();
|
||||||
|
|
||||||
let result = registry
|
let result = registry
|
||||||
.spawn_worker("embedded-worker-runtime", request)
|
.spawn_worker("embedded-worker-runtime", binding.clone(), request)
|
||||||
.expect("spawn request");
|
.expect("spawn request");
|
||||||
assert_eq!(result.state, WorkerOperationState::Accepted);
|
assert_eq!(result.state, WorkerOperationState::Accepted);
|
||||||
|
assert_eq!(
|
||||||
|
result.worker.as_ref().unwrap().worker.worker_id,
|
||||||
|
binding.worker_id.to_string()
|
||||||
|
);
|
||||||
let check = registry
|
let check = registry
|
||||||
.check_config_bundle("embedded-worker-runtime", bundle_ref)
|
.check_config_bundle("embedded-worker-runtime", bundle_ref)
|
||||||
.expect("bundle check");
|
.expect("bundle check");
|
||||||
@@ -4921,7 +4967,7 @@ mod tests {
|
|||||||
let mut request = embedded_spawn_request();
|
let mut request = embedded_spawn_request();
|
||||||
request.resolved_workspace_api = None;
|
request.resolved_workspace_api = None;
|
||||||
|
|
||||||
let spawned = runtime.spawn_worker(request);
|
let spawned = runtime.spawn_worker(test_create_binding(), request);
|
||||||
|
|
||||||
assert_eq!(spawned.state, WorkerOperationState::Rejected);
|
assert_eq!(spawned.state, WorkerOperationState::Rejected);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -4939,7 +4985,7 @@ mod tests {
|
|||||||
Arc::new(FailingSpawnBackend),
|
Arc::new(FailingSpawnBackend),
|
||||||
)
|
)
|
||||||
.expect("test backend should connect");
|
.expect("test backend should connect");
|
||||||
let spawned = runtime.spawn_worker(embedded_spawn_request());
|
let spawned = runtime.spawn_worker(test_create_binding(), embedded_spawn_request());
|
||||||
assert_eq!(spawned.state, WorkerOperationState::Rejected);
|
assert_eq!(spawned.state, WorkerOperationState::Rejected);
|
||||||
assert!(spawned.acceptance_evidence.is_empty());
|
assert!(spawned.acceptance_evidence.is_empty());
|
||||||
assert!(spawned.diagnostics.iter().any(|diagnostic| {
|
assert!(spawned.diagnostics.iter().any(|diagnostic| {
|
||||||
@@ -5006,7 +5052,7 @@ mod tests {
|
|||||||
Arc::new(AcceptingExecutionBackend::default()),
|
Arc::new(AcceptingExecutionBackend::default()),
|
||||||
)
|
)
|
||||||
.expect("test backend should connect");
|
.expect("test backend should connect");
|
||||||
let spawned = runtime.spawn_worker(embedded_spawn_request());
|
let spawned = runtime.spawn_worker(test_create_binding(), embedded_spawn_request());
|
||||||
assert_eq!(spawned.state, WorkerOperationState::Accepted);
|
assert_eq!(spawned.state, WorkerOperationState::Accepted);
|
||||||
let worker = spawned.worker.expect("created embedded worker");
|
let worker = spawned.worker.expect("created embedded worker");
|
||||||
assert!(worker.capabilities.can_stop);
|
assert!(worker.capabilities.can_stop);
|
||||||
@@ -5064,6 +5110,7 @@ mod tests {
|
|||||||
let spawned = registry
|
let spawned = registry
|
||||||
.spawn_worker(
|
.spawn_worker(
|
||||||
EMBEDDED_RUNTIME_ID,
|
EMBEDDED_RUNTIME_ID,
|
||||||
|
test_create_binding(),
|
||||||
WorkerSpawnRequest {
|
WorkerSpawnRequest {
|
||||||
intent: WorkerSpawnIntent::TicketRole {
|
intent: WorkerSpawnIntent::TicketRole {
|
||||||
ticket_id: "00001KVZSGT0Q".to_string(),
|
ticket_id: "00001KVZSGT0Q".to_string(),
|
||||||
@@ -5162,6 +5209,7 @@ mod tests {
|
|||||||
let spawned = registry
|
let spawned = registry
|
||||||
.spawn_worker(
|
.spawn_worker(
|
||||||
EMBEDDED_RUNTIME_ID,
|
EMBEDDED_RUNTIME_ID,
|
||||||
|
test_create_binding(),
|
||||||
WorkerSpawnRequest {
|
WorkerSpawnRequest {
|
||||||
intent: WorkerSpawnIntent::TicketRole {
|
intent: WorkerSpawnIntent::TicketRole {
|
||||||
ticket_id: "00001KVZSGT0Q".to_string(),
|
ticket_id: "00001KVZSGT0Q".to_string(),
|
||||||
@@ -5204,6 +5252,7 @@ mod tests {
|
|||||||
let result = registry
|
let result = registry
|
||||||
.spawn_worker(
|
.spawn_worker(
|
||||||
EMBEDDED_RUNTIME_ID,
|
EMBEDDED_RUNTIME_ID,
|
||||||
|
test_create_binding(),
|
||||||
WorkerSpawnRequest {
|
WorkerSpawnRequest {
|
||||||
intent: WorkerSpawnIntent::WorkspaceCompanion,
|
intent: WorkerSpawnIntent::WorkspaceCompanion,
|
||||||
requested_worker_name: None,
|
requested_worker_name: None,
|
||||||
@@ -5251,7 +5300,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn remote_runtime_registry_routes_commands_without_browser_secret_leaks() {
|
fn remote_runtime_registry_routes_commands_without_browser_secret_leaks() {
|
||||||
let worker_json = worker_json("remote:primary", "1");
|
let worker_id = EmbeddedWorkerId::from_legacy_u64(1).to_string();
|
||||||
|
let worker_json = worker_json("remote:primary", &worker_id);
|
||||||
let (base_url, server) = serve_mock_http(vec![
|
let (base_url, server) = serve_mock_http(vec![
|
||||||
mock_response(
|
mock_response(
|
||||||
"GET",
|
"GET",
|
||||||
@@ -5262,19 +5312,19 @@ mod tests {
|
|||||||
),
|
),
|
||||||
mock_response(
|
mock_response(
|
||||||
"GET",
|
"GET",
|
||||||
"/v1/workers/1",
|
format!("/v1/workers/{worker_id}"),
|
||||||
true,
|
true,
|
||||||
200,
|
200,
|
||||||
json!({ "worker": worker_json.clone() }).to_string(),
|
json!({ "worker": worker_json.clone() }).to_string(),
|
||||||
),
|
),
|
||||||
mock_response(
|
mock_response(
|
||||||
"POST",
|
"POST",
|
||||||
"/v1/workers/1/input",
|
format!("/v1/workers/{worker_id}/input"),
|
||||||
true,
|
true,
|
||||||
200,
|
200,
|
||||||
json!({
|
json!({
|
||||||
"ack": {
|
"ack": {
|
||||||
"worker_ref": { "runtime_id": "remote:primary", "worker_id": 1 },
|
"worker_ref": { "runtime_id": "remote:primary", "worker_id": worker_id.clone() },
|
||||||
"status": "running"
|
"status": "running"
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -5298,20 +5348,24 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let observation = registry
|
let observation = registry
|
||||||
.observation_source(&RuntimeWorkerRef::new("remote:primary", "1"))
|
.observation_source(&RuntimeWorkerRef::new("remote:primary", &worker_id))
|
||||||
.expect("remote runtime exposes backend-owned WS observation source");
|
.expect("remote runtime exposes backend-owned WS observation source");
|
||||||
let crate::observation::RuntimeObservationSource::RemoteWs(observation) = observation
|
let crate::observation::RuntimeObservationSource::RemoteWs(observation) = observation
|
||||||
else {
|
else {
|
||||||
panic!("remote runtime should expose a remote WS observation source");
|
panic!("remote runtime should expose a remote WS observation source");
|
||||||
};
|
};
|
||||||
assert!(observation.endpoint.starts_with("ws://127.0.0.1:"));
|
assert!(observation.endpoint.starts_with("ws://127.0.0.1:"));
|
||||||
assert!(observation.endpoint.ends_with("/v1/workers/1/protocol/ws"));
|
assert!(
|
||||||
|
observation
|
||||||
|
.endpoint
|
||||||
|
.ends_with(&format!("/v1/workers/{worker_id}/protocol/ws"))
|
||||||
|
);
|
||||||
assert_eq!(observation.bearer_token.as_deref(), Some(secret.as_str()));
|
assert_eq!(observation.bearer_token.as_deref(), Some(secret.as_str()));
|
||||||
|
|
||||||
let workers = registry.list_workers(10);
|
let workers = registry.list_workers(10);
|
||||||
assert_eq!(workers.items.len(), 1);
|
assert_eq!(workers.items.len(), 1);
|
||||||
assert_eq!(workers.items[0].worker.runtime_id, "remote:primary");
|
assert_eq!(workers.items[0].worker.runtime_id, "remote:primary");
|
||||||
assert_eq!(workers.items[0].worker.worker_id, "1");
|
assert_eq!(workers.items[0].worker.worker_id, worker_id.as_str());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
workers.items[0].implementation.kind,
|
workers.items[0].implementation.kind,
|
||||||
"remote_worker_runtime"
|
"remote_worker_runtime"
|
||||||
@@ -5324,7 +5378,7 @@ mod tests {
|
|||||||
|
|
||||||
let input = registry
|
let input = registry
|
||||||
.send_input(
|
.send_input(
|
||||||
&RuntimeWorkerRef::new("remote:primary", "1"),
|
&RuntimeWorkerRef::new("remote:primary", &worker_id),
|
||||||
WorkerInputRequest {
|
WorkerInputRequest {
|
||||||
kind: WorkerInputKind::User,
|
kind: WorkerInputKind::User,
|
||||||
content: "hello remote".to_string(),
|
content: "hello remote".to_string(),
|
||||||
@@ -5350,6 +5404,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn remote_runtime_projection_uses_canonical_worker_status_for_stop_capability() {
|
fn remote_runtime_projection_uses_canonical_worker_status_for_stop_capability() {
|
||||||
|
let worker_ids = (1..=4)
|
||||||
|
.map(|value| EmbeddedWorkerId::from_legacy_u64(value).to_string())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let worker_id = worker_ids[0].clone();
|
||||||
let (base_url, server) = serve_mock_http(vec![
|
let (base_url, server) = serve_mock_http(vec![
|
||||||
mock_response(
|
mock_response(
|
||||||
"GET",
|
"GET",
|
||||||
@@ -5358,21 +5416,26 @@ mod tests {
|
|||||||
200,
|
200,
|
||||||
json!({
|
json!({
|
||||||
"workers": [
|
"workers": [
|
||||||
worker_json_with_status("remote:primary", "1", "stopped"),
|
worker_json_with_status("remote:primary", &worker_ids[0], "stopped"),
|
||||||
worker_json_with_status("remote:primary", "2", "cancelled"),
|
worker_json_with_status("remote:primary", &worker_ids[1], "cancelled"),
|
||||||
worker_json_with_status("remote:primary", "3", "paused"),
|
worker_json_with_status("remote:primary", &worker_ids[2], "paused"),
|
||||||
worker_json_with_status("remote:primary", "4", "idle")
|
worker_json_with_status("remote:primary", &worker_ids[3], "idle")
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
.to_string(),
|
.to_string(),
|
||||||
),
|
),
|
||||||
mock_response(
|
mock_response(
|
||||||
"GET",
|
"GET",
|
||||||
"/v1/workers/1",
|
format!("/v1/workers/{worker_id}"),
|
||||||
true,
|
true,
|
||||||
200,
|
200,
|
||||||
json!({
|
json!({
|
||||||
"worker": worker_json_with_status("remote:primary", "1", "stopped")})
|
"worker": worker_json_with_status(
|
||||||
|
"remote:primary",
|
||||||
|
&worker_ids[0],
|
||||||
|
"stopped"
|
||||||
|
)
|
||||||
|
})
|
||||||
.to_string(),
|
.to_string(),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
@@ -5402,7 +5465,7 @@ mod tests {
|
|||||||
assert_eq!(workers.items[3].state, "idle");
|
assert_eq!(workers.items[3].state, "idle");
|
||||||
|
|
||||||
let stopped_detail = registry
|
let stopped_detail = registry
|
||||||
.worker(&RuntimeWorkerRef::new("remote:primary", "1"))
|
.worker(&RuntimeWorkerRef::new("remote:primary", &worker_id))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(!stopped_detail.capabilities.can_stop);
|
assert!(!stopped_detail.capabilities.can_stop);
|
||||||
assert_eq!(stopped_detail.state, "stopped");
|
assert_eq!(stopped_detail.state, "stopped");
|
||||||
@@ -5595,7 +5658,7 @@ mod tests {
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct MockResponse {
|
struct MockResponse {
|
||||||
method: &'static str,
|
method: &'static str,
|
||||||
path: &'static str,
|
path: String,
|
||||||
require_auth: bool,
|
require_auth: bool,
|
||||||
status: u16,
|
status: u16,
|
||||||
body: String,
|
body: String,
|
||||||
@@ -5603,14 +5666,14 @@ mod tests {
|
|||||||
|
|
||||||
fn mock_response(
|
fn mock_response(
|
||||||
method: &'static str,
|
method: &'static str,
|
||||||
path: &'static str,
|
path: impl Into<String>,
|
||||||
require_auth: bool,
|
require_auth: bool,
|
||||||
status: u16,
|
status: u16,
|
||||||
body: String,
|
body: String,
|
||||||
) -> MockResponse {
|
) -> MockResponse {
|
||||||
MockResponse {
|
MockResponse {
|
||||||
method,
|
method,
|
||||||
path,
|
path: path.into(),
|
||||||
require_auth,
|
require_auth,
|
||||||
status,
|
status,
|
||||||
body,
|
body,
|
||||||
@@ -5667,7 +5730,6 @@ mod tests {
|
|||||||
worker_id: &str,
|
worker_id: &str,
|
||||||
status: &str,
|
status: &str,
|
||||||
) -> serde_json::Value {
|
) -> serde_json::Value {
|
||||||
let worker_id = worker_id.parse::<u64>().unwrap();
|
|
||||||
json!({
|
json!({
|
||||||
"worker_ref": { "runtime_id": runtime_id, "worker_id": worker_id },
|
"worker_ref": { "runtime_id": runtime_id, "worker_id": worker_id },
|
||||||
"runtime_id": runtime_id,
|
"runtime_id": runtime_id,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ pub mod server;
|
|||||||
pub mod skills;
|
pub mod skills;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
pub mod worker_source;
|
pub mod worker_source;
|
||||||
|
pub mod workspace_catalog;
|
||||||
mod workspace_subscription;
|
mod workspace_subscription;
|
||||||
|
|
||||||
pub use authority::{
|
pub use authority::{
|
||||||
@@ -45,8 +46,15 @@ pub use repositories::{
|
|||||||
ConfiguredRepository, GitCommitSummary, GitRemoteSummary, GitRepositorySummary,
|
ConfiguredRepository, GitCommitSummary, GitRemoteSummary, GitRepositorySummary,
|
||||||
RepositoryLogRead, RepositoryRegistryReader, RepositorySummary,
|
RepositoryLogRead, RepositoryRegistryReader, RepositorySummary,
|
||||||
};
|
};
|
||||||
pub use server::{AuthConfig, ServerConfig, WorkspaceApi, build_router, serve};
|
pub use server::{
|
||||||
|
AuthConfig, ServerConfig, WorkspaceApi, WorkspaceServerApi, build_router,
|
||||||
|
build_workspace_server_router, serve, serve_workspace_catalog,
|
||||||
|
};
|
||||||
pub use store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord};
|
pub use store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord};
|
||||||
|
pub use workspace_catalog::{
|
||||||
|
InitialRepositoryIntent, WorkspaceCatalogService, WorkspaceCreateRequest,
|
||||||
|
WorkspaceCreateResponse,
|
||||||
|
};
|
||||||
|
|
||||||
use worker_runtime::identity::RuntimeWorkerRef;
|
use worker_runtime::identity::RuntimeWorkerRef;
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ use serde::{Deserialize, Serialize};
|
|||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key};
|
use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key};
|
||||||
use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig};
|
use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig};
|
||||||
use yoi_workspace_server::store::{RepositoryRecord, SqliteWorkspaceStore, TrustedRuntimeRecord};
|
use yoi_workspace_server::store::{SqliteWorkspaceStore, TrustedRuntimeRecord};
|
||||||
use yoi_workspace_server::{
|
use yoi_workspace_server::{
|
||||||
BackendRuntimesConfigFile, ControlPlaneStore, ServerConfig, WORKSPACE_BACKEND_CONFIG_TEMPLATE,
|
BackendRuntimesConfigFile, ControlPlaneStore, InitialRepositoryIntent, ServerConfig,
|
||||||
WorkspaceBackendConfigFile, WorkspaceIdentity, WorkspaceRecord, serve,
|
WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile, WorkspaceCatalogService,
|
||||||
|
WorkspaceCreateRequest, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -23,6 +24,7 @@ enum Command {
|
|||||||
ConfigDiff(WorkspacePathOptions),
|
ConfigDiff(WorkspacePathOptions),
|
||||||
Identity(Vec<String>),
|
Identity(Vec<String>),
|
||||||
TrustRuntime(Vec<String>),
|
TrustRuntime(Vec<String>),
|
||||||
|
MigrateDryRun { database: Option<PathBuf> },
|
||||||
Skills(SkillsCommand),
|
Skills(SkillsCommand),
|
||||||
Help,
|
Help,
|
||||||
}
|
}
|
||||||
@@ -85,6 +87,17 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Command::ConfigDiff(options) => run_config_diff(options),
|
Command::ConfigDiff(options) => run_config_diff(options),
|
||||||
Command::Identity(args) => run_identity_command(args),
|
Command::Identity(args) => run_identity_command(args),
|
||||||
Command::TrustRuntime(args) => run_trust_runtime_command(args),
|
Command::TrustRuntime(args) => run_trust_runtime_command(args),
|
||||||
|
Command::MigrateDryRun { database } => {
|
||||||
|
let database = database.unwrap_or_else(ServerConfig::default_server_database_path);
|
||||||
|
let plan = SqliteWorkspaceStore::migration_plan(&database).map_err(|error| {
|
||||||
|
CliError(format!(
|
||||||
|
"migration dry-run failed for {}: {error}",
|
||||||
|
database.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
println!("{}", serde_json::to_string_pretty(&plan)?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Command::Skills(command) => run_skills(command),
|
Command::Skills(command) => run_skills(command),
|
||||||
Command::Help => Ok(()),
|
Command::Help => Ok(()),
|
||||||
}
|
}
|
||||||
@@ -107,6 +120,7 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
|
|||||||
"config" => parse_config_command(rest),
|
"config" => parse_config_command(rest),
|
||||||
"identity" => Ok(Command::Identity(rest.to_vec())),
|
"identity" => Ok(Command::Identity(rest.to_vec())),
|
||||||
"trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())),
|
"trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())),
|
||||||
|
"migrate" => parse_migrate_command(rest),
|
||||||
"skills" => parse_skills_command(rest),
|
"skills" => parse_skills_command(rest),
|
||||||
"serve" => {
|
"serve" => {
|
||||||
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
||||||
@@ -120,7 +134,7 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
|
|||||||
Ok(Command::Help)
|
Ok(Command::Help)
|
||||||
}
|
}
|
||||||
other => Err(CliError(format!(
|
other => Err(CliError(format!(
|
||||||
"unknown command `{other}`; expected `init`, `config`, `identity`, `trust-runtime`, `skills`, or `serve`"
|
"unknown command `{other}`; expected `init`, `config`, `identity`, `trust-runtime`, `migrate`, `skills`, or `serve`"
|
||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,30 +153,21 @@ async fn run_init_with_database_path(
|
|||||||
if let Some(parent) = database_path.parent() {
|
if let Some(parent) = database_path.parent() {
|
||||||
tokio::fs::create_dir_all(parent).await?;
|
tokio::fs::create_dir_all(parent).await?;
|
||||||
}
|
}
|
||||||
let store = SqliteWorkspaceStore::open(&database_path)?;
|
let store = Arc::new(SqliteWorkspaceStore::open(&database_path)?);
|
||||||
store
|
let service = WorkspaceCatalogService::new(store);
|
||||||
.upsert_workspace(&WorkspaceRecord {
|
service.create_with_workspace_id(
|
||||||
workspace_id: identity.workspace_id.clone(),
|
WorkspaceCreateRequest {
|
||||||
owner_account_id: None,
|
operation_key: format!("cli-init:{}", identity.workspace_id),
|
||||||
display_name: identity.display_name.clone(),
|
display_name: identity.display_name.clone(),
|
||||||
state: "active".to_string(),
|
repository: InitialRepositoryIntent {
|
||||||
created_at: identity.created_at.clone(),
|
uri: options.workspace.display().to_string(),
|
||||||
updated_at: identity.created_at.clone(),
|
display_name: Some("Main repository".to_string()),
|
||||||
})
|
default_ref: Some("HEAD".to_string()),
|
||||||
.await?;
|
},
|
||||||
store.upsert_repository(&RepositoryRecord {
|
},
|
||||||
workspace_id: identity.workspace_id.clone(),
|
None,
|
||||||
repository_id: "main".to_string(),
|
Some(identity.workspace_id.clone()),
|
||||||
name: "Main repository".to_string(),
|
)?;
|
||||||
kind: "git".to_string(),
|
|
||||||
provider: Some("git".to_string()),
|
|
||||||
uri: options.workspace.display().to_string(),
|
|
||||||
default_ref: Some("HEAD".to_string()),
|
|
||||||
auth_ref_kind: None,
|
|
||||||
auth_ref_key: None,
|
|
||||||
created_at: identity.created_at.clone(),
|
|
||||||
updated_at: identity.created_at.clone(),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"yoi-server: initialized workspace `{}` ({}) in server DB `{}`",
|
"yoi-server: initialized workspace `{}` ({}) in server DB `{}`",
|
||||||
@@ -345,6 +350,7 @@ fn run_trust_runtime_command(args: Vec<String>) -> Result<(), Box<dyn std::error
|
|||||||
match subcommand.as_str() {
|
match subcommand.as_str() {
|
||||||
"add" => {
|
"add" => {
|
||||||
let mut runtime_id = None;
|
let mut runtime_id = None;
|
||||||
|
let mut workspace_id = None;
|
||||||
let mut base_url = None;
|
let mut base_url = None;
|
||||||
let mut public_key = None;
|
let mut public_key = None;
|
||||||
let mut display_name = None;
|
let mut display_name = None;
|
||||||
@@ -355,6 +361,9 @@ fn run_trust_runtime_command(args: Vec<String>) -> Result<(), Box<dyn std::error
|
|||||||
"--runtime-id" => {
|
"--runtime-id" => {
|
||||||
runtime_id = Some(take_value(&flag, inline_value, &mut args)?)
|
runtime_id = Some(take_value(&flag, inline_value, &mut args)?)
|
||||||
}
|
}
|
||||||
|
"--workspace-id" => {
|
||||||
|
workspace_id = Some(take_value(&flag, inline_value, &mut args)?)
|
||||||
|
}
|
||||||
"--base-url" | "--endpoint" => {
|
"--base-url" | "--endpoint" => {
|
||||||
base_url = Some(take_value(&flag, inline_value, &mut args)?)
|
base_url = Some(take_value(&flag, inline_value, &mut args)?)
|
||||||
}
|
}
|
||||||
@@ -377,15 +386,39 @@ fn run_trust_runtime_command(args: Vec<String>) -> Result<(), Box<dyn std::error
|
|||||||
}
|
}
|
||||||
let runtime_id = runtime_id
|
let runtime_id = runtime_id
|
||||||
.ok_or_else(|| CliError("trust-runtime add requires --runtime-id".to_string()))?;
|
.ok_or_else(|| CliError("trust-runtime add requires --runtime-id".to_string()))?;
|
||||||
|
let workspace_id = workspace_id
|
||||||
|
.ok_or_else(|| CliError("trust-runtime add requires --workspace-id".to_string()))?;
|
||||||
|
if !store
|
||||||
|
.list_workspaces()?
|
||||||
|
.iter()
|
||||||
|
.any(|workspace| workspace.workspace_id == workspace_id)
|
||||||
|
{
|
||||||
|
return Err(Box::new(CliError(format!(
|
||||||
|
"Workspace `{workspace_id}` is not registered"
|
||||||
|
))));
|
||||||
|
}
|
||||||
let base_url = base_url
|
let base_url = base_url
|
||||||
.ok_or_else(|| CliError("trust-runtime add requires --base-url".to_string()))?;
|
.ok_or_else(|| CliError("trust-runtime add requires --base-url".to_string()))?;
|
||||||
let public_key = public_key
|
let public_key = public_key
|
||||||
.ok_or_else(|| CliError("trust-runtime add requires --public-key".to_string()))?;
|
.ok_or_else(|| CliError("trust-runtime add requires --public-key".to_string()))?;
|
||||||
decode_public_key(&public_key)?;
|
decode_public_key(&public_key)?;
|
||||||
ensure_trusted_runtime_replace_allowed(&store, &runtime_id, replace)?;
|
ensure_trusted_runtime_replace_allowed(&store, &runtime_id, replace)?;
|
||||||
|
if let Some(existing) = store
|
||||||
|
.list_trusted_runtimes(true)?
|
||||||
|
.into_iter()
|
||||||
|
.find(|runtime| runtime.runtime_id == runtime_id)
|
||||||
|
{
|
||||||
|
if existing.workspace_id.as_deref() != Some(workspace_id.as_str()) {
|
||||||
|
return Err(Box::new(CliError(format!(
|
||||||
|
"runtime `{runtime_id}` is already assigned to Workspace `{}` and cannot be reparented",
|
||||||
|
existing.workspace_id.as_deref().unwrap_or("unassigned")
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
}
|
||||||
let now = Utc::now().to_rfc3339();
|
let now = Utc::now().to_rfc3339();
|
||||||
store.upsert_trusted_runtime(&TrustedRuntimeRecord {
|
store.upsert_trusted_runtime(&TrustedRuntimeRecord {
|
||||||
runtime_id: runtime_id.clone(),
|
runtime_id: runtime_id.clone(),
|
||||||
|
workspace_id: Some(workspace_id.clone()),
|
||||||
display_name: display_name.unwrap_or_else(|| runtime_id.clone()),
|
display_name: display_name.unwrap_or_else(|| runtime_id.clone()),
|
||||||
base_url,
|
base_url,
|
||||||
public_key,
|
public_key,
|
||||||
@@ -424,8 +457,9 @@ fn run_trust_runtime_command(args: Vec<String>) -> Result<(), Box<dyn std::error
|
|||||||
} else {
|
} else {
|
||||||
for runtime in records {
|
for runtime in records {
|
||||||
println!(
|
println!(
|
||||||
"runtime_id={} base_url={} public_key={} revoked_at={}",
|
"runtime_id={} workspace_id={} base_url={} public_key={} revoked_at={}",
|
||||||
runtime.runtime_id,
|
runtime.runtime_id,
|
||||||
|
runtime.workspace_id.unwrap_or_default(),
|
||||||
runtime.base_url,
|
runtime.base_url,
|
||||||
runtime.public_key,
|
runtime.public_key,
|
||||||
runtime.revoked_at.unwrap_or_default()
|
runtime.revoked_at.unwrap_or_default()
|
||||||
@@ -569,12 +603,28 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
let store = Arc::new(SqliteWorkspaceStore::open(&database_path)?);
|
let store = Arc::new(SqliteWorkspaceStore::open(&database_path)?);
|
||||||
let workspace = select_serve_workspace(store.as_ref())?;
|
let workspaces = store.list_workspaces()?;
|
||||||
let workspace_root = infer_workspace_root_from_repositories(store.as_ref(), &workspace)?;
|
let (identity, workspace_root) = if let Some(workspace) = workspaces.first() {
|
||||||
let identity = WorkspaceIdentity {
|
(
|
||||||
workspace_id: workspace.workspace_id.clone(),
|
WorkspaceIdentity {
|
||||||
created_at: workspace.created_at.clone(),
|
workspace_id: workspace.workspace_id.clone(),
|
||||||
display_name: workspace.display_name.clone(),
|
created_at: workspace.created_at.clone(),
|
||||||
|
display_name: workspace.display_name.clone(),
|
||||||
|
},
|
||||||
|
infer_workspace_root_from_repositories(store.as_ref(), workspace)?,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
WorkspaceIdentity {
|
||||||
|
workspace_id: "00000000-0000-0000-0000-000000000000".to_string(),
|
||||||
|
created_at: Utc::now().to_rfc3339(),
|
||||||
|
display_name: "Server bootstrap".to_string(),
|
||||||
|
},
|
||||||
|
database_path
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| CliError("server database path has no parent".to_string()))?
|
||||||
|
.to_path_buf(),
|
||||||
|
)
|
||||||
};
|
};
|
||||||
let runtime_config = BackendRuntimesConfigFile::load_default()?;
|
let runtime_config = BackendRuntimesConfigFile::load_default()?;
|
||||||
let mut resolved = WorkspaceBackendConfigFile::default().resolve_with_runtime_config(
|
let mut resolved = WorkspaceBackendConfigFile::default().resolve_with_runtime_config(
|
||||||
@@ -588,6 +638,7 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
|
|||||||
if let Some(listen) = options.listen {
|
if let Some(listen) = options.listen {
|
||||||
resolved = resolved.with_listen(listen);
|
resolved = resolved.with_listen(listen);
|
||||||
}
|
}
|
||||||
|
resolved.server.allow_local_workspace_bootstrap = resolved.listen.ip().is_loopback();
|
||||||
|
|
||||||
let listener = TcpListener::bind(resolved.listen).await?;
|
let listener = TcpListener::bind(resolved.listen).await?;
|
||||||
let local_addr = listener.local_addr()?;
|
let local_addr = listener.local_addr()?;
|
||||||
@@ -595,12 +646,12 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
|
|||||||
resolved = resolved.with_backend_base_url(format!("http://{local_addr}"));
|
resolved = resolved.with_backend_base_url(format!("http://{local_addr}"));
|
||||||
}
|
}
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"yoi-server: serving workspace `{}` from server DB `{}` on http://{}",
|
"yoi-server: serving {} workspace(s) from server DB `{}` on http://{}",
|
||||||
workspace.workspace_id,
|
workspaces.len(),
|
||||||
database_path.display(),
|
database_path.display(),
|
||||||
local_addr
|
local_addr
|
||||||
);
|
);
|
||||||
serve(resolved.server, store, listener).await?;
|
serve_workspace_catalog(resolved.server, store, listener).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,6 +668,9 @@ fn append_trusted_runtime_sources(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
for runtime in store.list_trusted_runtimes(false)? {
|
for runtime in store.list_trusted_runtimes(false)? {
|
||||||
|
let Some(workspace_id) = runtime.workspace_id.clone() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
let auth = RemoteRuntimeAuthConfig {
|
let auth = RemoteRuntimeAuthConfig {
|
||||||
server_id: server_identity.identity.identity_id.clone(),
|
server_id: server_identity.identity.identity_id.clone(),
|
||||||
server_private_key: server_identity.identity.private_key.clone(),
|
server_private_key: server_identity.identity.private_key.clone(),
|
||||||
@@ -627,6 +681,7 @@ fn append_trusted_runtime_sources(
|
|||||||
runtime.base_url,
|
runtime.base_url,
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
.with_workspace_id(workspace_id)
|
||||||
.with_auth(auth);
|
.with_auth(auth);
|
||||||
remote_runtime_sources.retain(|existing| existing.runtime_id != runtime.runtime_id);
|
remote_runtime_sources.retain(|existing| existing.runtime_id != runtime.runtime_id);
|
||||||
remote_runtime_sources.push(remote);
|
remote_runtime_sources.push(remote);
|
||||||
@@ -634,23 +689,6 @@ fn append_trusted_runtime_sources(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn select_serve_workspace(store: &SqliteWorkspaceStore) -> Result<WorkspaceRecord, CliError> {
|
|
||||||
let workspaces = store
|
|
||||||
.list_workspaces()
|
|
||||||
.map_err(|error| CliError(format!("failed to list workspaces from server DB: {error}")))?;
|
|
||||||
match workspaces.as_slice() {
|
|
||||||
[] => Err(CliError(
|
|
||||||
"server DB has no workspace records; run `yoi-server init --workspace <PATH>`"
|
|
||||||
.to_string(),
|
|
||||||
)),
|
|
||||||
[workspace] => Ok(workspace.clone()),
|
|
||||||
_ => Err(CliError(format!(
|
|
||||||
"server DB contains {} workspaces; serve workspace selection is not implemented yet",
|
|
||||||
workspaces.len()
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn infer_workspace_root_from_repositories(
|
fn infer_workspace_root_from_repositories(
|
||||||
store: &SqliteWorkspaceStore,
|
store: &SqliteWorkspaceStore,
|
||||||
workspace: &WorkspaceRecord,
|
workspace: &WorkspaceRecord,
|
||||||
@@ -718,6 +756,32 @@ fn parse_config_command(args: &[String]) -> Result<Command, CliError> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_migrate_command(args: &[String]) -> Result<Command, CliError> {
|
||||||
|
let mut dry_run = false;
|
||||||
|
let mut database = None;
|
||||||
|
let mut index = 0;
|
||||||
|
while index < args.len() {
|
||||||
|
match args[index].as_str() {
|
||||||
|
"--dry-run" => dry_run = true,
|
||||||
|
"--database" => {
|
||||||
|
index += 1;
|
||||||
|
database =
|
||||||
|
Some(PathBuf::from(args.get(index).ok_or_else(|| {
|
||||||
|
CliError("--database requires a path".to_string())
|
||||||
|
})?));
|
||||||
|
}
|
||||||
|
value => {
|
||||||
|
return Err(CliError(format!("unknown migrate option: {value}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
if !dry_run {
|
||||||
|
return Err(CliError("migrate currently requires --dry-run".to_string()));
|
||||||
|
}
|
||||||
|
Ok(Command::MigrateDryRun { database })
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_skills_command(args: &[String]) -> Result<Command, CliError> {
|
fn parse_skills_command(args: &[String]) -> Result<Command, CliError> {
|
||||||
let Some((subcommand, rest)) = args.split_first() else {
|
let Some((subcommand, rest)) = args.split_first() else {
|
||||||
print_skills_help();
|
print_skills_help();
|
||||||
@@ -875,7 +939,8 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
|
|||||||
|
|
||||||
fn print_help() {
|
fn print_help() {
|
||||||
println!(
|
println!(
|
||||||
"yoi-server\n\nUsage:\n yoi-server init [OPTIONS]\n yoi-server config <COMMAND> [OPTIONS]\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id <RUNTIME_ID>\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
|
"yoi-server\n\nUsage:\n yoi-server init [OPTIONS]\n yoi-server config <COMMAND> [OPTIONS]\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id <RUNTIME_ID>\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server migrate --dry-run [--database <PATH>]
|
||||||
|
yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -899,7 +964,8 @@ fn print_skills_help() {
|
|||||||
|
|
||||||
fn print_serve_help() {
|
fn print_serve_help() {
|
||||||
println!(
|
println!(
|
||||||
"yoi-server serve\n\nUsage:\n yoi-server serve [OPTIONS]\n\nDescription:\n Serves the Workspace recorded in the Yoi server DB. Workspace records are stored in the XDG/Yoi data directory, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n -h, --help Print help"
|
"yoi-server serve\n\nUsage:\n yoi-server migrate --dry-run [--database <PATH>]
|
||||||
|
yoi-server serve [OPTIONS]\n\nDescription:\n Serves the Workspace recorded in the Yoi server DB. Workspace records are stored in the XDG/Yoi data directory, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n -h, --help Print help"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -939,6 +1005,22 @@ mod tests {
|
|||||||
assert_eq!(name, "debug-rust");
|
assert_eq!(name, "debug-rust");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_migrate_requires_dry_run_and_accepts_database_path() {
|
||||||
|
let error = parse_migrate_command(&[]).unwrap_err();
|
||||||
|
assert_eq!(error.to_string(), "migrate currently requires --dry-run");
|
||||||
|
let command = parse_migrate_command(&[
|
||||||
|
"--dry-run".to_string(),
|
||||||
|
"--database".to_string(),
|
||||||
|
"/tmp/server.db".to_string(),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let Command::MigrateDryRun { database } = command else {
|
||||||
|
panic!("expected migration dry-run command");
|
||||||
|
};
|
||||||
|
assert_eq!(database, Some(PathBuf::from("/tmp/server.db")));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_serve_accepts_listen_only() {
|
fn parse_serve_accepts_listen_only() {
|
||||||
let args = vec!["--listen".to_string(), "127.0.0.1:0".to_string()];
|
let args = vec!["--listen".to_string(), "127.0.0.1:0".to_string()];
|
||||||
@@ -981,6 +1063,7 @@ mod tests {
|
|||||||
store
|
store
|
||||||
.upsert_trusted_runtime(&TrustedRuntimeRecord {
|
.upsert_trusted_runtime(&TrustedRuntimeRecord {
|
||||||
runtime_id: "runtime-a".to_string(),
|
runtime_id: "runtime-a".to_string(),
|
||||||
|
workspace_id: None,
|
||||||
display_name: "Runtime A".to_string(),
|
display_name: "Runtime A".to_string(),
|
||||||
base_url: "http://127.0.0.1:18080".to_string(),
|
base_url: "http://127.0.0.1:18080".to_string(),
|
||||||
public_key,
|
public_key,
|
||||||
@@ -1002,6 +1085,7 @@ mod tests {
|
|||||||
async fn init_creates_identity_local_config_and_server_records() {
|
async fn init_creates_identity_local_config_and_server_records() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let database_path = temp.path().join("data").join("server").join("server.db");
|
let database_path = temp.path().join("data").join("server").join("server.db");
|
||||||
|
std::fs::create_dir(temp.path().join(".git")).unwrap();
|
||||||
run_init_with_database_path(
|
run_init_with_database_path(
|
||||||
InitOptions {
|
InitOptions {
|
||||||
workspace: temp.path().canonicalize().unwrap(),
|
workspace: temp.path().canonicalize().unwrap(),
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ pub struct InvalidProjectRecord {
|
|||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct TicketSummary {
|
pub struct TicketSummary {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
pub resource_key: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub state: String,
|
pub state: String,
|
||||||
pub priority: String,
|
pub priority: String,
|
||||||
@@ -66,6 +67,7 @@ pub struct TicketListResponse {
|
|||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct TicketDetail {
|
pub struct TicketDetail {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
pub resource_key: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub state: String,
|
pub state: String,
|
||||||
pub readiness: Option<String>,
|
pub readiness: Option<String>,
|
||||||
@@ -121,6 +123,8 @@ pub struct TicketRelation {
|
|||||||
pub ticket_id: String,
|
pub ticket_id: String,
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
pub target: String,
|
pub target: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub target_resource_key: Option<String>,
|
||||||
pub note: Option<String>,
|
pub note: Option<String>,
|
||||||
pub author: String,
|
pub author: String,
|
||||||
pub at: String,
|
pub at: String,
|
||||||
@@ -130,6 +134,8 @@ pub struct TicketRelation {
|
|||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct DerivedTicketRelation {
|
pub struct DerivedTicketRelation {
|
||||||
pub source_ticket: String,
|
pub source_ticket: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub source_resource_key: Option<String>,
|
||||||
pub inverse_kind: String,
|
pub inverse_kind: String,
|
||||||
pub forward_kind: String,
|
pub forward_kind: String,
|
||||||
pub note: Option<String>,
|
pub note: Option<String>,
|
||||||
@@ -141,6 +147,8 @@ pub struct DerivedTicketRelation {
|
|||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct TicketRelationBlocker {
|
pub struct TicketRelationBlocker {
|
||||||
pub blocking_ticket: String,
|
pub blocking_ticket: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub blocking_resource_key: Option<String>,
|
||||||
pub reason_kind: String,
|
pub reason_kind: String,
|
||||||
pub relation_kind: String,
|
pub relation_kind: String,
|
||||||
pub note: Option<String>,
|
pub note: Option<String>,
|
||||||
@@ -174,6 +182,7 @@ impl From<ticket::TicketRelationView> for TicketRelationView {
|
|||||||
ticket_id: relation.ticket_id,
|
ticket_id: relation.ticket_id,
|
||||||
kind: relation.kind.as_str().to_string(),
|
kind: relation.kind.as_str().to_string(),
|
||||||
target: relation.target,
|
target: relation.target,
|
||||||
|
target_resource_key: None,
|
||||||
note: relation.note,
|
note: relation.note,
|
||||||
author: relation.author,
|
author: relation.author,
|
||||||
at: relation.at,
|
at: relation.at,
|
||||||
@@ -184,6 +193,7 @@ impl From<ticket::TicketRelationView> for TicketRelationView {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|relation| DerivedTicketRelation {
|
.map(|relation| DerivedTicketRelation {
|
||||||
source_ticket: relation.source_ticket,
|
source_ticket: relation.source_ticket,
|
||||||
|
source_resource_key: None,
|
||||||
inverse_kind: relation.inverse_kind,
|
inverse_kind: relation.inverse_kind,
|
||||||
forward_kind: relation.forward_kind.as_str().to_string(),
|
forward_kind: relation.forward_kind.as_str().to_string(),
|
||||||
note: relation.note,
|
note: relation.note,
|
||||||
@@ -196,6 +206,7 @@ impl From<ticket::TicketRelationView> for TicketRelationView {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|blocker| TicketRelationBlocker {
|
.map(|blocker| TicketRelationBlocker {
|
||||||
blocking_ticket: blocker.blocking_ticket,
|
blocking_ticket: blocker.blocking_ticket,
|
||||||
|
blocking_resource_key: None,
|
||||||
reason_kind: blocker.reason_kind,
|
reason_kind: blocker.reason_kind,
|
||||||
relation_kind: blocker.relation_kind.as_str().to_string(),
|
relation_kind: blocker.relation_kind.as_str().to_string(),
|
||||||
note: blocker.note,
|
note: blocker.note,
|
||||||
@@ -231,6 +242,7 @@ pub struct QueryPage {
|
|||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct ObjectiveLinkSummary {
|
pub struct ObjectiveLinkSummary {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
pub resource_key: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub state: String,
|
pub state: String,
|
||||||
}
|
}
|
||||||
@@ -252,6 +264,8 @@ pub struct TicketAssignmentSummary {
|
|||||||
pub assignment_id: String,
|
pub assignment_id: String,
|
||||||
pub runtime_id: String,
|
pub runtime_id: String,
|
||||||
pub worker_id: String,
|
pub worker_id: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub worker_resource_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -271,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 {
|
||||||
@@ -313,6 +342,7 @@ pub struct TicketQueryRequest {
|
|||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct TicketQueryItem {
|
pub struct TicketQueryItem {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
pub resource_key: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub state: String,
|
pub state: String,
|
||||||
pub readiness: Option<String>,
|
pub readiness: Option<String>,
|
||||||
@@ -364,6 +394,7 @@ pub struct ObjectiveQueryRequest {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct ObjectiveQueryItem {
|
pub struct ObjectiveQueryItem {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
pub resource_key: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub state: String,
|
pub state: String,
|
||||||
pub created_at: Option<String>,
|
pub created_at: Option<String>,
|
||||||
@@ -399,6 +430,7 @@ pub struct ObjectiveEventDetail {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct ObjectiveLinkedTicketSummary {
|
pub struct ObjectiveLinkedTicketSummary {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
pub resource_key: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub state: String,
|
pub state: String,
|
||||||
}
|
}
|
||||||
@@ -406,6 +438,7 @@ pub struct ObjectiveLinkedTicketSummary {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct ObjectiveSummary {
|
pub struct ObjectiveSummary {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
pub resource_key: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub state: String,
|
pub state: String,
|
||||||
pub created_at: Option<String>,
|
pub created_at: Option<String>,
|
||||||
@@ -418,6 +451,7 @@ pub struct ObjectiveSummary {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct ObjectiveDetail {
|
pub struct ObjectiveDetail {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
pub resource_key: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub state: String,
|
pub state: String,
|
||||||
pub revision: String,
|
pub revision: String,
|
||||||
@@ -456,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),
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ pub struct WorkerRetentionPolicyUpdate {
|
|||||||
pub struct WorkerRemovalPlanRequest {
|
pub struct WorkerRemovalPlanRequest {
|
||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
pub worker: RuntimeWorkerRef,
|
pub worker: RuntimeWorkerRef,
|
||||||
pub expected_worker_revision: String,
|
|
||||||
pub reason: String,
|
pub reason: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,8 +153,6 @@ pub enum WorkerRetentionError {
|
|||||||
WorkerNotFound,
|
WorkerNotFound,
|
||||||
#[error("Worker belongs to a different Workspace")]
|
#[error("Worker belongs to a different Workspace")]
|
||||||
CrossWorkspace,
|
CrossWorkspace,
|
||||||
#[error("Worker revision changed: expected {expected}, current {actual}")]
|
|
||||||
WorkerRevisionConflict { expected: String, actual: String },
|
|
||||||
#[error("Worker removal is blocked: {0:?}")]
|
#[error("Worker removal is blocked: {0:?}")]
|
||||||
Blocked(Vec<WorkerRemovalBlocker>),
|
Blocked(Vec<WorkerRemovalBlocker>),
|
||||||
#[error("Worker removal plan {plan_id} is stale: {reason}")]
|
#[error("Worker removal plan {plan_id} is stale: {reason}")]
|
||||||
@@ -166,6 +163,25 @@ pub enum WorkerRetentionError {
|
|||||||
Invalid(String),
|
Invalid(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn repair_worker_diagnostics_archive_table(conn: &Connection) -> crate::Result<bool> {
|
||||||
|
let existed: bool = conn.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='worker_diagnostics_archives')",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
if !existed {
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE worker_diagnostics_archives (
|
||||||
|
operation_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL,
|
||||||
|
worker_id TEXT NOT NULL, policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL,
|
||||||
|
committed_at TEXT NOT NULL, expires_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id),
|
||||||
|
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);",
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(!existed)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn create_worker_retention_tables(conn: &Connection) -> crate::Result<()> {
|
pub(crate) fn create_worker_retention_tables(conn: &Connection) -> crate::Result<()> {
|
||||||
conn.execute_batch(r#"
|
conn.execute_batch(r#"
|
||||||
CREATE TABLE workspace_worker_retention_policy_revisions (
|
CREATE TABLE workspace_worker_retention_policy_revisions (
|
||||||
@@ -285,21 +301,20 @@ impl SqliteWorkspaceStore {
|
|||||||
let worker=match load_worker(&tx,&req.workspace_id,&req.worker)? {
|
let worker=match load_worker(&tx,&req.workspace_id,&req.worker)? {
|
||||||
Some(v)=>v,
|
Some(v)=>v,
|
||||||
None=>{
|
None=>{
|
||||||
let other:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM worker_registry WHERE runtime_id=?1 AND runtime_worker_id=?2 AND workspace_id!=?3)",params![req.worker.runtime_id,req.worker.worker_id,req.workspace_id],|r|r.get(0))?;
|
let other:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM worker_registry WHERE runtime_id=?1 AND worker_id=?2 AND workspace_id!=?3)",params![req.worker.runtime_id,req.worker.worker_id,req.workspace_id],|r|r.get(0))?;
|
||||||
return Err(StoreError::InvalidInput(if other{"cross-workspace".into()}else{"worker-missing".into()}));
|
return Err(StoreError::InvalidInput(if other{"cross-workspace".into()}else{"worker-missing".into()}));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if worker.updated_at!=req.expected_worker_revision { return Err(StoreError::InvalidInput(format!("worker-conflict:{}:{}",req.expected_worker_revision,worker.updated_at))); }
|
|
||||||
let mut blockers=Vec::new();
|
let mut blockers=Vec::new();
|
||||||
if worker.retention_state=="pinned" { blockers.push(WorkerRemovalBlocker::Hold); }
|
if worker.retention_state=="pinned" { blockers.push(WorkerRemovalBlocker::Hold); }
|
||||||
if let Some((assignment_id,ticket_id))=tx.query_row("SELECT a.assignment_id,a.ticket_id FROM ticket_current_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3",params![req.workspace_id,req.worker.runtime_id,req.worker.worker_id],|r|Ok((r.get(0)?,r.get(1)?))).optional()? {
|
if let Some((assignment_id,ticket_id))=tx.query_row("SELECT a.assignment_id,a.ticket_id FROM ticket_current_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3",params![req.workspace_id,req.worker.runtime_id,req.worker.worker_id],|r|Ok((r.get(0)?,r.get(1)?))).optional()? {
|
||||||
blockers.push(WorkerRemovalBlocker::CurrentAssignment{assignment_id,ticket_id});
|
blockers.push(WorkerRemovalBlocker::CurrentAssignment{assignment_id,ticket_id});
|
||||||
}
|
}
|
||||||
let fp=fingerprint(req,inv,&policy,&blockers)?;
|
let fp=fingerprint(req,&worker.updated_at,inv,&policy,&blockers)?;
|
||||||
let plan_id=stable("wrp",&fp); let operation_id=stable("wro",&fp);
|
let plan_id=stable("wrp",&fp); let operation_id=stable("wro",&fp);
|
||||||
let archive_id=(policy.session_disposition==SessionDisposition::Archive).then(||stable("wra",&fp));
|
let archive_id=(policy.session_disposition==SessionDisposition::Archive).then(||stable("wra",&fp));
|
||||||
let state=if blockers.is_empty(){WorkerRemovalPlanState::Planned}else{WorkerRemovalPlanState::Blocked};
|
let state=if blockers.is_empty(){WorkerRemovalPlanState::Planned}else{WorkerRemovalPlanState::Blocked};
|
||||||
tx.execute("INSERT OR IGNORE INTO worker_removal_operations(operation_id,plan_id,input_fingerprint,workspace_id,runtime_id,worker_id,worker_revision,run_generation,policy_id,policy_revision,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,archive_id,blockers_json,state,reason,created_at,updated_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21,?21)",params![operation_id,plan_id,fp,req.workspace_id,req.worker.runtime_id,req.worker.worker_id,req.expected_worker_revision,inv.run_generation,policy.policy_id,policy.revision,sess(policy.session_disposition),meta(policy.metadata_disposition),archive_kind(policy.archive_retention),archive_seconds(policy.archive_retention),diag(policy.diagnostics_disposition),policy.diagnostics_retention_seconds,archive_id,serde_json::to_string(&blockers).map_err(|e|StoreError::InvalidInput(e.to_string()))?,state_s(state),req.reason,now])?;
|
tx.execute("INSERT OR IGNORE INTO worker_removal_operations(operation_id,plan_id,input_fingerprint,workspace_id,runtime_id,worker_id,worker_revision,run_generation,policy_id,policy_revision,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,archive_id,blockers_json,state,reason,created_at,updated_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21,?21)",params![operation_id,plan_id,fp,req.workspace_id,req.worker.runtime_id,req.worker.worker_id,worker.updated_at,inv.run_generation,policy.policy_id,policy.revision,sess(policy.session_disposition),meta(policy.metadata_disposition),archive_kind(policy.archive_retention),archive_seconds(policy.archive_retention),diag(policy.diagnostics_disposition),policy.diagnostics_retention_seconds,archive_id,serde_json::to_string(&blockers).map_err(|e|StoreError::InvalidInput(e.to_string()))?,state_s(state),req.reason,now])?;
|
||||||
let plan=load_plan(&tx,&plan_id)?.ok_or_else(||StoreError::InvalidInput("plan missing".into()))?;
|
let plan=load_plan(&tx,&plan_id)?.ok_or_else(||StoreError::InvalidInput("plan missing".into()))?;
|
||||||
if plan.input_fingerprint!=fp{return Err(StoreError::InvalidInput(format!("fingerprint:{}",plan.operation_id)));}
|
if plan.input_fingerprint!=fp{return Err(StoreError::InvalidInput(format!("fingerprint:{}",plan.operation_id)));}
|
||||||
tx.commit()?; Ok(plan)
|
tx.commit()?; Ok(plan)
|
||||||
@@ -366,11 +381,13 @@ impl SqliteWorkspaceStore {
|
|||||||
plan_id: plan.plan_id.clone(),
|
plan_id: plan.plan_id.clone(),
|
||||||
reason: "Worker disappeared after execution fence".to_string(),
|
reason: "Worker disappeared after execution fence".to_string(),
|
||||||
})?;
|
})?;
|
||||||
let worker_number = plan.worker.worker_id.parse::<u64>().map_err(|_| {
|
let worker_id = plan
|
||||||
WorkerRetentionError::Invalid(
|
.worker
|
||||||
"Runtime Worker id is not a canonical unsigned integer".to_string(),
|
.worker_id
|
||||||
)
|
.parse::<worker_runtime::identity::WorkerId>()
|
||||||
})?;
|
.map_err(|_| {
|
||||||
|
WorkerRetentionError::Invalid("Worker id must be a canonical UUIDv7".to_string())
|
||||||
|
})?;
|
||||||
let removed_at = plan.created_at.clone();
|
let removed_at = plan.created_at.clone();
|
||||||
let prior_failure_category = plan.failure_category.clone();
|
let prior_failure_category = plan.failure_category.clone();
|
||||||
Ok(PreparedWorkerRemoval {
|
Ok(PreparedWorkerRemoval {
|
||||||
@@ -380,7 +397,7 @@ impl SqliteWorkspaceStore {
|
|||||||
archive_id: plan.archive_id.clone(),
|
archive_id: plan.archive_id.clone(),
|
||||||
workspace_id: plan.workspace_id.clone(),
|
workspace_id: plan.workspace_id.clone(),
|
||||||
source_runtime_id: plan.worker.runtime_id.clone(),
|
source_runtime_id: plan.worker.runtime_id.clone(),
|
||||||
worker_id: worker_runtime::identity::WorkerId::new(worker_number),
|
worker_id: worker_id,
|
||||||
expected_worker_revision: plan.worker_revision.clone(),
|
expected_worker_revision: plan.worker_revision.clone(),
|
||||||
expected_run_generation: plan.run_generation,
|
expected_run_generation: plan.run_generation,
|
||||||
source_created_at: worker.created_at,
|
source_created_at: worker.created_at,
|
||||||
@@ -401,27 +418,22 @@ impl SqliteWorkspaceStore {
|
|||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
worker: &RuntimeWorkerRef,
|
worker: &RuntimeWorkerRef,
|
||||||
expected_worker_revision: &str,
|
|
||||||
reason: &str,
|
|
||||||
) -> Result<Option<PreparedWorkerRemoval>, WorkerRetentionError> {
|
) -> Result<Option<PreparedWorkerRemoval>, WorkerRetentionError> {
|
||||||
bounded("workspace", workspace_id, 160)?;
|
bounded("workspace", workspace_id, 160)?;
|
||||||
bounded("revision", expected_worker_revision, 256)?;
|
|
||||||
bounded("reason", reason, 512)?;
|
|
||||||
let plan = self.with_conn(|conn| {
|
let plan = self.with_conn(|conn| {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT plan_id FROM worker_removal_operations
|
"SELECT plan_id FROM worker_removal_operations
|
||||||
WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3
|
WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3
|
||||||
AND worker_revision=?4 AND reason=?5
|
AND state IN ('planned','executing','failed','succeeded')
|
||||||
AND state IN ('executing','failed','succeeded')
|
AND (
|
||||||
|
state='succeeded' OR worker_revision=(
|
||||||
|
SELECT updated_at FROM worker_registry
|
||||||
|
WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3
|
||||||
|
)
|
||||||
|
)
|
||||||
ORDER BY CASE state WHEN 'succeeded' THEN 0 ELSE 1 END,
|
ORDER BY CASE state WHEN 'succeeded' THEN 0 ELSE 1 END,
|
||||||
created_at DESC LIMIT 1",
|
created_at DESC LIMIT 1",
|
||||||
params![
|
params![workspace_id, worker.runtime_id, worker.worker_id],
|
||||||
workspace_id,
|
|
||||||
worker.runtime_id,
|
|
||||||
worker.worker_id,
|
|
||||||
expected_worker_revision,
|
|
||||||
reason,
|
|
||||||
],
|
|
||||||
|row| row.get::<_, String>(0),
|
|row| row.get::<_, String>(0),
|
||||||
)
|
)
|
||||||
.optional()
|
.optional()
|
||||||
@@ -435,11 +447,13 @@ impl SqliteWorkspaceStore {
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let prior_failure_category = plan.failure_category.clone();
|
let prior_failure_category = plan.failure_category.clone();
|
||||||
let worker_number = plan.worker.worker_id.parse::<u64>().map_err(|_| {
|
let worker_id = plan
|
||||||
WorkerRetentionError::Invalid(
|
.worker
|
||||||
"Runtime Worker id is not a canonical unsigned integer".to_string(),
|
.worker_id
|
||||||
)
|
.parse::<worker_runtime::identity::WorkerId>()
|
||||||
})?;
|
.map_err(|_| {
|
||||||
|
WorkerRetentionError::Invalid("Worker id must be a canonical UUIDv7".to_string())
|
||||||
|
})?;
|
||||||
let worker = if plan.state == WorkerRemovalPlanState::Succeeded {
|
let worker = if plan.state == WorkerRemovalPlanState::Succeeded {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -455,7 +469,7 @@ impl SqliteWorkspaceStore {
|
|||||||
archive_id: plan.archive_id.clone(),
|
archive_id: plan.archive_id.clone(),
|
||||||
workspace_id: plan.workspace_id.clone(),
|
workspace_id: plan.workspace_id.clone(),
|
||||||
source_runtime_id: plan.worker.runtime_id.clone(),
|
source_runtime_id: plan.worker.runtime_id.clone(),
|
||||||
worker_id: worker_runtime::identity::WorkerId::new(worker_number),
|
worker_id: worker_id,
|
||||||
expected_worker_revision: plan.worker_revision.clone(),
|
expected_worker_revision: plan.worker_revision.clone(),
|
||||||
expected_run_generation: plan.run_generation,
|
expected_run_generation: plan.run_generation,
|
||||||
source_created_at: worker
|
source_created_at: worker
|
||||||
@@ -544,7 +558,7 @@ impl SqliteWorkspaceStore {
|
|||||||
if plan.metadata_disposition==MetadataDisposition::Tombstone{
|
if plan.metadata_disposition==MetadataDisposition::Tombstone{
|
||||||
tx.execute("INSERT OR IGNORE INTO worker_tombstones(workspace_id,runtime_id,worker_id,display_name,profile,worker_created_at,removed_at,archive_id,policy_id,policy_revision,operation_id) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id,worker.display_name,worker.profile,worker.created_at,now,plan.archive_id,plan.policy_id,plan.policy_revision,operation_id])?;
|
tx.execute("INSERT OR IGNORE INTO worker_tombstones(workspace_id,runtime_id,worker_id,display_name,profile,worker_created_at,removed_at,archive_id,policy_id,policy_revision,operation_id) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id,worker.display_name,worker.profile,worker.created_at,now,plan.archive_id,plan.policy_id,plan.policy_revision,operation_id])?;
|
||||||
}
|
}
|
||||||
let deleted=tx.execute("DELETE FROM worker_registry WHERE workspace_id=?1 AND runtime_id=?2 AND runtime_worker_id=?3 AND updated_at=?4",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id,plan.worker_revision])?;
|
let deleted=tx.execute("DELETE FROM worker_registry WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND updated_at=?4",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id,plan.worker_revision])?;
|
||||||
if deleted!=1{return Err(StoreError::InvalidInput(format!("stale:{}:removal fence changed",plan.plan_id)));}
|
if deleted!=1{return Err(StoreError::InvalidInput(format!("stale:{}:removal fence changed",plan.plan_id)));}
|
||||||
tx.execute("UPDATE worker_removal_operations SET state='succeeded',failure_category=NULL,updated_at=?1 WHERE operation_id=?2",params![now,operation_id])?;
|
tx.execute("UPDATE worker_removal_operations SET state='succeeded',failure_category=NULL,updated_at=?1 WHERE operation_id=?2",params![now,operation_id])?;
|
||||||
tx.execute("INSERT OR IGNORE INTO worker_retention_audit_events(event_id,operation_id,workspace_id,event_kind,detail,created_at) VALUES(?1,?2,?3,'worker_removed',?4,?5)",params![stable("wre",operation_id),operation_id,workspace_id,format!("runtime_id={} worker_id={} session={} metadata={} diagnostics={}",plan.worker.runtime_id,plan.worker.worker_id,sess(plan.session_disposition),meta(plan.metadata_disposition),diag(plan.diagnostics_disposition)),now])?;
|
tx.execute("INSERT OR IGNORE INTO worker_retention_audit_events(event_id,operation_id,workspace_id,event_kind,detail,created_at) VALUES(?1,?2,?3,'worker_removed',?4,?5)",params![stable("wre",operation_id),operation_id,workspace_id,format!("runtime_id={} worker_id={} session={} metadata={} diagnostics={}",plan.worker.runtime_id,plan.worker.worker_id,sess(plan.session_disposition),meta(plan.metadata_disposition),diag(plan.diagnostics_disposition)),now])?;
|
||||||
@@ -593,7 +607,7 @@ impl SqliteWorkspaceStore {
|
|||||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
let policy_configured = load_policy(&tx, workspace_id)?.is_some();
|
let policy_configured = load_policy(&tx, workspace_id)?.is_some();
|
||||||
let mut statement = tx.prepare(
|
let mut statement = tx.prepare(
|
||||||
"SELECT CAST(runtime_worker_id AS TEXT), retention_state
|
"SELECT CAST(worker_id AS TEXT), retention_state
|
||||||
FROM worker_registry WHERE workspace_id=?1 AND runtime_id=?2",
|
FROM worker_registry WHERE workspace_id=?1 AND runtime_id=?2",
|
||||||
)?;
|
)?;
|
||||||
let registry = statement
|
let registry = statement
|
||||||
@@ -718,7 +732,7 @@ struct WorkerRow {
|
|||||||
updated_at: String,
|
updated_at: String,
|
||||||
}
|
}
|
||||||
fn load_worker(c: &Connection, w: &str, r: &RuntimeWorkerRef) -> crate::Result<Option<WorkerRow>> {
|
fn load_worker(c: &Connection, w: &str, r: &RuntimeWorkerRef) -> crate::Result<Option<WorkerRow>> {
|
||||||
c.query_row("SELECT display_name,profile,retention_state,created_at,updated_at FROM worker_registry WHERE workspace_id=?1 AND runtime_id=?2 AND runtime_worker_id=?3",params![w,r.runtime_id,r.worker_id],|x|Ok(WorkerRow{display_name:x.get(0)?,profile:x.get(1)?,retention_state:x.get(2)?,created_at:x.get(3)?,updated_at:x.get(4)?})).optional().map_err(StoreError::from)
|
c.query_row("SELECT display_name,profile,retention_state,created_at,updated_at FROM worker_registry WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3",params![w,r.runtime_id,r.worker_id],|x|Ok(WorkerRow{display_name:x.get(0)?,profile:x.get(1)?,retention_state:x.get(2)?,created_at:x.get(3)?,updated_at:x.get(4)?})).optional().map_err(StoreError::from)
|
||||||
}
|
}
|
||||||
fn load_policy(c: &Connection, w: &str) -> crate::Result<Option<WorkerRetentionPolicy>> {
|
fn load_policy(c: &Connection, w: &str) -> crate::Result<Option<WorkerRetentionPolicy>> {
|
||||||
c.query_row(
|
c.query_row(
|
||||||
@@ -825,6 +839,7 @@ fn stale_error(plan: &WorkerRemovalPlan, reason: &str) -> StoreError {
|
|||||||
}
|
}
|
||||||
fn fingerprint(
|
fn fingerprint(
|
||||||
r: &WorkerRemovalPlanRequest,
|
r: &WorkerRemovalPlanRequest,
|
||||||
|
worker_revision: &str,
|
||||||
i: &WorkerRetentionInventory,
|
i: &WorkerRetentionInventory,
|
||||||
p: &WorkerRetentionPolicy,
|
p: &WorkerRetentionPolicy,
|
||||||
b: &[WorkerRemovalBlocker],
|
b: &[WorkerRemovalBlocker],
|
||||||
@@ -833,7 +848,7 @@ fn fingerprint(
|
|||||||
r.workspace_id,
|
r.workspace_id,
|
||||||
r.worker.runtime_id,
|
r.worker.runtime_id,
|
||||||
r.worker.worker_id,
|
r.worker.worker_id,
|
||||||
r.expected_worker_revision,
|
worker_revision,
|
||||||
i.run_generation,
|
i.run_generation,
|
||||||
i.session_id,
|
i.session_id,
|
||||||
i.segment_ids,
|
i.segment_ids,
|
||||||
@@ -864,7 +879,6 @@ fn validate_plan(
|
|||||||
i: &WorkerRetentionInventory,
|
i: &WorkerRetentionInventory,
|
||||||
) -> Result<(), WorkerRetentionError> {
|
) -> Result<(), WorkerRetentionError> {
|
||||||
bounded("workspace", &r.workspace_id, 160)?;
|
bounded("workspace", &r.workspace_id, 160)?;
|
||||||
bounded("revision", &r.expected_worker_revision, 256)?;
|
|
||||||
bounded("reason", &r.reason, 2000)?;
|
bounded("reason", &r.reason, 2000)?;
|
||||||
if i.workspace_id != r.workspace_id
|
if i.workspace_id != r.workspace_id
|
||||||
|| i.runtime_id != r.worker.runtime_id
|
|| i.runtime_id != r.worker.runtime_id
|
||||||
@@ -924,13 +938,6 @@ fn map_error(e: StoreError) -> WorkerRetentionError {
|
|||||||
if m == "worker-missing" {
|
if m == "worker-missing" {
|
||||||
return WorkerRetentionError::WorkerNotFound;
|
return WorkerRetentionError::WorkerNotFound;
|
||||||
}
|
}
|
||||||
if let Some(x) = m.strip_prefix("worker-conflict:") {
|
|
||||||
let mut s = x.splitn(2, ':');
|
|
||||||
return WorkerRetentionError::WorkerRevisionConflict {
|
|
||||||
expected: s.next().unwrap_or_default().into(),
|
|
||||||
actual: s.next().unwrap_or_default().into(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if let Some(x) = m.strip_prefix("fingerprint:") {
|
if let Some(x) = m.strip_prefix("fingerprint:") {
|
||||||
return WorkerRetentionError::OperationFingerprintConflict {
|
return WorkerRetentionError::OperationFingerprintConflict {
|
||||||
operation_id: x.into(),
|
operation_id: x.into(),
|
||||||
@@ -1042,16 +1049,38 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::store::{ControlPlaneStore, TicketWorkerAssignmentRecord, WorkerRegistryRecord};
|
use crate::store::{ControlPlaneStore, TicketWorkerAssignmentRecord, WorkerRegistryRecord};
|
||||||
use worker_runtime::identity::WorkerId;
|
use worker_runtime::identity::WorkerId;
|
||||||
|
fn worker_id() -> WorkerId {
|
||||||
|
WorkerId::from_legacy_u64(1)
|
||||||
|
}
|
||||||
fn setup() -> SqliteWorkspaceStore {
|
fn setup() -> SqliteWorkspaceStore {
|
||||||
let s = SqliteWorkspaceStore::in_memory().unwrap();
|
let s = SqliteWorkspaceStore::in_memory().unwrap();
|
||||||
s.with_conn(|c|{c.execute("INSERT INTO workspaces(workspace_id,display_name,state,created_at,updated_at)VALUES('w','W','active','t','t')",[])?;c.execute("INSERT INTO worker_registry(workspace_id,runtime_id,runtime_worker_id,display_name,profile,retention_state,created_at,updated_at)VALUES('w','r',1,'one','builtin:coder','normal','created','rev1')",[])?;Ok(())}).unwrap();
|
s.with_conn(|c| {
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO workspaces(workspace_id,display_name,state,created_at,updated_at) \
|
||||||
|
VALUES('w','W','active','t','t')",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO worker_registry(\
|
||||||
|
workspace_id,worker_id,runtime_id,display_name,profile,retention_state,created_at,updated_at\
|
||||||
|
) VALUES('w',?1,'r','one','builtin:coder','normal','created','rev1')",
|
||||||
|
[worker_id().to_string()],
|
||||||
|
)?;
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit) \
|
||||||
|
VALUES ('w', 'ticket', 'ticket', 'Ticket', 'open', 'task', 'normal', '', 'planning', 1)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
fn inv() -> WorkerRetentionInventory {
|
fn inv() -> WorkerRetentionInventory {
|
||||||
WorkerRetentionInventory {
|
WorkerRetentionInventory {
|
||||||
workspace_id: "w".into(),
|
workspace_id: "w".into(),
|
||||||
runtime_id: "r".into(),
|
runtime_id: "r".into(),
|
||||||
worker_id: WorkerId::new(1),
|
worker_id: worker_id(),
|
||||||
run_generation: 2,
|
run_generation: 2,
|
||||||
session_id: Some("s".into()),
|
session_id: Some("s".into()),
|
||||||
segment_ids: vec!["a".into()],
|
segment_ids: vec!["a".into()],
|
||||||
@@ -1064,9 +1093,8 @@ mod tests {
|
|||||||
workspace_id: "w".into(),
|
workspace_id: "w".into(),
|
||||||
worker: RuntimeWorkerRef {
|
worker: RuntimeWorkerRef {
|
||||||
runtime_id: "r".into(),
|
runtime_id: "r".into(),
|
||||||
worker_id: "1".into(),
|
worker_id: worker_id().to_string(),
|
||||||
},
|
},
|
||||||
expected_worker_revision: "rev1".into(),
|
|
||||||
reason: "cleanup".into(),
|
reason: "cleanup".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1101,6 +1129,7 @@ mod tests {
|
|||||||
let a = s.plan_worker_removal(&req(), &inv()).unwrap();
|
let a = s.plan_worker_removal(&req(), &inv()).unwrap();
|
||||||
let b = s.plan_worker_removal(&req(), &inv()).unwrap();
|
let b = s.plan_worker_removal(&req(), &inv()).unwrap();
|
||||||
assert_eq!(a.plan_id, b.plan_id);
|
assert_eq!(a.plan_id, b.plan_id);
|
||||||
|
assert_eq!(a.worker_revision, "rev1");
|
||||||
s.with_conn(|c| {
|
s.with_conn(|c| {
|
||||||
c.execute(
|
c.execute(
|
||||||
"UPDATE worker_registry SET retention_state='pinned' WHERE workspace_id='w'",
|
"UPDATE worker_registry SET retention_state='pinned' WHERE workspace_id='w'",
|
||||||
@@ -1109,9 +1138,7 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut q = req();
|
let p = s.plan_worker_removal(&req(), &inv()).unwrap();
|
||||||
q.expected_worker_revision = "rev1".into();
|
|
||||||
let p = s.plan_worker_removal(&q, &inv()).unwrap();
|
|
||||||
assert_eq!(p.blockers, vec![WorkerRemovalBlocker::Hold]);
|
assert_eq!(p.blockers, vec![WorkerRemovalBlocker::Hold]);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
s.begin_worker_removal("w", &p.plan_id, &p.input_fingerprint),
|
s.begin_worker_removal("w", &p.plan_id, &p.input_fingerprint),
|
||||||
@@ -1213,7 +1240,10 @@ mod tests {
|
|||||||
SessionDisposition::Archive
|
SessionDisposition::Archive
|
||||||
);
|
);
|
||||||
assert_eq!(prepared.runtime_request.policy_revision, 1);
|
assert_eq!(prepared.runtime_request.policy_revision, 1);
|
||||||
assert_eq!(prepared.runtime_request.worker_id, WorkerId::new(1));
|
assert_eq!(
|
||||||
|
prepared.runtime_request.worker_id,
|
||||||
|
WorkerId::from_legacy_u64(1)
|
||||||
|
);
|
||||||
let retry = s
|
let retry = s
|
||||||
.prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint)
|
.prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1224,7 +1254,11 @@ mod tests {
|
|||||||
fn purge_tombstone_commit_is_idempotent() {
|
fn purge_tombstone_commit_is_idempotent() {
|
||||||
let s = setup();
|
let s = setup();
|
||||||
s.with_conn(|conn| {
|
s.with_conn(|conn| {
|
||||||
|
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES('w','ticket-old','ticket-old','Old Ticket','open','task','normal','','planning',1)", [])?;
|
||||||
|
conn.execute("INSERT INTO worker_registry(workspace_id,worker_id,runtime_id,display_name,profile,retention_state,created_at,updated_at) VALUES('w','1','r','old worker','builtin:coder','normal','created','rev1')", [])?;
|
||||||
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,runtime_id,worker_id,assigned_by,assigned_at) VALUES('w','ticket-old','assignment-old','r','1','test','t')", [])?;
|
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,runtime_id,worker_id,assigned_by,assigned_at) VALUES('w','ticket-old','assignment-old','r','1','test','t')", [])?;
|
||||||
|
conn.execute("DELETE FROM worker_registry WHERE workspace_id='w' AND runtime_id='r' AND worker_id='1'", [])?;
|
||||||
|
conn.execute("DELETE FROM typed_tickets WHERE workspace_id='w' AND ticket_id='ticket-old'", [])?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
let p = s.plan_worker_removal(&req(), &inv()).unwrap();
|
let p = s.plan_worker_removal(&req(), &inv()).unwrap();
|
||||||
@@ -1234,7 +1268,7 @@ mod tests {
|
|||||||
operation_id: p.operation_id.clone(),
|
operation_id: p.operation_id.clone(),
|
||||||
input_fingerprint: p.input_fingerprint.clone(),
|
input_fingerprint: p.input_fingerprint.clone(),
|
||||||
expected_worker_revision: p.worker_revision.clone(),
|
expected_worker_revision: p.worker_revision.clone(),
|
||||||
worker_id: WorkerId::new(1),
|
worker_id: worker_id(),
|
||||||
session_disposition: p.session_disposition,
|
session_disposition: p.session_disposition,
|
||||||
diagnostics_disposition: p.diagnostics_disposition,
|
diagnostics_disposition: p.diagnostics_disposition,
|
||||||
archive: Some(worker_runtime::retention::WorkerSessionArchiveManifest {
|
archive: Some(worker_runtime::retention::WorkerSessionArchiveManifest {
|
||||||
@@ -1242,7 +1276,7 @@ mod tests {
|
|||||||
archive_id: p.archive_id.clone().unwrap(),
|
archive_id: p.archive_id.clone().unwrap(),
|
||||||
workspace_id: "w".into(),
|
workspace_id: "w".into(),
|
||||||
source_runtime_id: "r".into(),
|
source_runtime_id: "r".into(),
|
||||||
source_worker_id: WorkerId::new(1),
|
source_worker_id: worker_id(),
|
||||||
source_session_id: "s".into(),
|
source_session_id: "s".into(),
|
||||||
segment_ids: vec!["a".into()],
|
segment_ids: vec!["a".into()],
|
||||||
source_created_at: "created".into(),
|
source_created_at: "created".into(),
|
||||||
@@ -1284,7 +1318,23 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn assignment_and_orphan_are_authoritative() {
|
fn assignment_and_orphan_are_authoritative() {
|
||||||
let s = setup();
|
let s = setup();
|
||||||
s.with_conn(|c|{c.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,runtime_id,worker_id,assigned_by,assigned_at)VALUES('w','ticket','assignment','r','1','test','t')",[])?;c.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,runtime_id,worker_id,updated_at)VALUES('w','ticket','assignment','r','1','t')",[])?;Ok(())}).unwrap();
|
s.with_conn(|c| {
|
||||||
|
let stable_worker_id = worker_id().to_string();
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO ticket_worker_assignments(\
|
||||||
|
workspace_id,ticket_id,assignment_id,runtime_id,worker_id,assigned_by,assigned_at\
|
||||||
|
) VALUES('w','ticket','assignment','r',?1,'test','t')",
|
||||||
|
[&stable_worker_id],
|
||||||
|
)?;
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO ticket_current_worker_assignments(\
|
||||||
|
workspace_id,ticket_id,assignment_id,runtime_id,worker_id,updated_at\
|
||||||
|
) VALUES('w','ticket','assignment','r',?1,'t')",
|
||||||
|
[&stable_worker_id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
let p = s.plan_worker_removal(&req(), &inv()).unwrap();
|
let p = s.plan_worker_removal(&req(), &inv()).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(&p.blockers[..],[WorkerRemovalBlocker::CurrentAssignment{assignment_id,ticket_id}] if assignment_id=="assignment"&&ticket_id=="ticket")
|
matches!(&p.blockers[..],[WorkerRemovalBlocker::CurrentAssignment{assignment_id,ticket_id}] if assignment_id=="assignment"&&ticket_id=="ticket")
|
||||||
@@ -1292,7 +1342,7 @@ mod tests {
|
|||||||
let runtime_only = WorkerRetentionInventory {
|
let runtime_only = WorkerRetentionInventory {
|
||||||
workspace_id: "w".into(),
|
workspace_id: "w".into(),
|
||||||
runtime_id: "r".into(),
|
runtime_id: "r".into(),
|
||||||
worker_id: WorkerId::new(2),
|
worker_id: WorkerId::from_legacy_u64(2),
|
||||||
run_generation: 1,
|
run_generation: 1,
|
||||||
session_id: Some("orphan-session".into()),
|
session_id: Some("orphan-session".into()),
|
||||||
segment_ids: vec![],
|
segment_ids: vec![],
|
||||||
@@ -1304,10 +1354,12 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(diagnostics.len(), 2);
|
assert_eq!(diagnostics.len(), 2);
|
||||||
assert!(diagnostics.iter().any(|item| {
|
assert!(diagnostics.iter().any(|item| {
|
||||||
item.worker_id == "2" && item.category == "runtime_aggregate_without_backend_registry"
|
item.worker_id == WorkerId::from_legacy_u64(2).to_string()
|
||||||
|
&& item.category == "runtime_aggregate_without_backend_registry"
|
||||||
}));
|
}));
|
||||||
assert!(diagnostics.iter().any(|item| {
|
assert!(diagnostics.iter().any(|item| {
|
||||||
item.worker_id == "1" && item.category == "backend_registry_without_runtime_aggregate"
|
item.worker_id == worker_id().to_string()
|
||||||
|
&& item.category == "backend_registry_without_runtime_aggregate"
|
||||||
}));
|
}));
|
||||||
let count: i64 = s
|
let count: i64 = s
|
||||||
.with_conn(|conn| {
|
.with_conn(|conn| {
|
||||||
@@ -1375,7 +1427,7 @@ mod tests {
|
|||||||
operation_id: p.operation_id.clone(),
|
operation_id: p.operation_id.clone(),
|
||||||
input_fingerprint: p.input_fingerprint.clone(),
|
input_fingerprint: p.input_fingerprint.clone(),
|
||||||
expected_worker_revision: p.worker_revision.clone(),
|
expected_worker_revision: p.worker_revision.clone(),
|
||||||
worker_id: WorkerId::new(1),
|
worker_id: worker_id(),
|
||||||
session_disposition: SessionDisposition::Purge,
|
session_disposition: SessionDisposition::Purge,
|
||||||
diagnostics_disposition: DiagnosticsDisposition::Purge,
|
diagnostics_disposition: DiagnosticsDisposition::Purge,
|
||||||
archive: None,
|
archive: None,
|
||||||
@@ -1394,7 +1446,7 @@ mod tests {
|
|||||||
operation_id: plan.operation_id.clone(),
|
operation_id: plan.operation_id.clone(),
|
||||||
input_fingerprint: plan.input_fingerprint.clone(),
|
input_fingerprint: plan.input_fingerprint.clone(),
|
||||||
expected_worker_revision: plan.worker_revision.clone(),
|
expected_worker_revision: plan.worker_revision.clone(),
|
||||||
worker_id: WorkerId::new(1),
|
worker_id: worker_id(),
|
||||||
session_disposition: plan.session_disposition,
|
session_disposition: plan.session_disposition,
|
||||||
diagnostics_disposition: plan.diagnostics_disposition,
|
diagnostics_disposition: plan.diagnostics_disposition,
|
||||||
archive: None,
|
archive: None,
|
||||||
@@ -1408,15 +1460,15 @@ mod tests {
|
|||||||
store
|
store
|
||||||
.begin_worker_removal("w", &plan.plan_id, &plan.input_fingerprint)
|
.begin_worker_removal("w", &plan.plan_id, &plan.input_fingerprint)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
result.worker_id = WorkerId::new(2);
|
result.worker_id = WorkerId::from_legacy_u64(2);
|
||||||
assert!(
|
assert!(
|
||||||
store
|
store
|
||||||
.commit_worker_removal("w", &plan.operation_id, &plan.input_fingerprint, &result)
|
.commit_worker_removal("w", &plan.operation_id, &plan.input_fingerprint, &result)
|
||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
let count: i64 = store.with_conn(|conn| conn.query_row(
|
let count: i64 = store.with_conn(|conn| conn.query_row(
|
||||||
"SELECT COUNT(*) FROM worker_registry WHERE workspace_id='w' AND runtime_id='r' AND runtime_worker_id=1",
|
"SELECT COUNT(*) FROM worker_registry WHERE workspace_id='w' AND runtime_id='r' AND worker_id=?1",
|
||||||
[],
|
[worker_id().to_string()],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
).map_err(StoreError::from)).unwrap();
|
).map_err(StoreError::from)).unwrap();
|
||||||
assert_eq!(count, 1);
|
assert_eq!(count, 1);
|
||||||
@@ -1433,7 +1485,7 @@ mod tests {
|
|||||||
workspace_id: "w".into(),
|
workspace_id: "w".into(),
|
||||||
worker: RuntimeWorkerRef {
|
worker: RuntimeWorkerRef {
|
||||||
runtime_id: "r".into(),
|
runtime_id: "r".into(),
|
||||||
worker_id: "1".into(),
|
worker_id: worker_id().to_string(),
|
||||||
},
|
},
|
||||||
display_name: "stale".into(),
|
display_name: "stale".into(),
|
||||||
profile: None,
|
profile: None,
|
||||||
@@ -1447,8 +1499,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
store.upsert_worker_registry(&stale).unwrap();
|
store.upsert_worker_registry(&stale).unwrap();
|
||||||
let revision: String = store.with_conn(|conn| conn.query_row(
|
let revision: String = store.with_conn(|conn| conn.query_row(
|
||||||
"SELECT updated_at FROM worker_registry WHERE workspace_id='w' AND runtime_id='r' AND runtime_worker_id=1",
|
"SELECT updated_at FROM worker_registry WHERE workspace_id='w' AND runtime_id='r' AND worker_id=?1",
|
||||||
[],
|
[worker_id().to_string()],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
).map_err(StoreError::from)).unwrap();
|
).map_err(StoreError::from)).unwrap();
|
||||||
assert_eq!(revision, "rev1");
|
assert_eq!(revision, "rev1");
|
||||||
@@ -1459,7 +1511,7 @@ mod tests {
|
|||||||
assignment_id: "new-assignment".into(),
|
assignment_id: "new-assignment".into(),
|
||||||
worker: RuntimeWorkerRef {
|
worker: RuntimeWorkerRef {
|
||||||
runtime_id: "r".into(),
|
runtime_id: "r".into(),
|
||||||
worker_id: "1".into(),
|
worker_id: worker_id().to_string(),
|
||||||
},
|
},
|
||||||
assigned_by: "test".into(),
|
assigned_by: "test".into(),
|
||||||
assigned_at: "t".into(),
|
assigned_at: "t".into(),
|
||||||
@@ -1488,7 +1540,7 @@ mod tests {
|
|||||||
operation_id: prepared.plan.operation_id.clone(),
|
operation_id: prepared.plan.operation_id.clone(),
|
||||||
input_fingerprint: prepared.plan.input_fingerprint.clone(),
|
input_fingerprint: prepared.plan.input_fingerprint.clone(),
|
||||||
expected_worker_revision: prepared.plan.worker_revision.clone(),
|
expected_worker_revision: prepared.plan.worker_revision.clone(),
|
||||||
worker_id: WorkerId::new(1),
|
worker_id: worker_id(),
|
||||||
session_disposition: prepared.plan.session_disposition,
|
session_disposition: prepared.plan.session_disposition,
|
||||||
diagnostics_disposition: prepared.plan.diagnostics_disposition,
|
diagnostics_disposition: prepared.plan.diagnostics_disposition,
|
||||||
archive: None,
|
archive: None,
|
||||||
@@ -1510,6 +1562,77 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_removal_recovery_is_target_keyed_and_preserves_original_reason() {
|
||||||
|
let store = setup();
|
||||||
|
let request = req();
|
||||||
|
let plan = store.plan_worker_removal(&request, &inv()).unwrap();
|
||||||
|
let recovered_planned = store
|
||||||
|
.recover_worker_removal_execution("w", &request.worker)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
recovered_planned.plan.state,
|
||||||
|
WorkerRemovalPlanState::Planned
|
||||||
|
);
|
||||||
|
assert_eq!(recovered_planned.plan.plan_id, plan.plan_id);
|
||||||
|
store
|
||||||
|
.prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint)
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.fail_worker_removal(
|
||||||
|
"w",
|
||||||
|
&plan.operation_id,
|
||||||
|
&plan.input_fingerprint,
|
||||||
|
"runtime_remove_failed",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let recovered = store
|
||||||
|
.recover_worker_removal_execution("w", &request.worker)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(recovered.plan.plan_id, plan.plan_id);
|
||||||
|
assert_eq!(recovered.plan.reason, request.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_failed_removal_is_not_recovered_after_worker_authority_changes() {
|
||||||
|
let store = setup();
|
||||||
|
let request = req();
|
||||||
|
let plan = store.plan_worker_removal(&request, &inv()).unwrap();
|
||||||
|
store
|
||||||
|
.prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint)
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.fail_worker_removal(
|
||||||
|
"w",
|
||||||
|
&plan.operation_id,
|
||||||
|
&plan.input_fingerprint,
|
||||||
|
"runtime_remove_failed",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.with_conn(|conn| {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE worker_registry SET updated_at='rev2' WHERE workspace_id='w' AND runtime_id='r' AND worker_id=?1",
|
||||||
|
[worker_id().to_string()],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.recover_worker_removal_execution("w", &request.worker)
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
let replacement = store.plan_worker_removal(&request, &inv()).unwrap();
|
||||||
|
assert_eq!(replacement.worker_revision, "rev2");
|
||||||
|
assert_ne!(replacement.plan_id, plan.plan_id);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn succeeded_worker_removal_recovers_after_registry_purge() {
|
fn succeeded_worker_removal_recovers_after_registry_purge() {
|
||||||
let s = setup();
|
let s = setup();
|
||||||
@@ -1522,7 +1645,7 @@ mod tests {
|
|||||||
operation_id: prepared.plan.operation_id.clone(),
|
operation_id: prepared.plan.operation_id.clone(),
|
||||||
input_fingerprint: prepared.plan.input_fingerprint.clone(),
|
input_fingerprint: prepared.plan.input_fingerprint.clone(),
|
||||||
expected_worker_revision: prepared.plan.worker_revision.clone(),
|
expected_worker_revision: prepared.plan.worker_revision.clone(),
|
||||||
worker_id: WorkerId::new(1),
|
worker_id: worker_id(),
|
||||||
session_disposition: prepared.plan.session_disposition,
|
session_disposition: prepared.plan.session_disposition,
|
||||||
diagnostics_disposition: prepared.plan.diagnostics_disposition,
|
diagnostics_disposition: prepared.plan.diagnostics_disposition,
|
||||||
archive: Some(worker_runtime::retention::WorkerSessionArchiveManifest {
|
archive: Some(worker_runtime::retention::WorkerSessionArchiveManifest {
|
||||||
@@ -1530,7 +1653,7 @@ mod tests {
|
|||||||
archive_id: prepared.plan.archive_id.clone().unwrap(),
|
archive_id: prepared.plan.archive_id.clone().unwrap(),
|
||||||
workspace_id: "w".into(),
|
workspace_id: "w".into(),
|
||||||
source_runtime_id: "r".into(),
|
source_runtime_id: "r".into(),
|
||||||
source_worker_id: WorkerId::new(1),
|
source_worker_id: worker_id(),
|
||||||
source_session_id: "s".into(),
|
source_session_id: "s".into(),
|
||||||
segment_ids: vec!["a".into()],
|
segment_ids: vec!["a".into()],
|
||||||
source_created_at: "created".into(),
|
source_created_at: "created".into(),
|
||||||
@@ -1562,18 +1685,13 @@ mod tests {
|
|||||||
.is_none()
|
.is_none()
|
||||||
);
|
);
|
||||||
let recovered = s
|
let recovered = s
|
||||||
.recover_worker_removal_execution(
|
.recover_worker_removal_execution("w", &request.worker)
|
||||||
"w",
|
|
||||||
&request.worker,
|
|
||||||
&request.expected_worker_revision,
|
|
||||||
&request.reason,
|
|
||||||
)
|
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(recovered.plan.state, WorkerRemovalPlanState::Succeeded);
|
assert_eq!(recovered.plan.state, WorkerRemovalPlanState::Succeeded);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
recovered.runtime_request.expected_worker_revision,
|
recovered.runtime_request.expected_worker_revision,
|
||||||
request.expected_worker_revision
|
plan.worker_revision
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1592,12 +1710,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let recovered = s
|
let recovered = s
|
||||||
.recover_worker_removal_execution(
|
.recover_worker_removal_execution("w", &request.worker)
|
||||||
"w",
|
|
||||||
&request.worker,
|
|
||||||
&request.expected_worker_revision,
|
|
||||||
&request.reason,
|
|
||||||
)
|
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use worker_runtime::execution::{
|
|||||||
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult,
|
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult,
|
||||||
WorkerExecutionRunState, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
|
WorkerExecutionRunState, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
|
||||||
};
|
};
|
||||||
|
use worker_runtime::identity::WorkerId;
|
||||||
use worker_runtime::profile_archive::{ProfileSourceArchiveRef, ProfileSourceGraphSummary};
|
use worker_runtime::profile_archive::{ProfileSourceArchiveRef, ProfileSourceGraphSummary};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -57,8 +58,8 @@ const TOKEN: &str = "runtime-subscription-test-token";
|
|||||||
|
|
||||||
fn create_request(name: &str) -> CreateWorkerRequest {
|
fn create_request(name: &str) -> CreateWorkerRequest {
|
||||||
CreateWorkerRequest {
|
CreateWorkerRequest {
|
||||||
idempotency_key: None,
|
worker_id: WorkerId::now_v7(),
|
||||||
idempotency_fingerprint: None,
|
create_fingerprint: "test-create".to_string(),
|
||||||
profile: ProfileSelector::Builtin("builtin:companion".to_string()),
|
profile: ProfileSelector::Builtin("builtin:companion".to_string()),
|
||||||
display_name: Some(name.to_string()),
|
display_name: Some(name.to_string()),
|
||||||
config_bundle: None,
|
config_bundle: None,
|
||||||
|
|||||||
+1386
-282
File diff suppressed because it is too large
Load Diff
+3069
-111
File diff suppressed because it is too large
Load Diff
@@ -175,7 +175,6 @@ pub(crate) trait VerifiedWorkerRemoveExecutor: Send + Sync {
|
|||||||
source: VerifiedWorkerMutationSource,
|
source: VerifiedWorkerMutationSource,
|
||||||
target_runtime_id: &str,
|
target_runtime_id: &str,
|
||||||
target_worker_id: &str,
|
target_worker_id: &str,
|
||||||
expected_worker_revision: &str,
|
|
||||||
reason: &str,
|
reason: &str,
|
||||||
) -> Result<worker::WorkspaceResponse, String>;
|
) -> Result<worker::WorkspaceResponse, String>;
|
||||||
}
|
}
|
||||||
@@ -217,7 +216,6 @@ impl worker_runtime::worker_source::EmbeddedWorkerMutationDispatcher
|
|||||||
proof: InProcessWorkerMutationProof,
|
proof: InProcessWorkerMutationProof,
|
||||||
target_runtime_id: &str,
|
target_runtime_id: &str,
|
||||||
target_worker_id: &str,
|
target_worker_id: &str,
|
||||||
expected_worker_revision: &str,
|
|
||||||
reason: &str,
|
reason: &str,
|
||||||
) -> Result<
|
) -> Result<
|
||||||
worker::WorkspaceResponse,
|
worker::WorkspaceResponse,
|
||||||
@@ -241,13 +239,7 @@ impl worker_runtime::worker_source::EmbeddedWorkerMutationDispatcher
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
executor
|
executor
|
||||||
.execute(
|
.execute(source, target_runtime_id, target_worker_id, reason)
|
||||||
source,
|
|
||||||
target_runtime_id,
|
|
||||||
target_worker_id,
|
|
||||||
expected_worker_revision,
|
|
||||||
reason,
|
|
||||||
)
|
|
||||||
.map_err(worker_runtime::worker_source::RuntimeWorkerMutationForwardError::Embedded)
|
.map_err(worker_runtime::worker_source::RuntimeWorkerMutationForwardError::Embedded)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use chrono::{SecondsFormat, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::store::{
|
||||||
|
ControlPlaneStore, RepositoryRecord, WorkspaceBootstrapRecord, WorkspaceRecord,
|
||||||
|
};
|
||||||
|
use crate::{Error, Result};
|
||||||
|
|
||||||
|
const DEFAULT_REPOSITORY_ID: &str = "main";
|
||||||
|
const MAX_DISPLAY_NAME_BYTES: usize = 200;
|
||||||
|
const MAX_OPERATION_KEY_BYTES: usize = 200;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct InitialRepositoryIntent {
|
||||||
|
pub uri: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_ref: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct WorkspaceCreateRequest {
|
||||||
|
pub operation_key: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub repository: InitialRepositoryIntent,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct WorkspaceCreateResponse {
|
||||||
|
pub workspace: WorkspaceRecord,
|
||||||
|
pub repository: RepositoryRecord,
|
||||||
|
pub config_revision: u64,
|
||||||
|
pub request_fingerprint: String,
|
||||||
|
pub replayed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct WorkspaceCatalogService {
|
||||||
|
store: Arc<dyn ControlPlaneStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkspaceCatalogService {
|
||||||
|
pub fn new(store: Arc<dyn ControlPlaneStore>) -> Self {
|
||||||
|
Self { store }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list(
|
||||||
|
&self,
|
||||||
|
owner_account_id: Option<&str>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<WorkspaceRecord>> {
|
||||||
|
let limit = limit.clamp(1, 200);
|
||||||
|
Ok(self
|
||||||
|
.store
|
||||||
|
.list_workspaces()?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|workspace| {
|
||||||
|
workspace.owner_account_id.is_none()
|
||||||
|
|| owner_account_id
|
||||||
|
.is_some_and(|owner| workspace.owner_account_id.as_deref() == Some(owner))
|
||||||
|
})
|
||||||
|
.take(limit)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create(
|
||||||
|
&self,
|
||||||
|
request: WorkspaceCreateRequest,
|
||||||
|
owner_account_id: Option<String>,
|
||||||
|
) -> Result<WorkspaceCreateResponse> {
|
||||||
|
self.create_internal(request, owner_account_id, None, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_first_ownerless(
|
||||||
|
&self,
|
||||||
|
request: WorkspaceCreateRequest,
|
||||||
|
) -> Result<WorkspaceCreateResponse> {
|
||||||
|
self.create_internal(request, None, None, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_with_workspace_id(
|
||||||
|
&self,
|
||||||
|
request: WorkspaceCreateRequest,
|
||||||
|
owner_account_id: Option<String>,
|
||||||
|
requested_workspace_id: Option<String>,
|
||||||
|
) -> Result<WorkspaceCreateResponse> {
|
||||||
|
self.create_internal(request, owner_account_id, requested_workspace_id, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_internal(
|
||||||
|
&self,
|
||||||
|
request: WorkspaceCreateRequest,
|
||||||
|
owner_account_id: Option<String>,
|
||||||
|
requested_workspace_id: Option<String>,
|
||||||
|
require_empty_catalog: bool,
|
||||||
|
) -> Result<WorkspaceCreateResponse> {
|
||||||
|
let operation_key = normalize_required(
|
||||||
|
"operation_key",
|
||||||
|
request.operation_key,
|
||||||
|
MAX_OPERATION_KEY_BYTES,
|
||||||
|
)?;
|
||||||
|
let display_name =
|
||||||
|
normalize_required("display_name", request.display_name, MAX_DISPLAY_NAME_BYTES)?;
|
||||||
|
let repository_path = validate_repository_uri(&request.repository.uri)?;
|
||||||
|
let repository_uri = repository_path.to_string_lossy().into_owned();
|
||||||
|
let repository_name = request
|
||||||
|
.repository
|
||||||
|
.display_name
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("Main repository")
|
||||||
|
.to_string();
|
||||||
|
let default_ref = request
|
||||||
|
.repository
|
||||||
|
.default_ref
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("HEAD")
|
||||||
|
.to_string();
|
||||||
|
let requested_workspace_id = requested_workspace_id
|
||||||
|
.map(|value| {
|
||||||
|
Uuid::parse_str(value.trim())
|
||||||
|
.map(|id| id.to_string())
|
||||||
|
.map_err(|_| Error::InvalidInput("workspace_id must be a UUID".to_string()))
|
||||||
|
})
|
||||||
|
.transpose()?;
|
||||||
|
let workspace_id = requested_workspace_id
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| Uuid::now_v7().to_string());
|
||||||
|
let fingerprint = workspace_create_fingerprint(
|
||||||
|
requested_workspace_id.as_deref(),
|
||||||
|
&display_name,
|
||||||
|
owner_account_id.as_deref(),
|
||||||
|
&repository_uri,
|
||||||
|
&repository_name,
|
||||||
|
&default_ref,
|
||||||
|
);
|
||||||
|
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
|
||||||
|
let result = self
|
||||||
|
.store
|
||||||
|
.create_workspace_bootstrap(&WorkspaceBootstrapRecord {
|
||||||
|
operation_key,
|
||||||
|
request_fingerprint: fingerprint.clone(),
|
||||||
|
require_empty_catalog,
|
||||||
|
workspace: WorkspaceRecord {
|
||||||
|
workspace_id: workspace_id.clone(),
|
||||||
|
owner_account_id,
|
||||||
|
display_name,
|
||||||
|
state: "active".to_string(),
|
||||||
|
created_at: now.clone(),
|
||||||
|
updated_at: now.clone(),
|
||||||
|
},
|
||||||
|
repository: RepositoryRecord {
|
||||||
|
workspace_id,
|
||||||
|
repository_id: DEFAULT_REPOSITORY_ID.to_string(),
|
||||||
|
name: repository_name,
|
||||||
|
kind: "git".to_string(),
|
||||||
|
provider: Some("git".to_string()),
|
||||||
|
uri: repository_uri,
|
||||||
|
default_ref: Some(default_ref),
|
||||||
|
auth_ref_kind: None,
|
||||||
|
auth_ref_key: None,
|
||||||
|
created_at: now.clone(),
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
})?;
|
||||||
|
Ok(WorkspaceCreateResponse {
|
||||||
|
workspace: result.workspace,
|
||||||
|
repository: result.repository,
|
||||||
|
config_revision: result.config_revision,
|
||||||
|
request_fingerprint: fingerprint,
|
||||||
|
replayed: result.replayed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_required(field: &str, value: String, max_bytes: usize) -> Result<String> {
|
||||||
|
let value = value.trim();
|
||||||
|
if value.is_empty() || value.len() > max_bytes {
|
||||||
|
return Err(Error::InvalidInput(format!(
|
||||||
|
"{field} must be between 1 and {max_bytes} bytes"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_repository_uri(uri: &str) -> Result<PathBuf> {
|
||||||
|
let uri = uri.trim();
|
||||||
|
if uri.is_empty() || uri.contains("://") {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"initial repository uri must be an absolute server-local path".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let path = Path::new(uri);
|
||||||
|
if !path.is_absolute() {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"initial repository uri must be an absolute server-local path".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let path = path.canonicalize().map_err(|error| {
|
||||||
|
Error::InvalidInput(format!("initial repository path is unavailable: {error}"))
|
||||||
|
})?;
|
||||||
|
if !path.is_dir() {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"initial repository path must be a directory".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let normal_git = path.join(".git").exists();
|
||||||
|
let bare_git = path.join("HEAD").is_file() && path.join("objects").is_dir();
|
||||||
|
if !normal_git && !bare_git {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"initial repository path is not a Git repository".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_create_fingerprint(
|
||||||
|
requested_workspace_id: Option<&str>,
|
||||||
|
display_name: &str,
|
||||||
|
owner_account_id: Option<&str>,
|
||||||
|
repository_uri: &str,
|
||||||
|
repository_name: &str,
|
||||||
|
default_ref: &str,
|
||||||
|
) -> String {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"requested_workspace_id": requested_workspace_id,
|
||||||
|
"display_name": display_name,
|
||||||
|
"owner_account_id": owner_account_id,
|
||||||
|
"repository": {
|
||||||
|
"repository_id": DEFAULT_REPOSITORY_ID,
|
||||||
|
"uri": repository_uri,
|
||||||
|
"display_name": repository_name,
|
||||||
|
"default_ref": default_ref,
|
||||||
|
"kind": "git",
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(serde_json::to_vec(&payload).expect("workspace fingerprint serializes"));
|
||||||
|
let digest = hasher.finalize();
|
||||||
|
let encoded = digest
|
||||||
|
.iter()
|
||||||
|
.map(|byte| format!("{byte:02x}"))
|
||||||
|
.collect::<String>();
|
||||||
|
format!("sha256:{encoded}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::store::SqliteWorkspaceStore;
|
||||||
|
|
||||||
|
fn git_repository() -> tempfile::TempDir {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::create_dir(dir.path().join(".git")).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_is_atomic_and_exact_retries_converge() {
|
||||||
|
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
||||||
|
let service = WorkspaceCatalogService::new(store.clone());
|
||||||
|
let repository = git_repository();
|
||||||
|
let request = WorkspaceCreateRequest {
|
||||||
|
operation_key: "request-1".to_string(),
|
||||||
|
display_name: "Workspace A".to_string(),
|
||||||
|
repository: InitialRepositoryIntent {
|
||||||
|
uri: repository.path().display().to_string(),
|
||||||
|
display_name: None,
|
||||||
|
default_ref: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let created = service.create(request.clone(), None).unwrap();
|
||||||
|
let replayed = service.create(request, None).unwrap();
|
||||||
|
|
||||||
|
assert!(!created.replayed);
|
||||||
|
assert!(replayed.replayed);
|
||||||
|
assert_eq!(
|
||||||
|
created.workspace.workspace_id,
|
||||||
|
replayed.workspace.workspace_id
|
||||||
|
);
|
||||||
|
assert_eq!(store.list_workspaces().unwrap().len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.list_repositories(&created.workspace.workspace_id)
|
||||||
|
.unwrap()
|
||||||
|
.len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.load_workspace_config(&created.workspace.workspace_id)
|
||||||
|
.unwrap()
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_ownerless_bootstrap_commits_exactly_one_workspace() {
|
||||||
|
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
||||||
|
let service = WorkspaceCatalogService::new(store.clone());
|
||||||
|
let repository_a = git_repository();
|
||||||
|
let repository_b = git_repository();
|
||||||
|
let requests = [
|
||||||
|
WorkspaceCreateRequest {
|
||||||
|
operation_key: "bootstrap-a".to_string(),
|
||||||
|
display_name: "Workspace A".to_string(),
|
||||||
|
repository: InitialRepositoryIntent {
|
||||||
|
uri: repository_a.path().display().to_string(),
|
||||||
|
display_name: None,
|
||||||
|
default_ref: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
WorkspaceCreateRequest {
|
||||||
|
operation_key: "bootstrap-b".to_string(),
|
||||||
|
display_name: "Workspace B".to_string(),
|
||||||
|
repository: InitialRepositoryIntent {
|
||||||
|
uri: repository_b.path().display().to_string(),
|
||||||
|
display_name: None,
|
||||||
|
default_ref: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let barrier = Arc::new(std::sync::Barrier::new(2));
|
||||||
|
let results = std::thread::scope(|scope| {
|
||||||
|
requests
|
||||||
|
.into_iter()
|
||||||
|
.map(|request| {
|
||||||
|
let service = service.clone();
|
||||||
|
let barrier = barrier.clone();
|
||||||
|
scope.spawn(move || {
|
||||||
|
barrier.wait();
|
||||||
|
service.create_first_ownerless(request)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.map(|handle| handle.join().unwrap())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
|
||||||
|
assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
|
||||||
|
assert_eq!(store.list_workspaces().unwrap().len(), 1);
|
||||||
|
let error = results
|
||||||
|
.into_iter()
|
||||||
|
.find_map(Result::err)
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
assert!(error.contains("catalog is empty"), "{error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn idempotency_key_reuse_with_different_payload_is_rejected() {
|
||||||
|
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
||||||
|
let service = WorkspaceCatalogService::new(store);
|
||||||
|
let repository = git_repository();
|
||||||
|
let mut request = WorkspaceCreateRequest {
|
||||||
|
operation_key: "request-1".to_string(),
|
||||||
|
display_name: "Workspace A".to_string(),
|
||||||
|
repository: InitialRepositoryIntent {
|
||||||
|
uri: repository.path().display().to_string(),
|
||||||
|
display_name: None,
|
||||||
|
default_ref: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
service.create(request.clone(), None).unwrap();
|
||||||
|
request.display_name = "Workspace B".to_string();
|
||||||
|
|
||||||
|
let error = service.create(request, None).unwrap_err().to_string();
|
||||||
|
assert!(error.contains("different input"), "{error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repository_intent_rejects_remote_and_non_git_paths() {
|
||||||
|
let remote = validate_repository_uri("https://example.test/repo.git").unwrap_err();
|
||||||
|
assert!(remote.to_string().contains("server-local path"));
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let non_git = validate_repository_uri(&dir.path().display().to_string()).unwrap_err();
|
||||||
|
assert!(non_git.to_string().contains("not a Git repository"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ use worker_runtime::identity::RuntimeWorkerRef;
|
|||||||
|
|
||||||
use crate::runtime_subscription::{BrokerSubscriptionEvent, RuntimeSubscriptionBroker};
|
use crate::runtime_subscription::{BrokerSubscriptionEvent, RuntimeSubscriptionBroker};
|
||||||
use crate::server::{WorkspaceApi, connect_workspace_worker_protocol};
|
use crate::server::{WorkspaceApi, connect_workspace_worker_protocol};
|
||||||
|
use crate::store::WorkspaceResourceKind;
|
||||||
|
|
||||||
const OUTBOUND_CAPACITY: usize = 256;
|
const OUTBOUND_CAPACITY: usize = 256;
|
||||||
|
|
||||||
@@ -65,6 +66,7 @@ pub(crate) async fn serve_workspace_subscription(api: WorkspaceApi, socket: WebS
|
|||||||
match selector {
|
match selector {
|
||||||
EventSubscriptionSelector::WorkspaceWorkers => {
|
EventSubscriptionSelector::WorkspaceWorkers => {
|
||||||
let task = tokio::spawn(run_workspace_workers(
|
let task = tokio::spawn(run_workspace_workers(
|
||||||
|
api.clone(),
|
||||||
broker.clone(),
|
broker.clone(),
|
||||||
request_id,
|
request_id,
|
||||||
subscription_id.clone(),
|
subscription_id.clone(),
|
||||||
@@ -273,6 +275,7 @@ async fn run_worker_protocol(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn run_workspace_workers(
|
async fn run_workspace_workers(
|
||||||
|
api: WorkspaceApi,
|
||||||
broker: RuntimeSubscriptionBroker,
|
broker: RuntimeSubscriptionBroker,
|
||||||
request_id: protocol::subscription::SubscriptionRequestId,
|
request_id: protocol::subscription::SubscriptionRequestId,
|
||||||
subscription_id: SubscriptionId,
|
subscription_id: SubscriptionId,
|
||||||
@@ -307,7 +310,7 @@ async fn run_workspace_workers(
|
|||||||
};
|
};
|
||||||
match event {
|
match event {
|
||||||
BrokerSubscriptionEvent::Snapshot { snapshot, .. } => {
|
BrokerSubscriptionEvent::Snapshot { snapshot, .. } => {
|
||||||
install_snapshot(&mut workers, &runtime_id, snapshot);
|
install_snapshot(&api, &mut workers, &runtime_id, snapshot);
|
||||||
pending.remove(&runtime_id);
|
pending.remove(&runtime_id);
|
||||||
}
|
}
|
||||||
BrokerSubscriptionEvent::Disconnected { .. }
|
BrokerSubscriptionEvent::Disconnected { .. }
|
||||||
@@ -371,7 +374,7 @@ async fn run_workspace_workers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
install_snapshot(&mut workers, &runtime_id, snapshot);
|
install_snapshot(&api, &mut workers, &runtime_id, snapshot);
|
||||||
if let Some(current) = workers.get_mut(&runtime_id) {
|
if let Some(current) = workers.get_mut(&runtime_id) {
|
||||||
for worker in current.values_mut() {
|
for worker in current.values_mut() {
|
||||||
let worker_ref =
|
let worker_ref =
|
||||||
@@ -397,6 +400,14 @@ async fn run_workspace_workers(
|
|||||||
BrokerSubscriptionEvent::Event { payload, .. } => match payload {
|
BrokerSubscriptionEvent::Event { payload, .. } => match payload {
|
||||||
SubscriptionEventPayload::WorkerUpserted { mut worker } => {
|
SubscriptionEventPayload::WorkerUpserted { mut worker } => {
|
||||||
worker.runtime_id = Some(runtime_id.clone());
|
worker.runtime_id = Some(runtime_id.clone());
|
||||||
|
let Ok(Some(resource_key)) = api.store.resource_key(
|
||||||
|
&api.config.workspace_id,
|
||||||
|
WorkspaceResourceKind::Worker,
|
||||||
|
worker.worker_id.as_str(),
|
||||||
|
) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
worker.resource_key = Some(resource_key);
|
||||||
let worker_ref = RuntimeWorkerRef::new(&runtime_id, worker.worker_id.as_str());
|
let worker_ref = RuntimeWorkerRef::new(&runtime_id, worker.worker_id.as_str());
|
||||||
let revision = next_revision(&mut revisions, &worker_ref);
|
let revision = next_revision(&mut revisions, &worker_ref);
|
||||||
worker.subject_revision = revision;
|
worker.subject_revision = revision;
|
||||||
@@ -474,6 +485,7 @@ async fn run_workspace_workers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn install_snapshot(
|
fn install_snapshot(
|
||||||
|
api: &WorkspaceApi,
|
||||||
workers: &mut HashMap<String, BTreeMap<String, SubscriptionWorker>>,
|
workers: &mut HashMap<String, BTreeMap<String, SubscriptionWorker>>,
|
||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
snapshot: SubscriptionSnapshot,
|
snapshot: SubscriptionSnapshot,
|
||||||
@@ -487,6 +499,14 @@ fn install_snapshot(
|
|||||||
let mut projected = BTreeMap::new();
|
let mut projected = BTreeMap::new();
|
||||||
for mut worker in snapshot_workers {
|
for mut worker in snapshot_workers {
|
||||||
worker.runtime_id = Some(runtime_id.to_string());
|
worker.runtime_id = Some(runtime_id.to_string());
|
||||||
|
let Ok(Some(resource_key)) = api.store.resource_key(
|
||||||
|
&api.config.workspace_id,
|
||||||
|
WorkspaceResourceKind::Worker,
|
||||||
|
worker.worker_id.as_str(),
|
||||||
|
) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
worker.resource_key = Some(resource_key);
|
||||||
projected.insert(worker.worker_id.to_string(), worker);
|
projected.insert(worker.worker_id.to_string(), worker);
|
||||||
}
|
}
|
||||||
workers.insert(runtime_id.to_string(), projected);
|
workers.insert(runtime_id.to_string(), projected);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ It is not a dumping ground for external research, old plans, API inventories, or
|
|||||||
14. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed.
|
14. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed.
|
||||||
15. [`development/rust-testing-strategy.md`](development/rust-testing-strategy.md) — what Yoi Rust tests should prove, where they belong, and how to name them.
|
15. [`development/rust-testing-strategy.md`](development/rust-testing-strategy.md) — what Yoi Rust tests should prove, where they belong, and how to name them.
|
||||||
16. [`development/validation.md`](development/validation.md) — how to check changes.
|
16. [`development/validation.md`](development/validation.md) — how to check changes.
|
||||||
|
17. [`development/workspace-schema-migrations.md`](development/workspace-schema-migrations.md) — how to preflight, apply, verify, and roll back control-plane SQLite schema changes.
|
||||||
|
|
||||||
## What belongs here
|
## What belongs here
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Do not insert turn-crossing information directly into context without first appe
|
|||||||
Forbidden examples:
|
Forbidden examples:
|
||||||
|
|
||||||
- Delivering a `Notify` or `WorkerEvent` only as a temporary context note.
|
- Delivering a `Notify` or `WorkerEvent` only as a temporary context note.
|
||||||
- Adding a `<system-reminder>` that explains behavior but is not persisted.
|
- Adding a system reminder that explains behavior but is not persisted.
|
||||||
- Rewriting old messages to include new facts.
|
- Rewriting old messages to include new facts.
|
||||||
- Letting UI/controller-only state become model-visible without a committed record.
|
- Letting UI/controller-only state become model-visible without a committed record.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Workspace database schema migration runbook
|
||||||
|
|
||||||
|
The Workspace Server owns one control-plane SQLite database. Schema changes are applied by the Server at startup; domain components such as Ticket and Merge Request contribute tables to that same database, but they do not create a second Workspace authority.
|
||||||
|
|
||||||
|
## Before deployment
|
||||||
|
|
||||||
|
1. Stop writes and shut down every Server process using the database. Do not run two Server generations against one database during migration.
|
||||||
|
2. Record the current binary revision and database schema version.
|
||||||
|
3. Take a byte-for-byte backup of the database and its WAL/SHM state using a SQLite-safe backup procedure.
|
||||||
|
4. Run the read-only plan with the new binary:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yoi-server migrate --dry-run --database <server.db>
|
||||||
|
```
|
||||||
|
|
||||||
|
The plan runs against an in-memory copy. It reports the current and target schema versions, migration names, Worker identity mappings, and repairs without mutating the source database. Workspace-resource preflight failures name the relation and bounded offending row identities; repair those rows through the owning domain authority before retrying.
|
||||||
|
|
||||||
|
## Applying
|
||||||
|
|
||||||
|
Start exactly one instance of the new Server binary against the database. Startup applies migration 39 in one SQLite transaction after the Ticket and Merge Request component schemas are available. The migration:
|
||||||
|
|
||||||
|
- rebuilds Ticket, Objective, assignment, Artifact, and human-key tables with Workspace-scoped composite identity;
|
||||||
|
- adds composite foreign keys for repository, Ticket, Objective, Worker, relation-target, and current-assignment references;
|
||||||
|
- materializes assignment-specific Worker tombstones for pre-v39 historical assignments whose valid Worker UUID no longer has a matching live registry row (including Workers deleted by the legacy cleanup path and Workers moved between Runtimes); a Worker ID that resolves only in another Workspace remains a preflight error;
|
||||||
|
- validates new historical assignment/event references with SQLite triggers while allowing those audit rows to survive later Ticket or Worker retention deletion; parent delete/Runtime-move triggers record exact Workspace-scoped tombstones, and startup accepts a missing live parent only when that tombstone exists, so an unrelated same ID in another Workspace cannot change the result; reservation operation ids remain intentionally unconstrained until their resources exist;
|
||||||
|
- checks the rebuilt schema with `PRAGMA foreign_key_check` before recording the schema version; and
|
||||||
|
- restores `PRAGMA foreign_keys = ON` whether the transaction commits or rolls back.
|
||||||
|
|
||||||
|
After startup, verify:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT MAX(version) FROM __yoi_schema_migrations;
|
||||||
|
PRAGMA foreign_key_check;
|
||||||
|
PRAGMA integrity_check;
|
||||||
|
```
|
||||||
|
|
||||||
|
The expected migration version is `39`, `foreign_key_check` returns no rows, and `integrity_check` returns `ok`.
|
||||||
|
|
||||||
|
## Failure and rollback
|
||||||
|
|
||||||
|
There is no in-place down migration. A failed migration transaction leaves the prior schema version and data intact. Keep the Server stopped, preserve the failure diagnostics, and either repair the preflight data with the prior generation or restore the complete pre-migration backup before retrying.
|
||||||
|
|
||||||
|
Never run an older binary after a newer schema version has committed. Startup fences this case and refuses to serve when the database schema version is newer than the binary supports. Rollback therefore means restoring both the prior binary and its matching pre-migration database backup; it does not mean pointing the old binary at the upgraded database.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Internal SubWorker feature installation fails before analysis starts
|
||||||
|
|
||||||
|
While implementing Ticket `00001KZXWKD01`, two read-only Internal SubWorkers were requested to investigate the backend and Web Console paths. Both `SubWorkerSpawn` operations failed before the child session started with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
install Internal Worker features: Worker feature installation failed:
|
||||||
|
builtin:worker-observation: required service requirement is not available:
|
||||||
|
builtin:worker.control
|
||||||
|
```
|
||||||
|
|
||||||
|
The requested `builtin:coder` child had read-only scope and did not need peer Worker observation for the delegated investigation. The failure prevented context splitting, so the parent Worker performed the investigation directly. No implementation or validation authority was lost.
|
||||||
|
|
||||||
|
## Improvement direction
|
||||||
|
|
||||||
|
Resolve the effective Internal SubWorker Profile so its installed feature set is satisfiable under the parent-provided services. Either install the required `worker.control` service before `worker-observation`, or avoid enabling `worker-observation` for a child that has no corresponding observation grant/service. Startup validation should identify the Profile feature that introduced the unsatisfied dependency and distinguish a configuration error from unavailable delegated authority.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# SubWorker spawn failed because the inherited profile required an unavailable control service
|
||||||
|
|
||||||
|
Date: 2026-08-19
|
||||||
|
|
||||||
|
## Observed behavior
|
||||||
|
|
||||||
|
While splitting a migration investigation into read-only Runtime and Server analysis, both `SubWorkerSpawn` calls failed before the child session started:
|
||||||
|
|
||||||
|
```text
|
||||||
|
install Internal Worker features: Worker feature installation failed:
|
||||||
|
builtin:worker-observation: required service requirement is not available:
|
||||||
|
builtin:worker.control
|
||||||
|
```
|
||||||
|
|
||||||
|
The requested children used `builtin:coder` with read-only scopes. No child was created and no delegated work ran.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
A parent Worker with the SubWorker tools available cannot necessarily spawn a catalog profile whose transitive features require `builtin:worker.control`. The failure occurs at profile feature installation rather than being rejected when the profile is selected or omitted from the available SubWorker profile choices. The parent must continue the investigation without context splitting.
|
||||||
|
|
||||||
|
## Expected behavior
|
||||||
|
|
||||||
|
The SubWorker spawn layer should either install the parent-owned `worker.control` service before resolving dependent child features, provide a SubWorker-compatible profile projection that does not require unavailable Workspace Worker control, or reject the profile choice up front with an actionable capability diagnostic. A read-only delegated scope must remain read-only; satisfying the service dependency must not widen filesystem or Workspace authority.
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
<system-reminder>
|
|
||||||
Workspace Dashboard observed that this Orchestrator Worker is idle while queued Ticket work is present.
|
Workspace Dashboard observed that this Orchestrator Worker is idle while queued Ticket work is present.
|
||||||
|
|
||||||
This is bounded attention only, not scheduler authority. Do not drain the queue automatically. Verify the Ticket is still `queued`, then use the guarded `SpawnTicketCoder` operation without a separate state transition; that operation records `queued -> inprogress` only after Worker creation, initial input, assignment, and Workdir finalization are durably accepted.
|
This is bounded attention only, not scheduler authority. Do not drain the queue automatically. Verify the Ticket is still `queued`, then use the guarded `SpawnTicketCoder` operation without a separate state transition; that operation records `queued -> inprogress` only after Worker creation, initial input, assignment, and Workdir finalization are durably accepted.
|
||||||
@@ -21,4 +20,3 @@ Additional queued Tickets omitted from this bounded notice: {{ omitted_ticket_co
|
|||||||
{% endif -%}
|
{% endif -%}
|
||||||
|
|
||||||
Preserve the existing human gate, dependency/conflict/capacity/dirty-workspace checks, and duplicate-start checks using actual Ticket state, role/session claims, visible Workers, and worktrees.
|
Preserve the existing human gate, dependency/conflict/capacity/dirty-workspace checks, and duplicate-start checks using actual Ticket state, role/session claims, visible Workers, and worktrees.
|
||||||
</system-reminder>
|
|
||||||
|
|||||||
@@ -22,4 +22,4 @@ Do not create or delegate an implementation worktree/branch until the Ticket rec
|
|||||||
|
|
||||||
Workspace roots, cwd, profile selector, and launch-prompt configuration are control-plane/environment facts rather than user instructions. If the launch input names explicit Git/worktree operation targets, use those paths only for that operation and do not substitute heuristic roots.
|
Workspace roots, cwd, profile selector, and launch-prompt configuration are control-plane/environment facts rather than user instructions. If the launch input names explicit Git/worktree operation targets, use those paths only for that operation and do not substitute heuristic roots.
|
||||||
|
|
||||||
Use `WorkerRemove` only for a terminal or authoritatively reassigned non-internal Coder after implementation, review, fix, merge/commit, and report handoffs are complete. Do not remove a Coder merely because one turn completed or it is temporarily idle; retain it while review or request-changes work can still return. The Worker must already be stopped, must not be restoring, must have no current Ticket assignment, pending notification, Reviewer handoff, legal hold, or pin, and must not be this Orchestrator. Immediately before removal, reread authoritative Ticket state, assignment, thread/review evidence, and the target Worker with `WorkerShow`; pass the exact current `updated_at` value as `expected_worker_revision` with a concise reason. After removal, reread the Worker catalog and attachment state. Treat revision, assignment, running/restoring, retention-policy, attachment-close, and attachment-release conflicts as authoritative failures: do not guess policy or retry with stale input. `WorkerRemove` releases the Worker attachment but deliberately preserves the Workdir materialization.
|
Use `WorkerRemove` only for a terminal or authoritatively reassigned non-internal Coder after implementation, review, fix, merge/commit, and report handoffs are complete. Do not remove a Coder merely because one turn completed or it is temporarily idle; retain it while review or request-changes work can still return. The Worker must already be stopped, must not be restoring, must have no current Ticket assignment, pending notification, Reviewer handoff, legal hold, or pin, and must not be this Orchestrator. Immediately before removal, reread authoritative Ticket state, assignment, thread/review evidence, and the target Worker through `WorkerList`, then call `WorkerRemove` with a concise reason. Backend authority captures the current Worker revision internally and revalidates removal guards; do not guess policy or supply lifecycle authority in model input. After removal, reread the Worker catalog and attachment state. Treat assignment, running/restoring, retention-policy, attachment-close, and attachment-release conflicts as authoritative failures. `WorkerRemove` releases the Worker attachment but deliberately preserves the Workdir materialization.
|
||||||
|
|||||||
@@ -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 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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ export type Permission = "read" | "write";
|
|||||||
|
|
||||||
export type InFlightToolCallState = "pending" | "streaming_args" | "done";
|
export type InFlightToolCallState = "pending" | "streaming_args" | "done";
|
||||||
|
|
||||||
|
export type CommandStatus = "running" | "completed" | "failed" | "timed_out" | "cancelled";
|
||||||
|
|
||||||
|
export type CommandStream = "stdout" | "stderr";
|
||||||
|
|
||||||
|
export type CommandStreamSlice = { start_offset: number, end_offset: number, content: string, truncated: boolean, };
|
||||||
|
|
||||||
|
export type CommandSnapshot = { command_id: string, tool_call_id: string | null, status: CommandStatus, started_at_ms: number, observed_at_ms: number, last_output_at_ms: number | null, stdout: CommandStreamSlice, stderr: CommandStreamSlice, exit_code: number | null, };
|
||||||
|
|
||||||
|
export type CommandEvent = { "kind": "started", command_id: string, tool_call_id: string | null, observed_at_ms: number, } | { "kind": "output", command_id: string, stream: CommandStream, start_offset: number, end_offset: number, content: string, observed_at_ms: number, } | { "kind": "terminal", command_id: string, status: CommandStatus, exit_code: number | null, stdout_end_offset: number, stderr_end_offset: number, observed_at_ms: number, };
|
||||||
|
|
||||||
export type ScopeRule = {
|
export type ScopeRule = {
|
||||||
/**
|
/**
|
||||||
* Target path. Must be absolute by the time a `Scope` is built from
|
* Target path. Must be absolute by the time a `Scope` is built from
|
||||||
@@ -51,7 +61,7 @@ export type RewindSummary = { truncated_to_entries: number, discarded_entries: n
|
|||||||
|
|
||||||
export type InFlightBlock = { "kind": "text", text: string, finished?: boolean, } | { "kind": "thinking", text: string, finished?: boolean, } | { "kind": "tool_call", id: string, name: string, args: string, state?: InFlightToolCallState, };
|
export type InFlightBlock = { "kind": "text", text: string, finished?: boolean, } | { "kind": "thinking", text: string, finished?: boolean, } | { "kind": "tool_call", id: string, name: string, args: string, state?: InFlightToolCallState, };
|
||||||
|
|
||||||
export type InFlightSnapshot = { blocks?: Array<InFlightBlock>, };
|
export type InFlightSnapshot = { blocks?: Array<InFlightBlock>, commands?: Array<CommandSnapshot>, };
|
||||||
|
|
||||||
export type InternalWorkerKind = "sub_worker";
|
export type InternalWorkerKind = "sub_worker";
|
||||||
|
|
||||||
@@ -125,10 +135,15 @@ export type SubscriptionWorker = { worker_id: SubscriptionWorkerId,
|
|||||||
* Runtime producers leave this unset because the connection identifies the Runtime.
|
* Runtime producers leave this unset because the connection identifies the Runtime.
|
||||||
*/
|
*/
|
||||||
runtime_id?: string | null,
|
runtime_id?: string | null,
|
||||||
|
/**
|
||||||
|
* Workspace-scoped canonical resource key. Runtime producers leave this unset;
|
||||||
|
* Workspace-facing projections must populate it before publishing the Worker.
|
||||||
|
*/
|
||||||
|
resource_key?: string | null,
|
||||||
/**
|
/**
|
||||||
* Producer-owned monotonic revision for this Worker subject.
|
* Producer-owned monotonic revision for this Worker subject.
|
||||||
*/
|
*/
|
||||||
subject_revision: number, state: SubscriptionWorkerState, workspace_id?: string | null, display_name?: string | null, profile?: string | null, repository_id?: string | null, working_directory_id?: SubscriptionWorkdirId | null, };
|
subject_revision: number, state: SubscriptionWorkerState, has_running_internal_workers: boolean, workspace_id?: string | null, display_name?: string | null, profile?: string | null, repository_id?: string | null, working_directory_id?: SubscriptionWorkdirId | null, };
|
||||||
|
|
||||||
export type SubscriptionWorkdir = { working_directory_id: SubscriptionWorkdirId, repository_id: string, state: string, primary_worker_id?: SubscriptionWorkerId | null, };
|
export type SubscriptionWorkdir = { working_directory_id: SubscriptionWorkdirId, repository_id: string, state: string, primary_worker_id?: SubscriptionWorkerId | null, };
|
||||||
|
|
||||||
@@ -178,4 +193,4 @@ in_flight?: InFlightSnapshot,
|
|||||||
* Parent-owned Internal Worker sessions visible to this client.
|
* Parent-owned Internal Worker sessions visible to this client.
|
||||||
* Service-private Internal Workers are deliberately excluded.
|
* Service-private Internal Workers are deliberately excluded.
|
||||||
*/
|
*/
|
||||||
internal_workers?: Array<InternalWorkerSnapshot>, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, revision: number, event: Event, } } | { "event": "segment_rotated", "data": { entry: unknown, } } | { "event": "status", "data": { status: WorkerStatus, } } | { "event": "completions", "data": { kind: CompletionKind, entries: Array<CompletionEntry>, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array<RewindTarget>, } } | { "event": "rewind_applied", "data": { entries: Array<unknown>, input: Array<Segment>, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compact_start" } | { "event": "compact_done", "data": { new_segment_id: string, } } | { "event": "compact_failed", "data": { error: string, } } | { "event": "shutdown" };
|
internal_workers?: Array<InternalWorkerSnapshot>, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, revision: number, event: Event, } } | { "event": "internal_worker_removed", "data": { worker: InternalWorkerRef, revision: number, } } | { "event": "segment_rotated", "data": { entry: unknown, } } | { "event": "status", "data": { status: WorkerStatus, } } | { "event": "command", "data": { event: CommandEvent, } } | { "event": "completions", "data": { kind: CompletionKind, entries: Array<CompletionEntry>, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array<RewindTarget>, } } | { "event": "rewind_applied", "data": { entries: Array<unknown>, input: Array<Segment>, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compact_start" } | { "event": "compact_done", "data": { new_segment_id: string, } } | { "event": "compact_failed", "data": { error: string, } } | { "event": "shutdown" };
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type InvalidProjectRecord = { label: string; reason: string };
|
|||||||
|
|
||||||
export type TicketSummary = {
|
export type TicketSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
|
resource_key: string;
|
||||||
title: string;
|
title: string;
|
||||||
state: string;
|
state: string;
|
||||||
priority: string;
|
priority: string;
|
||||||
@@ -51,7 +52,12 @@ export type TicketEventDetail = {
|
|||||||
references: Array<string>;
|
references: Array<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ObjectiveLinkSummary = { id: string; title: string; state: string };
|
export type ObjectiveLinkSummary = {
|
||||||
|
id: string;
|
||||||
|
resource_key: string;
|
||||||
|
title: string;
|
||||||
|
state: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type TicketEvidenceEvent = {
|
export type TicketEvidenceEvent = {
|
||||||
event_ref: string;
|
event_ref: string;
|
||||||
@@ -66,6 +72,7 @@ export type TicketAssignmentSummary = {
|
|||||||
assignment_id: string;
|
assignment_id: string;
|
||||||
runtime_id: string;
|
runtime_id: string;
|
||||||
worker_id: string;
|
worker_id: string;
|
||||||
|
worker_resource_key?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TicketMergeRequestSummary = {
|
export type TicketMergeRequestSummary = {
|
||||||
@@ -83,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;
|
||||||
@@ -115,6 +133,7 @@ export type TicketQueryRequest = {
|
|||||||
|
|
||||||
export type TicketQueryItem = {
|
export type TicketQueryItem = {
|
||||||
id: string;
|
id: string;
|
||||||
|
resource_key: string;
|
||||||
title: string;
|
title: string;
|
||||||
state: string;
|
state: string;
|
||||||
readiness: string | null;
|
readiness: string | null;
|
||||||
@@ -150,6 +169,7 @@ export type TicketRelation = {
|
|||||||
ticket_id: string;
|
ticket_id: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
target: string;
|
target: string;
|
||||||
|
target_resource_key?: string | null;
|
||||||
note: string | null;
|
note: string | null;
|
||||||
author: string;
|
author: string;
|
||||||
at: string;
|
at: string;
|
||||||
@@ -157,6 +177,7 @@ export type TicketRelation = {
|
|||||||
|
|
||||||
export type DerivedTicketRelation = {
|
export type DerivedTicketRelation = {
|
||||||
source_ticket: string;
|
source_ticket: string;
|
||||||
|
source_resource_key?: string | null;
|
||||||
inverse_kind: string;
|
inverse_kind: string;
|
||||||
forward_kind: string;
|
forward_kind: string;
|
||||||
note: string | null;
|
note: string | null;
|
||||||
@@ -166,6 +187,7 @@ export type DerivedTicketRelation = {
|
|||||||
|
|
||||||
export type TicketRelationBlocker = {
|
export type TicketRelationBlocker = {
|
||||||
blocking_ticket: string;
|
blocking_ticket: string;
|
||||||
|
blocking_resource_key?: string | null;
|
||||||
reason_kind: string;
|
reason_kind: string;
|
||||||
relation_kind: string;
|
relation_kind: string;
|
||||||
note: string | null;
|
note: string | null;
|
||||||
@@ -187,6 +209,7 @@ export type TicketRelationView = {
|
|||||||
|
|
||||||
export type TicketDetail = {
|
export type TicketDetail = {
|
||||||
id: string;
|
id: string;
|
||||||
|
resource_key: string;
|
||||||
title: string;
|
title: string;
|
||||||
state: string;
|
state: string;
|
||||||
readiness: string | null;
|
readiness: string | null;
|
||||||
|
|||||||
@@ -38,25 +38,31 @@ Deno.test("workspace route helpers scope browser routes and API by immutable wor
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("root layout bootstraps only the scoped workspace entry", async () => {
|
Deno.test("root layout leaves Workspace selection explicit", async () => {
|
||||||
const layout = await Deno.readTextFile(
|
const layout = await Deno.readTextFile(
|
||||||
new URL("./../../../routes/+layout.ts", import.meta.url),
|
new URL("./../../../routes/+layout.ts", import.meta.url),
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
layout.includes('loadJson<WorkspaceResponse>(fetch, "/api/workspace")'),
|
!layout.includes("/api/workspace") &&
|
||||||
"unscoped layout may use only the workspace-id bootstrap endpoint",
|
!layout.includes("redirect(") &&
|
||||||
|
layout.includes("Workspace selection is explicit"),
|
||||||
|
"root layout must not infer or redirect to a singleton Workspace",
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Workspace route changes dispose old multiplexed subscription state", async () => {
|
||||||
|
const [layout, multiplexer] = await Promise.all([
|
||||||
|
Deno.readTextFile(
|
||||||
|
new URL("./../../../routes/w/[workspaceId]/+layout.svelte", import.meta.url),
|
||||||
|
),
|
||||||
|
Deno.readTextFile(new URL("./../multiplexer.ts", import.meta.url)),
|
||||||
|
]);
|
||||||
assert(
|
assert(
|
||||||
layout.includes("throw redirect(307") &&
|
layout.includes("disposeWorkspaceMultiplexer(workspaceId)") &&
|
||||||
layout.includes("workspaceRoute(workspace.data.workspace_id)") &&
|
multiplexer.includes("multiplexers.delete(workspaceId)") &&
|
||||||
!layout.includes("scopedCompatibilityRoute") &&
|
multiplexer.includes("this.#subscriptions.clear()") &&
|
||||||
!layout.includes("workspaceRoute(workspaceId, pathname)"),
|
multiplexer.includes("this.#socket?.close()"),
|
||||||
"root layout should redirect only to the scoped workspace entry",
|
"changing Workspace must dispose old subscriptions and transport state",
|
||||||
);
|
|
||||||
assert(
|
|
||||||
!layout.includes("`/api${path}`") &&
|
|
||||||
!layout.includes('"/api/repositories"'),
|
|
||||||
"layout must not fall back to unscoped workspace-scoped API calls",
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,164 @@
|
|||||||
|
export type WorkspaceCatalogRecord = {
|
||||||
|
workspace_id: string;
|
||||||
|
owner_account_id: string | null;
|
||||||
|
display_name: string;
|
||||||
|
state: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceRepositoryRecord = {
|
||||||
|
workspace_id: string;
|
||||||
|
repository_id: string;
|
||||||
|
name: string;
|
||||||
|
kind: string;
|
||||||
|
uri: string;
|
||||||
|
default_ref: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceCatalogItem = WorkspaceCatalogRecord & {
|
||||||
|
repositories: WorkspaceRepositoryRecord[];
|
||||||
|
repository_error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateWorkspaceRequest = {
|
||||||
|
operation_key: string;
|
||||||
|
display_name: string;
|
||||||
|
repository: {
|
||||||
|
uri: string;
|
||||||
|
display_name: string | null;
|
||||||
|
default_ref: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateWorkspaceResponse = {
|
||||||
|
workspace: WorkspaceCatalogRecord;
|
||||||
|
repository: WorkspaceRepositoryRecord;
|
||||||
|
config_revision: number;
|
||||||
|
request_fingerprint: string;
|
||||||
|
replayed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class WorkspaceCatalogError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number | null,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "WorkspaceCatalogError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Fetch = typeof globalThis.fetch;
|
||||||
|
|
||||||
|
export async function listWorkspaces(
|
||||||
|
fetcher: Fetch,
|
||||||
|
): Promise<WorkspaceCatalogRecord[]> {
|
||||||
|
return await fetchJson<WorkspaceCatalogRecord[]>(
|
||||||
|
fetcher,
|
||||||
|
"/api/workspaces?limit=200",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWorkspaceRepositories(
|
||||||
|
fetcher: Fetch,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<WorkspaceRepositoryRecord[]> {
|
||||||
|
return await fetchJson<WorkspaceRepositoryRecord[]>(
|
||||||
|
fetcher,
|
||||||
|
`/api/w/${encodeURIComponent(workspaceId)}/repositories`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadWorkspaceCatalog(
|
||||||
|
fetcher: Fetch,
|
||||||
|
): Promise<WorkspaceCatalogItem[]> {
|
||||||
|
const workspaces = await listWorkspaces(fetcher);
|
||||||
|
return await Promise.all(
|
||||||
|
workspaces.map(async (workspace) => {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
...workspace,
|
||||||
|
repositories: await listWorkspaceRepositories(
|
||||||
|
fetcher,
|
||||||
|
workspace.workspace_id,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...workspace,
|
||||||
|
repositories: [],
|
||||||
|
repository_error: errorMessage(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWorkspace(
|
||||||
|
fetcher: Fetch,
|
||||||
|
request: CreateWorkspaceRequest,
|
||||||
|
): Promise<CreateWorkspaceResponse> {
|
||||||
|
return await fetchJson<CreateWorkspaceResponse>(fetcher, "/api/workspaces", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function creationErrorMessage(error: unknown): string {
|
||||||
|
if (!(error instanceof WorkspaceCatalogError)) {
|
||||||
|
return `Network error. The same operation can be retried safely. ${
|
||||||
|
errorMessage(error)
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
switch (error.status) {
|
||||||
|
case 400:
|
||||||
|
return `Validation failed. ${error.message}`;
|
||||||
|
case 401:
|
||||||
|
case 403:
|
||||||
|
return `You are not authorized to create this Workspace. ${error.message}`;
|
||||||
|
case 409:
|
||||||
|
return `Creation conflicts with current Backend state. ${error.message}`;
|
||||||
|
default:
|
||||||
|
return `Workspace creation failed. The same operation can be retried safely. ${error.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOperationKey(): string {
|
||||||
|
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||||
|
return `web-workspace-create-${crypto.randomUUID()}`;
|
||||||
|
}
|
||||||
|
return `web-workspace-create-${Date.now()}-${
|
||||||
|
Math.random().toString(16).slice(2)
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson<T>(
|
||||||
|
fetcher: Fetch,
|
||||||
|
input: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<T> {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetcher(input, init);
|
||||||
|
} catch (error) {
|
||||||
|
throw new WorkspaceCatalogError(null, errorMessage(error));
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
let detail = `${response.status} ${response.statusText}`.trim();
|
||||||
|
try {
|
||||||
|
const body = await response.json();
|
||||||
|
if (typeof body?.message === "string") detail = body.message;
|
||||||
|
else if (typeof body?.error === "string") detail = body.error;
|
||||||
|
} catch {
|
||||||
|
// Preserve the bounded status text when the Backend did not return JSON.
|
||||||
|
}
|
||||||
|
throw new WorkspaceCatalogError(response.status, detail);
|
||||||
|
}
|
||||||
|
return await response.json() as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
@@ -19,7 +19,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
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';
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolSummary(line: ConsoleLine): { label: string; suffix: string; rest: string } {
|
function toolSummary(line: ConsoleLine): { label: string; suffix: string; rest: string } {
|
||||||
@@ -49,24 +50,29 @@
|
|||||||
{#if shouldRenderHeading(item)}
|
{#if shouldRenderHeading(item)}
|
||||||
<div class="message-heading">
|
<div class="message-heading">
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
{#if item.streaming}<small>streaming</small>{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{:else if item.kind === 'tool'}
|
{:else if item.kind === 'tool'}
|
||||||
<div class="tool-summary">
|
<div class="tool-summary">
|
||||||
<span class="tool-label">{toolSummary(item).label}</span>
|
<span class="tool-label">{toolSummary(item).label}</span>
|
||||||
<span class="tool-separator"> — </span>
|
<span class="tool-separator"> — </span>
|
||||||
<span class={`tool-suffix ${item.toolCall?.state ?? ''}`}>{toolSummary(item).suffix}</span>
|
<span class={`tool-suffix ${item.toolCall?.state ?? ''}`}>{toolSummary(item).suffix}</span>
|
||||||
{#if item.streaming}<small>streaming</small>{/if}
|
|
||||||
</div>
|
|
||||||
{:else if item.streaming}
|
|
||||||
<div class="message-heading streaming-heading">
|
|
||||||
<small>streaming</small>
|
|
||||||
</div>
|
</div>
|
||||||
{/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">{bodyTextAfterToolSummary(item)}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
{:else if item.kind === 'user'}
|
||||||
|
<div class="user-message">
|
||||||
|
<span class="user-prompt" aria-hidden="true">></span>
|
||||||
|
<div><RichMarkdown text={item.body || '—'} /></div>
|
||||||
|
</div>
|
||||||
|
{:else if item.kind === 'activity'}
|
||||||
|
<p class="activity-summary">{item.body || '—'}</p>
|
||||||
|
{:else if item.kind === 'task_reminder'}
|
||||||
|
<p class="task-reminder-summary">{item.body || 'task reminder'}</p>
|
||||||
|
{:else if item.kind === 'run_stats'}
|
||||||
|
<p class="run-stats">{item.body}</p>
|
||||||
{:else if shouldRenderMarkdown(item)}
|
{:else if shouldRenderMarkdown(item)}
|
||||||
<RichMarkdown text={item.body || '—'} />
|
<RichMarkdown text={item.body || '—'} />
|
||||||
{:else}
|
{:else}
|
||||||
@@ -102,6 +108,48 @@
|
|||||||
color: var(--tui-green);
|
color: var(--tui-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-message {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 0.55rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-prompt {
|
||||||
|
color: var(--tui-green);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-summary,
|
||||||
|
.task-reminder-summary {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-reminder-summary {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-line.error .activity-summary {
|
||||||
|
color: var(--tui-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-stats {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.console-line.assistant {
|
.console-line.assistant {
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
@@ -154,14 +202,6 @@
|
|||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-summary small {
|
|
||||||
margin-left: var(--space-2);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.74rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-label {
|
.tool-label {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
color: var(--tui-cyan);
|
color: var(--tui-cyan);
|
||||||
@@ -208,18 +248,6 @@
|
|||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-heading.streaming-heading {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-heading small {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.74rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-diff {
|
.console-diff {
|
||||||
background: color-mix(in oklch, var(--bg-raised) 85%, black);
|
background: color-mix(in oklch, var(--bg-raised) 85%, black);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
|
|||||||
@@ -1,12 +1,26 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { taskCounts, type ConsoleTask } from "./tasks.ts";
|
import { taskCounts, type ConsoleTask } from "./tasks.ts";
|
||||||
|
|
||||||
|
type WorkerViewTab = {
|
||||||
|
sessionId: string | null;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
tasks: ConsoleTask[];
|
tasks: ConsoleTask[];
|
||||||
mode: "mini" | "pane";
|
mode: "mini" | "pane";
|
||||||
|
workerViews?: WorkerViewTab[];
|
||||||
|
selectedWorkerViewSessionId?: string | null;
|
||||||
|
onSelectWorkerView?: (sessionId: string | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { tasks, mode }: Props = $props();
|
let {
|
||||||
|
tasks,
|
||||||
|
mode,
|
||||||
|
workerViews = [],
|
||||||
|
selectedWorkerViewSessionId = null,
|
||||||
|
onSelectWorkerView = () => {},
|
||||||
|
}: Props = $props();
|
||||||
const counts = $derived(taskCounts(tasks));
|
const counts = $derived(taskCounts(tasks));
|
||||||
const activeTasks = $derived(
|
const activeTasks = $derived(
|
||||||
tasks
|
tasks
|
||||||
@@ -28,7 +42,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if mode === "mini" && tasks.length > 0}
|
{#if mode === "mini" && (tasks.length > 0 || workerViews.length > 1)}
|
||||||
<section class="task-mini" aria-label="Worker task summary">
|
<section class="task-mini" aria-label="Worker task summary">
|
||||||
{#each activeTasks as task (task.taskid)}
|
{#each activeTasks as task (task.taskid)}
|
||||||
<div class="task-mini-row">
|
<div class="task-mini-row">
|
||||||
@@ -38,8 +52,25 @@
|
|||||||
<span class="task-subject">{task.subject.split("\n", 1)[0]}</span>
|
<span class="task-subject">{task.subject.split("\n", 1)[0]}</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
<div class="task-summary">
|
<div class="task-summary-row">
|
||||||
{counts.total} task(s) — pending: {counts.pending}, inprogress: {counts.inprogress}, completed: {counts.completed}, deleted: {counts.deleted}
|
<span class="task-summary">
|
||||||
|
{counts.total} task(s) — pending: {counts.pending}, inprogress: {counts.inprogress}, completed: {counts.completed}, deleted: {counts.deleted}
|
||||||
|
</span>
|
||||||
|
{#if workerViews.length > 1}
|
||||||
|
<span class="worker-view-tabs" role="group" aria-label="Worker transcript view">
|
||||||
|
<span aria-hidden="true">[ </span>
|
||||||
|
{#each workerViews as view, index (view.sessionId ?? "main")}
|
||||||
|
{#if index > 0}<span aria-hidden="true"> | </span>{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={view.sessionId === selectedWorkerViewSessionId}
|
||||||
|
class:active={view.sessionId === selectedWorkerViewSessionId}
|
||||||
|
onclick={() => onSelectWorkerView(view.sessionId)}
|
||||||
|
>{view.label}</button>
|
||||||
|
{/each}
|
||||||
|
<span aria-hidden="true"> ]</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{:else if mode === "pane"}
|
{:else if mode === "pane"}
|
||||||
@@ -95,12 +126,69 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.task-mini-row,
|
.task-mini-row,
|
||||||
.task-heading {
|
.task-heading,
|
||||||
|
.task-summary-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-summary-row {
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-summary {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-view-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 60%;
|
||||||
|
margin-left: auto;
|
||||||
|
overflow-x: auto;
|
||||||
|
color: var(--text-muted);
|
||||||
|
scrollbar-width: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-view-tabs::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-view-tabs button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-view-tabs button:hover,
|
||||||
|
.worker-view-tabs button:focus-visible {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-view-tabs button:focus-visible {
|
||||||
|
outline: 1px solid currentcolor;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-view-tabs button.active {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.task-mark,
|
.task-mark,
|
||||||
.task-id {
|
.task-id {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
type Props = {
|
||||||
|
values: readonly string[];
|
||||||
|
intervalMs?: number;
|
||||||
|
ariaLabel?: string;
|
||||||
|
class?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
values,
|
||||||
|
intervalMs = 100,
|
||||||
|
ariaLabel,
|
||||||
|
class: className,
|
||||||
|
}: Props = $props();
|
||||||
|
let index = $state(0);
|
||||||
|
const value = $derived(values.length > 0 ? values[index % values.length] : "");
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const length = values.length;
|
||||||
|
const delay = Math.max(16, intervalMs);
|
||||||
|
index = 0;
|
||||||
|
if (length <= 1) return;
|
||||||
|
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
index = (index + 1) % length;
|
||||||
|
}, delay);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class={className}
|
||||||
|
class:sequence-loop={true}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-hidden={ariaLabel ? undefined : "true"}
|
||||||
|
>{value}</span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sequence-loop {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 1ch;
|
||||||
|
text-align: center;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script module lang="ts">
|
||||||
|
export const SPINNER_FRAMES = [
|
||||||
|
"⣷",
|
||||||
|
"⣯",
|
||||||
|
"⣟",
|
||||||
|
"⡿",
|
||||||
|
"⢿",
|
||||||
|
"⣻",
|
||||||
|
"⣽",
|
||||||
|
"⣾",
|
||||||
|
] as const;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import SequenceLoop from "./SequenceLoop.svelte";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
intervalMs?: number;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { intervalMs = 90, label = "Running" }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="spinner" role="img" aria-label={label}>
|
||||||
|
<SequenceLoop values={SPINNER_FRAMES} {intervalMs} />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.spinner {
|
||||||
|
display: inline-flex;
|
||||||
|
color: var(--spinner-color, var(--accent));
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Spinner from "./Spinner.svelte";
|
||||||
|
import { formatRunElapsed, formatRunTokens } from "./run-status";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
startedAtMs: number | null;
|
||||||
|
requests: number;
|
||||||
|
uploadTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { startedAtMs, requests, uploadTokens, outputTokens }: Props = $props();
|
||||||
|
let nowMs = $state(Date.now());
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
startedAtMs;
|
||||||
|
nowMs = Date.now();
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
nowMs = Date.now();
|
||||||
|
}, 1_000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
});
|
||||||
|
|
||||||
|
const elapsed = $derived(formatRunElapsed(nowMs - (startedAtMs ?? nowMs)));
|
||||||
|
const requestLabel = $derived(requests === 1 ? "req" : "reqs");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="worker-run-status" role="status" aria-live="off">
|
||||||
|
<Spinner />
|
||||||
|
<span>{elapsed}</span>
|
||||||
|
<span aria-hidden="true">・</span>
|
||||||
|
<span>{requests} {requestLabel}</span>
|
||||||
|
<span aria-hidden="true">|</span>
|
||||||
|
<span>↑{formatRunTokens(uploadTokens)}/↓{formatRunTokens(outputTokens)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.worker-run-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.42rem;
|
||||||
|
min-height: 1.35rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import type { Event } from "$lib/generated/protocol";
|
import type { Event } from "$lib/generated/protocol";
|
||||||
import {
|
import {
|
||||||
|
type ConsoleEventInput,
|
||||||
type ConsoleLine,
|
type ConsoleLine,
|
||||||
|
consoleWorkerViews,
|
||||||
createConsoleProjector,
|
createConsoleProjector,
|
||||||
isConsoleProjectionEvent,
|
isConsoleProjectionEvent,
|
||||||
projectConsole,
|
projectConsole,
|
||||||
|
projectConsoleLines,
|
||||||
|
projectOverviewLines,
|
||||||
|
resolveConsoleViewScrollTop,
|
||||||
|
resolveConsoleWorkerView,
|
||||||
segmentsToText,
|
segmentsToText,
|
||||||
selectConsoleTimelineLines,
|
selectConsoleTimelineLines,
|
||||||
workerConsoleHref,
|
workerConsoleHref,
|
||||||
@@ -334,6 +340,137 @@ Deno.test("projectConsole groups tool call lifecycle into one Call block", () =>
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("projectConsole streams distinct Bash stdout and stderr through terminal status", () => {
|
||||||
|
const projection = projectConsole([
|
||||||
|
{
|
||||||
|
eventId: "command-tool",
|
||||||
|
event: {
|
||||||
|
event: "tool_call_done",
|
||||||
|
data: {
|
||||||
|
id: "bash-stream",
|
||||||
|
name: "Bash",
|
||||||
|
arguments: JSON.stringify({ command: "long-command" }),
|
||||||
|
},
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "command-started",
|
||||||
|
event: {
|
||||||
|
event: "command",
|
||||||
|
data: {
|
||||||
|
event: {
|
||||||
|
kind: "started",
|
||||||
|
command_id: "command-1",
|
||||||
|
tool_call_id: "bash-stream",
|
||||||
|
observed_at_ms: 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "command-stdout",
|
||||||
|
event: {
|
||||||
|
event: "command",
|
||||||
|
data: {
|
||||||
|
event: {
|
||||||
|
kind: "output",
|
||||||
|
command_id: "command-1",
|
||||||
|
stream: "stdout",
|
||||||
|
start_offset: 0,
|
||||||
|
end_offset: 6,
|
||||||
|
content: "ready\n",
|
||||||
|
observed_at_ms: 1100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "command-stderr",
|
||||||
|
event: {
|
||||||
|
event: "command",
|
||||||
|
data: {
|
||||||
|
event: {
|
||||||
|
kind: "output",
|
||||||
|
command_id: "command-1",
|
||||||
|
stream: "stderr",
|
||||||
|
start_offset: 0,
|
||||||
|
end_offset: 5,
|
||||||
|
content: "warn\n",
|
||||||
|
observed_at_ms: 1200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "command-terminal",
|
||||||
|
event: {
|
||||||
|
event: "command",
|
||||||
|
data: {
|
||||||
|
event: {
|
||||||
|
kind: "terminal",
|
||||||
|
command_id: "command-1",
|
||||||
|
status: "failed",
|
||||||
|
exit_code: 7,
|
||||||
|
stdout_end_offset: 6,
|
||||||
|
stderr_end_offset: 5,
|
||||||
|
observed_at_ms: 1300,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||||
|
assert(line.body.includes("Bash — failed (exit 7)"), line.body);
|
||||||
|
assert(line.body.includes("elapsed 300ms"), line.body);
|
||||||
|
assert(line.body.includes("stdout:\nready\n"), line.body);
|
||||||
|
assert(line.body.includes("stderr:\nwarn\n"), line.body);
|
||||||
|
assertEquals(line.streaming, false);
|
||||||
|
assertEquals(line.error, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("snapshot restores bounded in-flight Bash command output", () => {
|
||||||
|
const snapshot = snapshotEvent("/repo");
|
||||||
|
if (snapshot.event !== "snapshot") throw new Error("snapshot fixture expected");
|
||||||
|
snapshot.data.status = "running";
|
||||||
|
snapshot.data.in_flight = {
|
||||||
|
blocks: [{
|
||||||
|
kind: "tool_call",
|
||||||
|
id: "bash-snapshot",
|
||||||
|
name: "Bash",
|
||||||
|
args: JSON.stringify({ command: "slow" }),
|
||||||
|
state: "done",
|
||||||
|
}],
|
||||||
|
commands: [{
|
||||||
|
command_id: "command-2",
|
||||||
|
tool_call_id: "bash-snapshot",
|
||||||
|
status: "running",
|
||||||
|
started_at_ms: 1000,
|
||||||
|
observed_at_ms: 1250,
|
||||||
|
last_output_at_ms: 1200,
|
||||||
|
stdout: {
|
||||||
|
start_offset: 1024,
|
||||||
|
end_offset: 1031,
|
||||||
|
content: "tail\n",
|
||||||
|
truncated: true,
|
||||||
|
},
|
||||||
|
stderr: { start_offset: 0, end_offset: 0, content: "", truncated: false },
|
||||||
|
exit_code: null,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
const projection = projectConsole([{ eventId: "snapshot-command", event: snapshot }]);
|
||||||
|
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||||
|
assert(line.body.includes("Bash — running…"), line.body);
|
||||||
|
assert(
|
||||||
|
line.body.includes("elapsed 250ms · last output at +200ms"),
|
||||||
|
line.body,
|
||||||
|
);
|
||||||
|
assert(line.body.includes("[stdout tail; earlier output omitted]"), line.body);
|
||||||
|
assert(line.body.includes("stdout:\ntail\n"), line.body);
|
||||||
|
assertEquals(line.streaming, true);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("projectConsole caps default tool request and result previews", () => {
|
Deno.test("projectConsole caps default tool request and result previews", () => {
|
||||||
const projection = projectConsole([
|
const projection = projectConsole([
|
||||||
{
|
{
|
||||||
@@ -758,6 +895,9 @@ Deno.test("projectConsole aggregates Read calls without showing file content", (
|
|||||||
!toolLines[0].body.includes("another content"),
|
!toolLines[0].body.includes("another content"),
|
||||||
"Read aggregate should not display file contents",
|
"Read aggregate should not display file contents",
|
||||||
);
|
);
|
||||||
|
const overview = projectOverviewLines(projection.lines);
|
||||||
|
assertEquals(overview.length, 1);
|
||||||
|
assertEquals(overview[0].body, "2 files read");
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("projectConsole renders Edit calls with structured diff lines", () => {
|
Deno.test("projectConsole renders Edit calls with structured diff lines", () => {
|
||||||
@@ -856,11 +996,13 @@ Deno.test("projectConsole hides lifecycle events and renders system items", () =
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assertEquals(projection.lines.length, 1);
|
assertEquals(projection.lines.length, 2);
|
||||||
assertEquals(projection.lines[0].kind, "system");
|
assertEquals(projection.lines[0].kind, "run_stats");
|
||||||
assertEquals(projection.lines[0].title, "System · notification");
|
assertEquals(projection.lines[0].body, "0s ・0 reqs ↑0/↓0");
|
||||||
|
assertEquals(projection.lines[1].kind, "system");
|
||||||
|
assertEquals(projection.lines[1].title, "System · notification");
|
||||||
assertEquals(
|
assertEquals(
|
||||||
projection.lines[0].body,
|
projection.lines[1].body,
|
||||||
"Reread Ticket 00001KZ6TSGG5 before acting.",
|
"Reread Ticket 00001KZ6TSGG5 before acting.",
|
||||||
);
|
);
|
||||||
assertEquals(projection.status, "running");
|
assertEquals(projection.status, "running");
|
||||||
@@ -1287,7 +1429,10 @@ Deno.test("Internal Worker output stays separate and revision-fenced", () => {
|
|||||||
}]);
|
}]);
|
||||||
assertEquals(projection.lines, []);
|
assertEquals(projection.lines, []);
|
||||||
assertEquals(projection.internalWorkers.length, 1);
|
assertEquals(projection.internalWorkers.length, 1);
|
||||||
assertEquals(projection.internalWorkers[0].console.lines[0].body, "child output");
|
assertEquals(
|
||||||
|
projection.internalWorkers[0].console.lines[0].body,
|
||||||
|
"child output",
|
||||||
|
);
|
||||||
|
|
||||||
projection = projector.append([{
|
projection = projector.append([{
|
||||||
eventId: "2",
|
eventId: "2",
|
||||||
@@ -1301,6 +1446,96 @@ Deno.test("Internal Worker output stays separate and revision-fenced", () => {
|
|||||||
},
|
},
|
||||||
}]);
|
}]);
|
||||||
assertEquals(projection.internalWorkers[0].console.lines.length, 1);
|
assertEquals(projection.internalWorkers[0].console.lines.length, 1);
|
||||||
|
|
||||||
|
const views = consoleWorkerViews(projection);
|
||||||
|
assertEquals(views.map((view) => [view.sessionId, view.label]), [
|
||||||
|
[null, "main"],
|
||||||
|
["child-session", "research"],
|
||||||
|
]);
|
||||||
|
assertEquals(
|
||||||
|
resolveConsoleWorkerView(projection, "child-session").console.lines[0].body,
|
||||||
|
"child output",
|
||||||
|
);
|
||||||
|
assertEquals(resolveConsoleWorkerView(projection, "missing").sessionId, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("console Worker views expose only direct Internal Workers", () => {
|
||||||
|
const projector = createConsoleProjector();
|
||||||
|
projector.append([{
|
||||||
|
eventId: "nested",
|
||||||
|
event: {
|
||||||
|
event: "internal_worker",
|
||||||
|
data: {
|
||||||
|
worker: {
|
||||||
|
session_id: "child-session",
|
||||||
|
name: "research",
|
||||||
|
parent_session_id: "parent-session",
|
||||||
|
kind: "sub_worker",
|
||||||
|
},
|
||||||
|
revision: 1,
|
||||||
|
event: {
|
||||||
|
event: "internal_worker",
|
||||||
|
data: {
|
||||||
|
worker: {
|
||||||
|
session_id: "grandchild-session",
|
||||||
|
name: "nested",
|
||||||
|
parent_session_id: "child-session",
|
||||||
|
kind: "sub_worker",
|
||||||
|
},
|
||||||
|
revision: 1,
|
||||||
|
event: { event: "status", data: { status: "running" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
|
||||||
|
projector.append([{
|
||||||
|
eventId: "peer",
|
||||||
|
event: {
|
||||||
|
event: "internal_worker",
|
||||||
|
data: {
|
||||||
|
worker: {
|
||||||
|
session_id: "peer-other",
|
||||||
|
name: "research",
|
||||||
|
parent_session_id: "parent-session",
|
||||||
|
kind: "sub_worker",
|
||||||
|
},
|
||||||
|
revision: 1,
|
||||||
|
event: { event: "status", data: { status: "idle" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const projection = projector.snapshot();
|
||||||
|
const views = consoleWorkerViews(projection);
|
||||||
|
assertEquals(views.map((view) => view.sessionId), [
|
||||||
|
null,
|
||||||
|
"child-session",
|
||||||
|
"peer-other",
|
||||||
|
]);
|
||||||
|
assertEquals(views[1].label, "research · ession");
|
||||||
|
assertEquals(views[2].label, "research · -other");
|
||||||
|
assertEquals(
|
||||||
|
resolveConsoleWorkerView(projection, "grandchild-session").sessionId,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("console Worker view scroll restores manual offsets and auto-follow", () => {
|
||||||
|
assertEquals(resolveConsoleViewScrollTop(undefined, 1000, 200), 1000);
|
||||||
|
assertEquals(
|
||||||
|
resolveConsoleViewScrollTop({ top: 100, autoFollow: true }, 1000, 200),
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
resolveConsoleViewScrollTop({ top: 300, autoFollow: false }, 1000, 200),
|
||||||
|
300,
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
resolveConsoleViewScrollTop({ top: 900, autoFollow: false }, 1000, 200),
|
||||||
|
800,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("parent snapshot authoritatively replaces Internal Worker projections", () => {
|
Deno.test("parent snapshot authoritatively replaces Internal Worker projections", () => {
|
||||||
@@ -1314,9 +1549,36 @@ Deno.test("parent snapshot authoritatively replaces Internal Worker projections"
|
|||||||
kind: "sub_worker",
|
kind: "sub_worker",
|
||||||
},
|
},
|
||||||
revision: 4,
|
revision: 4,
|
||||||
entries: [],
|
entries: [{
|
||||||
|
kind: "assistant_item",
|
||||||
|
ts: 1,
|
||||||
|
item: {
|
||||||
|
kind: "tool_call",
|
||||||
|
call_id: "committed-call",
|
||||||
|
name: "Read",
|
||||||
|
arguments: JSON.stringify({ file_path: "/repo/a.md" }),
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
kind: "tool_result",
|
||||||
|
ts: 2,
|
||||||
|
item: {
|
||||||
|
kind: "tool_result",
|
||||||
|
call_id: "committed-call",
|
||||||
|
summary: "read file",
|
||||||
|
content: "content",
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
}],
|
||||||
status: "idle",
|
status: "idle",
|
||||||
in_flight: { blocks: [] },
|
in_flight: {
|
||||||
|
blocks: [{
|
||||||
|
kind: "tool_call",
|
||||||
|
id: "committed-call",
|
||||||
|
name: "Read",
|
||||||
|
args: JSON.stringify({ file_path: "/repo/a.md" }),
|
||||||
|
state: "done",
|
||||||
|
}],
|
||||||
|
},
|
||||||
internal_workers: [],
|
internal_workers: [],
|
||||||
}];
|
}];
|
||||||
const projector = createConsoleProjector();
|
const projector = createConsoleProjector();
|
||||||
@@ -1337,9 +1599,110 @@ Deno.test("parent snapshot authoritatively replaces Internal Worker projections"
|
|||||||
},
|
},
|
||||||
}]);
|
}]);
|
||||||
const projection = projector.append([{ eventId: "snapshot", event }]);
|
const projection = projector.append([{ eventId: "snapshot", event }]);
|
||||||
assertEquals(projection.internalWorkers.map((worker) => worker.worker.session_id), [
|
assertEquals(
|
||||||
"replacement",
|
projection.internalWorkers.map((worker) => worker.worker.session_id),
|
||||||
]);
|
[
|
||||||
|
"replacement",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const childLines = projection.internalWorkers[0].console.lines;
|
||||||
|
assertEquals(childLines.length, 1);
|
||||||
|
assertEquals(new Set(childLines.map((line) => line.id)).size, 1);
|
||||||
|
assertEquals(childLines[0].kind, "tool");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("terminal Internal Worker removal drops descendants and fences late events", () => {
|
||||||
|
const worker = {
|
||||||
|
session_id: "child-session",
|
||||||
|
name: "child",
|
||||||
|
parent_session_id: "parent-session",
|
||||||
|
kind: "sub_worker" as const,
|
||||||
|
};
|
||||||
|
const nestedWorker = {
|
||||||
|
session_id: "grandchild-session",
|
||||||
|
name: "grandchild",
|
||||||
|
parent_session_id: "child-session",
|
||||||
|
kind: "sub_worker" as const,
|
||||||
|
};
|
||||||
|
const projector = createConsoleProjector();
|
||||||
|
let projection = projector.append([{
|
||||||
|
eventId: "child",
|
||||||
|
event: {
|
||||||
|
event: "internal_worker",
|
||||||
|
data: {
|
||||||
|
worker,
|
||||||
|
revision: 2,
|
||||||
|
event: {
|
||||||
|
event: "internal_worker",
|
||||||
|
data: {
|
||||||
|
worker: nestedWorker,
|
||||||
|
revision: 1,
|
||||||
|
event: { event: "text_done", data: { text: "nested" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
assertEquals(projection.internalWorkers.length, 1);
|
||||||
|
assertEquals(
|
||||||
|
projection.internalWorkers[0].console.internalWorkers.length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
projection = projector.append([{
|
||||||
|
eventId: "removed",
|
||||||
|
event: {
|
||||||
|
event: "internal_worker_removed",
|
||||||
|
data: { worker, revision: 3 },
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
eventId: "late",
|
||||||
|
event: {
|
||||||
|
event: "internal_worker",
|
||||||
|
data: {
|
||||||
|
worker,
|
||||||
|
revision: 4,
|
||||||
|
event: { event: "text_done", data: { text: "must stay removed" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
assertEquals(projection.internalWorkers, []);
|
||||||
|
|
||||||
|
const snapshot = snapshotEvent("/repo");
|
||||||
|
projection = projector.append([{ eventId: "snapshot", event: snapshot }]);
|
||||||
|
assertEquals(projection.internalWorkers, []);
|
||||||
|
assertEquals(projection.removedInternalWorkers, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("stale Internal Worker removal cannot discard a newer projection", () => {
|
||||||
|
const worker = {
|
||||||
|
session_id: "child-session",
|
||||||
|
name: "child",
|
||||||
|
parent_session_id: "parent-session",
|
||||||
|
kind: "sub_worker" as const,
|
||||||
|
};
|
||||||
|
const projector = createConsoleProjector();
|
||||||
|
projector.append([{
|
||||||
|
eventId: "current",
|
||||||
|
event: {
|
||||||
|
event: "internal_worker",
|
||||||
|
data: {
|
||||||
|
worker,
|
||||||
|
revision: 4,
|
||||||
|
event: { event: "text_done", data: { text: "current" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const projection = projector.append([{
|
||||||
|
eventId: "stale-removal",
|
||||||
|
event: {
|
||||||
|
event: "internal_worker_removed",
|
||||||
|
data: { worker, revision: 3 },
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
assertEquals(projection.internalWorkers.length, 1);
|
||||||
|
assertEquals(projection.internalWorkers[0].revision, 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("snapshot restores TaskStore state from system history", () => {
|
Deno.test("snapshot restores TaskStore state from system history", () => {
|
||||||
@@ -1369,3 +1732,182 @@ Deno.test("snapshot restores TaskStore state from system history", () => {
|
|||||||
}]);
|
}]);
|
||||||
assertEquals(projection.taskNextId, 4);
|
assertEquals(projection.taskNextId, 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("overview hides typed task reminders after restoring TaskStore state", () => {
|
||||||
|
const body =
|
||||||
|
`[Session TaskStore snapshot]\n\n\`\`\`json\n{\n "tasks": [{"taskid": 8, "status": "inprogress", "subject": "Visible in Tasks", "description": "Hidden in overview"}]\n}\n\`\`\``;
|
||||||
|
const projection = projectConsole([{
|
||||||
|
eventId: "task-reminder",
|
||||||
|
event: {
|
||||||
|
event: "system_item",
|
||||||
|
data: {
|
||||||
|
item: { kind: "task_reminder", body },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
|
||||||
|
assertEquals(projection.tasks[0]?.taskid, 8);
|
||||||
|
assertEquals(projection.lines[0]?.systemItemKind, "task_reminder");
|
||||||
|
assertEquals(projectConsoleLines(projection.lines, "overview"), []);
|
||||||
|
const normal = projectConsoleLines(projection.lines, "normal");
|
||||||
|
assertEquals(normal.length, 1);
|
||||||
|
assertEquals(normal[0].kind, "task_reminder");
|
||||||
|
assertEquals(
|
||||||
|
normal[0].body,
|
||||||
|
"task reminder: [Session TaskStore snapshot]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("overview hides thinking and aggregates uninterrupted tool activity", () => {
|
||||||
|
const toolLine = (
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
diff?: ConsoleLine["diff"],
|
||||||
|
): ConsoleLine => ({
|
||||||
|
id,
|
||||||
|
kind: "tool",
|
||||||
|
title: `Call · ${name}`,
|
||||||
|
body: name,
|
||||||
|
source: "event",
|
||||||
|
diff,
|
||||||
|
toolCall: {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
argsStream: "",
|
||||||
|
state: "done",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const overview = projectOverviewLines([
|
||||||
|
consoleLine("user", "user"),
|
||||||
|
consoleLine("assistant-before", "assistant"),
|
||||||
|
consoleLine("thought-before-tools", "thinking"),
|
||||||
|
toolLine("read-a", "Read"),
|
||||||
|
consoleLine("thought-between-tools", "thinking"),
|
||||||
|
toolLine("read-b", "Read"),
|
||||||
|
toolLine("bash-a", "Bash"),
|
||||||
|
consoleLine("assistant-after-tools", "assistant"),
|
||||||
|
toolLine("edit-a", "Edit", [
|
||||||
|
{ kind: "remove", oldNumber: 1, content: "old" },
|
||||||
|
{ kind: "add", newNumber: 1, content: "new" },
|
||||||
|
{ kind: "add", newNumber: 2, content: "next" },
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assertEquals(overview.map((line) => line.kind), [
|
||||||
|
"user",
|
||||||
|
"assistant",
|
||||||
|
"activity",
|
||||||
|
"assistant",
|
||||||
|
"activity",
|
||||||
|
]);
|
||||||
|
assertEquals(overview[2].body, "2 files read・ran 1 command");
|
||||||
|
assertEquals(overview[4].body, "edited +2/-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("overview hides in-flight thinking and keeps tool failures visible", () => {
|
||||||
|
const overview = projectOverviewLines([
|
||||||
|
{
|
||||||
|
...consoleLine("thinking-in-flight", "in_flight"),
|
||||||
|
title: "in-flight thinking",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...consoleLine("failed-read", "tool"),
|
||||||
|
error: true,
|
||||||
|
toolCall: {
|
||||||
|
id: "failed-read",
|
||||||
|
name: "Read",
|
||||||
|
argsStream: "",
|
||||||
|
state: "error",
|
||||||
|
isError: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assertEquals(overview.length, 1);
|
||||||
|
assertEquals(overview[0].kind, "activity");
|
||||||
|
assertEquals(overview[0].body, "1 file read\n1 failed");
|
||||||
|
assertEquals(overview[0].error, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("RunEnd appends TUI-compatible request and token stats", () => {
|
||||||
|
const events: ConsoleEventInput[] = [
|
||||||
|
{
|
||||||
|
eventId: "invoke",
|
||||||
|
observedAtMs: 1_000,
|
||||||
|
event: { event: "invoke_start", data: { kind: "user_send" } },
|
||||||
|
},
|
||||||
|
...Array.from({ length: 5 }, (_, index) => ({
|
||||||
|
eventId: `turn-${index}`,
|
||||||
|
observedAtMs: 1_010 + index,
|
||||||
|
event: { event: "turn_start", data: { turn: index + 1 } } as Event,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
eventId: "usage",
|
||||||
|
observedAtMs: 1_020,
|
||||||
|
event: {
|
||||||
|
event: "usage",
|
||||||
|
data: {
|
||||||
|
input_tokens: 60_000,
|
||||||
|
cache_read_input_tokens: 3_500,
|
||||||
|
output_tokens: 1_200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "run-end",
|
||||||
|
observedAtMs: 621_000,
|
||||||
|
event: { event: "run_end", data: { result: "finished" } },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const projection = projectConsole(events);
|
||||||
|
const stats = projection.lines.filter((line) => line.kind === "run_stats");
|
||||||
|
assertEquals(stats.length, 1);
|
||||||
|
assertEquals(stats[0].body, "10m20s ・5 reqs ↑56.5k/↓1.2k");
|
||||||
|
assertEquals(
|
||||||
|
projectConsoleLines(projection.lines, "overview").at(-1)?.kind,
|
||||||
|
"run_stats",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
projectConsoleLines(projection.lines, "normal").at(-1)?.kind,
|
||||||
|
"run_stats",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("new invoke resets stats before the next RunEnd", () => {
|
||||||
|
const projector = createConsoleProjector();
|
||||||
|
projector.append([
|
||||||
|
{
|
||||||
|
eventId: "first-invoke",
|
||||||
|
event: { event: "invoke_start", data: { kind: "user_send" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "first-turn",
|
||||||
|
event: { event: "turn_start", data: { turn: 1 } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "first-usage",
|
||||||
|
event: {
|
||||||
|
event: "usage",
|
||||||
|
data: { input_tokens: 1_000, output_tokens: 100 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "first-end",
|
||||||
|
event: { event: "run_end", data: { result: "finished" } },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const projection = projector.append([
|
||||||
|
{
|
||||||
|
eventId: "second-invoke",
|
||||||
|
event: { event: "invoke_start", data: { kind: "notify" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "second-end",
|
||||||
|
event: { event: "run_end", data: { result: "finished" } },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assertEquals(projection.lines.at(-1)?.body, "0s ・0 reqs ↑0/↓0");
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type {
|
import type {
|
||||||
Alert,
|
Alert,
|
||||||
|
CommandEvent,
|
||||||
|
CommandSnapshot,
|
||||||
|
CommandStreamSlice,
|
||||||
Event as ProtocolEvent,
|
Event as ProtocolEvent,
|
||||||
InFlightBlock,
|
InFlightBlock,
|
||||||
InFlightToolCallState,
|
InFlightToolCallState,
|
||||||
@@ -8,6 +11,13 @@ import type {
|
|||||||
Segment,
|
Segment,
|
||||||
} from "$lib/generated/protocol";
|
} from "$lib/generated/protocol";
|
||||||
import { workspaceRoute } from "$lib/workspace/api/http";
|
import { workspaceRoute } from "$lib/workspace/api/http";
|
||||||
|
import {
|
||||||
|
applyRunActivityEvent,
|
||||||
|
emptyRunActivityStats,
|
||||||
|
formatRunElapsedCompact,
|
||||||
|
formatRunTokens,
|
||||||
|
type RunActivityStats,
|
||||||
|
} from "./run-status.ts";
|
||||||
import {
|
import {
|
||||||
applyTaskSnapshotText,
|
applyTaskSnapshotText,
|
||||||
applyTaskToolCall,
|
applyTaskToolCall,
|
||||||
@@ -19,6 +29,9 @@ export type ConsoleLineKind =
|
|||||||
| "assistant"
|
| "assistant"
|
||||||
| "thinking"
|
| "thinking"
|
||||||
| "tool"
|
| "tool"
|
||||||
|
| "activity"
|
||||||
|
| "task_reminder"
|
||||||
|
| "run_stats"
|
||||||
| "status"
|
| "status"
|
||||||
| "error"
|
| "error"
|
||||||
| "usage"
|
| "usage"
|
||||||
@@ -42,6 +55,7 @@ type ToolCallView = {
|
|||||||
output?: string | null;
|
output?: string | null;
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
cwd?: string | null;
|
cwd?: string | null;
|
||||||
|
command?: CommandSnapshot;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConsoleDiffLine = {
|
export type ConsoleDiffLine = {
|
||||||
@@ -51,6 +65,8 @@ export type ConsoleDiffLine = {
|
|||||||
content: string;
|
content: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ConsoleViewMode = "overview" | "normal";
|
||||||
|
|
||||||
export type ConsoleLine = {
|
export type ConsoleLine = {
|
||||||
id: string;
|
id: string;
|
||||||
kind: ConsoleLineKind;
|
kind: ConsoleLineKind;
|
||||||
@@ -63,6 +79,10 @@ export type ConsoleLine = {
|
|||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
error?: boolean;
|
error?: boolean;
|
||||||
toolCall?: ToolCallView;
|
toolCall?: ToolCallView;
|
||||||
|
/** Number of calls represented by a lower-level aggregate line. */
|
||||||
|
toolCallCount?: number;
|
||||||
|
/** Typed `SystemItem.kind` used by presentation-only projections. */
|
||||||
|
systemItemKind?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type InternalWorkerProjection = {
|
export type InternalWorkerProjection = {
|
||||||
@@ -71,18 +91,57 @@ export type InternalWorkerProjection = {
|
|||||||
console: ConsoleProjection;
|
console: ConsoleProjection;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FlattenedInternalWorkerProjection = InternalWorkerProjection & {
|
export type ConsoleViewScroll = {
|
||||||
depth: number;
|
top: number;
|
||||||
|
autoFollow: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function flattenInternalWorkers(
|
export function resolveConsoleViewScrollTop(
|
||||||
workers: InternalWorkerProjection[],
|
state: ConsoleViewScroll | undefined,
|
||||||
depth = 0,
|
scrollHeight: number,
|
||||||
): FlattenedInternalWorkerProjection[] {
|
clientHeight: number,
|
||||||
return workers.flatMap((worker) => [
|
): number {
|
||||||
{ ...worker, depth },
|
if (!state || state.autoFollow) return scrollHeight;
|
||||||
...flattenInternalWorkers(worker.console.internalWorkers, depth + 1),
|
return Math.min(state.top, Math.max(0, scrollHeight - clientHeight));
|
||||||
]);
|
}
|
||||||
|
|
||||||
|
export type ConsoleWorkerView = {
|
||||||
|
sessionId: string | null;
|
||||||
|
label: string;
|
||||||
|
console: ConsoleProjection;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function consoleWorkerViews(
|
||||||
|
projection: ConsoleProjection,
|
||||||
|
): ConsoleWorkerView[] {
|
||||||
|
const labels = projection.internalWorkers.map((worker) =>
|
||||||
|
worker.worker.name || "subworker"
|
||||||
|
);
|
||||||
|
const labelCounts = new Map<string, number>();
|
||||||
|
for (const label of labels) {
|
||||||
|
labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ sessionId: null, label: "main", console: projection },
|
||||||
|
...projection.internalWorkers.map((worker, index) => {
|
||||||
|
const label = labels[index] ?? "subworker";
|
||||||
|
return {
|
||||||
|
sessionId: worker.worker.session_id,
|
||||||
|
label: labelCounts.get(label) === 1
|
||||||
|
? label
|
||||||
|
: `${label} · ${worker.worker.session_id.slice(-6)}`,
|
||||||
|
console: worker.console,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveConsoleWorkerView(
|
||||||
|
projection: ConsoleProjection,
|
||||||
|
selectedSessionId: string | null,
|
||||||
|
): ConsoleWorkerView {
|
||||||
|
const views = consoleWorkerViews(projection);
|
||||||
|
return views.find((view) => view.sessionId === selectedSessionId) ?? views[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConsoleProjection = {
|
export type ConsoleProjection = {
|
||||||
@@ -91,9 +150,12 @@ export type ConsoleProjection = {
|
|||||||
taskNextId: number;
|
taskNextId: number;
|
||||||
status: string | null;
|
status: string | null;
|
||||||
usage: string | null;
|
usage: string | null;
|
||||||
|
runActivity: RunActivityStats;
|
||||||
cwd: string | null;
|
cwd: string | null;
|
||||||
lastEventId: string | null;
|
lastEventId: string | null;
|
||||||
internalWorkers: InternalWorkerProjection[];
|
internalWorkers: InternalWorkerProjection[];
|
||||||
|
/** Terminal child-session fences, reset only by an authoritative snapshot. */
|
||||||
|
removedInternalWorkers: Record<string, number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConsoleTimelineLineSelection = {
|
export type ConsoleTimelineLineSelection = {
|
||||||
@@ -176,9 +238,11 @@ export function emptyConsoleProjection(): ConsoleProjection {
|
|||||||
taskNextId: 1,
|
taskNextId: 1,
|
||||||
status: null,
|
status: null,
|
||||||
usage: null,
|
usage: null,
|
||||||
|
runActivity: emptyRunActivityStats(),
|
||||||
cwd: null,
|
cwd: null,
|
||||||
lastEventId: null,
|
lastEventId: null,
|
||||||
internalWorkers: [],
|
internalWorkers: [],
|
||||||
|
removedInternalWorkers: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,6 +288,343 @@ function projectVisibleConsole(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isOverviewThinkingLine(line: ConsoleLine): boolean {
|
||||||
|
return line.kind === "thinking" ||
|
||||||
|
(line.kind === "in_flight" && line.title === "in-flight thinking");
|
||||||
|
}
|
||||||
|
|
||||||
|
function representedToolCallCount(line: ConsoleLine): number {
|
||||||
|
return Math.max(1, line.toolCallCount ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function overviewToolActivityLine(group: ConsoleLine[]): ConsoleLine {
|
||||||
|
const first = group[0]!;
|
||||||
|
const last = group[group.length - 1]!;
|
||||||
|
let readCount = 0;
|
||||||
|
let searchCount = 0;
|
||||||
|
let commandCount = 0;
|
||||||
|
let editCount = 0;
|
||||||
|
let writeCount = 0;
|
||||||
|
let additions = 0;
|
||||||
|
let deletions = 0;
|
||||||
|
let failedCount = 0;
|
||||||
|
let activeCount = 0;
|
||||||
|
let readActive = false;
|
||||||
|
let searchActive = false;
|
||||||
|
let commandActive = false;
|
||||||
|
let editActive = false;
|
||||||
|
let writeActive = false;
|
||||||
|
const otherCounts = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const line of group) {
|
||||||
|
const count = representedToolCallCount(line);
|
||||||
|
const name = line.toolCall?.name ?? "Tool";
|
||||||
|
const state = line.toolCall?.state;
|
||||||
|
if (state === "error" || line.error || line.toolCall?.isError) {
|
||||||
|
failedCount += count;
|
||||||
|
}
|
||||||
|
const callActive = state === "pending" || state === "streaming_args" ||
|
||||||
|
state === "running";
|
||||||
|
if (callActive) activeCount += count;
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
case "Read":
|
||||||
|
readCount += count;
|
||||||
|
readActive ||= callActive;
|
||||||
|
break;
|
||||||
|
case "Glob":
|
||||||
|
case "Grep":
|
||||||
|
case "WebSearch":
|
||||||
|
case "SearchSessionEntries":
|
||||||
|
searchCount += count;
|
||||||
|
searchActive ||= callActive;
|
||||||
|
break;
|
||||||
|
case "Bash":
|
||||||
|
commandCount += count;
|
||||||
|
commandActive ||= callActive;
|
||||||
|
break;
|
||||||
|
case "Edit":
|
||||||
|
editCount += count;
|
||||||
|
editActive ||= callActive;
|
||||||
|
if (state === "done") {
|
||||||
|
additions += line.diff?.filter((diff) =>
|
||||||
|
diff.kind === "add"
|
||||||
|
).length ?? 0;
|
||||||
|
deletions += line.diff?.filter((diff) =>
|
||||||
|
diff.kind === "remove"
|
||||||
|
).length ?? 0;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "Write":
|
||||||
|
writeCount += count;
|
||||||
|
writeActive ||= callActive;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
otherCounts.set(name, (otherCounts.get(name) ?? 0) + count);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = activeCount > 0;
|
||||||
|
const primary: string[] = [];
|
||||||
|
if (readCount > 0) {
|
||||||
|
primary.push(
|
||||||
|
readActive
|
||||||
|
? `reading ${readCount} file${readCount === 1 ? "" : "s"}`
|
||||||
|
: `${readCount} file${readCount === 1 ? "" : "s"} read`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (searchCount > 0) {
|
||||||
|
primary.push(
|
||||||
|
searchActive
|
||||||
|
? `searching ${searchCount} time${searchCount === 1 ? "" : "s"}`
|
||||||
|
: `searched ${searchCount} time${searchCount === 1 ? "" : "s"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (commandCount > 0) {
|
||||||
|
primary.push(
|
||||||
|
commandActive
|
||||||
|
? `running ${commandCount} command${commandCount === 1 ? "" : "s"}`
|
||||||
|
: `ran ${commandCount} command${commandCount === 1 ? "" : "s"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (
|
||||||
|
const [name, count] of [...otherCounts].sort(([left], [right]) =>
|
||||||
|
left.localeCompare(right)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
primary.push(count === 1 ? name : `${count} ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const changes: string[] = [];
|
||||||
|
if (editCount > 0) {
|
||||||
|
if (editActive) {
|
||||||
|
changes.push(`editing ${editCount} file${editCount === 1 ? "" : "s"}`);
|
||||||
|
} else if (additions > 0 || deletions > 0) {
|
||||||
|
changes.push(`edited +${additions}/-${deletions}`);
|
||||||
|
} else {
|
||||||
|
changes.push(`edited ${editCount} file${editCount === 1 ? "" : "s"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (writeCount > 0) {
|
||||||
|
changes.push(
|
||||||
|
writeActive
|
||||||
|
? `writing ${writeCount} file${writeCount === 1 ? "" : "s"}`
|
||||||
|
: `wrote ${writeCount} file${writeCount === 1 ? "" : "s"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (failedCount > 0) changes.push(`${failedCount} failed`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `activity-${first.id}-${last.id}`,
|
||||||
|
kind: "activity",
|
||||||
|
title: "Activity",
|
||||||
|
body: [primary.join("・"), ...changes].filter(Boolean).join("\n"),
|
||||||
|
source: "event",
|
||||||
|
streaming: active,
|
||||||
|
error: failedCount > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the overview-only Console presentation. Protocol projection retains
|
||||||
|
* full tool and thinking state for reconciliation, but the visible history
|
||||||
|
* hides thinking and folds each uninterrupted tool run into one activity.
|
||||||
|
*/
|
||||||
|
export function projectOverviewLines(lines: ConsoleLine[]): ConsoleLine[] {
|
||||||
|
const overview: ConsoleLine[] = [];
|
||||||
|
let toolGroup: ConsoleLine[] = [];
|
||||||
|
|
||||||
|
const flushTools = () => {
|
||||||
|
if (toolGroup.length === 0) return;
|
||||||
|
overview.push(overviewToolActivityLine(toolGroup));
|
||||||
|
toolGroup = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.systemItemKind === "task_reminder") continue;
|
||||||
|
if (isOverviewThinkingLine(line)) continue;
|
||||||
|
if (line.kind === "tool" && line.toolCall) {
|
||||||
|
toolGroup.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
flushTools();
|
||||||
|
overview.push(line);
|
||||||
|
}
|
||||||
|
flushTools();
|
||||||
|
return overview;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectNormalLines(lines: ConsoleLine[]): ConsoleLine[] {
|
||||||
|
return lines.map((line) => {
|
||||||
|
if (line.systemItemKind !== "task_reminder") return line;
|
||||||
|
const first = line.body
|
||||||
|
.split("\n")
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.find(Boolean);
|
||||||
|
return {
|
||||||
|
...line,
|
||||||
|
kind: "task_reminder",
|
||||||
|
title: "Task reminder",
|
||||||
|
body: first ? `task reminder: ${first}` : "task reminder",
|
||||||
|
detail: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectConsoleLines(
|
||||||
|
lines: ConsoleLine[],
|
||||||
|
mode: ConsoleViewMode,
|
||||||
|
): ConsoleLine[] {
|
||||||
|
return mode === "overview"
|
||||||
|
? projectOverviewLines(lines)
|
||||||
|
: projectNormalLines(lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendSnapshotInFlightLines(
|
||||||
|
projection: ConsoleProjection,
|
||||||
|
blocks: InFlightBlock[],
|
||||||
|
eventId: string,
|
||||||
|
cwd: string | null,
|
||||||
|
): void {
|
||||||
|
const lineIds = new Set(projection.lines.map((line) => line.id));
|
||||||
|
blocks.forEach((block, index) => {
|
||||||
|
const pending = inFlightLine(`${eventId}:${index}`, block, cwd);
|
||||||
|
if (lineIds.has(pending.id)) return;
|
||||||
|
projection.lines.push(pending);
|
||||||
|
lineIds.add(pending.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMAND_STREAM_DISPLAY_BYTES = 32 * 1024;
|
||||||
|
|
||||||
|
function appendSnapshotCommands(
|
||||||
|
projection: ConsoleProjection,
|
||||||
|
commands: CommandSnapshot[],
|
||||||
|
eventId: string,
|
||||||
|
): void {
|
||||||
|
commands.forEach((command) => upsertCommandSnapshot(projection, eventId, command));
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertCommandSnapshot(
|
||||||
|
projection: ConsoleProjection,
|
||||||
|
eventId: string,
|
||||||
|
command: CommandSnapshot,
|
||||||
|
): void {
|
||||||
|
const toolCallId = command.tool_call_id ?? `command:${command.command_id}`;
|
||||||
|
const existingIndex = findToolCallLineIndex(projection, toolCallId);
|
||||||
|
const existing = existingIndex >= 0
|
||||||
|
? projection.lines[existingIndex].toolCall
|
||||||
|
: undefined;
|
||||||
|
upsertToolCall(projection, eventId, toolCallId, {
|
||||||
|
name: existing?.name ?? "Bash",
|
||||||
|
state: existing?.state ?? "running",
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCommandEvent(
|
||||||
|
projection: ConsoleProjection,
|
||||||
|
eventId: string,
|
||||||
|
event: CommandEvent,
|
||||||
|
): void {
|
||||||
|
if (event.kind === "started") {
|
||||||
|
upsertCommandSnapshot(projection, eventId, {
|
||||||
|
command_id: event.command_id,
|
||||||
|
tool_call_id: event.tool_call_id,
|
||||||
|
status: "running",
|
||||||
|
started_at_ms: event.observed_at_ms,
|
||||||
|
observed_at_ms: event.observed_at_ms,
|
||||||
|
last_output_at_ms: null,
|
||||||
|
stdout: emptyCommandStream(),
|
||||||
|
stderr: emptyCommandStream(),
|
||||||
|
exit_code: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = projection.lines.findIndex((line) =>
|
||||||
|
line.toolCall?.command?.command_id === event.command_id
|
||||||
|
);
|
||||||
|
if (index < 0) {
|
||||||
|
if (event.kind === "output") {
|
||||||
|
const stream = commandStreamFromEvent(event);
|
||||||
|
upsertCommandSnapshot(projection, eventId, {
|
||||||
|
command_id: event.command_id,
|
||||||
|
tool_call_id: null,
|
||||||
|
status: "running",
|
||||||
|
started_at_ms: event.observed_at_ms,
|
||||||
|
observed_at_ms: event.observed_at_ms,
|
||||||
|
last_output_at_ms: event.observed_at_ms,
|
||||||
|
stdout: event.stream === "stdout" ? stream : emptyCommandStream(),
|
||||||
|
stderr: event.stream === "stderr" ? stream : emptyCommandStream(),
|
||||||
|
exit_code: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = projection.lines[index].toolCall!.command!;
|
||||||
|
if (event.kind === "terminal") {
|
||||||
|
upsertCommandSnapshot(projection, eventId, {
|
||||||
|
...existing,
|
||||||
|
status: event.status,
|
||||||
|
exit_code: event.exit_code,
|
||||||
|
observed_at_ms: event.observed_at_ms,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updatedStream = appendCommandStream(
|
||||||
|
event.stream === "stdout" ? existing.stdout : existing.stderr,
|
||||||
|
event.start_offset,
|
||||||
|
event.end_offset,
|
||||||
|
event.content,
|
||||||
|
);
|
||||||
|
upsertCommandSnapshot(projection, eventId, {
|
||||||
|
...existing,
|
||||||
|
observed_at_ms: event.observed_at_ms,
|
||||||
|
last_output_at_ms: event.observed_at_ms,
|
||||||
|
stdout: event.stream === "stdout" ? updatedStream : existing.stdout,
|
||||||
|
stderr: event.stream === "stderr" ? updatedStream : existing.stderr,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyCommandStream(): CommandStreamSlice {
|
||||||
|
return { start_offset: 0, end_offset: 0, content: "", truncated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandStreamFromEvent(
|
||||||
|
event: Extract<CommandEvent, { kind: "output" }>,
|
||||||
|
): CommandStreamSlice {
|
||||||
|
return appendCommandStream(
|
||||||
|
emptyCommandStream(),
|
||||||
|
event.start_offset,
|
||||||
|
event.end_offset,
|
||||||
|
event.content,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendCommandStream(
|
||||||
|
existing: CommandStreamSlice,
|
||||||
|
startOffset: number,
|
||||||
|
endOffset: number,
|
||||||
|
content: string,
|
||||||
|
): CommandStreamSlice {
|
||||||
|
if (endOffset <= existing.end_offset) return existing;
|
||||||
|
const contiguous = startOffset === existing.end_offset;
|
||||||
|
const combined = contiguous ? `${existing.content}${content}` : content;
|
||||||
|
const tail = combined.length > COMMAND_STREAM_DISPLAY_BYTES
|
||||||
|
? combined.slice(-COMMAND_STREAM_DISPLAY_BYTES)
|
||||||
|
: combined;
|
||||||
|
return {
|
||||||
|
start_offset: endOffset - tail.length,
|
||||||
|
end_offset: endOffset,
|
||||||
|
content: tail,
|
||||||
|
truncated: existing.truncated || !contiguous || tail.length < combined.length ||
|
||||||
|
startOffset > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function projectInternalWorkerSnapshot(
|
function projectInternalWorkerSnapshot(
|
||||||
snapshot: InternalWorkerSnapshot,
|
snapshot: InternalWorkerSnapshot,
|
||||||
eventId: string,
|
eventId: string,
|
||||||
@@ -235,15 +636,17 @@ function projectInternalWorkerSnapshot(
|
|||||||
cwd,
|
cwd,
|
||||||
);
|
);
|
||||||
console.status = snapshot.status;
|
console.status = snapshot.status;
|
||||||
for (const block of snapshot.in_flight?.blocks ?? []) {
|
appendSnapshotInFlightLines(
|
||||||
console.lines.push(
|
console,
|
||||||
inFlightLine(
|
snapshot.in_flight?.blocks ?? [],
|
||||||
`${eventId}:internal:${snapshot.worker.session_id}:in-flight`,
|
`${eventId}:internal:${snapshot.worker.session_id}:in-flight`,
|
||||||
block,
|
cwd,
|
||||||
cwd,
|
);
|
||||||
),
|
appendSnapshotCommands(
|
||||||
);
|
console,
|
||||||
}
|
snapshot.in_flight?.commands ?? [],
|
||||||
|
`${eventId}:internal:${snapshot.worker.session_id}:command`,
|
||||||
|
);
|
||||||
if (snapshot.error) {
|
if (snapshot.error) {
|
||||||
console.lines.push({
|
console.lines.push({
|
||||||
id: `${eventId}:internal:${snapshot.worker.session_id}:error`,
|
id: `${eventId}:internal:${snapshot.worker.session_id}:error`,
|
||||||
@@ -263,19 +666,25 @@ function projectInternalWorkerSnapshot(
|
|||||||
|
|
||||||
export function applyProtocolEvent(
|
export function applyProtocolEvent(
|
||||||
projection: ConsoleProjection,
|
projection: ConsoleProjection,
|
||||||
envelope: { eventId: string; event: ProtocolEvent },
|
envelope: ConsoleEventInput,
|
||||||
): ConsoleProjection {
|
): ConsoleProjection {
|
||||||
|
const event = envelope.event;
|
||||||
const next: ConsoleProjection = {
|
const next: ConsoleProjection = {
|
||||||
lines: [...projection.lines],
|
lines: [...projection.lines],
|
||||||
tasks: [...projection.tasks],
|
tasks: [...projection.tasks],
|
||||||
taskNextId: projection.taskNextId,
|
taskNextId: projection.taskNextId,
|
||||||
status: projection.status,
|
status: projection.status,
|
||||||
usage: projection.usage,
|
usage: projection.usage,
|
||||||
|
runActivity: applyRunActivityEvent(
|
||||||
|
projection.runActivity,
|
||||||
|
event,
|
||||||
|
envelope.observedAtMs ?? 0,
|
||||||
|
),
|
||||||
cwd: projection.cwd,
|
cwd: projection.cwd,
|
||||||
lastEventId: envelope.eventId,
|
lastEventId: envelope.eventId,
|
||||||
internalWorkers: [...projection.internalWorkers],
|
internalWorkers: [...projection.internalWorkers],
|
||||||
|
removedInternalWorkers: { ...projection.removedInternalWorkers },
|
||||||
};
|
};
|
||||||
const event = envelope.event;
|
|
||||||
|
|
||||||
switch (event.event) {
|
switch (event.event) {
|
||||||
case "user_message":
|
case "user_message":
|
||||||
@@ -385,15 +794,30 @@ export function applyProtocolEvent(
|
|||||||
next.lines = snapshot.lines;
|
next.lines = snapshot.lines;
|
||||||
next.tasks = snapshot.tasks;
|
next.tasks = snapshot.tasks;
|
||||||
next.taskNextId = snapshot.taskNextId;
|
next.taskNextId = snapshot.taskNextId;
|
||||||
for (const block of event.data.in_flight?.blocks ?? []) {
|
appendSnapshotInFlightLines(
|
||||||
next.lines.push(inFlightLine(envelope.eventId, block, next.cwd));
|
next,
|
||||||
}
|
event.data.in_flight?.blocks ?? [],
|
||||||
|
`${envelope.eventId}:snapshot-in-flight`,
|
||||||
|
next.cwd,
|
||||||
|
);
|
||||||
|
appendSnapshotCommands(
|
||||||
|
next,
|
||||||
|
event.data.in_flight?.commands ?? [],
|
||||||
|
`${envelope.eventId}:snapshot-command`,
|
||||||
|
);
|
||||||
next.internalWorkers = (event.data.internal_workers ?? []).map((worker) =>
|
next.internalWorkers = (event.data.internal_workers ?? []).map((worker) =>
|
||||||
projectInternalWorkerSnapshot(worker, envelope.eventId, next.cwd)
|
projectInternalWorkerSnapshot(worker, envelope.eventId, next.cwd)
|
||||||
);
|
);
|
||||||
|
next.removedInternalWorkers = {};
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "internal_worker": {
|
case "internal_worker": {
|
||||||
|
if (
|
||||||
|
Object.hasOwn(
|
||||||
|
next.removedInternalWorkers,
|
||||||
|
event.data.worker.session_id,
|
||||||
|
)
|
||||||
|
) break;
|
||||||
const existingIndex = next.internalWorkers.findIndex((worker) =>
|
const existingIndex = next.internalWorkers.findIndex((worker) =>
|
||||||
worker.worker.session_id === event.data.worker.session_id
|
worker.worker.session_id === event.data.worker.session_id
|
||||||
);
|
);
|
||||||
@@ -412,15 +836,32 @@ export function applyProtocolEvent(
|
|||||||
eventId:
|
eventId:
|
||||||
`${envelope.eventId}:internal:${event.data.worker.session_id}:${event.data.revision}`,
|
`${envelope.eventId}:internal:${event.data.worker.session_id}:${event.data.revision}`,
|
||||||
event: event.data.event,
|
event: event.data.event,
|
||||||
|
observedAtMs: envelope.observedAtMs,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
if (existingIndex >= 0) next.internalWorkers[existingIndex] = updated;
|
if (existingIndex >= 0) next.internalWorkers[existingIndex] = updated;
|
||||||
else next.internalWorkers.push(updated);
|
else next.internalWorkers.push(updated);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "internal_worker_removed": {
|
||||||
|
const existingIndex = next.internalWorkers.findIndex((worker) =>
|
||||||
|
worker.worker.session_id === event.data.worker.session_id
|
||||||
|
);
|
||||||
|
const existingRevision = existingIndex >= 0
|
||||||
|
? next.internalWorkers[existingIndex].revision
|
||||||
|
: 0;
|
||||||
|
if (event.data.revision <= existingRevision) break;
|
||||||
|
next.removedInternalWorkers[event.data.worker.session_id] =
|
||||||
|
event.data.revision;
|
||||||
|
if (existingIndex >= 0) next.internalWorkers.splice(existingIndex, 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "status":
|
case "status":
|
||||||
next.status = event.data.status;
|
next.status = event.data.status;
|
||||||
break;
|
break;
|
||||||
|
case "command":
|
||||||
|
applyCommandEvent(next, envelope.eventId, event.data.event);
|
||||||
|
break;
|
||||||
case "segment_rotated": {
|
case "segment_rotated": {
|
||||||
const retainedErrors = next.lines.filter((line) => line.kind === "error");
|
const retainedErrors = next.lines.filter((line) => line.kind === "error");
|
||||||
const segment = snapshotProjectionFromEntries(
|
const segment = snapshotProjectionFromEntries(
|
||||||
@@ -440,7 +881,15 @@ export function applyProtocolEvent(
|
|||||||
case "llm_call_end":
|
case "llm_call_end":
|
||||||
case "llm_retry":
|
case "llm_retry":
|
||||||
case "llm_continuation":
|
case "llm_continuation":
|
||||||
|
break;
|
||||||
case "run_end":
|
case "run_end":
|
||||||
|
next.lines.push(
|
||||||
|
runStatsLine(
|
||||||
|
envelope.eventId,
|
||||||
|
next.runActivity,
|
||||||
|
envelope.observedAtMs ?? next.runActivity.startedAtMs ?? 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case "alert":
|
case "alert":
|
||||||
appendAlertLine(next, envelope.eventId, event.data);
|
appendAlertLine(next, envelope.eventId, event.data);
|
||||||
@@ -497,6 +946,22 @@ export function segmentsToText(segments: Segment[]): string {
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runStatsLine(
|
||||||
|
eventId: string,
|
||||||
|
stats: RunActivityStats,
|
||||||
|
endedAtMs: number,
|
||||||
|
): ConsoleLine {
|
||||||
|
const elapsedMs = endedAtMs - (stats.startedAtMs ?? endedAtMs);
|
||||||
|
return line(
|
||||||
|
eventId,
|
||||||
|
"run_stats",
|
||||||
|
"Run stats",
|
||||||
|
`${formatRunElapsedCompact(elapsedMs)} ・${stats.requests} reqs ↑${
|
||||||
|
formatRunTokens(stats.uploadTokens)
|
||||||
|
}/↓${formatRunTokens(stats.outputTokens)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function line(
|
function line(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
kind: ConsoleLineKind,
|
kind: ConsoleLineKind,
|
||||||
@@ -527,7 +992,10 @@ function systemItemLine(eventId: string, item: unknown): ConsoleLine {
|
|||||||
const title = `System · ${itemKind.replaceAll("_", " ")}`;
|
const title = `System · ${itemKind.replaceAll("_", " ")}`;
|
||||||
const body = stringField(item, "body") ?? stringField(item, "message") ??
|
const body = stringField(item, "body") ?? stringField(item, "message") ??
|
||||||
stringField(item, "content") ?? jsonPreview(item);
|
stringField(item, "content") ?? jsonPreview(item);
|
||||||
return line(eventId, "system", title, body);
|
return {
|
||||||
|
...line(eventId, "system", title, body),
|
||||||
|
systemItemKind: itemKind,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function upsertStatusLine(
|
function upsertStatusLine(
|
||||||
@@ -771,6 +1239,10 @@ function refreshedToolLine(item: ConsoleLine): ConsoleLine {
|
|||||||
if (!toolCall) {
|
if (!toolCall) {
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
const commandTerminal = toolCall.command !== undefined &&
|
||||||
|
toolCall.command.status !== "running";
|
||||||
|
const commandError = toolCall.command !== undefined &&
|
||||||
|
["failed", "timed_out", "cancelled"].includes(toolCall.command.status);
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
title: item.title.startsWith("Call · Tool result")
|
title: item.title.startsWith("Call · Tool result")
|
||||||
@@ -779,8 +1251,8 @@ function refreshedToolLine(item: ConsoleLine): ConsoleLine {
|
|||||||
body: renderToolCall(toolCall),
|
body: renderToolCall(toolCall),
|
||||||
detail: toolCallDetail(toolCall),
|
detail: toolCallDetail(toolCall),
|
||||||
diff: toolCall.name === "Edit" ? editDiff(toolCall) : undefined,
|
diff: toolCall.name === "Edit" ? editDiff(toolCall) : undefined,
|
||||||
streaming: !["done", "error"].includes(toolCall.state),
|
streaming: !["done", "error"].includes(toolCall.state) && !commandTerminal,
|
||||||
error: toolCall.state === "error",
|
error: toolCall.state === "error" || commandError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -852,6 +1324,12 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
|
|||||||
source: "event",
|
source: "event",
|
||||||
streaming: inProgress,
|
streaming: inProgress,
|
||||||
error: hasError,
|
error: hasError,
|
||||||
|
toolCall: {
|
||||||
|
...calls[0]!,
|
||||||
|
state: hasError ? "error" : inProgress ? "running" : "done",
|
||||||
|
isError: hasError,
|
||||||
|
},
|
||||||
|
toolCallCount: count,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1025,9 +1503,57 @@ function renderBashTool(toolCall: ToolCallView): string {
|
|||||||
const args = parsedArgs(toolCall);
|
const args = parsedArgs(toolCall);
|
||||||
const command = stringField(args, "command");
|
const command = stringField(args, "command");
|
||||||
return compactLines([
|
return compactLines([
|
||||||
`Bash — ${stateSuffix(toolCall.state)}`,
|
`Bash — ${commandStateSuffix(toolCall)}`,
|
||||||
command ? `$ ${command}` : argsText(toolCall),
|
command ? `$ ${command}` : argsText(toolCall),
|
||||||
cappedDisplaySection(resultText(toolCall), 10),
|
commandTiming(toolCall.command),
|
||||||
|
["done", "error"].includes(toolCall.state)
|
||||||
|
? cappedDisplaySection(resultText(toolCall), 10)
|
||||||
|
: renderLiveCommandOutput(toolCall.command),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandStateSuffix(toolCall: ToolCallView): string {
|
||||||
|
const command = toolCall.command;
|
||||||
|
if (!command) return stateSuffix(toolCall.state);
|
||||||
|
if (command.status === "completed") {
|
||||||
|
return command.exit_code === null
|
||||||
|
? "completed"
|
||||||
|
: `completed (exit ${command.exit_code})`;
|
||||||
|
}
|
||||||
|
if (command.status === "failed") {
|
||||||
|
return command.exit_code === null ? "failed" : `failed (exit ${command.exit_code})`;
|
||||||
|
}
|
||||||
|
if (command.status === "timed_out") return "timed out";
|
||||||
|
if (command.status === "cancelled") return "cancelled";
|
||||||
|
return "running…";
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandTiming(command?: CommandSnapshot): string | undefined {
|
||||||
|
if (!command) return undefined;
|
||||||
|
const elapsed = Math.max(0, command.observed_at_ms - command.started_at_ms);
|
||||||
|
if (command.status !== "running") return `elapsed ${durationLabel(elapsed)}`;
|
||||||
|
if (command.last_output_at_ms === null) {
|
||||||
|
return `elapsed ${durationLabel(elapsed)} · awaiting first output`;
|
||||||
|
}
|
||||||
|
const lastOutputElapsed = Math.max(
|
||||||
|
0,
|
||||||
|
command.last_output_at_ms - command.started_at_ms,
|
||||||
|
);
|
||||||
|
return `elapsed ${durationLabel(elapsed)} · last output at +${durationLabel(lastOutputElapsed)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationLabel(milliseconds: number): string {
|
||||||
|
if (milliseconds < 1000) return `${milliseconds}ms`;
|
||||||
|
return `${(milliseconds / 1000).toFixed(milliseconds < 10_000 ? 1 : 0)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLiveCommandOutput(command?: CommandSnapshot): string | undefined {
|
||||||
|
if (!command) return undefined;
|
||||||
|
return compactLines([
|
||||||
|
command.stdout.truncated ? "[stdout tail; earlier output omitted]" : undefined,
|
||||||
|
command.stdout.content ? `stdout:\n${command.stdout.content}` : undefined,
|
||||||
|
command.stderr.truncated ? "[stderr tail; earlier output omitted]" : undefined,
|
||||||
|
command.stderr.content ? `stderr:\n${command.stderr.content}` : undefined,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1276,9 +1802,11 @@ function snapshotProjectionFromEntries(
|
|||||||
taskNextId: 1,
|
taskNextId: 1,
|
||||||
status: null,
|
status: null,
|
||||||
usage: null,
|
usage: null,
|
||||||
|
runActivity: emptyRunActivityStats(),
|
||||||
cwd,
|
cwd,
|
||||||
lastEventId: eventId,
|
lastEventId: eventId,
|
||||||
internalWorkers: [],
|
internalWorkers: [],
|
||||||
|
removedInternalWorkers: {},
|
||||||
};
|
};
|
||||||
entries.forEach((entry, index) =>
|
entries.forEach((entry, index) =>
|
||||||
applyLogEntry(projection, `${eventId}-snapshot-${index}`, entry)
|
applyLogEntry(projection, `${eventId}-snapshot-${index}`, entry)
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import {
|
||||||
|
applyRunActivityEvent,
|
||||||
|
emptyRunActivityStats,
|
||||||
|
formatRunElapsed,
|
||||||
|
formatRunElapsedCompact,
|
||||||
|
formatRunTokens,
|
||||||
|
} from "./run-status.ts";
|
||||||
|
|
||||||
|
function assertEquals(actual: unknown, expected: unknown): void {
|
||||||
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||||
|
throw new Error(
|
||||||
|
`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("run activity follows TUI request and net-token accounting", () => {
|
||||||
|
let stats = applyRunActivityEvent(
|
||||||
|
emptyRunActivityStats(),
|
||||||
|
{ event: "invoke_start", data: { kind: "user_send" } },
|
||||||
|
1_000,
|
||||||
|
);
|
||||||
|
stats = applyRunActivityEvent(
|
||||||
|
stats,
|
||||||
|
{ event: "turn_start", data: { turn: 1 } },
|
||||||
|
1_010,
|
||||||
|
);
|
||||||
|
stats = applyRunActivityEvent(
|
||||||
|
stats,
|
||||||
|
{
|
||||||
|
event: "usage",
|
||||||
|
data: {
|
||||||
|
input_tokens: 25_000,
|
||||||
|
cache_read_input_tokens: 20_000,
|
||||||
|
output_tokens: 3_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
1_020,
|
||||||
|
);
|
||||||
|
stats = applyRunActivityEvent(
|
||||||
|
stats,
|
||||||
|
{ event: "turn_start", data: { turn: 2 } },
|
||||||
|
1_030,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(stats, {
|
||||||
|
startedAtMs: 1_000,
|
||||||
|
requests: 2,
|
||||||
|
uploadTokens: 5_000,
|
||||||
|
outputTokens: 3_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("new invoke and running snapshot reset run activity", () => {
|
||||||
|
const previous = {
|
||||||
|
startedAtMs: 1,
|
||||||
|
requests: 3,
|
||||||
|
uploadTokens: 100,
|
||||||
|
outputTokens: 20,
|
||||||
|
};
|
||||||
|
assertEquals(
|
||||||
|
applyRunActivityEvent(
|
||||||
|
previous,
|
||||||
|
{ event: "invoke_start", data: { kind: "notify" } },
|
||||||
|
9_000,
|
||||||
|
),
|
||||||
|
{ startedAtMs: 9_000, requests: 0, uploadTokens: 0, outputTokens: 0 },
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
applyRunActivityEvent(
|
||||||
|
previous,
|
||||||
|
{
|
||||||
|
event: "snapshot",
|
||||||
|
data: {
|
||||||
|
entries: [],
|
||||||
|
greeting: { text: "", profile: "" },
|
||||||
|
status: "idle",
|
||||||
|
in_flight: {},
|
||||||
|
internal_workers: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
10_000,
|
||||||
|
),
|
||||||
|
emptyRunActivityStats(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("run status formatting matches the compact TUI shape", () => {
|
||||||
|
assertEquals(formatRunElapsed(88_900), "1m 28s");
|
||||||
|
assertEquals(formatRunElapsed(3_723_000), "1h 2m 3s");
|
||||||
|
assertEquals(formatRunElapsedCompact(620_000), "10m20s");
|
||||||
|
assertEquals(formatRunTokens(25_000), "25.0k");
|
||||||
|
assertEquals(formatRunTokens(3_000), "3.0k");
|
||||||
|
assertEquals(formatRunTokens(999), "999");
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { Event as ProtocolEvent } from "$lib/generated/protocol";
|
||||||
|
|
||||||
|
export type RunActivityStats = {
|
||||||
|
startedAtMs: number | null;
|
||||||
|
requests: number;
|
||||||
|
uploadTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function emptyRunActivityStats(): RunActivityStats {
|
||||||
|
return {
|
||||||
|
startedAtMs: null,
|
||||||
|
requests: 0,
|
||||||
|
uploadTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyRunActivityEvent(
|
||||||
|
current: RunActivityStats,
|
||||||
|
event: ProtocolEvent,
|
||||||
|
observedAtMs: number,
|
||||||
|
): RunActivityStats {
|
||||||
|
switch (event.event) {
|
||||||
|
case "invoke_start":
|
||||||
|
return { ...emptyRunActivityStats(), startedAtMs: observedAtMs };
|
||||||
|
case "snapshot":
|
||||||
|
return event.data.status === "running"
|
||||||
|
? { ...emptyRunActivityStats(), startedAtMs: observedAtMs }
|
||||||
|
: emptyRunActivityStats();
|
||||||
|
case "turn_start":
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
startedAtMs: current.startedAtMs ?? observedAtMs,
|
||||||
|
requests: current.requests + 1,
|
||||||
|
};
|
||||||
|
case "usage": {
|
||||||
|
const input = event.data.input_tokens ?? 0;
|
||||||
|
const cacheRead = event.data.cache_read_input_tokens ?? 0;
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
startedAtMs: current.startedAtMs ?? observedAtMs,
|
||||||
|
uploadTokens: current.uploadTokens + Math.max(0, input - cacheRead),
|
||||||
|
outputTokens: current.outputTokens + (event.data.output_tokens ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRunElapsed(elapsedMs: number): string {
|
||||||
|
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1_000));
|
||||||
|
const hours = Math.floor(totalSeconds / 3_600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3_600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
|
||||||
|
if (minutes > 0) return `${minutes}m ${seconds}s`;
|
||||||
|
return `${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRunElapsedCompact(elapsedMs: number): string {
|
||||||
|
return formatRunElapsed(elapsedMs).replaceAll(" ", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Match the TUI token abbreviation contract. */
|
||||||
|
export function formatRunTokens(tokens: number): string {
|
||||||
|
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
|
||||||
|
return String(tokens);
|
||||||
|
}
|
||||||
@@ -797,7 +797,7 @@ Deno.test("Web Console renders the client-projected Worker task store", async ()
|
|||||||
|
|
||||||
assert(
|
assert(
|
||||||
consolePage.includes("ConsoleTasks") &&
|
consolePage.includes("ConsoleTasks") &&
|
||||||
consolePage.includes("consoleProjection.tasks") &&
|
consolePage.includes("selectedConsoleProjection.tasks") &&
|
||||||
consolePage.includes("taskPaneOpen"),
|
consolePage.includes("taskPaneOpen"),
|
||||||
"Console should expose the projected task store through its existing client model",
|
"Console should expose the projected task store through its existing client model",
|
||||||
);
|
);
|
||||||
@@ -818,3 +818,44 @@ Deno.test("Web Console renders the client-projected Worker task store", async ()
|
|||||||
"Task projection should replay the protocol client-side without adding a task API",
|
"Task projection should replay the protocol client-side without adding a task API",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("Web Console switches main and direct SubWorker views from the Tasks row", async () => {
|
||||||
|
const consolePage = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const tasksComponent = await Deno.readTextFile(
|
||||||
|
new URL("./ConsoleTasks.svelte", import.meta.url),
|
||||||
|
);
|
||||||
|
const consoleModel = await Deno.readTextFile(
|
||||||
|
new URL("./model.ts", import.meta.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
consolePage.includes("selectedWorkerViewSessionId") &&
|
||||||
|
consolePage.includes("selectConsoleWorkerView") &&
|
||||||
|
consolePage.includes("selectedConsoleProjection.lines") &&
|
||||||
|
consolePage.includes("selectedConsoleProjection.tasks") &&
|
||||||
|
consolePage.includes("onSelectWorkerView") &&
|
||||||
|
consolePage.includes("selectConsoleWorkerView(resolvedSessionId, false)") &&
|
||||||
|
consolePage.includes("consoleWorkerViewSelectionIsResolved") &&
|
||||||
|
!consolePage.includes("internal-worker-pane") &&
|
||||||
|
!consolePage.includes("flattenInternalWorkers"),
|
||||||
|
"Console should render one selected transcript/task projection without appending Internal Worker panes",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
tasksComponent.includes('role="group"') &&
|
||||||
|
tasksComponent.includes("aria-pressed") &&
|
||||||
|
tasksComponent.includes("onclick") &&
|
||||||
|
tasksComponent.includes("tasks.length > 0 || workerViews.length > 1"),
|
||||||
|
"Tasks summary should expose a clickable and accessible Worker view selector even with zero tasks",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
consoleModel.includes("consoleWorkerViews") &&
|
||||||
|
consoleModel.includes("projection.internalWorkers.map") &&
|
||||||
|
consoleModel.includes("resolveConsoleWorkerView"),
|
||||||
|
"Worker view selection should use direct Internal Worker session identities with main fallback",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { resolveWorkerControlShortcut } from "./worker-control-shortcuts.ts";
|
||||||
|
|
||||||
|
function assertEquals(actual: unknown, expected: unknown): void {
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error(`expected ${String(expected)}, got ${String(actual)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
protocolOpen: true,
|
||||||
|
running: false,
|
||||||
|
paused: false,
|
||||||
|
composerFocused: false,
|
||||||
|
draftBlank: true,
|
||||||
|
editableTarget: false,
|
||||||
|
hasSelection: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.test("Worker control shortcuts match TUI pause cancel and resume keys", () => {
|
||||||
|
assertEquals(
|
||||||
|
resolveWorkerControlShortcut(
|
||||||
|
{ key: "c", ctrlKey: true },
|
||||||
|
{ ...base, running: true },
|
||||||
|
),
|
||||||
|
"pause",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
resolveWorkerControlShortcut(
|
||||||
|
{ key: "x", ctrlKey: true },
|
||||||
|
{ ...base, paused: true },
|
||||||
|
),
|
||||||
|
"cancel",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
resolveWorkerControlShortcut(
|
||||||
|
{ key: "Enter" },
|
||||||
|
{ ...base, paused: true, composerFocused: true },
|
||||||
|
),
|
||||||
|
"resume",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Worker control shortcuts preserve browser editing operations", () => {
|
||||||
|
for (
|
||||||
|
const state of [
|
||||||
|
{ ...base, running: true, editableTarget: true },
|
||||||
|
{ ...base, running: true, hasSelection: true },
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
assertEquals(
|
||||||
|
resolveWorkerControlShortcut({ key: "c", ctrlKey: true }, state),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertEquals(
|
||||||
|
resolveWorkerControlShortcut(
|
||||||
|
{ key: "x", ctrlKey: true },
|
||||||
|
{ ...base, running: true, editableTarget: true },
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Resume requires a blank focused composer and paused Worker", () => {
|
||||||
|
assertEquals(
|
||||||
|
resolveWorkerControlShortcut(
|
||||||
|
{ key: "Enter" },
|
||||||
|
{ ...base, paused: true, composerFocused: true, draftBlank: false },
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
resolveWorkerControlShortcut(
|
||||||
|
{ key: "Enter" },
|
||||||
|
{ ...base, paused: true, composerFocused: false },
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
export type WorkerControlShortcut = "pause" | "cancel" | "resume";
|
||||||
|
|
||||||
|
export type WorkerControlShortcutEvent = {
|
||||||
|
key: string;
|
||||||
|
ctrlKey?: boolean;
|
||||||
|
metaKey?: boolean;
|
||||||
|
altKey?: boolean;
|
||||||
|
shiftKey?: boolean;
|
||||||
|
repeat?: boolean;
|
||||||
|
isComposing?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkerControlShortcutState = {
|
||||||
|
protocolOpen: boolean;
|
||||||
|
running: boolean;
|
||||||
|
paused: boolean;
|
||||||
|
composerFocused: boolean;
|
||||||
|
draftBlank: boolean;
|
||||||
|
editableTarget: boolean;
|
||||||
|
hasSelection: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Resolve the TUI-compatible Worker control shortcut without side effects. */
|
||||||
|
export function resolveWorkerControlShortcut(
|
||||||
|
event: WorkerControlShortcutEvent,
|
||||||
|
state: WorkerControlShortcutState,
|
||||||
|
): WorkerControlShortcut | null {
|
||||||
|
if (!state.protocolOpen || event.repeat || event.isComposing) return null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.key === "Enter" && state.paused && state.composerFocused &&
|
||||||
|
state.draftBlank && !event.ctrlKey && !event.metaKey && !event.altKey &&
|
||||||
|
!event.shiftKey
|
||||||
|
) {
|
||||||
|
return "resume";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey ||
|
||||||
|
state.editableTarget || state.hasSelection
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (event.key.toLowerCase()) {
|
||||||
|
case "c":
|
||||||
|
return state.running ? "pause" : null;
|
||||||
|
case "x":
|
||||||
|
return state.running || state.paused ? "cancel" : null;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,13 @@ export function workspaceMultiplexer(workspaceId: string): WorkspaceMultiplexer
|
|||||||
return multiplexer;
|
return multiplexer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function disposeWorkspaceMultiplexer(workspaceId: string): void {
|
||||||
|
const multiplexer = multiplexers.get(workspaceId);
|
||||||
|
if (!multiplexer) return;
|
||||||
|
multiplexers.delete(workspaceId);
|
||||||
|
multiplexer.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
export class WorkspaceMultiplexer {
|
export class WorkspaceMultiplexer {
|
||||||
readonly #workspaceId: string;
|
readonly #workspaceId: string;
|
||||||
readonly #subscriptions = new Map<string, ActiveSubscription>();
|
readonly #subscriptions = new Map<string, ActiveSubscription>();
|
||||||
@@ -219,6 +226,22 @@ export class WorkspaceMultiplexer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.#closed = true;
|
||||||
|
if (this.#reconnectTimer) {
|
||||||
|
clearTimeout(this.#reconnectTimer);
|
||||||
|
this.#reconnectTimer = null;
|
||||||
|
}
|
||||||
|
for (const subscription of this.#subscriptions.values()) {
|
||||||
|
subscription.listener.onStatus?.('closed', 'Workspace selection changed');
|
||||||
|
}
|
||||||
|
this.#subscriptions.clear();
|
||||||
|
this.#requests.clear();
|
||||||
|
this.#runtimeSubscriptions.clear();
|
||||||
|
this.#socket?.close();
|
||||||
|
this.#socket = null;
|
||||||
|
}
|
||||||
|
|
||||||
#send(frame: SubscriptionFrame): void {
|
#send(frame: SubscriptionFrame): void {
|
||||||
if (this.#socket?.readyState !== WebSocket.OPEN) return;
|
if (this.#socket?.readyState !== WebSocket.OPEN) return;
|
||||||
this.#socket.send(JSON.stringify(frame));
|
this.#socket.send(JSON.stringify(frame));
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
const RESOURCE_KEY_PATTERN = /^(T|O|W)-(\d+)/;
|
||||||
|
|
||||||
|
export function resourceKey(reference: string): string {
|
||||||
|
const match = RESOURCE_KEY_PATTERN.exec(reference);
|
||||||
|
return match ? `${match[1]}-${match[2]}` : reference;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function slugifyResourceTitle(title: string): string {
|
||||||
|
const slug = title
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/\p{Mark}+/gu, "")
|
||||||
|
.toLocaleLowerCase("en-US")
|
||||||
|
.replace(/[^\p{Letter}\p{Number}]+/gu, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 80)
|
||||||
|
.replace(/-+$/g, "");
|
||||||
|
return slug || "resource";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalResourceReference(
|
||||||
|
resourceKey: string,
|
||||||
|
title: string,
|
||||||
|
): string {
|
||||||
|
return `${resourceKey}-${slugifyResourceTitle(title)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ticketHref(
|
||||||
|
workspaceId: string,
|
||||||
|
ticket: { resource_key: string; title: string },
|
||||||
|
): string {
|
||||||
|
return `/w/${encodeURIComponent(workspaceId)}/tickets/${encodeURIComponent(canonicalResourceReference(ticket.resource_key, ticket.title))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function objectiveHref(
|
||||||
|
workspaceId: string,
|
||||||
|
objective: { resource_key: string; title: string },
|
||||||
|
): string {
|
||||||
|
return `/w/${encodeURIComponent(workspaceId)}/objectives/${encodeURIComponent(canonicalResourceReference(objective.resource_key, objective.title))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function workerHref(
|
||||||
|
workspaceId: string,
|
||||||
|
worker: { resource_key: string; display_name: string },
|
||||||
|
): string {
|
||||||
|
const reference = canonicalResourceReference(worker.resource_key, worker.display_name);
|
||||||
|
return `/w/${encodeURIComponent(workspaceId)}/workers/${encodeURIComponent(reference)}`;
|
||||||
|
}
|
||||||
@@ -14,7 +14,10 @@ export type WorkspaceProfileApi = {
|
|||||||
getProfiles(workspaceId: string): Promise<ProfileSettingsResponse>;
|
getProfiles(workspaceId: string): Promise<ProfileSettingsResponse>;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function requestJson<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
|
async function requestJson<T>(
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<T> {
|
||||||
const response = await fetch(input, init);
|
const response = await fetch(input, init);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`request failed: ${response.status}`);
|
throw new Error(`request failed: ${response.status}`);
|
||||||
@@ -44,7 +47,9 @@ export async function updateWorkspaceMetadataSettings(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchProfileSettings(workspaceId: string): Promise<ProfileSettingsResponse> {
|
export async function fetchProfileSettings(
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<ProfileSettingsResponse> {
|
||||||
return await requestJson<ProfileSettingsResponse>(
|
return await requestJson<ProfileSettingsResponse>(
|
||||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`,
|
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`,
|
||||||
);
|
);
|
||||||
@@ -52,20 +57,12 @@ export async function fetchProfileSettings(workspaceId: string): Promise<Profile
|
|||||||
|
|
||||||
export function createWorkspaceProfileApi(): WorkspaceProfileApi {
|
export function createWorkspaceProfileApi(): WorkspaceProfileApi {
|
||||||
return {
|
return {
|
||||||
async getMetadata(workspaceId) {
|
getMetadata: fetchWorkspaceMetadataSettings,
|
||||||
return await requestJson<WorkspaceMetadataSettingsResponse>(
|
|
||||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/metadata`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
async updateMetadata(workspaceId, displayName, expectedRevision) {
|
async updateMetadata(workspaceId, displayName, expectedRevision) {
|
||||||
return await requestJson<WorkspaceMetadataMutationResponse>(
|
return await updateWorkspaceMetadataSettings(workspaceId, {
|
||||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/metadata`,
|
display_name: displayName,
|
||||||
{
|
revision: expectedRevision,
|
||||||
method: "PUT",
|
});
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify({ display_name: displayName, expected_revision: expectedRevision }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
getProfiles: fetchProfileSettings,
|
getProfiles: fetchProfileSettings,
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user