Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b1b8a8846 | ||
|
|
9fb1b90856 | ||
|
|
30d4023475 | ||
|
|
70432f3d12 | ||
|
|
7ee6c307fc | ||
|
|
36cfbbe6d2 | ||
|
|
a2e1a3d939 | ||
|
|
9dc8d9a77a | ||
|
|
3f6bb65eb1 | ||
|
|
8a70f3cb26 | ||
|
|
6c5b8315a3 | ||
|
|
690ed0f121 | ||
|
|
fd60c2b8be | ||
|
|
e27b4feb25 | ||
|
|
09a33e7283 | ||
|
|
d87441448e | ||
|
|
f783f10f6e | ||
|
|
70bdb2d723 | ||
|
|
4c1ef04378 | ||
|
|
a595af133c | ||
|
|
f74f3cd133 | ||
|
|
bcd4848458 | ||
|
|
fc05bf9711 | ||
|
|
63ad590262 | ||
|
|
0fd1193b6b | ||
|
|
d996822957 | ||
|
|
96349721cb | ||
|
|
8344921b65 | ||
|
|
c97b3b7b77 | ||
|
|
e00e675ed1 | ||
|
|
538da1f2b2 | ||
|
|
bad37ddc7d | ||
|
|
175eda9f29 | ||
|
|
14c806d38f | ||
|
|
b29b003ea3 | ||
|
|
faa727965b | ||
|
|
d2ffbf2c40 | ||
|
|
5418fad7d7 | ||
|
|
510795f1c5 | ||
|
|
9e0d499987 | ||
|
|
e96fde0632 | ||
|
|
eea79dead4 | ||
|
|
c4a3f4ba1e | ||
|
|
4a4a01b730 |
Generated
+20
@@ -4412,6 +4412,9 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"unicode-normalization",
|
||||
"unicode-properties",
|
||||
"unicode-security",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -5326,6 +5329,7 @@ name = "tui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"agen",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"client",
|
||||
"crossterm 0.28.1",
|
||||
@@ -5436,6 +5440,22 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-script"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-security"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e4ddba1535dd35ed8b61c52166b7155d7f4e4b8847cec6f48e71dc66d8b5e50"
|
||||
dependencies = [
|
||||
"unicode-normalization",
|
||||
"unicode-script",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.2"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::transport::websocket::{Socket as WebSocket, SocketError as WebSocketError};
|
||||
use crate::{BackendApiClient, BackendApiClientError, Client};
|
||||
use reqwest::Method as HttpMethod;
|
||||
use serde::Deserialize;
|
||||
use std::fmt;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
||||
@@ -51,6 +52,123 @@ impl BackendRuntimeTarget {
|
||||
pub fn display_label(&self) -> String {
|
||||
format!("{}:{}", self.runtime_id, self.worker_id)
|
||||
}
|
||||
|
||||
pub async fn upload_file(
|
||||
&self,
|
||||
file_name: &str,
|
||||
media_type: &str,
|
||||
content: Vec<u8>,
|
||||
) -> Result<protocol::UploadedFileRef, BackendRuntimeClientError> {
|
||||
self.upload_file_with_id(
|
||||
&uuid::Uuid::now_v7().to_string(),
|
||||
file_name,
|
||||
media_type,
|
||||
content,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn upload_file_with_id(
|
||||
&self,
|
||||
upload_id: &str,
|
||||
file_name: &str,
|
||||
media_type: &str,
|
||||
content: Vec<u8>,
|
||||
) -> Result<protocol::UploadedFileRef, BackendRuntimeClientError> {
|
||||
let api = BackendApiClient::from_stored_token(&self.base_url)?;
|
||||
let worker_path = format!(
|
||||
"/api/w/{}/runtimes/{}/workers/{}",
|
||||
path_segment_encode(&self.workspace_id),
|
||||
path_segment_encode(&self.runtime_id),
|
||||
path_segment_encode(&self.worker_id),
|
||||
);
|
||||
let grant_path = format!(
|
||||
"{worker_path}/attachment-upload-grants?file_name={}&media_type={}&upload_id={}",
|
||||
path_segment_encode(file_name),
|
||||
path_segment_encode(media_type),
|
||||
path_segment_encode(&upload_id),
|
||||
);
|
||||
let grant_response = api
|
||||
.request(HttpMethod::POST, &grant_path)?
|
||||
.send()
|
||||
.await
|
||||
.map_err(BackendRuntimeClientError::Http)?;
|
||||
api.check_status(grant_response.status())?;
|
||||
let grant = grant_response
|
||||
.json::<AttachmentUploadGrantResponse>()
|
||||
.await
|
||||
.map_err(BackendRuntimeClientError::Http)?;
|
||||
let upload_path = format!(
|
||||
"{worker_path}/attachment-uploads/{}",
|
||||
path_segment_encode(&grant.upload_id),
|
||||
);
|
||||
let response = api
|
||||
.request(HttpMethod::PUT, &upload_path)?
|
||||
.body(content)
|
||||
.send()
|
||||
.await
|
||||
.map_err(BackendRuntimeClientError::Http)?;
|
||||
api.check_status(response.status())?;
|
||||
response
|
||||
.json::<UploadedFileResponse>()
|
||||
.await
|
||||
.map(|response| response.file)
|
||||
.map_err(BackendRuntimeClientError::Http)
|
||||
}
|
||||
|
||||
pub async fn cancel_file_upload(
|
||||
&self,
|
||||
upload_id: &str,
|
||||
) -> Result<(), BackendRuntimeClientError> {
|
||||
let api = BackendApiClient::from_stored_token(&self.base_url)?;
|
||||
let path = format!(
|
||||
"/api/w/{}/runtimes/{}/workers/{}/attachment-uploads/{}",
|
||||
path_segment_encode(&self.workspace_id),
|
||||
path_segment_encode(&self.runtime_id),
|
||||
path_segment_encode(&self.worker_id),
|
||||
path_segment_encode(upload_id),
|
||||
);
|
||||
let response = api
|
||||
.request(HttpMethod::DELETE, &path)?
|
||||
.send()
|
||||
.await
|
||||
.map_err(BackendRuntimeClientError::Http)?;
|
||||
api.check_status(response.status())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_uploaded_file(
|
||||
&self,
|
||||
artifact_id: &str,
|
||||
) -> Result<(), BackendRuntimeClientError> {
|
||||
let api = BackendApiClient::from_stored_token(&self.base_url)?;
|
||||
let path = format!(
|
||||
"/api/w/{}/runtimes/{}/workers/{}/attachments/{}",
|
||||
path_segment_encode(&self.workspace_id),
|
||||
path_segment_encode(&self.runtime_id),
|
||||
path_segment_encode(&self.worker_id),
|
||||
path_segment_encode(artifact_id),
|
||||
);
|
||||
let response = api
|
||||
.request(HttpMethod::DELETE, &path)?
|
||||
.send()
|
||||
.await
|
||||
.map_err(BackendRuntimeClientError::Http)?;
|
||||
api.check_status(response.status())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AttachmentUploadGrantResponse {
|
||||
upload_id: String,
|
||||
#[allow(dead_code)]
|
||||
expires_at_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UploadedFileResponse {
|
||||
file: protocol::UploadedFileRef,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -512,7 +630,7 @@ mod tests {
|
||||
"capabilities": {"can_stop": true, "can_spawn_followup": false},
|
||||
"working_directory": {
|
||||
"working_directory_id": "wd-1",
|
||||
"repository_id": "main",
|
||||
"repository_key": "main",
|
||||
"materializer_kind": "local_git_worktree",
|
||||
"status": "active",
|
||||
"occupied_by": {
|
||||
@@ -525,11 +643,9 @@ mod tests {
|
||||
});
|
||||
|
||||
let worker: BackendWorkerSummary = serde_json::from_value(payload.clone()).unwrap();
|
||||
let occupied_by = worker
|
||||
.working_directory
|
||||
.unwrap()
|
||||
.occupied_by
|
||||
.expect("occupied Workdir");
|
||||
let workdir = worker.working_directory.unwrap();
|
||||
assert_eq!(workdir.repository_key, "main");
|
||||
let occupied_by = workdir.occupied_by.expect("occupied Workdir");
|
||||
assert_eq!(occupied_by.runtime_id, "arcadia");
|
||||
assert_eq!(occupied_by.worker_id, "worker-opaque-64");
|
||||
|
||||
|
||||
@@ -1438,6 +1438,28 @@ mod tests {
|
||||
assert!(resolved.manifest.feature.workspace_worker_discovery.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_orchestrator_keeps_cleanup_tool_providers_enabled() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let resolved = ProfileResolver::new()
|
||||
.with_workspace_base(tmp.path())
|
||||
.resolve(
|
||||
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "orchestrator"),
|
||||
ProfileResolveOptions::with_worker_name("orchestrator-worker"),
|
||||
)
|
||||
.unwrap();
|
||||
let feature = resolved.manifest.feature;
|
||||
|
||||
assert!(feature.worker.enabled);
|
||||
assert!(!feature.worker.direct_spawn);
|
||||
assert!(feature.manage_workdir.enabled);
|
||||
assert!(feature.merge_request.show);
|
||||
assert!(feature.merge_request.readiness_check);
|
||||
assert!(feature.merge_request.complete);
|
||||
assert!(!feature.merge_request.open);
|
||||
assert!(!feature.merge_request.review);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_resolution_requires_runtime_worker_name() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
@@ -251,6 +251,48 @@ pub struct PasteArtifactRef {
|
||||
pub source_entry_id: String,
|
||||
}
|
||||
|
||||
/// Availability recorded for an uploaded client-local file.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UploadedFileAvailability {
|
||||
Available,
|
||||
Unavailable,
|
||||
IntegrityFailed,
|
||||
}
|
||||
|
||||
impl UploadedFileAvailability {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Available => "available",
|
||||
Self::Unavailable => "unavailable",
|
||||
Self::IntegrityFailed => "integrity_failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Session-owned immutable reference to a client-local uploaded file.
|
||||
///
|
||||
/// Upload transports return an unbound reference. Worker fills
|
||||
/// `source_entry_id` immediately before the containing user input is committed;
|
||||
/// committed Session Log and public snapshot records therefore always retain
|
||||
/// the durable source-entry identity without storing the file body.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
pub struct UploadedFileRef {
|
||||
pub artifact_id: String,
|
||||
pub file_name: String,
|
||||
pub media_type: String,
|
||||
pub created_at_ms: u64,
|
||||
pub availability: UploadedFileAvailability,
|
||||
pub byte_len: u64,
|
||||
pub sha256: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_entry_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
@@ -272,6 +314,10 @@ pub enum Segment {
|
||||
/// committing input. Clients may receive this in history/event projections;
|
||||
/// the body is intentionally absent.
|
||||
PasteArtifact { artifact: PasteArtifactRef },
|
||||
/// Client-local file uploaded into the owning Worker session before submit.
|
||||
/// The Session Log stores only this immutable reference, never file bytes or
|
||||
/// the client's local path.
|
||||
UploadedFile { file: UploadedFileRef },
|
||||
/// `@<path>` file-system reference. Worker resolves readable files to
|
||||
/// `[File: <path>]` attachments and readable normal directories to shallow
|
||||
/// `[Dir: <path>]` listings; the flattened user text keeps the literal
|
||||
@@ -327,6 +373,20 @@ impl Segment {
|
||||
artifact.sha256
|
||||
);
|
||||
}
|
||||
Segment::UploadedFile { file } => {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(
|
||||
out,
|
||||
"[Attached file {} stored as input artifact {}: {} bytes, {}, {}, created at {} ms, sha256 {}; use SearchInputArtifact and ReadInputArtifact for supported text content]",
|
||||
file.file_name,
|
||||
file.artifact_id,
|
||||
file.byte_len,
|
||||
file.media_type,
|
||||
file.availability.as_str(),
|
||||
file.created_at_ms,
|
||||
file.sha256
|
||||
);
|
||||
}
|
||||
Segment::FileRef { path } => {
|
||||
out.push('@');
|
||||
out.push_str(path);
|
||||
@@ -1305,6 +1365,29 @@ mod tests {
|
||||
assert!(!projected.contains("pasted body"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uploaded_file_segment_roundtrips_without_path_or_body() {
|
||||
let file = UploadedFileRef {
|
||||
artifact_id: "019ca7c8-57b6-7f05-8edf-524147aba7b3".to_string(),
|
||||
file_name: "notes.md".to_string(),
|
||||
media_type: "text/markdown".to_string(),
|
||||
created_at_ms: 1_700_000_000_001,
|
||||
availability: UploadedFileAvailability::Available,
|
||||
byte_len: 128,
|
||||
sha256: "b".repeat(64),
|
||||
source_entry_id: Some("entry-2".to_string()),
|
||||
};
|
||||
let segment = Segment::UploadedFile { file: file.clone() };
|
||||
let json = serde_json::to_string(&segment).unwrap();
|
||||
assert!(!json.contains("/home/user/private"));
|
||||
assert!(!json.contains("file body"));
|
||||
assert_eq!(serde_json::from_str::<Segment>(&json).unwrap(), segment);
|
||||
let projected = Segment::flatten_to_text(&[segment]);
|
||||
assert!(projected.contains("notes.md"));
|
||||
assert!(projected.contains(&file.artifact_id));
|
||||
assert!(projected.contains("ReadInputArtifact"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_run_flow_segment_roundtrip() {
|
||||
let method = Method::Run {
|
||||
|
||||
@@ -170,6 +170,23 @@ fn validate_identifier(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_repository_key(value: &str) -> Result<(), SubscriptionValidationError> {
|
||||
let bytes = value.as_bytes();
|
||||
if bytes.is_empty()
|
||||
|| bytes.len() > 64
|
||||
|| bytes.first() == Some(&b'-')
|
||||
|| bytes.last() == Some(&b'-')
|
||||
|| !bytes
|
||||
.iter()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
|
||||
{
|
||||
return Err(SubscriptionValidationError::InvalidIdentifier {
|
||||
field: "repository_key",
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_rejection_message(message: &str) -> Result<(), SubscriptionValidationError> {
|
||||
if message.is_empty() {
|
||||
return Err(SubscriptionValidationError::EmptyRejectionMessage);
|
||||
@@ -567,7 +584,12 @@ pub struct SubscriptionWorker {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profile: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(skip))]
|
||||
pub repository_id: Option<String>,
|
||||
/// Workspace-facing Repository key. Runtime producers leave this unset and
|
||||
/// Workspace Server projections replace `repository_id` with this field.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub repository_key: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub working_directory_id: Option<SubscriptionWorkdirId>,
|
||||
}
|
||||
@@ -584,6 +606,14 @@ impl SubscriptionWorker {
|
||||
if let Some(repository_id) = &self.repository_id {
|
||||
validate_identifier("repository_id", repository_id, MAX_RESOURCE_ID_BYTES)?;
|
||||
}
|
||||
if let Some(repository_key) = &self.repository_key {
|
||||
validate_repository_key(repository_key)?;
|
||||
}
|
||||
if self.repository_id.is_some() && self.repository_key.is_some() {
|
||||
return Err(SubscriptionValidationError::InvalidIdentifier {
|
||||
field: "repository_authority",
|
||||
});
|
||||
}
|
||||
if let Some(working_directory_id) = &self.working_directory_id {
|
||||
working_directory_id.validate()?;
|
||||
}
|
||||
@@ -595,7 +625,13 @@ impl SubscriptionWorker {
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct SubscriptionWorkdir {
|
||||
pub working_directory_id: SubscriptionWorkdirId,
|
||||
pub repository_id: String,
|
||||
/// Runtime-internal Repository id. Workspace-facing TypeScript contracts
|
||||
/// omit this field and require `repository_key` from the Server projection.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(skip))]
|
||||
pub repository_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub repository_key: Option<String>,
|
||||
pub state: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub primary_worker_id: Option<SubscriptionWorkerId>,
|
||||
@@ -604,7 +640,41 @@ pub struct SubscriptionWorkdir {
|
||||
impl SubscriptionWorkdir {
|
||||
pub fn validate(&self) -> Result<(), SubscriptionValidationError> {
|
||||
self.working_directory_id.validate()?;
|
||||
validate_identifier("repository_id", &self.repository_id, MAX_RESOURCE_ID_BYTES)?;
|
||||
match (&self.repository_id, &self.repository_key) {
|
||||
(Some(repository_id), None) => {
|
||||
validate_identifier("repository_id", repository_id, MAX_RESOURCE_ID_BYTES)?;
|
||||
}
|
||||
(None, Some(repository_key)) => validate_repository_key(repository_key)?,
|
||||
_ => {
|
||||
return Err(SubscriptionValidationError::InvalidIdentifier {
|
||||
field: "repository_authority",
|
||||
});
|
||||
}
|
||||
}
|
||||
validate_identifier("workdir_state", &self.state, MAX_RESOURCE_ID_BYTES)?;
|
||||
if let Some(worker_id) = &self.primary_worker_id {
|
||||
worker_id.validate()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Workspace-facing Workdir summary. Backend-generated Repository UUIDs never
|
||||
/// enter this DTO; Workspace Server must resolve the required Repository key.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct WorkspaceSubscriptionWorkdir {
|
||||
pub working_directory_id: SubscriptionWorkdirId,
|
||||
pub repository_key: String,
|
||||
pub state: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub primary_worker_id: Option<SubscriptionWorkerId>,
|
||||
}
|
||||
|
||||
impl WorkspaceSubscriptionWorkdir {
|
||||
pub fn validate(&self) -> Result<(), SubscriptionValidationError> {
|
||||
self.working_directory_id.validate()?;
|
||||
validate_repository_key(&self.repository_key)?;
|
||||
validate_identifier("workdir_state", &self.state, MAX_RESOURCE_ID_BYTES)?;
|
||||
if let Some(worker_id) = &self.primary_worker_id {
|
||||
worker_id.validate()?;
|
||||
@@ -625,7 +695,7 @@ pub enum SubscriptionSnapshot {
|
||||
events: Vec<WorkerProtocolEvent>,
|
||||
},
|
||||
WorkspaceWorkdirs {
|
||||
workdirs: Vec<SubscriptionWorkdir>,
|
||||
workdirs: Vec<WorkspaceSubscriptionWorkdir>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -693,7 +763,7 @@ pub enum SubscriptionEventPayload {
|
||||
event: WorkerProtocolEvent,
|
||||
},
|
||||
WorkdirUpserted {
|
||||
workdir: SubscriptionWorkdir,
|
||||
workdir: WorkspaceSubscriptionWorkdir,
|
||||
},
|
||||
WorkdirRemoved {
|
||||
working_directory_id: SubscriptionWorkdirId,
|
||||
@@ -811,10 +881,42 @@ mod tests {
|
||||
display_name: Some(format!("Worker {value}")),
|
||||
profile: Some("builtin:coder".to_string()),
|
||||
repository_id: None,
|
||||
repository_key: None,
|
||||
working_directory_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_and_workspace_repository_identity_projections_do_not_alias() {
|
||||
let mut runtime_worker = worker("worker-1");
|
||||
runtime_worker.repository_id = Some("01890f47-3c22-7cc0-98c4-dc0c0c07398f".to_string());
|
||||
runtime_worker.validate().unwrap();
|
||||
let runtime_json = serde_json::to_value(&runtime_worker).unwrap();
|
||||
assert_eq!(
|
||||
runtime_json["repository_id"],
|
||||
"01890f47-3c22-7cc0-98c4-dc0c0c07398f"
|
||||
);
|
||||
assert!(runtime_json.get("repository_key").is_none());
|
||||
|
||||
let mut workspace_worker = worker("worker-1");
|
||||
workspace_worker.repository_key = Some("main".to_string());
|
||||
workspace_worker.validate().unwrap();
|
||||
let workspace_json = serde_json::to_value(&workspace_worker).unwrap();
|
||||
assert_eq!(workspace_json["repository_key"], "main");
|
||||
assert!(workspace_json.get("repository_id").is_none());
|
||||
|
||||
let workspace_workdir = WorkspaceSubscriptionWorkdir {
|
||||
working_directory_id: SubscriptionWorkdirId::new("workdir-1").unwrap(),
|
||||
repository_key: "main".to_string(),
|
||||
state: "active".to_string(),
|
||||
primary_worker_id: Some(worker_id("worker-1")),
|
||||
};
|
||||
workspace_workdir.validate().unwrap();
|
||||
let workdir_json = serde_json::to_value(&workspace_workdir).unwrap();
|
||||
assert_eq!(workdir_json["repository_key"], "main");
|
||||
assert!(workdir_json.get("repository_id").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscribe_frame_has_stable_versioned_json_shape() {
|
||||
let frame = SubscriptionFrame::new(SubscriptionFramePayload::Request(
|
||||
|
||||
@@ -11,14 +11,15 @@ use crate::{
|
||||
PasteArtifactRef, Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult,
|
||||
ScopeRule, Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole,
|
||||
SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
|
||||
ToolResultDisposition, TurnResult, WorkerEvent, WorkerStatus,
|
||||
ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef, WorkerEvent,
|
||||
WorkerStatus,
|
||||
subscription::{
|
||||
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
|
||||
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
|
||||
SubscriptionRequestId, SubscriptionResponse, SubscriptionSnapshot,
|
||||
SubscriptionTerminationCode, SubscriptionWorkdir, SubscriptionWorkdirId,
|
||||
SubscriptionWorker, SubscriptionWorkerId, SubscriptionWorkerIds,
|
||||
SubscriptionWorkerProtocolMethod, SubscriptionWorkerState,
|
||||
SubscriptionTerminationCode, SubscriptionWorkdirId, SubscriptionWorker,
|
||||
SubscriptionWorkerId, SubscriptionWorkerIds, SubscriptionWorkerProtocolMethod,
|
||||
SubscriptionWorkerState, WorkspaceSubscriptionWorkdir,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -59,6 +60,8 @@ pub fn generated_protocol_types() -> String {
|
||||
push_decl::<CommandEvent>(&cfg, &mut output);
|
||||
push_decl::<CompactionLifecycleState>(&cfg, &mut output);
|
||||
push_decl::<CompactionLifecycle>(&cfg, &mut output);
|
||||
push_decl::<UploadedFileAvailability>(&cfg, &mut output);
|
||||
push_decl::<UploadedFileRef>(&cfg, &mut output);
|
||||
push_decl::<ScopeRule>(&cfg, &mut output);
|
||||
push_decl::<CompletionEntry>(&cfg, &mut output);
|
||||
push_decl::<RewindTargetId>(&cfg, &mut output);
|
||||
@@ -92,7 +95,7 @@ pub fn generated_protocol_types() -> String {
|
||||
push_decl::<SubscriptionWorkerState>(&cfg, &mut output);
|
||||
push_decl::<EventSubscriptionSelector>(&cfg, &mut output);
|
||||
push_decl::<SubscriptionWorker>(&cfg, &mut output);
|
||||
push_decl::<SubscriptionWorkdir>(&cfg, &mut output);
|
||||
push_decl::<WorkspaceSubscriptionWorkdir>(&cfg, &mut output);
|
||||
push_decl::<SubscriptionSnapshot>(&cfg, &mut output);
|
||||
push_decl::<SubscriptionEventPayload>(&cfg, &mut output);
|
||||
push_decl::<SubscriptionRejectionCode>(&cfg, &mut output);
|
||||
@@ -136,6 +139,14 @@ fn export_decl(decl: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn workspace_typescript_omits_runtime_repository_ids() {
|
||||
let generated = generated_protocol_types();
|
||||
assert!(!generated.contains("repository_id?:"), "{generated}");
|
||||
assert!(!generated.contains("repository_id:"), "{generated}");
|
||||
assert!(generated.contains("repository_key"), "{generated}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_protocol_types_are_current() {
|
||||
let expected = generated_protocol_types();
|
||||
|
||||
@@ -16,6 +16,9 @@ uuid = { workspace = true, features = ["v7", "serde"] }
|
||||
thiserror = { workspace = true }
|
||||
protocol = { workspace = true }
|
||||
tracing.workspace = true
|
||||
unicode-normalization = "0.1.25"
|
||||
unicode-properties = { version = "0.1.4", features = ["general-category"] }
|
||||
unicode-security = "0.1.2"
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = { workspace = true }
|
||||
|
||||
@@ -19,8 +19,15 @@ use crate::event_trace::TraceEntry;
|
||||
use crate::paste_artifact::{read_from_dir, write_to_dir};
|
||||
use crate::segment_log::LogEntry;
|
||||
use crate::store::{Store, StoreError};
|
||||
use crate::{PasteArtifactLimits, SegmentId, SessionId};
|
||||
use protocol::PasteArtifactRef;
|
||||
use crate::uploaded_file::{
|
||||
bind_uploaded_file, clear_uploaded_file_binding, copy_committed_uploaded_files,
|
||||
delete_uncommitted_uploaded_files, delete_uploaded_file, list_uploaded_file_refs,
|
||||
read_uploaded_file, read_uploaded_file_by_id, write_uploaded_file,
|
||||
};
|
||||
use crate::{
|
||||
PasteArtifactLimits, SegmentId, SessionId, UploadedFileLimits, UploadedFileUploadContext,
|
||||
};
|
||||
use protocol::{PasteArtifactRef, UploadedFileRef};
|
||||
use std::fs;
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -115,6 +122,40 @@ impl FsStore {
|
||||
self.session_dir(session_id).join("artifacts").join("paste")
|
||||
}
|
||||
|
||||
fn uploaded_file_is_referenced(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
artifact_id: &str,
|
||||
) -> Result<bool, StoreError> {
|
||||
fn segments_contain(segments: &[protocol::Segment], artifact_id: &str) -> bool {
|
||||
segments.iter().any(|segment| {
|
||||
matches!(
|
||||
segment,
|
||||
protocol::Segment::UploadedFile { file }
|
||||
if file.artifact_id == artifact_id
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
for segment_id in self.list_segments(session_id)? {
|
||||
for entry in self.read_all(session_id, segment_id)? {
|
||||
let referenced = match entry {
|
||||
LogEntry::AnnotatedUserInput { segments, .. } => {
|
||||
segments_contain(&segments, artifact_id)
|
||||
}
|
||||
LogEntry::InputSegmentsCheckpoint { user_segments, .. } => user_segments
|
||||
.iter()
|
||||
.any(|segments| segments_contain(segments, artifact_id)),
|
||||
_ => false,
|
||||
};
|
||||
if referenced {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn paste_artifact_path(&self, session_id: SessionId, artifact_id: &str) -> PathBuf {
|
||||
self.paste_artifact_dir(session_id)
|
||||
@@ -389,6 +430,144 @@ impl Store for FsStore {
|
||||
read_from_dir(&self.paste_artifact_dir(session_id), artifact_id)
|
||||
}
|
||||
|
||||
fn write_uploaded_file(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
file_name: &str,
|
||||
media_type: &str,
|
||||
content: &[u8],
|
||||
limits: UploadedFileLimits,
|
||||
) -> Result<UploadedFileRef, StoreError> {
|
||||
let _guard = self
|
||||
.append_lock
|
||||
.lock()
|
||||
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
|
||||
write_uploaded_file(
|
||||
&self.paste_artifact_dir(session_id),
|
||||
file_name,
|
||||
media_type,
|
||||
content,
|
||||
None,
|
||||
limits,
|
||||
)
|
||||
}
|
||||
|
||||
fn write_uploaded_file_with_context(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
file_name: &str,
|
||||
media_type: &str,
|
||||
content: &[u8],
|
||||
context: &UploadedFileUploadContext,
|
||||
limits: UploadedFileLimits,
|
||||
) -> Result<UploadedFileRef, StoreError> {
|
||||
let _guard = self
|
||||
.append_lock
|
||||
.lock()
|
||||
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
|
||||
write_uploaded_file(
|
||||
&self.paste_artifact_dir(session_id),
|
||||
file_name,
|
||||
media_type,
|
||||
content,
|
||||
Some(context),
|
||||
limits,
|
||||
)
|
||||
}
|
||||
|
||||
fn read_uploaded_file(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
reference: &UploadedFileRef,
|
||||
) -> Result<Vec<u8>, StoreError> {
|
||||
read_uploaded_file(&self.paste_artifact_dir(session_id), reference)
|
||||
}
|
||||
|
||||
fn read_uploaded_file_by_id(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
artifact_id: &str,
|
||||
) -> Result<(UploadedFileRef, Vec<u8>), StoreError> {
|
||||
read_uploaded_file_by_id(&self.paste_artifact_dir(session_id), artifact_id)
|
||||
}
|
||||
|
||||
fn bind_uploaded_file(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
reference: &UploadedFileRef,
|
||||
source_entry_id: &str,
|
||||
) -> Result<UploadedFileRef, StoreError> {
|
||||
let _guard = self
|
||||
.append_lock
|
||||
.lock()
|
||||
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
|
||||
let dir = self.paste_artifact_dir(session_id);
|
||||
match bind_uploaded_file(&dir, reference, source_entry_id) {
|
||||
Err(StoreError::ArtifactAlreadyCommitted) => {
|
||||
let (stored, _) = read_uploaded_file_by_id(&dir, &reference.artifact_id)?;
|
||||
let previous_source = stored
|
||||
.source_entry_id
|
||||
.ok_or(StoreError::ArtifactIntegrityMismatch)?;
|
||||
if self.uploaded_file_is_referenced(session_id, &reference.artifact_id)? {
|
||||
return Err(StoreError::ArtifactAlreadyCommitted);
|
||||
}
|
||||
clear_uploaded_file_binding(&dir, &reference.artifact_id, &previous_source)?;
|
||||
bind_uploaded_file(&dir, reference, source_entry_id)
|
||||
}
|
||||
result => result,
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_uploaded_file(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
artifact_id: &str,
|
||||
) -> Result<bool, StoreError> {
|
||||
let _guard = self
|
||||
.append_lock
|
||||
.lock()
|
||||
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
|
||||
delete_uploaded_file(&self.paste_artifact_dir(session_id), artifact_id)
|
||||
}
|
||||
|
||||
fn delete_uncommitted_uploaded_files(&self, session_id: SessionId) -> Result<u64, StoreError> {
|
||||
let _guard = self
|
||||
.append_lock
|
||||
.lock()
|
||||
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
|
||||
let dir = self.paste_artifact_dir(session_id);
|
||||
let mut removed = delete_uncommitted_uploaded_files(&dir)?;
|
||||
for reference in list_uploaded_file_refs(&dir)? {
|
||||
let Some(source_entry_id) = reference.source_entry_id.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
if !self.uploaded_file_is_referenced(session_id, &reference.artifact_id)? {
|
||||
clear_uploaded_file_binding(&dir, &reference.artifact_id, source_entry_id)?;
|
||||
if delete_uploaded_file(&dir, &reference.artifact_id)? {
|
||||
removed = removed
|
||||
.checked_add(1)
|
||||
.ok_or(StoreError::ArtifactQuotaExceeded)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
fn copy_committed_uploaded_files(
|
||||
&self,
|
||||
source_session_id: SessionId,
|
||||
target_session_id: SessionId,
|
||||
) -> Result<u64, StoreError> {
|
||||
let _guard = self
|
||||
.append_lock
|
||||
.lock()
|
||||
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
|
||||
copy_committed_uploaded_files(
|
||||
&self.paste_artifact_dir(source_session_id),
|
||||
&self.paste_artifact_dir(target_session_id),
|
||||
)
|
||||
}
|
||||
|
||||
fn append_trace(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
@@ -548,6 +727,284 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uploaded_file_persists_trusted_upload_context_without_projecting_it() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let store = FsStore::new(tmp.path()).unwrap();
|
||||
let session_id = new_session_id();
|
||||
let context = UploadedFileUploadContext {
|
||||
upload_id: "upload-1".into(),
|
||||
principal_id: "account-1".into(),
|
||||
workspace_id: "workspace-1".into(),
|
||||
runtime_id: "runtime-1".into(),
|
||||
worker_id: "worker-1".into(),
|
||||
};
|
||||
let reference = store
|
||||
.write_uploaded_file_with_context(
|
||||
session_id,
|
||||
"notes.txt",
|
||||
"text/plain",
|
||||
b"hello",
|
||||
&context,
|
||||
UploadedFileLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let raw = fs::read_to_string(
|
||||
store
|
||||
.paste_artifact_dir(session_id)
|
||||
.join(format!("{}.file.json", reference.artifact_id)),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(raw.contains("account-1"));
|
||||
assert!(raw.contains("workspace-1"));
|
||||
assert!(raw.contains("runtime-1"));
|
||||
assert!(raw.contains("worker-1"));
|
||||
assert!(
|
||||
!serde_json::to_string(&reference)
|
||||
.unwrap()
|
||||
.contains("account-1")
|
||||
);
|
||||
|
||||
let replay = store
|
||||
.write_uploaded_file_with_context(
|
||||
session_id,
|
||||
"notes.txt",
|
||||
"text/plain",
|
||||
b"hello",
|
||||
&context,
|
||||
UploadedFileLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(replay.artifact_id, reference.artifact_id);
|
||||
assert!(matches!(
|
||||
store.write_uploaded_file_with_context(
|
||||
session_id,
|
||||
"renamed.txt",
|
||||
"text/plain",
|
||||
b"hello",
|
||||
&context,
|
||||
UploadedFileLimits::default(),
|
||||
),
|
||||
Err(StoreError::InvalidUploadedFileName)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uploaded_file_exact_replay_succeeds_at_session_count_limit() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let store = FsStore::new(tmp.path()).unwrap();
|
||||
let session_id = new_session_id();
|
||||
let limits = UploadedFileLimits {
|
||||
max_file_bytes: 1,
|
||||
max_session_bytes: crate::DEFAULT_MAX_SESSION_UPLOADED_FILES,
|
||||
};
|
||||
let mut first = None;
|
||||
for index in 0..crate::DEFAULT_MAX_SESSION_UPLOADED_FILES {
|
||||
let reference = store
|
||||
.write_uploaded_file(
|
||||
session_id,
|
||||
&format!("file-{index}.txt"),
|
||||
"text/plain",
|
||||
b"x",
|
||||
limits,
|
||||
)
|
||||
.unwrap();
|
||||
first.get_or_insert(reference);
|
||||
}
|
||||
|
||||
let replay = store
|
||||
.write_uploaded_file(session_id, "file-0.txt", "text/plain", b"x", limits)
|
||||
.unwrap();
|
||||
assert_eq!(replay.artifact_id, first.unwrap().artifact_id);
|
||||
assert!(matches!(
|
||||
store.write_uploaded_file(session_id, "overflow.txt", "text/plain", b"x", limits),
|
||||
Err(StoreError::ArtifactQuotaExceeded)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uploaded_files_are_session_scoped_integrity_checked_and_removable() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let store = FsStore::new(tmp.path()).unwrap();
|
||||
let owner = new_session_id();
|
||||
let other = new_session_id();
|
||||
let limits = UploadedFileLimits {
|
||||
max_file_bytes: 16,
|
||||
max_session_bytes: 16,
|
||||
};
|
||||
let reference = store
|
||||
.write_uploaded_file(owner, "notes.txt", "text/plain", b"hello", limits)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(reference.file_name, "notes.txt");
|
||||
assert_eq!(reference.media_type, "text/plain");
|
||||
assert_eq!(reference.byte_len, 5);
|
||||
assert_eq!(reference.source_entry_id, None);
|
||||
assert_eq!(
|
||||
store.read_uploaded_file(owner, &reference).unwrap(),
|
||||
b"hello"
|
||||
);
|
||||
assert!(store.read_uploaded_file(other, &reference).is_err());
|
||||
|
||||
let mut forged = reference.clone();
|
||||
forged.file_name = "other.txt".to_string();
|
||||
assert!(matches!(
|
||||
store.read_uploaded_file(owner, &forged),
|
||||
Err(StoreError::ArtifactIntegrityMismatch)
|
||||
));
|
||||
assert!(
|
||||
store
|
||||
.delete_uploaded_file(owner, &reference.artifact_id)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
!store
|
||||
.delete_uploaded_file(owner, &reference.artifact_id)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(store.read_uploaded_file(owner, &reference).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uploaded_file_validation_and_shared_quota_fail_closed() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let store = FsStore::new(tmp.path()).unwrap();
|
||||
let session_id = new_session_id();
|
||||
let limits = UploadedFileLimits {
|
||||
max_file_bytes: 8,
|
||||
max_session_bytes: 8,
|
||||
};
|
||||
assert!(matches!(
|
||||
store.write_uploaded_file(session_id, "../secret", "text/plain", b"x", limits),
|
||||
Err(StoreError::InvalidUploadedFileName)
|
||||
));
|
||||
assert!(matches!(
|
||||
store.write_uploaded_file(session_id, "notes.txt", "not a type", b"x", limits),
|
||||
Err(StoreError::InvalidUploadedFileMediaType)
|
||||
));
|
||||
assert!(matches!(
|
||||
store.write_uploaded_file(
|
||||
session_id,
|
||||
"safe\u{202e}txt.exe",
|
||||
"text/plain",
|
||||
b"x",
|
||||
limits
|
||||
),
|
||||
Err(StoreError::InvalidUploadedFileName)
|
||||
));
|
||||
assert!(matches!(
|
||||
store.write_uploaded_file(session_id, "image.png", "image/png", b"not a png", limits),
|
||||
Err(StoreError::ArtifactIntegrityMismatch)
|
||||
));
|
||||
let pending = store
|
||||
.write_uploaded_file(session_id, "Readme.txt", "text/plain", b"x", limits)
|
||||
.unwrap();
|
||||
let replay = store
|
||||
.write_uploaded_file(session_id, "Readme.txt", "text/plain", b"x", limits)
|
||||
.unwrap();
|
||||
assert_eq!(replay.artifact_id, pending.artifact_id);
|
||||
assert!(matches!(
|
||||
store.write_uploaded_file(session_id, "README.txt", "text/plain", b"changed", limits),
|
||||
Err(StoreError::InvalidUploadedFileName)
|
||||
));
|
||||
assert!(matches!(
|
||||
store.write_uploaded_file(session_id, "README.txt", "text/plain", b"y", limits),
|
||||
Err(StoreError::InvalidUploadedFileName)
|
||||
));
|
||||
store
|
||||
.bind_uploaded_file(session_id, &pending, "entry-from-failed-submit")
|
||||
.unwrap();
|
||||
let bound = store
|
||||
.bind_uploaded_file(session_id, &pending, "entry-upload")
|
||||
.unwrap();
|
||||
store
|
||||
.create_segment(
|
||||
session_id,
|
||||
new_segment_id(),
|
||||
&[LogEntry::InputSegmentsCheckpoint {
|
||||
ts: 1,
|
||||
user_segments: vec![vec![protocol::Segment::UploadedFile {
|
||||
file: bound.clone(),
|
||||
}]],
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
let other = store
|
||||
.write_uploaded_file(session_id, "other.txt", "text/plain", b"z", limits)
|
||||
.unwrap();
|
||||
let stale = store
|
||||
.write_uploaded_file(session_id, "stale.txt", "text/plain", b"s", limits)
|
||||
.unwrap();
|
||||
store
|
||||
.bind_uploaded_file(session_id, &stale, "entry-never-committed")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store.delete_uncommitted_uploaded_files(session_id).unwrap(),
|
||||
2
|
||||
);
|
||||
assert!(store.read_uploaded_file(session_id, &other).is_err());
|
||||
assert!(store.read_uploaded_file(session_id, &stale).is_err());
|
||||
assert_eq!(store.read_uploaded_file(session_id, &bound).unwrap(), b"x");
|
||||
let fork_session_id = new_session_id();
|
||||
assert_eq!(
|
||||
store
|
||||
.copy_committed_uploaded_files(session_id, fork_session_id)
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
store.read_uploaded_file(fork_session_id, &bound).unwrap(),
|
||||
b"x"
|
||||
);
|
||||
store
|
||||
.write_paste_artifact(
|
||||
session_id,
|
||||
"entry-1",
|
||||
"1234",
|
||||
PasteArtifactLimits {
|
||||
max_artifact_bytes: 8,
|
||||
max_session_bytes: 8,
|
||||
max_session_artifacts: 4,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
store.write_uploaded_file(session_id, "notes.txt", "text/plain", b"56789", limits),
|
||||
Err(StoreError::ArtifactQuotaExceeded)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uploaded_file_names_reject_format_mixed_script_and_confusable_forms() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let store = FsStore::new(tmp.path()).unwrap();
|
||||
let session_id = new_session_id();
|
||||
let limits = UploadedFileLimits::default();
|
||||
|
||||
for file_name in [
|
||||
"safe\u{00ad}name.txt",
|
||||
"safe\u{061c}name.txt",
|
||||
"safe\u{180e}name.txt",
|
||||
"safe\u{e0001}name.txt",
|
||||
"p\u{0430}ypal.txt",
|
||||
"report.\u{03c1}df",
|
||||
"\u{0440}\u{0430}\u{0443}\u{0440}\u{0430}\u{04cf}.txt",
|
||||
"\u{ff26}\u{ff49}\u{ff4c}\u{ff45}.txt",
|
||||
"re\u{0301}sume\u{0301}.txt",
|
||||
] {
|
||||
assert!(matches!(
|
||||
store.write_uploaded_file(session_id, file_name, "text/plain", b"safe", limits),
|
||||
Err(StoreError::InvalidUploadedFileName)
|
||||
));
|
||||
}
|
||||
|
||||
for file_name in ["notes.txt", "résumé.txt", "日本語.txt", "📎.txt"] {
|
||||
store
|
||||
.write_uploaded_file(session_id, file_name, "text/plain", b"safe", limits)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_artifact_limits_and_corruption_fail_closed() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
//! system_prompt: None,
|
||||
//! config: &config,
|
||||
//! history: Vec::new(),
|
||||
//! user_segments: Vec::new(),
|
||||
//! })?;
|
||||
//! ```
|
||||
|
||||
@@ -41,6 +42,7 @@ pub mod segment;
|
||||
pub mod segment_log;
|
||||
pub mod store;
|
||||
pub mod system_item;
|
||||
pub mod uploaded_file;
|
||||
pub mod worker_metadata;
|
||||
pub mod worker_session_store;
|
||||
|
||||
@@ -66,6 +68,11 @@ pub use store::{Store, StoreError};
|
||||
pub use system_item::{
|
||||
PromptRenderProvenance, SystemItem, SystemReminder, SystemReminderSource, render_worker_event,
|
||||
};
|
||||
pub use uploaded_file::{
|
||||
DEFAULT_MAX_FILES_PER_SUBMISSION, DEFAULT_MAX_SESSION_ARTIFACT_BYTES,
|
||||
DEFAULT_MAX_SESSION_UPLOADED_FILES, DEFAULT_MAX_UPLOADED_FILE_BYTES, UploadedFileLimits,
|
||||
UploadedFileUploadContext,
|
||||
};
|
||||
pub use worker_metadata::{
|
||||
CombinedStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerAggregateStore, WorkerMetadata,
|
||||
WorkerMetadataStore, WorkerPeer, WorkerReclaimedChild, WorkerSpawnedChild,
|
||||
|
||||
@@ -38,6 +38,34 @@ pub(crate) struct StoredPasteArtifact {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
pub(crate) fn stored_paste_usage(artifact_dir: &Path) -> Result<(u64, u64), StoreError> {
|
||||
if !artifact_dir.exists() {
|
||||
return Ok((0, 0));
|
||||
}
|
||||
let mut aggregate = 0_u64;
|
||||
let mut artifact_count = 0_u64;
|
||||
for entry in fs::read_dir(artifact_dir)? {
|
||||
let path = entry?.path();
|
||||
let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if !name.ends_with(".json") || name.ends_with(".file.json") {
|
||||
continue;
|
||||
}
|
||||
let stored: StoredPasteArtifact = serde_json::from_slice(&fs::read(&path)?)?;
|
||||
verify(&stored, &stored.reference.artifact_id)?;
|
||||
artifact_count = artifact_count.checked_add(1).ok_or_else(|| {
|
||||
StoreError::PasteArtifactLimit("session artifact count overflow".to_string())
|
||||
})?;
|
||||
aggregate = aggregate
|
||||
.checked_add(stored.reference.byte_len)
|
||||
.ok_or_else(|| {
|
||||
StoreError::PasteArtifactLimit("session aggregate size overflow".to_string())
|
||||
})?;
|
||||
}
|
||||
Ok((aggregate, artifact_count))
|
||||
}
|
||||
|
||||
pub(crate) fn write_to_dir(
|
||||
artifact_dir: &Path,
|
||||
source_entry_id: &str,
|
||||
@@ -58,24 +86,15 @@ pub(crate) fn write_to_dir(
|
||||
.write(true)
|
||||
.open(artifact_dir.join(".aggregate.lock"))?;
|
||||
FileExt::lock_exclusive(&aggregate_lock)?;
|
||||
let mut aggregate = 0_u64;
|
||||
let mut artifact_count = 0_u64;
|
||||
for entry in fs::read_dir(artifact_dir)? {
|
||||
let path = entry?.path();
|
||||
if path.extension().and_then(|value| value.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let stored: StoredPasteArtifact = serde_json::from_slice(&fs::read(&path)?)?;
|
||||
verify(&stored, &stored.reference.artifact_id)?;
|
||||
artifact_count = artifact_count.checked_add(1).ok_or_else(|| {
|
||||
StoreError::PasteArtifactLimit("session artifact count overflow".to_string())
|
||||
})?;
|
||||
aggregate = aggregate
|
||||
.checked_add(stored.reference.byte_len)
|
||||
.ok_or_else(|| {
|
||||
StoreError::PasteArtifactLimit("session aggregate size overflow".to_string())
|
||||
})?;
|
||||
}
|
||||
let (paste_bytes, artifact_count) = stored_paste_usage(artifact_dir)?;
|
||||
let (uploaded_bytes, uploaded_count) =
|
||||
crate::uploaded_file::stored_uploaded_file_usage(artifact_dir)?;
|
||||
let aggregate = paste_bytes.checked_add(uploaded_bytes).ok_or_else(|| {
|
||||
StoreError::PasteArtifactLimit("session aggregate size overflow".to_string())
|
||||
})?;
|
||||
let artifact_count = artifact_count.checked_add(uploaded_count).ok_or_else(|| {
|
||||
StoreError::PasteArtifactLimit("session artifact count overflow".to_string())
|
||||
})?;
|
||||
let projected = aggregate.checked_add(byte_len).ok_or_else(|| {
|
||||
StoreError::PasteArtifactLimit("session aggregate size overflow".to_string())
|
||||
})?;
|
||||
|
||||
@@ -41,6 +41,24 @@ pub fn project_session_snapshot(session_id: SessionId, log: &[LogEntry]) -> Sess
|
||||
entries.clear();
|
||||
extend_history(&mut entries, history, None, *ts);
|
||||
}
|
||||
LogEntry::InputSegmentsCheckpoint { user_segments, .. } => {
|
||||
let mut segments = user_segments.iter();
|
||||
for entry in &mut entries {
|
||||
let is_user = matches!(
|
||||
&entry.data,
|
||||
SessionSnapshotEntryData::UserInput { .. }
|
||||
| SessionSnapshotEntryData::Message {
|
||||
role: SessionMessageRole::User,
|
||||
..
|
||||
}
|
||||
);
|
||||
if is_user && let Some(checkpoint) = segments.next() {
|
||||
entry.data = SessionSnapshotEntryData::UserInput {
|
||||
segments: checkpoint.clone(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts,
|
||||
segments,
|
||||
@@ -357,6 +375,63 @@ mod tests {
|
||||
assert!(json.contains("visible"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compacted_checkpoint_restores_uploaded_file_segments() {
|
||||
let session_id = crate::new_session_id();
|
||||
let user_entry_id = LoggedSessionHistoryEntryId::new();
|
||||
let file = protocol::UploadedFileRef {
|
||||
artifact_id: "019ca7c8-57b6-7f05-8edf-524147aba7b3".into(),
|
||||
file_name: "notes.md".into(),
|
||||
media_type: "text/markdown".into(),
|
||||
created_at_ms: 7,
|
||||
availability: protocol::UploadedFileAvailability::Available,
|
||||
byte_len: 12,
|
||||
sha256: "a".repeat(64),
|
||||
source_entry_id: Some(user_entry_id.0.clone()),
|
||||
};
|
||||
let segment = Segment::UploadedFile { file };
|
||||
let log = vec![
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 10,
|
||||
session_id,
|
||||
system_prompt: None,
|
||||
config: RequestConfig::default(),
|
||||
history: vec![LoggedHistoryEntry {
|
||||
item: LoggedItem::Message {
|
||||
role: LoggedRole::User,
|
||||
content: vec![LoggedContentPart::Text {
|
||||
text: "[Attached file: notes.md]".into(),
|
||||
}],
|
||||
},
|
||||
metadata: LoggedSessionHistoryMetadata {
|
||||
entry_id: user_entry_id,
|
||||
origin: LoggedSessionHistoryOrigin::HumanInput {
|
||||
account_id: "account-1".into(),
|
||||
},
|
||||
derivation: None,
|
||||
},
|
||||
}],
|
||||
forked_from: None,
|
||||
compacted_from: Some(crate::SegmentOrigin {
|
||||
segment_id: crate::new_segment_id(),
|
||||
at_turn_index: 1,
|
||||
}),
|
||||
},
|
||||
LogEntry::InputSegmentsCheckpoint {
|
||||
ts: 10,
|
||||
user_segments: vec![vec![segment.clone()]],
|
||||
},
|
||||
];
|
||||
|
||||
let snapshot = project_current_session_snapshot(&log);
|
||||
assert_eq!(
|
||||
snapshot.entries[0].data,
|
||||
SessionSnapshotEntryData::UserInput {
|
||||
segments: vec![segment]
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotated_user_input_attaches_segments_to_first_user_role_entry_for_any_origin() {
|
||||
let session_id = crate::new_session_id();
|
||||
|
||||
@@ -17,6 +17,33 @@ pub struct SegmentStartState<'a> {
|
||||
pub system_prompt: Option<&'a str>,
|
||||
pub config: &'a RequestConfig,
|
||||
pub history: Vec<LoggedHistoryEntry>,
|
||||
pub user_segments: Vec<Vec<Segment>>,
|
||||
}
|
||||
|
||||
fn seed_entries(
|
||||
ts: u64,
|
||||
session_id: SessionId,
|
||||
state: SegmentStartState<'_>,
|
||||
forked_from: Option<SegmentOrigin>,
|
||||
compacted_from: Option<SegmentOrigin>,
|
||||
) -> Vec<LogEntry> {
|
||||
let entry = LogEntry::AnnotatedSegmentStart {
|
||||
ts,
|
||||
session_id,
|
||||
system_prompt: state.system_prompt.map(String::from),
|
||||
config: state.config.clone(),
|
||||
history: state.history,
|
||||
forked_from,
|
||||
compacted_from,
|
||||
};
|
||||
let mut entries = vec![entry];
|
||||
if !state.user_segments.is_empty() {
|
||||
entries.push(LogEntry::InputSegmentsCheckpoint {
|
||||
ts,
|
||||
user_segments: state.user_segments,
|
||||
});
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
/// Create a new session + initial segment, writing the initial
|
||||
@@ -42,16 +69,8 @@ pub fn create_segment_with_ids(
|
||||
segment_id: SegmentId,
|
||||
state: SegmentStartState<'_>,
|
||||
) -> Result<(), StoreError> {
|
||||
let entry = LogEntry::AnnotatedSegmentStart {
|
||||
ts: segment_log::now_millis(),
|
||||
session_id,
|
||||
system_prompt: state.system_prompt.map(String::from),
|
||||
config: state.config.clone(),
|
||||
history: state.history.to_vec(),
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
};
|
||||
store.append(session_id, segment_id, &entry)
|
||||
let entries = seed_entries(segment_log::now_millis(), session_id, state, None, None);
|
||||
store.create_segment(session_id, segment_id, &entries)
|
||||
}
|
||||
|
||||
/// Create a compacted segment from an existing one. Inherits the source's
|
||||
@@ -68,19 +87,17 @@ pub fn create_compacted_segment(
|
||||
source_turn_count: usize,
|
||||
) -> Result<SegmentId, StoreError> {
|
||||
let segment_id = crate::new_segment_id();
|
||||
let entry = LogEntry::AnnotatedSegmentStart {
|
||||
ts: segment_log::now_millis(),
|
||||
session_id: source_session_id,
|
||||
system_prompt: state.system_prompt.map(String::from),
|
||||
config: state.config.clone(),
|
||||
history: state.history.to_vec(),
|
||||
forked_from: None,
|
||||
compacted_from: Some(SegmentOrigin {
|
||||
let entries = seed_entries(
|
||||
segment_log::now_millis(),
|
||||
source_session_id,
|
||||
state,
|
||||
None,
|
||||
Some(SegmentOrigin {
|
||||
segment_id: source_segment_id,
|
||||
at_turn_index: source_turn_count,
|
||||
}),
|
||||
};
|
||||
store.append(source_session_id, segment_id, &entry)?;
|
||||
);
|
||||
store.create_segment(source_session_id, segment_id, &entries)?;
|
||||
Ok(segment_id)
|
||||
}
|
||||
|
||||
@@ -152,21 +169,19 @@ pub fn ensure_head_or_fork(
|
||||
}
|
||||
let source_segment_id = *segment_id;
|
||||
let fork_id = crate::new_segment_id();
|
||||
let entry = LogEntry::AnnotatedSegmentStart {
|
||||
ts: segment_log::now_millis(),
|
||||
let entries = seed_entries(
|
||||
segment_log::now_millis(),
|
||||
session_id,
|
||||
system_prompt: state.system_prompt.map(String::from),
|
||||
config: state.config.clone(),
|
||||
history: state.history.to_vec(),
|
||||
forked_from: Some(SegmentOrigin {
|
||||
state,
|
||||
Some(SegmentOrigin {
|
||||
segment_id: source_segment_id,
|
||||
at_turn_index,
|
||||
}),
|
||||
compacted_from: None,
|
||||
};
|
||||
store.create_segment(session_id, fork_id, &[entry])?;
|
||||
None,
|
||||
);
|
||||
store.create_segment(session_id, fork_id, &entries)?;
|
||||
*segment_id = fork_id;
|
||||
*entries_written = 1;
|
||||
*entries_written = entries.len();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -420,20 +435,14 @@ pub fn save_config_changed(
|
||||
/// [`fork_at`] or [`ensure_head_or_fork`] instead.
|
||||
pub fn fork(
|
||||
store: &impl Store,
|
||||
source_session_id: SessionId,
|
||||
state: SegmentStartState<'_>,
|
||||
) -> Result<(SessionId, SegmentId), StoreError> {
|
||||
let session_id = crate::new_session_id();
|
||||
let fork_id = crate::new_segment_id();
|
||||
let entry = LogEntry::AnnotatedSegmentStart {
|
||||
ts: segment_log::now_millis(),
|
||||
session_id,
|
||||
system_prompt: state.system_prompt.map(String::from),
|
||||
config: state.config.clone(),
|
||||
history: state.history.to_vec(),
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
};
|
||||
store.create_segment(session_id, fork_id, &[entry])?;
|
||||
let entries = seed_entries(segment_log::now_millis(), session_id, state, None, None);
|
||||
store.create_segment(session_id, fork_id, &entries)?;
|
||||
store.copy_committed_uploaded_files(source_session_id, session_id)?;
|
||||
Ok((session_id, fork_id))
|
||||
}
|
||||
|
||||
@@ -460,11 +469,18 @@ pub fn fork_at(
|
||||
) -> Result<SegmentId, StoreError> {
|
||||
let entries = store.read_all(source_session_id, source_id)?;
|
||||
let cut = if at_turn_index == 0 {
|
||||
// Branch directly after the SegmentStart (or whatever opens the
|
||||
// segment), before any turn completes.
|
||||
// Branch from the seeded state before any new turn completes. A typed
|
||||
// input checkpoint immediately following SegmentStart is part of that
|
||||
// seed and must stay atomic with its annotated history.
|
||||
entries
|
||||
.iter()
|
||||
.position(|e| !matches!(e, LogEntry::AnnotatedSegmentStart { .. }))
|
||||
.position(|entry| {
|
||||
!matches!(
|
||||
entry,
|
||||
LogEntry::AnnotatedSegmentStart { .. }
|
||||
| LogEntry::InputSegmentsCheckpoint { .. }
|
||||
)
|
||||
})
|
||||
.unwrap_or(entries.len())
|
||||
} else {
|
||||
entries
|
||||
@@ -476,8 +492,9 @@ pub fn fork_at(
|
||||
let state = segment_log::collect_state(&entries[..cut]);
|
||||
|
||||
let fork_id = crate::new_segment_id();
|
||||
let ts = segment_log::now_millis();
|
||||
let entry = LogEntry::AnnotatedSegmentStart {
|
||||
ts: segment_log::now_millis(),
|
||||
ts,
|
||||
session_id: source_session_id,
|
||||
system_prompt: state.system_prompt,
|
||||
config: state.config,
|
||||
@@ -488,7 +505,14 @@ pub fn fork_at(
|
||||
}),
|
||||
compacted_from: None,
|
||||
};
|
||||
store.create_segment(source_session_id, fork_id, &[entry])?;
|
||||
let mut fork_entries = vec![entry];
|
||||
if !state.user_segments.is_empty() {
|
||||
fork_entries.push(LogEntry::InputSegmentsCheckpoint {
|
||||
ts,
|
||||
user_segments: state.user_segments,
|
||||
});
|
||||
}
|
||||
store.create_segment(source_session_id, fork_id, &fork_entries)?;
|
||||
Ok(fork_id)
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,14 @@ pub enum LogEntry {
|
||||
compacted_from: Option<SegmentOrigin>,
|
||||
},
|
||||
|
||||
/// Typed user-segment projection accompanying a compacted or forked
|
||||
/// SegmentStart history snapshot. This keeps attachment identity and
|
||||
/// metadata aligned with retained user entries without embedding bodies.
|
||||
InputSegmentsCheckpoint {
|
||||
ts: u64,
|
||||
user_segments: Vec<Vec<Segment>>,
|
||||
},
|
||||
|
||||
/// IDLE → active marker. Records the start of a new self-driving
|
||||
/// cycle (Invoke range). The range extends implicitly until the
|
||||
/// next `Invoke` entry; this entry carries the trigger only — the
|
||||
@@ -273,6 +281,9 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
||||
.map(|entry| Item::from(entry.item))
|
||||
.collect();
|
||||
}
|
||||
LogEntry::InputSegmentsCheckpoint { user_segments, .. } => {
|
||||
state.user_segments = user_segments.clone();
|
||||
}
|
||||
LogEntry::Invoke { .. } => {
|
||||
// A terminal run record below clears or refines this. If the
|
||||
// log ends first, restore must treat the turn as interrupted.
|
||||
|
||||
@@ -13,8 +13,10 @@
|
||||
|
||||
use crate::event_trace::TraceEntry;
|
||||
use crate::segment_log::LogEntry;
|
||||
use crate::{PasteArtifactLimits, SegmentId, SessionId};
|
||||
use protocol::PasteArtifactRef;
|
||||
use crate::{
|
||||
PasteArtifactLimits, SegmentId, SessionId, UploadedFileLimits, UploadedFileUploadContext,
|
||||
};
|
||||
use protocol::{PasteArtifactRef, UploadedFileRef};
|
||||
|
||||
/// Errors from the persistence store.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -42,6 +44,30 @@ pub enum StoreError {
|
||||
|
||||
#[error("paste artifact size limit exceeded: {0}")]
|
||||
PasteArtifactLimit(String),
|
||||
|
||||
#[error("uploaded file is too large")]
|
||||
ArtifactTooLarge,
|
||||
|
||||
#[error("session artifact aggregate quota exceeded")]
|
||||
ArtifactQuotaExceeded,
|
||||
|
||||
#[error("uploaded file reference integrity check failed")]
|
||||
ArtifactIntegrityMismatch,
|
||||
|
||||
#[error("uploaded file name is invalid")]
|
||||
InvalidUploadedFileName,
|
||||
|
||||
#[error("uploaded file media type is invalid")]
|
||||
InvalidUploadedFileMediaType,
|
||||
|
||||
#[error("uploaded file is already committed to session history")]
|
||||
ArtifactAlreadyCommitted,
|
||||
|
||||
#[error("artifact id is invalid")]
|
||||
InvalidArtifactId,
|
||||
|
||||
#[error("artifact timestamp is invalid")]
|
||||
InvalidTimestamp,
|
||||
}
|
||||
|
||||
/// Sync persistence backend for segment logs.
|
||||
@@ -150,6 +176,77 @@ pub trait Store: Send + Sync {
|
||||
Err(StoreError::PasteArtifactUnsupported)
|
||||
}
|
||||
|
||||
/// Persist a client-local file before a submission references it.
|
||||
fn write_uploaded_file(
|
||||
&self,
|
||||
_session_id: SessionId,
|
||||
_file_name: &str,
|
||||
_media_type: &str,
|
||||
_content: &[u8],
|
||||
_limits: UploadedFileLimits,
|
||||
) -> Result<UploadedFileRef, StoreError> {
|
||||
Err(StoreError::PasteArtifactUnsupported)
|
||||
}
|
||||
|
||||
fn write_uploaded_file_with_context(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
file_name: &str,
|
||||
media_type: &str,
|
||||
content: &[u8],
|
||||
_context: &UploadedFileUploadContext,
|
||||
limits: UploadedFileLimits,
|
||||
) -> Result<UploadedFileRef, StoreError> {
|
||||
self.write_uploaded_file(session_id, file_name, media_type, content, limits)
|
||||
}
|
||||
|
||||
/// Read and integrity-check an uploaded file owned by `session_id`.
|
||||
fn read_uploaded_file(
|
||||
&self,
|
||||
_session_id: SessionId,
|
||||
_reference: &UploadedFileRef,
|
||||
) -> Result<Vec<u8>, StoreError> {
|
||||
Err(StoreError::PasteArtifactUnsupported)
|
||||
}
|
||||
|
||||
fn read_uploaded_file_by_id(
|
||||
&self,
|
||||
_session_id: SessionId,
|
||||
_artifact_id: &str,
|
||||
) -> Result<(UploadedFileRef, Vec<u8>), StoreError> {
|
||||
Err(StoreError::PasteArtifactUnsupported)
|
||||
}
|
||||
|
||||
fn bind_uploaded_file(
|
||||
&self,
|
||||
_session_id: SessionId,
|
||||
_reference: &UploadedFileRef,
|
||||
_source_entry_id: &str,
|
||||
) -> Result<UploadedFileRef, StoreError> {
|
||||
Err(StoreError::PasteArtifactUnsupported)
|
||||
}
|
||||
|
||||
/// Delete an uncommitted uploaded file owned by `session_id`.
|
||||
fn delete_uploaded_file(
|
||||
&self,
|
||||
_session_id: SessionId,
|
||||
_artifact_id: &str,
|
||||
) -> Result<bool, StoreError> {
|
||||
Err(StoreError::PasteArtifactUnsupported)
|
||||
}
|
||||
|
||||
fn delete_uncommitted_uploaded_files(&self, _session_id: SessionId) -> Result<u64, StoreError> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn copy_committed_uploaded_files(
|
||||
&self,
|
||||
_source_session_id: SessionId,
|
||||
_target_session_id: SessionId,
|
||||
) -> Result<u64, StoreError> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Append a trace entry to the debug event trace file.
|
||||
fn append_trace(
|
||||
&self,
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::Path,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||
use fs4::fs_std::FileExt;
|
||||
use protocol::{UploadedFileAvailability, UploadedFileRef};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
use unicode_properties::general_category::{GeneralCategory, UnicodeGeneralCategory};
|
||||
use unicode_security::{confusable_detection::skeleton, mixed_script::MixedScript};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::StoreError;
|
||||
|
||||
type Result<T> = std::result::Result<T, StoreError>;
|
||||
|
||||
pub const DEFAULT_MAX_UPLOADED_FILE_BYTES: u64 = 10 * 1024 * 1024;
|
||||
pub const DEFAULT_MAX_SESSION_ARTIFACT_BYTES: u64 = 32 * 1024 * 1024;
|
||||
pub const DEFAULT_MAX_FILES_PER_SUBMISSION: usize = 8;
|
||||
pub const DEFAULT_MAX_SESSION_UPLOADED_FILES: u64 = 256;
|
||||
const MAX_FILE_NAME_CHARS: usize = 255;
|
||||
const MAX_MEDIA_TYPE_BYTES: usize = 127;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct UploadedFileLimits {
|
||||
pub max_file_bytes: u64,
|
||||
pub max_session_bytes: u64,
|
||||
}
|
||||
|
||||
impl Default for UploadedFileLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_file_bytes: DEFAULT_MAX_UPLOADED_FILE_BYTES,
|
||||
max_session_bytes: DEFAULT_MAX_SESSION_ARTIFACT_BYTES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UploadedFileUploadContext {
|
||||
pub upload_id: String,
|
||||
pub principal_id: String,
|
||||
pub workspace_id: String,
|
||||
pub runtime_id: String,
|
||||
pub worker_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct StoredUploadedFile {
|
||||
file_name: String,
|
||||
media_type: String,
|
||||
created_at_ms: u64,
|
||||
byte_len: u64,
|
||||
sha256: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
source_entry_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
upload_context: Option<UploadedFileUploadContext>,
|
||||
content_base64: String,
|
||||
}
|
||||
|
||||
pub(crate) fn validate_file_name(file_name: &str) -> Result<()> {
|
||||
let normalized: String = file_name.nfkc().collect();
|
||||
let has_unsafe_component = file_name
|
||||
.split('.')
|
||||
.filter(|part| !part.is_empty())
|
||||
.any(|part| {
|
||||
let confusable_skeleton: String = skeleton(part).collect();
|
||||
let ascii_confusable = part.chars().any(|ch| !ch.is_ascii())
|
||||
&& confusable_skeleton.is_ascii()
|
||||
&& !confusable_skeleton.eq_ignore_ascii_case(part);
|
||||
!part.is_single_script() || ascii_confusable
|
||||
});
|
||||
|
||||
if file_name.is_empty()
|
||||
|| file_name.chars().count() > MAX_FILE_NAME_CHARS
|
||||
|| file_name == "."
|
||||
|| file_name == ".."
|
||||
|| normalized != file_name
|
||||
|| has_unsafe_component
|
||||
|| file_name.chars().any(|ch| {
|
||||
ch.is_control()
|
||||
|| ch.general_category() == GeneralCategory::Format
|
||||
|| matches!(ch, '/' | '\\')
|
||||
})
|
||||
{
|
||||
return Err(StoreError::InvalidUploadedFileName);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_media_type(media_type: &str) -> Result<()> {
|
||||
let valid = !media_type.is_empty()
|
||||
&& media_type.len() <= MAX_MEDIA_TYPE_BYTES
|
||||
&& media_type.is_ascii()
|
||||
&& !media_type
|
||||
.bytes()
|
||||
.any(|byte| byte.is_ascii_control() || byte == b' ')
|
||||
&& media_type.split_once('/').is_some_and(|(kind, subtype)| {
|
||||
!kind.is_empty()
|
||||
&& !subtype.is_empty()
|
||||
&& kind.bytes().chain(subtype.bytes()).all(|byte| {
|
||||
byte.is_ascii_alphanumeric()
|
||||
|| matches!(
|
||||
byte,
|
||||
b'!' | b'#' | b'$' | b'&' | b'^' | b'_' | b'.' | b'+' | b'-'
|
||||
)
|
||||
})
|
||||
});
|
||||
let allowed = media_type.starts_with("text/")
|
||||
|| matches!(
|
||||
media_type,
|
||||
"application/json"
|
||||
| "application/pdf"
|
||||
| "image/png"
|
||||
| "image/jpeg"
|
||||
| "image/gif"
|
||||
| "image/webp"
|
||||
);
|
||||
if !valid || !allowed {
|
||||
return Err(StoreError::InvalidUploadedFileMediaType);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalized_file_name(file_name: &str) -> String {
|
||||
file_name.nfkc().flat_map(char::to_lowercase).collect()
|
||||
}
|
||||
|
||||
fn validate_content(media_type: &str, content: &[u8]) -> Result<()> {
|
||||
if content.is_empty() {
|
||||
return Err(StoreError::InvalidUploadedFileMediaType);
|
||||
}
|
||||
let matches_declared_type = if media_type.starts_with("text/") {
|
||||
std::str::from_utf8(content).is_ok()
|
||||
} else {
|
||||
match media_type {
|
||||
"application/json" => serde_json::from_slice::<serde_json::Value>(content).is_ok(),
|
||||
"application/pdf" => content.starts_with(b"%PDF-"),
|
||||
"image/png" => content.starts_with(b"\x89PNG\r\n\x1a\n"),
|
||||
"image/jpeg" => content.starts_with(&[0xff, 0xd8, 0xff]),
|
||||
"image/gif" => content.starts_with(b"GIF87a") || content.starts_with(b"GIF89a"),
|
||||
"image/webp" => {
|
||||
content.len() >= 12 && content.starts_with(b"RIFF") && &content[8..12] == b"WEBP"
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
if !matches_declared_type {
|
||||
return Err(StoreError::ArtifactIntegrityMismatch);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn record_path(dir: &Path, artifact_id: &str) -> Result<std::path::PathBuf> {
|
||||
let id = Uuid::parse_str(artifact_id).map_err(|_| StoreError::InvalidArtifactId)?;
|
||||
Ok(dir.join(format!("{id}.file.json")))
|
||||
}
|
||||
|
||||
fn now_ms() -> Result<u64> {
|
||||
let value = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|_| StoreError::InvalidTimestamp)?
|
||||
.as_millis();
|
||||
u64::try_from(value).map_err(|_| StoreError::InvalidTimestamp)
|
||||
}
|
||||
|
||||
fn digest(bytes: &[u8]) -> String {
|
||||
Sha256::digest(bytes)
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn stored_uploaded_file_usage(dir: &Path) -> Result<(u64, u64)> {
|
||||
if !dir.exists() {
|
||||
return Ok((0, 0));
|
||||
}
|
||||
let mut bytes = 0_u64;
|
||||
let mut count = 0_u64;
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if !entry.file_type()?.is_file()
|
||||
|| !path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.ends_with(".file.json"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
|
||||
bytes = bytes
|
||||
.checked_add(stored.byte_len)
|
||||
.ok_or(StoreError::ArtifactQuotaExceeded)?;
|
||||
count = count
|
||||
.checked_add(1)
|
||||
.ok_or(StoreError::ArtifactQuotaExceeded)?;
|
||||
}
|
||||
Ok((bytes, count))
|
||||
}
|
||||
|
||||
pub(crate) fn write_uploaded_file(
|
||||
dir: &Path,
|
||||
file_name: &str,
|
||||
media_type: &str,
|
||||
content: &[u8],
|
||||
context: Option<&UploadedFileUploadContext>,
|
||||
limits: UploadedFileLimits,
|
||||
) -> Result<UploadedFileRef> {
|
||||
validate_file_name(file_name)?;
|
||||
validate_media_type(media_type)?;
|
||||
validate_content(media_type, content)?;
|
||||
let byte_len = u64::try_from(content.len()).map_err(|_| StoreError::ArtifactTooLarge)?;
|
||||
let sha256 = digest(content);
|
||||
if byte_len > limits.max_file_bytes {
|
||||
return Err(StoreError::ArtifactTooLarge);
|
||||
}
|
||||
|
||||
fs::create_dir_all(dir)?;
|
||||
let aggregate_lock = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(dir.join(".aggregate.lock"))?;
|
||||
FileExt::lock_exclusive(&aggregate_lock)?;
|
||||
let (paste_bytes, _) = crate::paste_artifact::stored_paste_usage(dir)?;
|
||||
let (file_bytes, file_count) = stored_uploaded_file_usage(dir)?;
|
||||
let normalized_name = normalized_file_name(file_name);
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let path = entry?.path();
|
||||
if !path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.ends_with(".file.json"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
|
||||
let same_context = context.is_some() && stored.upload_context.as_ref() == context;
|
||||
let same_uncommitted_name = stored.source_entry_id.is_none()
|
||||
&& normalized_file_name(&stored.file_name) == normalized_name;
|
||||
if same_context || same_uncommitted_name {
|
||||
if stored.file_name == file_name
|
||||
&& stored.media_type == media_type
|
||||
&& stored.byte_len == byte_len
|
||||
&& stored.sha256 == sha256
|
||||
&& stored.upload_context.as_ref() == context
|
||||
{
|
||||
let artifact_id = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.and_then(|name| name.strip_suffix(".file.json"))
|
||||
.ok_or(StoreError::InvalidArtifactId)?
|
||||
.to_string();
|
||||
return Ok(UploadedFileRef {
|
||||
artifact_id,
|
||||
file_name: stored.file_name,
|
||||
media_type: stored.media_type,
|
||||
created_at_ms: stored.created_at_ms,
|
||||
availability: UploadedFileAvailability::Available,
|
||||
byte_len: stored.byte_len,
|
||||
sha256: stored.sha256,
|
||||
source_entry_id: None,
|
||||
});
|
||||
}
|
||||
return Err(StoreError::InvalidUploadedFileName);
|
||||
}
|
||||
}
|
||||
if file_count >= DEFAULT_MAX_SESSION_UPLOADED_FILES {
|
||||
return Err(StoreError::ArtifactQuotaExceeded);
|
||||
}
|
||||
if paste_bytes
|
||||
.checked_add(file_bytes)
|
||||
.and_then(|total| total.checked_add(byte_len))
|
||||
.is_none_or(|total| total > limits.max_session_bytes)
|
||||
{
|
||||
return Err(StoreError::ArtifactQuotaExceeded);
|
||||
}
|
||||
|
||||
let artifact_id = Uuid::now_v7().to_string();
|
||||
let created_at_ms = now_ms()?;
|
||||
let stored = StoredUploadedFile {
|
||||
file_name: file_name.to_owned(),
|
||||
media_type: media_type.to_owned(),
|
||||
created_at_ms,
|
||||
byte_len,
|
||||
sha256: sha256.clone(),
|
||||
source_entry_id: None,
|
||||
upload_context: context.cloned(),
|
||||
content_base64: BASE64.encode(content),
|
||||
};
|
||||
let path = record_path(dir, &artifact_id)?;
|
||||
let temp = dir.join(format!(".{artifact_id}.file.tmp"));
|
||||
fs::write(&temp, serde_json::to_vec(&stored)?)?;
|
||||
fs::rename(&temp, &path)?;
|
||||
|
||||
Ok(UploadedFileRef {
|
||||
artifact_id,
|
||||
file_name: file_name.to_owned(),
|
||||
media_type: media_type.to_owned(),
|
||||
created_at_ms,
|
||||
availability: UploadedFileAvailability::Available,
|
||||
byte_len,
|
||||
sha256,
|
||||
source_entry_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn read_uploaded_file_by_id(
|
||||
dir: &Path,
|
||||
artifact_id: &str,
|
||||
) -> Result<(UploadedFileRef, Vec<u8>)> {
|
||||
let stored: StoredUploadedFile =
|
||||
serde_json::from_slice(&fs::read(record_path(dir, artifact_id)?)?)?;
|
||||
let content = BASE64
|
||||
.decode(&stored.content_base64)
|
||||
.map_err(|_| StoreError::ArtifactIntegrityMismatch)?;
|
||||
if u64::try_from(content.len()).ok() != Some(stored.byte_len)
|
||||
|| digest(&content) != stored.sha256
|
||||
{
|
||||
return Err(StoreError::ArtifactIntegrityMismatch);
|
||||
}
|
||||
let reference = UploadedFileRef {
|
||||
artifact_id: artifact_id.to_owned(),
|
||||
file_name: stored.file_name,
|
||||
media_type: stored.media_type,
|
||||
created_at_ms: stored.created_at_ms,
|
||||
availability: UploadedFileAvailability::Available,
|
||||
byte_len: stored.byte_len,
|
||||
sha256: stored.sha256,
|
||||
source_entry_id: stored.source_entry_id,
|
||||
};
|
||||
Ok((reference, content))
|
||||
}
|
||||
|
||||
pub(crate) fn read_uploaded_file(dir: &Path, reference: &UploadedFileRef) -> Result<Vec<u8>> {
|
||||
let (stored_reference, content) = read_uploaded_file_by_id(dir, &reference.artifact_id)?;
|
||||
if stored_reference.file_name != reference.file_name
|
||||
|| stored_reference.media_type != reference.media_type
|
||||
|| stored_reference.created_at_ms != reference.created_at_ms
|
||||
|| stored_reference.byte_len != reference.byte_len
|
||||
|| stored_reference.sha256 != reference.sha256
|
||||
|| stored_reference.source_entry_id != reference.source_entry_id
|
||||
{
|
||||
return Err(StoreError::ArtifactIntegrityMismatch);
|
||||
}
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
pub(crate) fn clear_uploaded_file_binding(
|
||||
dir: &Path,
|
||||
artifact_id: &str,
|
||||
expected_source_entry_id: &str,
|
||||
) -> Result<()> {
|
||||
fs::create_dir_all(dir)?;
|
||||
let aggregate_lock = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(dir.join(".aggregate.lock"))?;
|
||||
FileExt::lock_exclusive(&aggregate_lock)?;
|
||||
let path = record_path(dir, artifact_id)?;
|
||||
let mut stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
|
||||
if stored.source_entry_id.as_deref() != Some(expected_source_entry_id) {
|
||||
return Err(StoreError::ArtifactIntegrityMismatch);
|
||||
}
|
||||
stored.source_entry_id = None;
|
||||
let temp = dir.join(format!(".{artifact_id}.file.unbind.tmp"));
|
||||
fs::write(&temp, serde_json::to_vec(&stored)?)?;
|
||||
fs::rename(temp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn bind_uploaded_file(
|
||||
dir: &Path,
|
||||
reference: &UploadedFileRef,
|
||||
source_entry_id: &str,
|
||||
) -> Result<UploadedFileRef> {
|
||||
if source_entry_id.is_empty() || reference.source_entry_id.is_some() {
|
||||
return Err(StoreError::ArtifactIntegrityMismatch);
|
||||
}
|
||||
let aggregate_lock = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(dir.join(".aggregate.lock"))?;
|
||||
FileExt::lock_exclusive(&aggregate_lock)?;
|
||||
let (stored_reference, _) = read_uploaded_file_by_id(dir, &reference.artifact_id)?;
|
||||
if stored_reference.file_name != reference.file_name
|
||||
|| stored_reference.media_type != reference.media_type
|
||||
|| stored_reference.created_at_ms != reference.created_at_ms
|
||||
|| stored_reference.byte_len != reference.byte_len
|
||||
|| stored_reference.sha256 != reference.sha256
|
||||
{
|
||||
return Err(StoreError::ArtifactIntegrityMismatch);
|
||||
}
|
||||
let path = record_path(dir, &reference.artifact_id)?;
|
||||
let mut stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
|
||||
if stored.source_entry_id.is_some() {
|
||||
return Err(StoreError::ArtifactAlreadyCommitted);
|
||||
}
|
||||
stored.source_entry_id = Some(source_entry_id.to_owned());
|
||||
let temp = dir.join(format!(".{}.file.bind.tmp", reference.artifact_id));
|
||||
fs::write(&temp, serde_json::to_vec(&stored)?)?;
|
||||
fs::rename(&temp, path)?;
|
||||
let mut bound = reference.clone();
|
||||
bound.source_entry_id = Some(source_entry_id.to_owned());
|
||||
Ok(bound)
|
||||
}
|
||||
|
||||
pub(crate) fn list_uploaded_file_refs(dir: &Path) -> Result<Vec<UploadedFileRef>> {
|
||||
if !dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut refs = Vec::new();
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let path = entry?.path();
|
||||
let Some(artifact_id) = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.and_then(|name| name.strip_suffix(".file.json"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
refs.push(read_uploaded_file_by_id(dir, artifact_id)?.0);
|
||||
}
|
||||
Ok(refs)
|
||||
}
|
||||
|
||||
pub(crate) fn copy_committed_uploaded_files(source_dir: &Path, target_dir: &Path) -> Result<u64> {
|
||||
if !source_dir.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
fs::create_dir_all(target_dir)?;
|
||||
let target_lock = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(target_dir.join(".aggregate.lock"))?;
|
||||
FileExt::lock_exclusive(&target_lock)?;
|
||||
let mut copied = 0_u64;
|
||||
for entry in fs::read_dir(source_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if !name.ends_with(".file.json") {
|
||||
continue;
|
||||
}
|
||||
let bytes = fs::read(&path)?;
|
||||
let stored: StoredUploadedFile = serde_json::from_slice(&bytes)?;
|
||||
if stored.source_entry_id.is_none() {
|
||||
continue;
|
||||
}
|
||||
let target = target_dir.join(name);
|
||||
if target.exists() {
|
||||
let existing: StoredUploadedFile = serde_json::from_slice(&fs::read(&target)?)?;
|
||||
if existing.sha256 != stored.sha256
|
||||
|| existing.file_name != stored.file_name
|
||||
|| existing.source_entry_id != stored.source_entry_id
|
||||
{
|
||||
return Err(StoreError::ArtifactIntegrityMismatch);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let temp = target_dir.join(format!(".{name}.copy.tmp"));
|
||||
fs::write(&temp, &bytes)?;
|
||||
fs::rename(temp, target)?;
|
||||
copied = copied
|
||||
.checked_add(1)
|
||||
.ok_or(StoreError::ArtifactQuotaExceeded)?;
|
||||
}
|
||||
Ok(copied)
|
||||
}
|
||||
|
||||
pub(crate) fn delete_uncommitted_uploaded_files(dir: &Path) -> Result<u64> {
|
||||
fs::create_dir_all(dir)?;
|
||||
let aggregate_lock = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(dir.join(".aggregate.lock"))?;
|
||||
FileExt::lock_exclusive(&aggregate_lock)?;
|
||||
let mut removed = 0_u64;
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if !path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.ends_with(".file.json"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
|
||||
if stored.source_entry_id.is_none() {
|
||||
fs::remove_file(path)?;
|
||||
removed = removed
|
||||
.checked_add(1)
|
||||
.ok_or(StoreError::ArtifactQuotaExceeded)?;
|
||||
}
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
pub(crate) fn delete_uploaded_file(dir: &Path, artifact_id: &str) -> Result<bool> {
|
||||
fs::create_dir_all(dir)?;
|
||||
let aggregate_lock = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(dir.join(".aggregate.lock"))?;
|
||||
FileExt::lock_exclusive(&aggregate_lock)?;
|
||||
let path = record_path(dir, artifact_id)?;
|
||||
let stored = match fs::read(&path) {
|
||||
Ok(bytes) => serde_json::from_slice::<StoredUploadedFile>(&bytes)?,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if stored.source_entry_id.is_some() {
|
||||
return Err(StoreError::ArtifactAlreadyCommitted);
|
||||
}
|
||||
match fs::remove_file(path) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use agen::{Engine, History};
|
||||
use async_trait::async_trait;
|
||||
use common::MockLlmClient;
|
||||
use protocol::{Segment, SessionSnapshotEntryData, UploadedFileAvailability, UploadedFileRef};
|
||||
use session_store::{FsStore, LogEntry, SegmentStartState, Store, collect_state};
|
||||
|
||||
// =============================================================================
|
||||
@@ -236,6 +237,7 @@ async fn session_run_logs_entries() {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: annotated(&worker.history()),
|
||||
user_segments: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -284,6 +286,7 @@ async fn session_restore_round_trip() {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: annotated(&worker.history()),
|
||||
user_segments: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -323,6 +326,7 @@ async fn session_run_with_tool_call() {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: annotated(&worker.history()),
|
||||
user_segments: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -358,6 +362,7 @@ async fn session_resume_after_pause() {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: annotated(&worker.history()),
|
||||
user_segments: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -397,6 +402,7 @@ async fn session_fork_creates_new_session() {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: annotated(&worker.history()),
|
||||
user_segments: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -404,28 +410,38 @@ async fn session_fork_creates_new_session() {
|
||||
let (worker, _) = run_and_persist(worker, &store, sid, segid, "Hello").await;
|
||||
|
||||
let original_history_len = worker.history().len();
|
||||
let source_user_segments = session_store::restore(&store, sid, segid)
|
||||
.unwrap()
|
||||
.user_segments;
|
||||
let (fork_sid, fork_segid) = session_store::fork(
|
||||
&store,
|
||||
sid,
|
||||
SegmentStartState {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: annotated(&worker.history()),
|
||||
user_segments: source_user_segments.clone(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(fork_sid, sid, "`fork` mints a fresh Session");
|
||||
|
||||
// Fork should have a SegmentStart with the current history
|
||||
// Fork should have an annotated seed and typed input checkpoint.
|
||||
let fork_entries = store.read_all(fork_sid, fork_segid).unwrap();
|
||||
assert_eq!(fork_entries.len(), 1);
|
||||
assert_eq!(fork_entries.len(), 2);
|
||||
assert!(matches!(
|
||||
&fork_entries[0],
|
||||
LogEntry::AnnotatedSegmentStart { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
&fork_entries[1],
|
||||
LogEntry::InputSegmentsCheckpoint { .. }
|
||||
));
|
||||
|
||||
let fork_state = collect_state(&fork_entries);
|
||||
assert_eq!(fork_state.session_id, Some(fork_sid));
|
||||
assert_eq!(fork_state.history.len(), original_history_len);
|
||||
assert_eq!(fork_state.user_segments, source_user_segments);
|
||||
assert_eq!(fork_state.system_prompt.as_deref(), Some("System prompt"));
|
||||
}
|
||||
|
||||
@@ -441,6 +457,7 @@ async fn session_fork_at_truncates_within_session() {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: annotated(&worker.history()),
|
||||
user_segments: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -454,7 +471,11 @@ async fn session_fork_at_truncates_within_session() {
|
||||
let fork_segid = session_store::fork_at(&store, sid, segid, worker.turn_count()).unwrap();
|
||||
|
||||
let fork_entries = store.read_all(sid, fork_segid).unwrap();
|
||||
assert_eq!(fork_entries.len(), 1); // Just the new SegmentStart
|
||||
assert_eq!(fork_entries.len(), 2);
|
||||
assert!(matches!(
|
||||
&fork_entries[1],
|
||||
LogEntry::InputSegmentsCheckpoint { .. }
|
||||
));
|
||||
|
||||
let fork_state = collect_state(&fork_entries);
|
||||
assert_eq!(fork_state.session_id, Some(sid), "fork_at inherits Session");
|
||||
@@ -466,6 +487,7 @@ async fn session_fork_at_truncates_within_session() {
|
||||
.position(|e| matches!(e, LogEntry::TurnEnd { turn_count, .. } if *turn_count == worker.turn_count()))
|
||||
.expect("source segment has the matching TurnEnd");
|
||||
let source_state_at_fork = collect_state(&all_entries[..=turn_end_pos]);
|
||||
assert_eq!(fork_state.user_segments, source_state_at_fork.user_segments);
|
||||
assert_eq!(fork_state.history.len(), source_state_at_fork.history.len());
|
||||
assert_eq!(
|
||||
fork_state.annotated_history, source_state_at_fork.annotated_history,
|
||||
@@ -491,6 +513,84 @@ async fn session_fork_at_truncates_within_session() {
|
||||
assert!(segs.contains(&fork_segid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewound_fork_preserves_uploaded_file_segments_in_snapshot() {
|
||||
let (_dir, store) = make_store();
|
||||
let config = RequestConfig::default();
|
||||
let (sid, segid) = session_store::create_segment(
|
||||
&store,
|
||||
SegmentStartState {
|
||||
system_prompt: Some("System prompt"),
|
||||
config: &config,
|
||||
history: Vec::new(),
|
||||
user_segments: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let uploaded = UploadedFileRef {
|
||||
artifact_id: "uploaded-file-1".into(),
|
||||
file_name: "notes.txt".into(),
|
||||
media_type: "text/plain".into(),
|
||||
created_at_ms: 123,
|
||||
availability: UploadedFileAvailability::Available,
|
||||
byte_len: 5,
|
||||
sha256: "a".repeat(64),
|
||||
source_entry_id: Some("entry-1".into()),
|
||||
};
|
||||
let segments = vec![Segment::UploadedFile {
|
||||
file: uploaded.clone(),
|
||||
}];
|
||||
session_store::save_user_input(
|
||||
&store,
|
||||
sid,
|
||||
segid,
|
||||
segments.clone(),
|
||||
annotated(&[Item::user_message(Segment::flatten_to_text(&segments))]),
|
||||
)
|
||||
.unwrap();
|
||||
session_store::save_turn_end(&store, sid, segid, 1).unwrap();
|
||||
|
||||
let fork_segid = session_store::fork_at(&store, sid, segid, 1).unwrap();
|
||||
let fork_entries = store.read_all(sid, fork_segid).unwrap();
|
||||
let snapshot = session_store::public_snapshot::project_session_snapshot(sid, &fork_entries);
|
||||
|
||||
assert!(fork_entries.iter().any(|entry| matches!(
|
||||
entry,
|
||||
LogEntry::InputSegmentsCheckpoint { user_segments, .. }
|
||||
if user_segments == &vec![segments.clone()]
|
||||
)));
|
||||
assert!(snapshot.entries.iter().any(|entry| matches!(
|
||||
&entry.data,
|
||||
SessionSnapshotEntryData::UserInput { segments: restored }
|
||||
if restored == &segments
|
||||
)));
|
||||
|
||||
let fork_state = collect_state(&fork_entries);
|
||||
let (copied_session_id, copied_segment_id) = session_store::fork(
|
||||
&store,
|
||||
sid,
|
||||
SegmentStartState {
|
||||
system_prompt: fork_state.system_prompt.as_deref(),
|
||||
config: &fork_state.config,
|
||||
history: fork_state.annotated_history.clone(),
|
||||
user_segments: fork_state.user_segments.clone(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let copied_entries = store
|
||||
.read_all(copied_session_id, copied_segment_id)
|
||||
.unwrap();
|
||||
let copied_snapshot = session_store::public_snapshot::project_session_snapshot(
|
||||
copied_session_id,
|
||||
&copied_entries,
|
||||
);
|
||||
assert!(copied_snapshot.entries.iter().any(|entry| matches!(
|
||||
&entry.data,
|
||||
SessionSnapshotEntryData::UserInput { segments: restored }
|
||||
if restored == &segments
|
||||
)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_config_changed_logged() {
|
||||
let (_dir, store) = make_store();
|
||||
@@ -503,6 +603,7 @@ async fn session_config_changed_logged() {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: annotated(&worker.history()),
|
||||
user_segments: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -536,6 +637,7 @@ async fn session_auto_forks_on_conflict() {
|
||||
system_prompt: worker_a.get_system_prompt(),
|
||||
config: worker_a.request_config(),
|
||||
history: annotated(&worker_a.history()),
|
||||
user_segments: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -564,6 +666,7 @@ async fn session_auto_forks_on_conflict() {
|
||||
system_prompt: worker_a.get_system_prompt(),
|
||||
config: worker_a.request_config(),
|
||||
history: annotated(&worker_a.history()),
|
||||
user_segments: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -623,6 +726,7 @@ async fn nested_past_fork_leaves_ancestors_immutable() {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: annotated(&worker.history()),
|
||||
user_segments: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -653,12 +757,19 @@ async fn nested_past_fork_leaves_ancestors_immutable() {
|
||||
let fork1_entries = store.read_all(sid, fork1).unwrap();
|
||||
assert_eq!(
|
||||
fork1_entries.len(),
|
||||
1,
|
||||
"fork1 is just its SegmentStart seed"
|
||||
2,
|
||||
"fork1 stores its SegmentStart and typed input checkpoint"
|
||||
);
|
||||
|
||||
// fork2's lineage points at fork1, not the root.
|
||||
match &store.read_all(sid, fork2).unwrap()[0] {
|
||||
// fork2's lineage points at fork1, not the root, and the typed seed remains
|
||||
// intact across the nested turn-zero fork.
|
||||
let fork2_entries = store.read_all(sid, fork2).unwrap();
|
||||
assert_eq!(fork2_entries.len(), 2);
|
||||
assert_eq!(
|
||||
collect_state(&fork2_entries).user_segments,
|
||||
collect_state(&fork1_entries).user_segments
|
||||
);
|
||||
match &fork2_entries[0] {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
forked_from: Some(origin),
|
||||
..
|
||||
|
||||
@@ -489,6 +489,7 @@ pub struct NewTicket {
|
||||
pub workflow_state: Option<TicketWorkflowState>,
|
||||
pub queued_by: Option<String>,
|
||||
pub queued_at: Option<String>,
|
||||
#[serde(rename = "repository_key")]
|
||||
pub repository_id: Option<String>,
|
||||
pub ref_selector: Option<String>,
|
||||
}
|
||||
@@ -519,6 +520,7 @@ impl NewTicket {
|
||||
#[serde(tag = "action", rename_all = "snake_case")]
|
||||
pub enum TicketTargetEdit {
|
||||
Set {
|
||||
#[serde(rename = "repository_key")]
|
||||
repository_id: String,
|
||||
ref_selector: Option<String>,
|
||||
},
|
||||
@@ -1610,6 +1612,7 @@ pub struct TicketMeta {
|
||||
pub workflow_state_explicit: bool,
|
||||
pub queued_by: Option<String>,
|
||||
pub queued_at: Option<String>,
|
||||
#[serde(rename = "repository_key")]
|
||||
pub repository_id: Option<String>,
|
||||
pub ref_selector: Option<String>,
|
||||
pub raw: BTreeMap<String, String>,
|
||||
|
||||
@@ -402,8 +402,8 @@ struct TicketCreateParams {
|
||||
queued_at: Option<String>,
|
||||
/// Optional target Workspace repository id.
|
||||
#[serde(default)]
|
||||
repository_id: Option<String>,
|
||||
/// Optional target Git ref selector. Requires `repository_id`.
|
||||
repository_key: Option<String>,
|
||||
/// Optional target Git ref selector. Requires `repository_key`.
|
||||
#[serde(default)]
|
||||
ref_selector: Option<String>,
|
||||
}
|
||||
@@ -944,7 +944,7 @@ impl Tool for TicketCreateTool {
|
||||
input.workflow_state = params.state.map(TicketWorkflowStateParam::into_state);
|
||||
input.queued_by = None;
|
||||
input.queued_at = params.queued_at;
|
||||
input.repository_id = params.repository_id;
|
||||
input.repository_id = params.repository_key;
|
||||
input.ref_selector = params.ref_selector;
|
||||
|
||||
let created = self
|
||||
@@ -1173,7 +1173,7 @@ impl Tool for TicketMarkReadyTool {
|
||||
json!({
|
||||
"ticket": ticket.meta.id,
|
||||
"state": ticket.meta.workflow_state.as_str(),
|
||||
"repository_id": ticket.meta.repository_id,
|
||||
"repository_key": ticket.meta.repository_id,
|
||||
"ref_selector": ticket.meta.ref_selector,
|
||||
"ok": true
|
||||
}),
|
||||
@@ -1206,7 +1206,7 @@ impl Tool for TicketIntakeReadyTool {
|
||||
json!({
|
||||
"ticket": ticket.meta.id,
|
||||
"state": ticket.meta.workflow_state.as_str(),
|
||||
"repository_id": ticket.meta.repository_id,
|
||||
"repository_key": ticket.meta.repository_id,
|
||||
"ref_selector": ticket.meta.ref_selector,
|
||||
"ok": true
|
||||
}),
|
||||
@@ -1940,11 +1940,11 @@ mod tests {
|
||||
fn resolve_target(
|
||||
&self,
|
||||
_workspace_id: &str,
|
||||
repository_id: Option<&str>,
|
||||
repository_key: Option<&str>,
|
||||
ref_selector: Option<&str>,
|
||||
) -> crate::Result<crate::ResolvedTicketTarget> {
|
||||
Ok(crate::ResolvedTicketTarget {
|
||||
repository_id: repository_id.unwrap_or("main").to_owned(),
|
||||
repository_id: repository_key.unwrap_or("main").to_owned(),
|
||||
ref_selector: ref_selector.unwrap_or("develop").to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -30,4 +30,5 @@ pulldown-cmark = { version = "0.13.3", default-features = false }
|
||||
agen.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait.workspace = true
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -778,6 +778,20 @@ impl App {
|
||||
Some(self.method_for_run(segments))
|
||||
}
|
||||
|
||||
pub fn restore_unsent_run(&mut self, method: &Method) {
|
||||
let Method::Run { input } = method else {
|
||||
return;
|
||||
};
|
||||
self.pending_submit_rollback = None;
|
||||
if self.input.is_empty() {
|
||||
self.input.replace_with_segments(input);
|
||||
self.completion = None;
|
||||
} else {
|
||||
self.queued_inputs
|
||||
.push_front(QueuedInput::new(input.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
fn method_for_run(&mut self, segments: Vec<Segment>) -> Method {
|
||||
// TurnHeader / UserMessage blocks are pushed only after the Worker
|
||||
// emits `Event::UserMessage` from a committed `LogEntry::AnnotatedUserInput`.
|
||||
@@ -928,6 +942,10 @@ impl App {
|
||||
Some(self.method_for_run(queued.segments))
|
||||
}
|
||||
|
||||
pub fn clear_actionbar_notice(&mut self) {
|
||||
self.actionbar_notice = None;
|
||||
}
|
||||
|
||||
pub fn push_error(&mut self, message: impl Into<String>) {
|
||||
self.blocks.push(Block::Alert {
|
||||
level: AlertLevel::Error,
|
||||
|
||||
@@ -7,15 +7,15 @@ use client::{
|
||||
list_backend_workers, restore_backend_worker,
|
||||
};
|
||||
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Constraint, Layout};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::{Frame, Terminal, TerminalOptions, Viewport};
|
||||
|
||||
use crate::backend_workspace_picker::select_backend_workspace;
|
||||
use crate::console;
|
||||
use crate::inline_terminal::with_inline_terminal;
|
||||
|
||||
const MAX_ROWS: usize = 10;
|
||||
const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4;
|
||||
@@ -127,31 +127,32 @@ fn pick_worker(
|
||||
workers.truncate(MAX_ROWS);
|
||||
|
||||
let mut state = BackendWorkerPickerState::new(target, workers);
|
||||
let mut terminal = make_inline_terminal()?;
|
||||
loop {
|
||||
terminal.draw(|frame| draw(frame, &state))?;
|
||||
match poll_event()? {
|
||||
None => continue,
|
||||
Some(Action::Up) => state.previous(),
|
||||
Some(Action::Down) => state.next(),
|
||||
Some(Action::Submit) => {
|
||||
close_viewport(&mut terminal)?;
|
||||
return Ok(WorkerPickerResult::Selected(
|
||||
state.selected_worker().clone(),
|
||||
));
|
||||
with_inline_terminal(
|
||||
VIEWPORT_LINES,
|
||||
|terminal| -> Result<_, Box<dyn std::error::Error>> {
|
||||
loop {
|
||||
terminal.draw(|frame| draw(frame, &state))?;
|
||||
match poll_event()? {
|
||||
None => continue,
|
||||
Some(Action::Up) => state.previous(),
|
||||
Some(Action::Down) => state.next(),
|
||||
Some(Action::Submit) => {
|
||||
return Ok(WorkerPickerResult::Selected(
|
||||
state.selected_worker().clone(),
|
||||
));
|
||||
}
|
||||
Some(Action::SwitchWorkspace) => {
|
||||
return Ok(WorkerPickerResult::SwitchWorkspace);
|
||||
}
|
||||
Some(Action::Cancel) => {
|
||||
return Err(Box::new(io::Error::other(
|
||||
"Backend worker picker cancelled",
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Action::SwitchWorkspace) => {
|
||||
close_viewport(&mut terminal)?;
|
||||
return Ok(WorkerPickerResult::SwitchWorkspace);
|
||||
}
|
||||
Some(Action::Cancel) => {
|
||||
close_viewport(&mut terminal)?;
|
||||
return Err(Box::new(io::Error::other(
|
||||
"Backend worker picker cancelled",
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
struct BackendWorkerPickerState {
|
||||
@@ -184,27 +185,6 @@ impl BackendWorkerPickerState {
|
||||
}
|
||||
}
|
||||
|
||||
fn make_inline_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
|
||||
let backend = CrosstermBackend::new(io::stdout());
|
||||
Terminal::with_options(
|
||||
backend,
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Inline(VIEWPORT_LINES),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn close_viewport(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
|
||||
let area = terminal.get_frame().area();
|
||||
let last_row = area.bottom().saturating_sub(1);
|
||||
terminal.set_cursor_position((0, last_row))?;
|
||||
use std::io::Write;
|
||||
let mut out = io::stdout();
|
||||
out.write_all(b"\r\n")?;
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
enum Action {
|
||||
Up,
|
||||
Down,
|
||||
@@ -370,7 +350,7 @@ fn working_directory_text(worker: &BackendWorkerSummary) -> String {
|
||||
let cleanliness = wd.cleanliness.as_deref().unwrap_or("unknown");
|
||||
format!(
|
||||
"wd:{}:{} {} {}",
|
||||
wd.repository_id, wd.working_directory_id, wd.status, cleanliness
|
||||
wd.repository_key, wd.working_directory_id, wd.status, cleanliness
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+611
-18
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::future::Future;
|
||||
use std::io;
|
||||
@@ -15,9 +16,9 @@ use crossterm::event::{
|
||||
};
|
||||
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
|
||||
use crossterm::{Command, execute};
|
||||
use protocol::{Event, Method, WorkerStatus};
|
||||
use protocol::{Event, Method, Segment, UploadedFileRef, WorkerStatus};
|
||||
#[cfg(feature = "e2e-test")]
|
||||
use protocol::{Greeting, RewindSummary, RewindTarget, RewindTargetId, Segment};
|
||||
use protocol::{Greeting, RewindSummary, RewindTarget, RewindTargetId};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use standalone::{StandaloneHost, StandaloneLaunchConfig};
|
||||
@@ -120,23 +121,141 @@ fn copy_selection_to_terminal(app: &mut App) -> bool {
|
||||
copy_selection_to_writer(app, &mut stdout)
|
||||
}
|
||||
|
||||
type AttachmentUploadResult = Result<UploadedFileRef, String>;
|
||||
|
||||
fn mark_attachments_after_transport_acceptance(
|
||||
pending: &mut Vec<UploadedFileRef>,
|
||||
awaiting_acceptance: &mut Vec<UploadedFileRef>,
|
||||
) {
|
||||
awaiting_acceptance.append(pending);
|
||||
}
|
||||
|
||||
fn reconcile_attachment_submission(
|
||||
pending: &mut Vec<UploadedFileRef>,
|
||||
awaiting_acceptance: &mut Vec<UploadedFileRef>,
|
||||
event: &Event,
|
||||
) -> bool {
|
||||
match event {
|
||||
Event::UserMessage { segments, .. } => {
|
||||
let accepted_ids = segments
|
||||
.iter()
|
||||
.filter_map(|segment| match segment {
|
||||
Segment::UploadedFile { file } => Some(file.artifact_id.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let awaited_ids = awaiting_acceptance
|
||||
.iter()
|
||||
.map(|file| file.artifact_id.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
let accepts_exact_submission = !awaited_ids.is_empty() && accepted_ids == awaited_ids;
|
||||
if accepts_exact_submission {
|
||||
awaiting_acceptance.clear();
|
||||
}
|
||||
accepts_exact_submission
|
||||
}
|
||||
Event::Error { .. } if !awaiting_acceptance.is_empty() => {
|
||||
pending.append(awaiting_acceptance);
|
||||
false
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
struct ConsoleConnection<T> {
|
||||
client: Client<T>,
|
||||
standalone_host: Option<StandaloneHost>,
|
||||
backend_target: Option<BackendRuntimeTarget>,
|
||||
pending_attachments: Vec<UploadedFileRef>,
|
||||
awaiting_attachment_acceptance: Vec<UploadedFileRef>,
|
||||
upload_tasks: Vec<tokio::task::JoinHandle<()>>,
|
||||
active_uploads: usize,
|
||||
upload_ids: HashMap<PathBuf, String>,
|
||||
}
|
||||
|
||||
async fn upload_client_path(
|
||||
target: &BackendRuntimeTarget,
|
||||
path: &Path,
|
||||
upload_id: &str,
|
||||
) -> Result<UploadedFileRef, Box<dyn std::error::Error>> {
|
||||
let metadata = tokio::fs::metadata(path).await?;
|
||||
if !metadata.is_file() {
|
||||
return Err(
|
||||
io::Error::new(io::ErrorKind::InvalidInput, "attachment path is not a file").into(),
|
||||
);
|
||||
}
|
||||
if metadata.len() > 10 * 1024 * 1024 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"attachment exceeds the 10 MiB limit",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"attachment file name is not valid UTF-8",
|
||||
)
|
||||
})?;
|
||||
let media_type = attachment_media_type(path).ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"attachment file type is not supported",
|
||||
)
|
||||
})?;
|
||||
let bytes = tokio::fs::read(path).await?;
|
||||
target
|
||||
.upload_file_with_id(upload_id, file_name, media_type, bytes)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
fn attachment_media_type(path: &Path) -> Option<&'static str> {
|
||||
match path
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())?
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"txt" | "log" | "rs" | "toml" | "yaml" | "yml" | "dcdl" | "csv" => Some("text/plain"),
|
||||
"md" => Some("text/markdown"),
|
||||
"json" => Some("application/json"),
|
||||
"pdf" => Some("application/pdf"),
|
||||
"png" => Some("image/png"),
|
||||
"jpg" | "jpeg" => Some("image/jpeg"),
|
||||
"gif" => Some("image/gif"),
|
||||
"webp" => Some("image/webp"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Socket> ConsoleConnection<T> {
|
||||
fn new(client: Client<T>) -> Self {
|
||||
Self {
|
||||
client,
|
||||
standalone_host: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_standalone_host(client: Client<T>, host: StandaloneHost) -> Self {
|
||||
Self {
|
||||
client,
|
||||
standalone_host: Some(host),
|
||||
backend_target: None,
|
||||
pending_attachments: Vec::new(),
|
||||
awaiting_attachment_acceptance: Vec::new(),
|
||||
upload_tasks: Vec::new(),
|
||||
active_uploads: 0,
|
||||
upload_ids: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_backend_target(client: Client<T>, target: BackendRuntimeTarget) -> Self {
|
||||
Self {
|
||||
client,
|
||||
standalone_host: None,
|
||||
backend_target: Some(target),
|
||||
pending_attachments: Vec::new(),
|
||||
awaiting_attachment_acceptance: Vec::new(),
|
||||
upload_tasks: Vec::new(),
|
||||
active_uploads: 0,
|
||||
upload_ids: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,10 +268,102 @@ impl<T: Socket> ConsoleConnection<T> {
|
||||
}
|
||||
|
||||
async fn send(&mut self, method: &Method) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(self.client.send(method).await?)
|
||||
let mut prepared = method.clone();
|
||||
let carries_attachments =
|
||||
matches!(prepared, Method::Run { .. }) && !self.pending_attachments.is_empty();
|
||||
if let Method::Run { input } = &mut prepared {
|
||||
input.extend(
|
||||
self.pending_attachments
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|file| Segment::UploadedFile { file }),
|
||||
);
|
||||
}
|
||||
self.client.send(&prepared).await?;
|
||||
if carries_attachments {
|
||||
self.mark_attachments_awaiting_acceptance();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start_upload(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
result_tx: mpsc::UnboundedSender<AttachmentUploadResult>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let target = self.backend_target.clone().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"client-local file upload is available only for Backend Workers",
|
||||
)
|
||||
})?;
|
||||
self.upload_tasks.retain(|task| !task.is_finished());
|
||||
let upload_id = self
|
||||
.upload_ids
|
||||
.entry(path.clone())
|
||||
.or_insert_with(|| uuid::Uuid::now_v7().to_string())
|
||||
.clone();
|
||||
self.active_uploads = self.active_uploads.saturating_add(1);
|
||||
self.upload_tasks.push(tokio::spawn(async move {
|
||||
let result = upload_client_path(&target, &path, &upload_id)
|
||||
.await
|
||||
.map_err(|error| error.to_string());
|
||||
let _ = result_tx.send(result);
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish_upload(&mut self) {
|
||||
self.active_uploads = self.active_uploads.saturating_sub(1);
|
||||
self.upload_tasks.retain(|task| !task.is_finished());
|
||||
}
|
||||
|
||||
fn has_active_uploads(&self) -> bool {
|
||||
self.active_uploads != 0
|
||||
}
|
||||
|
||||
fn observe_worker_event(&mut self, event: &Event) -> bool {
|
||||
let attachments_accepted = reconcile_attachment_submission(
|
||||
&mut self.pending_attachments,
|
||||
&mut self.awaiting_attachment_acceptance,
|
||||
event,
|
||||
);
|
||||
if attachments_accepted {
|
||||
self.upload_ids.clear();
|
||||
}
|
||||
attachments_accepted
|
||||
}
|
||||
|
||||
fn mark_attachments_awaiting_acceptance(&mut self) {
|
||||
mark_attachments_after_transport_acceptance(
|
||||
&mut self.pending_attachments,
|
||||
&mut self.awaiting_attachment_acceptance,
|
||||
);
|
||||
}
|
||||
|
||||
async fn clear_pending_attachments(&mut self) {
|
||||
for task in self.upload_tasks.drain(..) {
|
||||
task.abort();
|
||||
}
|
||||
self.active_uploads = 0;
|
||||
let upload_ids = std::mem::take(&mut self.upload_ids)
|
||||
.into_values()
|
||||
.collect::<Vec<_>>();
|
||||
let references = std::mem::take(&mut self.pending_attachments);
|
||||
if let Some(target) = &self.backend_target {
|
||||
for upload_id in upload_ids {
|
||||
let _ = target.cancel_file_upload(&upload_id).await;
|
||||
}
|
||||
for reference in references {
|
||||
let _ = target.delete_uploaded_file(&reference.artifact_id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn shutdown(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
self.pending_attachments
|
||||
.append(&mut self.awaiting_attachment_acceptance);
|
||||
self.clear_pending_attachments().await;
|
||||
if let Some(host) = self.standalone_host.take() {
|
||||
host.shutdown().await?;
|
||||
}
|
||||
@@ -248,12 +459,13 @@ pub(crate) async fn run_backend_runtime(
|
||||
target: BackendRuntimeTarget,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let worker_label = target.display_label();
|
||||
let attachment_target = target.clone();
|
||||
let client = connect_backend_runtime(target).await?;
|
||||
let mut terminal = enter_fullscreen()?;
|
||||
let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||
let mut app = App::new_with_persistent_input_history(worker_label, &workspace_root);
|
||||
app.connected = true;
|
||||
let mut connection = ConsoleConnection::new(client);
|
||||
let mut connection = ConsoleConnection::with_backend_target(client, attachment_target);
|
||||
let result = run_loop(&mut terminal, &mut app, &mut connection).await;
|
||||
let _ = leave_fullscreen(&mut terminal);
|
||||
result
|
||||
@@ -529,11 +741,13 @@ enum E2eRewindInput {
|
||||
enum LoopInput<P> {
|
||||
Terminal(TerminalEventResult),
|
||||
Worker(P),
|
||||
Upload(AttachmentUploadResult),
|
||||
Tick,
|
||||
}
|
||||
|
||||
async fn next_loop_input<P, F, T>(
|
||||
term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>,
|
||||
upload_rx: &mut mpsc::UnboundedReceiver<AttachmentUploadResult>,
|
||||
connected: bool,
|
||||
pod_next: F,
|
||||
animate: bool,
|
||||
@@ -554,6 +768,9 @@ where
|
||||
))
|
||||
}))
|
||||
}
|
||||
upload = upload_rx.recv() => {
|
||||
LoopInput::Upload(upload.unwrap_or_else(|| Err("attachment upload queue stopped".into())))
|
||||
}
|
||||
event = pod_next, if connected => LoopInput::Worker(event),
|
||||
_ = animation_tick, if animate => LoopInput::Tick,
|
||||
}
|
||||
@@ -563,13 +780,14 @@ async fn drain_terminal_events<T: Socket>(
|
||||
app: &mut App,
|
||||
client: &mut ConsoleConnection<T>,
|
||||
term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>,
|
||||
upload_tx: &mpsc::UnboundedSender<AttachmentUploadResult>,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let mut handled = false;
|
||||
for _ in 0..TERMINAL_EVENT_DRAIN_LIMIT {
|
||||
match term_rx.try_recv() {
|
||||
Ok(event) => {
|
||||
handled = true;
|
||||
handle_terminal_event(app, client, event?).await?;
|
||||
handle_terminal_event(app, client, upload_tx, event?).await?;
|
||||
if app.quit {
|
||||
break;
|
||||
}
|
||||
@@ -595,8 +813,11 @@ async fn drain_worker_events<T: Socket>(
|
||||
match client.try_next_event()? {
|
||||
Some(ev) => {
|
||||
handled = true;
|
||||
if client.observe_worker_event(&ev) {
|
||||
app.clear_actionbar_notice();
|
||||
}
|
||||
if let Some(method) = app.handle_worker_event(ev) {
|
||||
client.send(&method).await?;
|
||||
send_console_method(app, client, &method).await?;
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
@@ -611,6 +832,7 @@ async fn run_loop<T: Socket>(
|
||||
client: &mut ConsoleConnection<T>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?;
|
||||
let (upload_tx, mut upload_rx) = mpsc::unbounded_channel();
|
||||
let mut animation_tick = tokio::time::interval(Duration::from_millis(80));
|
||||
animation_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
@@ -621,7 +843,8 @@ async fn run_loop<T: Socket>(
|
||||
break;
|
||||
}
|
||||
|
||||
let handled_term_event = drain_terminal_events(app, client, &mut term_rx).await?;
|
||||
let handled_term_event =
|
||||
drain_terminal_events(app, client, &mut term_rx, &upload_tx).await?;
|
||||
if app.quit {
|
||||
break;
|
||||
}
|
||||
@@ -633,6 +856,7 @@ async fn run_loop<T: Socket>(
|
||||
|
||||
match next_loop_input(
|
||||
&mut term_rx,
|
||||
&mut upload_rx,
|
||||
app.connected,
|
||||
client.next_event(),
|
||||
app.running,
|
||||
@@ -641,12 +865,39 @@ async fn run_loop<T: Socket>(
|
||||
.await
|
||||
{
|
||||
LoopInput::Terminal(term_event) => {
|
||||
handle_terminal_event(app, client, term_event?).await?;
|
||||
handle_terminal_event(app, client, &upload_tx, term_event?).await?;
|
||||
}
|
||||
LoopInput::Upload(result) => {
|
||||
client.finish_upload();
|
||||
match result {
|
||||
Ok(reference) => {
|
||||
app.flash_actionbar_notice(
|
||||
format!(
|
||||
"[{} · {} bytes · ready] Send a message or use /clear-attachments.",
|
||||
reference.file_name, reference.byte_len
|
||||
),
|
||||
ActionbarNoticeLevel::Info,
|
||||
ActionbarNoticeSource::Tui,
|
||||
Duration::from_secs(60 * 60),
|
||||
);
|
||||
if !client
|
||||
.pending_attachments
|
||||
.iter()
|
||||
.any(|pending| pending.artifact_id == reference.artifact_id)
|
||||
{
|
||||
client.pending_attachments.push(reference);
|
||||
}
|
||||
}
|
||||
Err(error) => app.push_error(format!("Attachment upload failed: {error}")),
|
||||
}
|
||||
}
|
||||
LoopInput::Worker(event) => match event? {
|
||||
Some(ev) => {
|
||||
if client.observe_worker_event(&ev) {
|
||||
app.clear_actionbar_notice();
|
||||
}
|
||||
if let Some(method) = app.handle_worker_event(ev) {
|
||||
client.send(&method).await?;
|
||||
send_console_method(app, client, &method).await?;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
@@ -664,15 +915,104 @@ async fn run_loop<T: Socket>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn attachment_command_path(method: &Method) -> Option<PathBuf> {
|
||||
let Method::Run { input } = method else {
|
||||
return None;
|
||||
};
|
||||
let [Segment::Text { content }] = input.as_slice() else {
|
||||
return None;
|
||||
};
|
||||
let path = content.strip_prefix("/attach ")?.trim();
|
||||
(!path.is_empty()).then(|| PathBuf::from(path))
|
||||
}
|
||||
|
||||
fn is_clear_attachments_command(method: &Method) -> bool {
|
||||
let Method::Run { input } = method else {
|
||||
return false;
|
||||
};
|
||||
matches!(
|
||||
input.as_slice(),
|
||||
[Segment::Text { content }] if content.trim() == "/clear-attachments"
|
||||
)
|
||||
}
|
||||
|
||||
async fn send_console_method<T: Socket>(
|
||||
app: &mut App,
|
||||
client: &mut ConsoleConnection<T>,
|
||||
method: &Method,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if matches!(method, Method::Run { .. }) && client.has_active_uploads() {
|
||||
app.restore_unsent_run(method);
|
||||
app.flash_actionbar_notice(
|
||||
"Attachment upload is still in progress; wait or use /clear-attachments.",
|
||||
ActionbarNoticeLevel::Info,
|
||||
ActionbarNoticeSource::Tui,
|
||||
Duration::from_secs(60 * 60),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let sends_attachments =
|
||||
matches!(method, Method::Run { .. }) && !client.pending_attachments.is_empty();
|
||||
if let Err(error) = client.send(method).await {
|
||||
if sends_attachments {
|
||||
app.restore_unsent_run(method);
|
||||
app.push_error(format!(
|
||||
"Attachment submission failed: {error}. Pending attachments were retained; retry Send."
|
||||
));
|
||||
app.flash_actionbar_notice(
|
||||
"Attachment submission failed; pending attachments are ready to retry.",
|
||||
ActionbarNoticeLevel::Error,
|
||||
ActionbarNoticeSource::Tui,
|
||||
Duration::from_secs(60 * 60),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
if sends_attachments {
|
||||
app.flash_actionbar_notice(
|
||||
"Submitting attachments; waiting for Worker acceptance…",
|
||||
ActionbarNoticeLevel::Info,
|
||||
ActionbarNoticeSource::Tui,
|
||||
Duration::from_secs(60 * 60),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_terminal_event<T: Socket>(
|
||||
app: &mut App,
|
||||
client: &mut ConsoleConnection<T>,
|
||||
upload_tx: &mpsc::UnboundedSender<AttachmentUploadResult>,
|
||||
event: TermEvent,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match event {
|
||||
TermEvent::Key(key) => {
|
||||
if let Some(method) = handle_key(app, key) {
|
||||
client.send(&method).await?;
|
||||
if let Some(path) = attachment_command_path(&method) {
|
||||
match client.start_upload(path, upload_tx.clone()) {
|
||||
Ok(()) => app.flash_actionbar_notice(
|
||||
"Uploading attachment… Use /clear-attachments to cancel.",
|
||||
ActionbarNoticeLevel::Info,
|
||||
ActionbarNoticeSource::Tui,
|
||||
Duration::from_secs(30),
|
||||
),
|
||||
Err(error) => {
|
||||
app.push_error(format!("Attachment upload failed: {error}"));
|
||||
}
|
||||
}
|
||||
} else if is_clear_attachments_command(&method) {
|
||||
client.clear_pending_attachments().await;
|
||||
app.flash_actionbar_notice(
|
||||
"Removed pending attachments.",
|
||||
ActionbarNoticeLevel::Info,
|
||||
ActionbarNoticeSource::Tui,
|
||||
Duration::from_secs(4),
|
||||
);
|
||||
} else {
|
||||
send_console_method(app, client, &method).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
TermEvent::Mouse(mouse) => {
|
||||
@@ -1134,7 +1474,11 @@ fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::text_selection::{HistoryViewport, SelectionRow};
|
||||
use protocol::{Event, RewindTarget, RewindTargetId, Segment};
|
||||
use async_trait::async_trait;
|
||||
use protocol::{
|
||||
Event, RewindTarget, RewindTargetId, RunResult, Segment, UploadedFileAvailability,
|
||||
UploadedFileRef, WorkerStatus,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn standalone_console_starts_with_in_process_connection_ready() {
|
||||
@@ -1144,6 +1488,217 @@ mod tests {
|
||||
assert!(app.connected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_local_attachment_commands_are_typed_and_do_not_send_the_path() {
|
||||
let attach = Method::Run {
|
||||
input: vec![Segment::text("/attach /tmp/report.md")],
|
||||
};
|
||||
assert_eq!(
|
||||
attachment_command_path(&attach),
|
||||
Some(PathBuf::from("/tmp/report.md"))
|
||||
);
|
||||
assert!(!is_clear_attachments_command(&attach));
|
||||
|
||||
let clear = Method::Run {
|
||||
input: vec![Segment::text("/clear-attachments")],
|
||||
};
|
||||
assert!(is_clear_attachments_command(&clear));
|
||||
assert_eq!(attachment_command_path(&clear), None);
|
||||
assert_eq!(
|
||||
attachment_media_type(Path::new("report.webp")),
|
||||
Some("image/webp")
|
||||
);
|
||||
assert_eq!(attachment_media_type(Path::new("program.exe")), None);
|
||||
}
|
||||
|
||||
struct FailOnceSocket {
|
||||
fail_next_send: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Socket for FailOnceSocket {
|
||||
type Error = io::Error;
|
||||
|
||||
async fn send(&mut self, _message: String) -> Result<(), Self::Error> {
|
||||
if std::mem::take(&mut self.fail_next_send) {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::ConnectionReset,
|
||||
"disconnected",
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn next(&mut self) -> Result<Option<String>, Self::Error> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn try_next(&mut self) -> Result<Option<String>, Self::Error> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn attachment_submission_waits_for_authoritative_acceptance_and_can_retry() {
|
||||
let file = UploadedFileRef {
|
||||
artifact_id: "artifact-1".into(),
|
||||
file_name: "notes.txt".into(),
|
||||
media_type: "text/plain".into(),
|
||||
created_at_ms: 1,
|
||||
availability: UploadedFileAvailability::Available,
|
||||
byte_len: 5,
|
||||
sha256: "a".repeat(64),
|
||||
source_entry_id: None,
|
||||
};
|
||||
let mut connection = ConsoleConnection {
|
||||
client: Client::new(FailOnceSocket {
|
||||
fail_next_send: true,
|
||||
}),
|
||||
standalone_host: None,
|
||||
backend_target: None,
|
||||
pending_attachments: vec![file.clone()],
|
||||
awaiting_attachment_acceptance: Vec::new(),
|
||||
upload_tasks: Vec::new(),
|
||||
active_uploads: 0,
|
||||
upload_ids: HashMap::new(),
|
||||
};
|
||||
|
||||
let mut app = App::new("worker".into());
|
||||
app.input.insert_str("inspect");
|
||||
|
||||
connection.active_uploads = 1;
|
||||
let blocked = app.submit_input().unwrap();
|
||||
assert!(app.input.is_empty());
|
||||
send_console_method(&mut app, &mut connection, &blocked)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(app.input.plain_text(), "inspect");
|
||||
assert_eq!(connection.pending_attachments, vec![file.clone()]);
|
||||
assert!(connection.awaiting_attachment_acceptance.is_empty());
|
||||
|
||||
connection.active_uploads = 0;
|
||||
let failed = app.submit_input().unwrap();
|
||||
send_console_method(&mut app, &mut connection, &failed)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(app.input.plain_text(), "inspect");
|
||||
assert_eq!(connection.pending_attachments, vec![file.clone()]);
|
||||
assert!(connection.awaiting_attachment_acceptance.is_empty());
|
||||
|
||||
let retry = app.submit_input().unwrap();
|
||||
send_console_method(&mut app, &mut connection, &retry)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(app.input.is_empty());
|
||||
assert!(connection.pending_attachments.is_empty());
|
||||
assert_eq!(
|
||||
connection.awaiting_attachment_acceptance,
|
||||
vec![file.clone()]
|
||||
);
|
||||
|
||||
connection.observe_worker_event(&Event::UserMessage {
|
||||
segments: vec![Segment::text("inspect"), Segment::UploadedFile { file }],
|
||||
});
|
||||
assert!(connection.pending_attachments.is_empty());
|
||||
assert!(connection.awaiting_attachment_acceptance.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queued_attachment_send_failure_restores_draft_without_exiting_console() {
|
||||
let file = UploadedFileRef {
|
||||
artifact_id: "artifact-queued".into(),
|
||||
file_name: "queued.txt".into(),
|
||||
media_type: "text/plain".into(),
|
||||
created_at_ms: 1,
|
||||
availability: UploadedFileAvailability::Available,
|
||||
byte_len: 1,
|
||||
sha256: "a".repeat(64),
|
||||
source_entry_id: None,
|
||||
};
|
||||
let mut connection = ConsoleConnection {
|
||||
client: Client::new(FailOnceSocket {
|
||||
fail_next_send: true,
|
||||
}),
|
||||
standalone_host: None,
|
||||
backend_target: None,
|
||||
pending_attachments: vec![file.clone()],
|
||||
awaiting_attachment_acceptance: Vec::new(),
|
||||
upload_tasks: Vec::new(),
|
||||
active_uploads: 0,
|
||||
upload_ids: HashMap::new(),
|
||||
};
|
||||
let mut app = App::new("worker".into());
|
||||
app.set_worker_status(WorkerStatus::Running);
|
||||
app.input.insert_str("queued inspect");
|
||||
assert!(app.submit_input().is_none());
|
||||
|
||||
let method = app
|
||||
.handle_worker_event(Event::RunEnd {
|
||||
result: RunResult::Finished,
|
||||
})
|
||||
.expect("queued run must be released");
|
||||
send_console_method(&mut app, &mut connection, &method)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(app.input.plain_text(), "queued inspect");
|
||||
assert_eq!(connection.pending_attachments, vec![file]);
|
||||
assert!(connection.awaiting_attachment_acceptance.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_attachment_echo_does_not_acknowledge_an_atomic_submission() {
|
||||
let first = UploadedFileRef {
|
||||
artifact_id: "artifact-1".into(),
|
||||
file_name: "one.txt".into(),
|
||||
media_type: "text/plain".into(),
|
||||
created_at_ms: 1,
|
||||
availability: UploadedFileAvailability::Available,
|
||||
byte_len: 1,
|
||||
sha256: "a".repeat(64),
|
||||
source_entry_id: None,
|
||||
};
|
||||
let second = UploadedFileRef {
|
||||
artifact_id: "artifact-2".into(),
|
||||
file_name: "two.txt".into(),
|
||||
..first.clone()
|
||||
};
|
||||
let mut pending = Vec::new();
|
||||
let mut awaiting = vec![first.clone(), second.clone()];
|
||||
let expected = awaiting.clone();
|
||||
|
||||
for segments in [
|
||||
vec![Segment::UploadedFile {
|
||||
file: first.clone(),
|
||||
}],
|
||||
vec![
|
||||
Segment::UploadedFile {
|
||||
file: second.clone(),
|
||||
},
|
||||
Segment::UploadedFile {
|
||||
file: first.clone(),
|
||||
},
|
||||
],
|
||||
vec![
|
||||
Segment::UploadedFile {
|
||||
file: first.clone(),
|
||||
},
|
||||
Segment::UploadedFile {
|
||||
file: first.clone(),
|
||||
},
|
||||
],
|
||||
] {
|
||||
assert!(!reconcile_attachment_submission(
|
||||
&mut pending,
|
||||
&mut awaiting,
|
||||
&Event::UserMessage { segments },
|
||||
));
|
||||
assert_eq!(awaiting, expected);
|
||||
assert!(pending.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_worker_mouse_capture_avoids_drag_and_all_motion_modes() {
|
||||
let mut ansi = String::new();
|
||||
@@ -1254,10 +1809,12 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn animation_tick_wakes_loop_while_running() {
|
||||
let (_tx, mut rx) = mpsc::unbounded_channel::<TerminalEventResult>();
|
||||
let (_upload_tx, mut upload_rx) = mpsc::unbounded_channel();
|
||||
|
||||
assert!(matches!(
|
||||
next_loop_input(
|
||||
&mut rx,
|
||||
&mut upload_rx,
|
||||
true,
|
||||
std::future::pending::<Option<u8>>(),
|
||||
true,
|
||||
@@ -1268,9 +1825,41 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn attachment_upload_completion_wakes_console_loop() {
|
||||
let (_terminal_tx, mut terminal_rx) = mpsc::unbounded_channel();
|
||||
let (upload_tx, mut upload_rx) = mpsc::unbounded_channel();
|
||||
let file = UploadedFileRef {
|
||||
artifact_id: "019ca7c8-57b6-7f05-8edf-524147aba7b3".into(),
|
||||
file_name: "notes.txt".into(),
|
||||
media_type: "text/plain".into(),
|
||||
created_at_ms: 1,
|
||||
availability: protocol::UploadedFileAvailability::Available,
|
||||
byte_len: 1,
|
||||
sha256: "a".repeat(64),
|
||||
source_entry_id: None,
|
||||
};
|
||||
upload_tx.send(Ok(file.clone())).unwrap();
|
||||
|
||||
match next_loop_input(
|
||||
&mut terminal_rx,
|
||||
&mut upload_rx,
|
||||
true,
|
||||
std::future::pending::<Option<u8>>(),
|
||||
false,
|
||||
std::future::pending::<()>(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
LoopInput::Upload(Ok(received)) => assert_eq!(received, file),
|
||||
_ => panic!("expected attachment upload result"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_event_is_selected_before_ready_worker_event() {
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let (_upload_tx, mut upload_rx) = mpsc::unbounded_channel();
|
||||
tx.send(Ok(TermEvent::Key(KeyEvent::new(
|
||||
KeyCode::Char('x'),
|
||||
KeyModifiers::NONE,
|
||||
@@ -1279,6 +1868,7 @@ mod tests {
|
||||
|
||||
match next_loop_input(
|
||||
&mut rx,
|
||||
&mut upload_rx,
|
||||
true,
|
||||
std::future::ready(Some(())),
|
||||
false,
|
||||
@@ -1296,9 +1886,11 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn terminal_event_is_preserved_after_worker_event_wins() {
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let (_upload_tx, mut upload_rx) = mpsc::unbounded_channel();
|
||||
|
||||
match next_loop_input(
|
||||
&mut rx,
|
||||
&mut upload_rx,
|
||||
true,
|
||||
std::future::ready(Some(1_u8)),
|
||||
false,
|
||||
@@ -1318,6 +1910,7 @@ mod tests {
|
||||
|
||||
match next_loop_input(
|
||||
&mut rx,
|
||||
&mut upload_rx,
|
||||
true,
|
||||
std::future::ready(Some(2_u8)),
|
||||
false,
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
use std::io::{self, Stdout, Write};
|
||||
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::{TerminalOptions, Viewport};
|
||||
|
||||
pub(crate) type InlineTerminal = Terminal<CrosstermBackend<Stdout>>;
|
||||
|
||||
struct InlineTerminalGuard {
|
||||
terminal: InlineTerminal,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
impl InlineTerminalGuard {
|
||||
fn open(height: u16) -> io::Result<Self> {
|
||||
let terminal = Terminal::with_options(
|
||||
CrosstermBackend::new(io::stdout()),
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Inline(height),
|
||||
},
|
||||
)?;
|
||||
Ok(Self {
|
||||
terminal,
|
||||
closed: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn close(&mut self) -> io::Result<()> {
|
||||
if self.closed {
|
||||
return Ok(());
|
||||
}
|
||||
self.closed = true;
|
||||
|
||||
let area = self.terminal.get_frame().area();
|
||||
let last_row = area.bottom().saturating_sub(1);
|
||||
let cursor_result = self.terminal.set_cursor_position((0, last_row));
|
||||
let output_result = write_viewport_terminator(&mut io::stdout());
|
||||
cursor_result?;
|
||||
output_result
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InlineTerminalGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.close();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_inline_terminal<T, E>(
|
||||
height: u16,
|
||||
run: impl FnOnce(&mut InlineTerminal) -> Result<T, E>,
|
||||
) -> Result<T, E>
|
||||
where
|
||||
E: From<io::Error>,
|
||||
{
|
||||
let mut guard = InlineTerminalGuard::open(height).map_err(E::from)?;
|
||||
let result = run(&mut guard.terminal);
|
||||
let close_result = guard.close();
|
||||
match result {
|
||||
Ok(value) => {
|
||||
close_result.map_err(E::from)?;
|
||||
Ok(value)
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_viewport_terminator(output: &mut impl Write) -> io::Result<()> {
|
||||
output.write_all(b"\r\n")?;
|
||||
output.flush()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn viewport_terminator_moves_following_output_to_a_fresh_line() {
|
||||
let mut output = Vec::new();
|
||||
|
||||
write_viewport_terminator(&mut output).unwrap();
|
||||
|
||||
assert_eq!(output, b"\r\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_viewport_construction_is_owned_by_this_module() {
|
||||
fn assert_shared_owner(path: &std::path::Path) {
|
||||
for entry in std::fs::read_dir(path).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.is_dir() {
|
||||
assert_shared_owner(&path);
|
||||
} else if path.extension().and_then(|value| value.to_str()) == Some("rs")
|
||||
&& path.file_name().and_then(|value| value.to_str())
|
||||
!= Some("inline_terminal.rs")
|
||||
{
|
||||
let source = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(
|
||||
!source.contains("Viewport::Inline"),
|
||||
"{} constructs an inline viewport outside its shared owner",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert_shared_owner(&std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"));
|
||||
}
|
||||
}
|
||||
@@ -266,6 +266,13 @@ impl InputBuffer {
|
||||
protocol::Segment::PasteArtifact { artifact } => {
|
||||
self.atoms.push(Atom::PasteArtifact(artifact.clone()));
|
||||
}
|
||||
protocol::Segment::UploadedFile { file } => {
|
||||
self.atoms.extend(
|
||||
format!("[Attached file: {}]", file.file_name)
|
||||
.chars()
|
||||
.map(Atom::Char),
|
||||
);
|
||||
}
|
||||
protocol::Segment::FileRef { path } => {
|
||||
self.atoms
|
||||
.push(Atom::FileRef(FileRefAtom { path: path.clone() }));
|
||||
|
||||
+5
-34
@@ -1,17 +1,17 @@
|
||||
use std::io::{self, Stdout, Write};
|
||||
use std::process::ExitCode;
|
||||
use std::time::Duration;
|
||||
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Constraint, Layout};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::{Frame, Terminal, TerminalOptions, Viewport};
|
||||
use secrets::{SecretStore, SecretValue};
|
||||
|
||||
use crate::inline_terminal::{InlineTerminal, with_inline_terminal};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Mode {
|
||||
Normal,
|
||||
@@ -235,7 +235,6 @@ pub async fn launch() -> ExitCode {
|
||||
}
|
||||
|
||||
type UiResult<T> = Result<T, Box<dyn std::error::Error>>;
|
||||
type InlineTerminal = Terminal<CrosstermBackend<Stdout>>;
|
||||
|
||||
const MAX_ROWS: usize = 10;
|
||||
const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 5;
|
||||
@@ -270,37 +269,9 @@ impl Drop for RawModeGuard {
|
||||
fn run(store: SecretStore) -> UiResult<()> {
|
||||
enable_raw_mode()?;
|
||||
let guard = RawModeGuard::new();
|
||||
let mut terminal = make_inline_terminal()?;
|
||||
let result = run_loop(&mut terminal, store);
|
||||
let close_result = close_viewport(&mut terminal);
|
||||
drop(terminal);
|
||||
let result = with_inline_terminal(VIEWPORT_LINES, |terminal| run_loop(terminal, store));
|
||||
guard.restore();
|
||||
result?;
|
||||
close_result?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn make_inline_terminal() -> io::Result<InlineTerminal> {
|
||||
let backend = CrosstermBackend::new(io::stdout());
|
||||
Terminal::with_options(
|
||||
backend,
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Inline(VIEWPORT_LINES),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Park the cursor at the very bottom of the inline viewport and emit one
|
||||
/// newline before dropping the terminal. This matches the resume picker and
|
||||
/// keeps the shell prompt (or a later inline viewport) from drawing over rows.
|
||||
fn close_viewport(terminal: &mut InlineTerminal) -> io::Result<()> {
|
||||
let area = terminal.get_frame().area();
|
||||
let last_row = area.bottom().saturating_sub(1);
|
||||
terminal.set_cursor_position((0, last_row))?;
|
||||
let mut out = io::stdout();
|
||||
out.write_all(b"\r\n")?;
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
result
|
||||
}
|
||||
|
||||
fn run_loop(terminal: &mut InlineTerminal, store: SecretStore) -> UiResult<()> {
|
||||
|
||||
@@ -10,6 +10,7 @@ mod composer_keys;
|
||||
mod console;
|
||||
#[cfg(feature = "e2e-test")]
|
||||
mod e2e_observer;
|
||||
mod inline_terminal;
|
||||
mod input;
|
||||
pub mod keys;
|
||||
mod markdown;
|
||||
|
||||
@@ -3,15 +3,14 @@ use std::time::Duration;
|
||||
|
||||
use client::{StandaloneWorkerListIntent, StandaloneWorkerResumeIntent, Target};
|
||||
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::layout::{Constraint, Layout};
|
||||
use ratatui::prelude::{Color, Line, Modifier, Span, Style};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::{TerminalOptions, Viewport};
|
||||
use standalone::{StandaloneListScope, StandaloneWorkerRecord, StandaloneWorkerStore};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::inline_terminal::with_inline_terminal;
|
||||
|
||||
const LIMIT: usize = 100;
|
||||
|
||||
pub(crate) fn pick(
|
||||
@@ -57,41 +56,36 @@ fn run_picker(
|
||||
records: Vec<StandaloneWorkerRecord>,
|
||||
) -> Result<Option<StandaloneWorkerRecord>, StandalonePickerError> {
|
||||
let height = u16::try_from(records.len().saturating_add(3).min(20)).unwrap_or(20);
|
||||
let mut terminal = Terminal::with_options(
|
||||
CrosstermBackend::new(io::stdout()),
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Inline(height),
|
||||
},
|
||||
)
|
||||
.map_err(StandalonePickerError::Io)?;
|
||||
let mut selected = 0usize;
|
||||
loop {
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &records, selected))
|
||||
.map_err(StandalonePickerError::Io)?;
|
||||
if !event::poll(Duration::from_millis(100)).map_err(StandalonePickerError::Io)? {
|
||||
continue;
|
||||
}
|
||||
let TermEvent::Key(key) = event::read().map_err(StandalonePickerError::Io)? else {
|
||||
continue;
|
||||
};
|
||||
if key.kind == KeyEventKind::Release {
|
||||
continue;
|
||||
}
|
||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') if !ctrl => {
|
||||
selected = selected.saturating_sub(1);
|
||||
with_inline_terminal(height, |terminal| {
|
||||
let mut selected = 0usize;
|
||||
loop {
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &records, selected))
|
||||
.map_err(StandalonePickerError::Io)?;
|
||||
if !event::poll(Duration::from_millis(100)).map_err(StandalonePickerError::Io)? {
|
||||
continue;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if !ctrl => {
|
||||
selected = (selected + 1).min(records.len() - 1);
|
||||
let TermEvent::Key(key) = event::read().map_err(StandalonePickerError::Io)? else {
|
||||
continue;
|
||||
};
|
||||
if key.kind == KeyEventKind::Release {
|
||||
continue;
|
||||
}
|
||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') if !ctrl => {
|
||||
selected = selected.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if !ctrl => {
|
||||
selected = (selected + 1).min(records.len() - 1);
|
||||
}
|
||||
KeyCode::Enter => return Ok(Some(records[selected].clone())),
|
||||
KeyCode::Esc => return Ok(None),
|
||||
KeyCode::Char('c') if ctrl => return Ok(None),
|
||||
_ => {}
|
||||
}
|
||||
KeyCode::Enter => return Ok(Some(records[selected].clone())),
|
||||
KeyCode::Esc => return Ok(None),
|
||||
KeyCode::Char('c') if ctrl => return Ok(None),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn draw(frame: &mut ratatui::Frame<'_>, records: &[StandaloneWorkerRecord], selected: usize) {
|
||||
@@ -149,7 +143,7 @@ pub(crate) enum StandalonePickerError {
|
||||
)]
|
||||
NoWorkers { include_all: bool },
|
||||
#[error("standalone Worker picker I/O failed: {0}")]
|
||||
Io(#[source] io::Error),
|
||||
Io(#[from] io::Error),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
use std::io::{self, Stdout};
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use manifest::ProfileDiscovery;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::layout::{Constraint, Direction, Layout};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::inline_terminal::{InlineTerminal, with_inline_terminal};
|
||||
|
||||
const VIEWPORT_HEIGHT: u16 = 6;
|
||||
const FALLBACK_WORKER_NAME: &str = "worker";
|
||||
|
||||
@@ -182,15 +182,16 @@ pub(crate) fn select(
|
||||
return Err(StandaloneSpawnError::NoProfiles);
|
||||
}
|
||||
|
||||
let terminal = open_inline_terminal()?;
|
||||
run_picker(
|
||||
terminal,
|
||||
SpawnForm::new(worker_name, default_worker_name, choices),
|
||||
)
|
||||
with_inline_terminal(VIEWPORT_HEIGHT, |terminal| {
|
||||
run_picker(
|
||||
terminal,
|
||||
SpawnForm::new(worker_name, default_worker_name, choices),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn run_picker(
|
||||
mut terminal: Terminal<CrosstermBackend<Stdout>>,
|
||||
terminal: &mut InlineTerminal,
|
||||
mut form: SpawnForm,
|
||||
) -> Result<Option<StandaloneSpawnSelection>, StandaloneSpawnError> {
|
||||
loop {
|
||||
@@ -222,13 +223,6 @@ fn run_picker(
|
||||
}
|
||||
}
|
||||
|
||||
fn open_inline_terminal() -> io::Result<Terminal<CrosstermBackend<Stdout>>> {
|
||||
let options = ratatui::TerminalOptions {
|
||||
viewport: ratatui::Viewport::Inline(VIEWPORT_HEIGHT),
|
||||
};
|
||||
Terminal::with_options(CrosstermBackend::new(io::stdout()), options)
|
||||
}
|
||||
|
||||
fn profile_choices(registry: &manifest::ProfileRegistry) -> Vec<ProfileChoice> {
|
||||
registry
|
||||
.entries()
|
||||
|
||||
@@ -1308,6 +1308,16 @@ fn chip_span_for(seg: &Segment, fallback: Style) -> (Style, String) {
|
||||
artifact.created_at_ms
|
||||
),
|
||||
),
|
||||
Segment::UploadedFile { file } => (
|
||||
Style::default().fg(Color::Cyan),
|
||||
format!(
|
||||
"[Attached {} | {} bytes, {}, {}]",
|
||||
file.file_name,
|
||||
file.byte_len,
|
||||
file.media_type,
|
||||
file.availability.as_str()
|
||||
),
|
||||
),
|
||||
Segment::FileRef { path } => (Style::default().fg(Color::Cyan), format!("@{path}")),
|
||||
Segment::Flow { selector } => (
|
||||
Style::default().fg(Color::Yellow),
|
||||
@@ -1335,6 +1345,13 @@ fn segment_display_text(seg: &Segment) -> String {
|
||||
artifact.availability.as_str(),
|
||||
artifact.created_at_ms
|
||||
),
|
||||
Segment::UploadedFile { file } => format!(
|
||||
"[Attached {} | {} bytes, {}, {}]",
|
||||
file.file_name,
|
||||
file.byte_len,
|
||||
file.media_type,
|
||||
file.availability.as_str()
|
||||
),
|
||||
Segment::FileRef { path } => format!("@{path}"),
|
||||
Segment::Flow { selector } => format!("[Flow: {selector}]"),
|
||||
Segment::Unknown => "[unknown segment]".to_owned(),
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use workspace_api::{
|
||||
RuntimeWorkingDirectoryCleanupTarget, RuntimeWorkingDirectorySummary,
|
||||
WorkingDirectoryCleanupTarget, WorkingDirectoryMaterializerKind as MaterializerKind,
|
||||
WorkingDirectoryOccupancy, WorkingDirectoryStatusKind, WorkingDirectorySummary,
|
||||
};
|
||||
|
||||
@@ -92,9 +92,9 @@ pub struct WorkingDirectoryRepository {
|
||||
}
|
||||
|
||||
pub use workdir::workspace::{
|
||||
MaterializerKind, WorkingDirectoryCleanupTarget, WorkingDirectoryCurrentObservation,
|
||||
MaterializerKind, RuntimeWorkingDirectoryCleanupTarget as WorkingDirectoryCleanupTarget,
|
||||
RuntimeWorkingDirectorySummary as WorkingDirectorySummary, WorkingDirectoryCurrentObservation,
|
||||
WorkingDirectoryOccupancy, WorkingDirectoryProvenance, WorkingDirectoryStatusKind,
|
||||
WorkingDirectorySummary,
|
||||
};
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::interaction::WorkerInput;
|
||||
#[cfg(feature = "ws-server")]
|
||||
use crate::observation::WorkerObservationEvent;
|
||||
use crate::working_directory::{WorkingDirectoryBinding, WorkingDirectoryDiagnostic};
|
||||
use protocol::Method;
|
||||
use protocol::{Method, UploadedFileRef};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
@@ -33,6 +33,8 @@ pub enum WorkerExecutionOperation {
|
||||
Spawn,
|
||||
Restore,
|
||||
Input,
|
||||
UploadFile,
|
||||
DeleteUploadedFile,
|
||||
ProtocolMethod,
|
||||
Stop,
|
||||
Cancel,
|
||||
@@ -385,6 +387,31 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
||||
input: WorkerInput,
|
||||
) -> WorkerExecutionResult;
|
||||
|
||||
fn upload_file(
|
||||
&self,
|
||||
_handle: &WorkerExecutionHandle,
|
||||
_file_name: &str,
|
||||
_media_type: &str,
|
||||
_content: &[u8],
|
||||
_context: Option<&session_store::UploadedFileUploadContext>,
|
||||
) -> Result<UploadedFileRef, WorkerExecutionResult> {
|
||||
Err(WorkerExecutionResult::unsupported(
|
||||
WorkerExecutionOperation::UploadFile,
|
||||
"execution backend does not support file upload",
|
||||
))
|
||||
}
|
||||
|
||||
fn delete_uploaded_file(
|
||||
&self,
|
||||
_handle: &WorkerExecutionHandle,
|
||||
_artifact_id: &str,
|
||||
) -> WorkerExecutionResult {
|
||||
WorkerExecutionResult::unsupported(
|
||||
WorkerExecutionOperation::DeleteUploadedFile,
|
||||
"execution backend does not support uploaded-file deletion",
|
||||
)
|
||||
}
|
||||
|
||||
fn dispatch_method(
|
||||
&self,
|
||||
_handle: &WorkerExecutionHandle,
|
||||
@@ -514,6 +541,26 @@ impl WorkerExecutionBackendRef {
|
||||
self.backend.dispatch_input(handle, input)
|
||||
}
|
||||
|
||||
pub(crate) fn upload_file(
|
||||
&self,
|
||||
handle: &WorkerExecutionHandle,
|
||||
file_name: &str,
|
||||
media_type: &str,
|
||||
content: &[u8],
|
||||
context: Option<&session_store::UploadedFileUploadContext>,
|
||||
) -> Result<UploadedFileRef, WorkerExecutionResult> {
|
||||
self.backend
|
||||
.upload_file(handle, file_name, media_type, content, context)
|
||||
}
|
||||
|
||||
pub(crate) fn delete_uploaded_file(
|
||||
&self,
|
||||
handle: &WorkerExecutionHandle,
|
||||
artifact_id: &str,
|
||||
) -> WorkerExecutionResult {
|
||||
self.backend.delete_uploaded_file(handle, artifact_id)
|
||||
}
|
||||
|
||||
pub(crate) fn dispatch_method(
|
||||
&self,
|
||||
handle: &WorkerExecutionHandle,
|
||||
|
||||
@@ -32,7 +32,7 @@ use axum::body::{Body, Bytes};
|
||||
use axum::extract::rejection::{JsonRejection, QueryRejection};
|
||||
#[cfg(feature = "ws-server")]
|
||||
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
use axum::extract::{DefaultBodyLimit, Extension, Path, Query, State};
|
||||
use axum::http::{Method, Request, StatusCode, header};
|
||||
use axum::middleware::{self, Next};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
@@ -238,6 +238,14 @@ fn runtime_http_router_with_optional_auth(
|
||||
post(execute_worker_retention),
|
||||
)
|
||||
.route("/v1/workers/{worker_id}/input", post(send_worker_input))
|
||||
.route(
|
||||
"/v1/workers/{worker_id}/attachments",
|
||||
post(upload_worker_file).layer(DefaultBodyLimit::max(MAX_WORKER_FILE_UPLOAD_BYTES)),
|
||||
)
|
||||
.route(
|
||||
"/v1/workers/{worker_id}/attachments/{artifact_id}",
|
||||
delete(delete_worker_uploaded_file),
|
||||
)
|
||||
.route("/v1/workers/{worker_id}/restore", post(restore_worker))
|
||||
.route(
|
||||
"/v1/workers/{worker_id}/workspace-api",
|
||||
@@ -263,6 +271,9 @@ fn runtime_http_router_with_optional_auth(
|
||||
.layer(middleware::from_fn_with_state(state, require_runtime_auth))
|
||||
}
|
||||
|
||||
pub const MAX_WORKER_FILE_UPLOAD_BYTES: usize =
|
||||
session_store::DEFAULT_MAX_UPLOADED_FILE_BYTES as usize;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RuntimeHttpState {
|
||||
runtime: Runtime,
|
||||
@@ -375,6 +386,32 @@ pub struct RuntimeHttpWorkerInputResponse {
|
||||
pub ack: WorkerInteractionAck,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct RuntimeHttpUploadFileQuery {
|
||||
pub file_name: String,
|
||||
pub media_type: String,
|
||||
#[serde(default)]
|
||||
pub upload_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub principal_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub workspace_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub runtime_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub owner_worker_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeHttpUploadedFileResponse {
|
||||
pub file: protocol::UploadedFileRef,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeHttpUploadedFileDeleteResponse {
|
||||
pub deleted: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeHttpWorkerCompletionsRequest {
|
||||
pub kind: protocol::CompletionKind,
|
||||
@@ -1420,6 +1457,115 @@ async fn worker_completions(
|
||||
}))
|
||||
}
|
||||
|
||||
async fn upload_worker_file(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
auth: Option<Extension<RuntimeAuthContext>>,
|
||||
Path(worker_id): Path<String>,
|
||||
Query(query): Query<RuntimeHttpUploadFileQuery>,
|
||||
body: Bytes,
|
||||
) -> RestResult<RuntimeHttpUploadedFileResponse> {
|
||||
let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
|
||||
let context = match (
|
||||
query.upload_id,
|
||||
query.principal_id,
|
||||
query.workspace_id,
|
||||
query.runtime_id,
|
||||
query.owner_worker_id,
|
||||
) {
|
||||
(None, None, None, None, None) => None,
|
||||
(
|
||||
Some(upload_id),
|
||||
Some(principal_id),
|
||||
Some(workspace_id),
|
||||
Some(runtime_id),
|
||||
Some(owner_worker_id),
|
||||
) => {
|
||||
if owner_worker_id != worker_ref.worker_id.to_string() {
|
||||
return Err(RuntimeHttpRestError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"uploaded_file_owner_mismatch",
|
||||
"uploaded file context does not match the target Worker",
|
||||
));
|
||||
}
|
||||
Some(session_store::UploadedFileUploadContext {
|
||||
upload_id,
|
||||
principal_id,
|
||||
workspace_id,
|
||||
runtime_id,
|
||||
worker_id: owner_worker_id,
|
||||
})
|
||||
}
|
||||
_ => {
|
||||
return Err(RuntimeHttpRestError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"uploaded_file_context_incomplete",
|
||||
"uploaded file context fields must be provided together",
|
||||
));
|
||||
}
|
||||
};
|
||||
let file = match auth_workspace_scope(&state, auth.as_ref())? {
|
||||
Some(scope) => {
|
||||
if context
|
||||
.as_ref()
|
||||
.is_some_and(|context| context.workspace_id != scope.workspace_id)
|
||||
{
|
||||
return Err(RuntimeHttpRestError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"uploaded_file_workspace_mismatch",
|
||||
"uploaded file context does not match the authenticated Workspace",
|
||||
));
|
||||
}
|
||||
match context.as_ref() {
|
||||
Some(context) => state.runtime.upload_worker_file_with_context_scoped(
|
||||
&scope,
|
||||
&worker_ref,
|
||||
&query.file_name,
|
||||
&query.media_type,
|
||||
&body,
|
||||
context,
|
||||
),
|
||||
None => state.runtime.upload_worker_file_scoped(
|
||||
&scope,
|
||||
&worker_ref,
|
||||
&query.file_name,
|
||||
&query.media_type,
|
||||
&body,
|
||||
),
|
||||
}
|
||||
}
|
||||
None => state.runtime.upload_worker_file(
|
||||
&worker_ref,
|
||||
&query.file_name,
|
||||
&query.media_type,
|
||||
&body,
|
||||
),
|
||||
}
|
||||
.map_err(RuntimeHttpRestError::runtime)?;
|
||||
Ok(Json(RuntimeHttpUploadedFileResponse { file }))
|
||||
}
|
||||
|
||||
async fn delete_worker_uploaded_file(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
auth: Option<Extension<RuntimeAuthContext>>,
|
||||
Path((worker_id, artifact_id)): Path<(String, String)>,
|
||||
) -> RestResult<RuntimeHttpUploadedFileDeleteResponse> {
|
||||
let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
|
||||
match auth_workspace_scope(&state, auth.as_ref())? {
|
||||
Some(scope) => {
|
||||
state
|
||||
.runtime
|
||||
.delete_worker_uploaded_file_scoped(&scope, &worker_ref, &artifact_id)
|
||||
}
|
||||
None => state
|
||||
.runtime
|
||||
.delete_worker_uploaded_file(&worker_ref, &artifact_id),
|
||||
}
|
||||
.map_err(RuntimeHttpRestError::runtime)?;
|
||||
Ok(Json(RuntimeHttpUploadedFileDeleteResponse {
|
||||
deleted: true,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn stop_worker(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
auth: Option<Extension<RuntimeAuthContext>>,
|
||||
@@ -1621,7 +1767,7 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
|
||||
if path.ends_with("/workspace-api") {
|
||||
return Some("workers:create");
|
||||
}
|
||||
if path.ends_with("/input") || path.ends_with("/restore") {
|
||||
if path.ends_with("/input") || path.ends_with("/restore") || path.contains("/attachments") {
|
||||
return Some("workers:input");
|
||||
}
|
||||
if path.ends_with("/stop") || path.ends_with("/cancel") {
|
||||
@@ -1882,6 +2028,21 @@ mod tests {
|
||||
WorkdirPath, WorkdirSessionCapabilities,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn attachment_routes_require_worker_input_permission() {
|
||||
assert_eq!(
|
||||
required_runtime_permission(&Method::POST, "/v1/workers/7/attachments"),
|
||||
Some("workers:input")
|
||||
);
|
||||
assert_eq!(
|
||||
required_runtime_permission(
|
||||
&Method::DELETE,
|
||||
"/v1/workers/7/attachments/019ca7c8-57b6-7f05-8edf-524147aba7b3"
|
||||
),
|
||||
Some("workers:input")
|
||||
);
|
||||
}
|
||||
|
||||
fn test_bundle(profile: ProfileSelector) -> ConfigBundle {
|
||||
ConfigBundle {
|
||||
metadata: ConfigBundleMetadata {
|
||||
|
||||
@@ -33,3 +33,4 @@ pub mod working_directory;
|
||||
pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
|
||||
pub use management::RuntimeOptions;
|
||||
pub use runtime::{Runtime, RuntimeWorkspaceScope};
|
||||
pub use session_store::UploadedFileUploadContext;
|
||||
|
||||
@@ -1205,6 +1205,138 @@ impl Runtime {
|
||||
})
|
||||
}
|
||||
|
||||
/// Store a client-local file in the owning Worker session before input submit.
|
||||
pub fn upload_worker_file_scoped(
|
||||
&self,
|
||||
scope: &RuntimeWorkspaceScope,
|
||||
worker_ref: &WorkerRef,
|
||||
file_name: &str,
|
||||
media_type: &str,
|
||||
content: &[u8],
|
||||
) -> Result<protocol::UploadedFileRef, RuntimeError> {
|
||||
self.ensure_worker_in_workspace(scope, worker_ref)?;
|
||||
self.upload_worker_file(worker_ref, file_name, media_type, content)
|
||||
}
|
||||
|
||||
pub fn upload_worker_file(
|
||||
&self,
|
||||
worker_ref: &WorkerRef,
|
||||
file_name: &str,
|
||||
media_type: &str,
|
||||
content: &[u8],
|
||||
) -> Result<protocol::UploadedFileRef, RuntimeError> {
|
||||
self.upload_worker_file_inner(worker_ref, file_name, media_type, content, None)
|
||||
}
|
||||
|
||||
pub fn upload_worker_file_with_context_scoped(
|
||||
&self,
|
||||
scope: &RuntimeWorkspaceScope,
|
||||
worker_ref: &WorkerRef,
|
||||
file_name: &str,
|
||||
media_type: &str,
|
||||
content: &[u8],
|
||||
context: &session_store::UploadedFileUploadContext,
|
||||
) -> Result<protocol::UploadedFileRef, RuntimeError> {
|
||||
self.ensure_worker_in_workspace(scope, worker_ref)?;
|
||||
self.upload_worker_file_inner(worker_ref, file_name, media_type, content, Some(context))
|
||||
}
|
||||
|
||||
pub fn upload_worker_file_with_context(
|
||||
&self,
|
||||
worker_ref: &WorkerRef,
|
||||
file_name: &str,
|
||||
media_type: &str,
|
||||
content: &[u8],
|
||||
context: &session_store::UploadedFileUploadContext,
|
||||
) -> Result<protocol::UploadedFileRef, RuntimeError> {
|
||||
self.upload_worker_file_inner(worker_ref, file_name, media_type, content, Some(context))
|
||||
}
|
||||
|
||||
fn upload_worker_file_inner(
|
||||
&self,
|
||||
worker_ref: &WorkerRef,
|
||||
file_name: &str,
|
||||
media_type: &str,
|
||||
content: &[u8],
|
||||
context: Option<&session_store::UploadedFileUploadContext>,
|
||||
) -> Result<protocol::UploadedFileRef, RuntimeError> {
|
||||
let (backend, handle) = {
|
||||
let state = self.lock()?;
|
||||
state.ensure_running()?;
|
||||
state.ensure_worker_ref(worker_ref)?;
|
||||
let worker = state.worker(worker_ref)?;
|
||||
match (
|
||||
state.execution_backend.clone(),
|
||||
worker.execution_handle.clone(),
|
||||
) {
|
||||
(Some(backend), Some(handle)) => (backend, handle),
|
||||
_ => {
|
||||
return Err(RuntimeError::WorkerExecutionUnavailable {
|
||||
worker_id: worker_ref.worker_id.clone(),
|
||||
message: "worker has no live execution handle".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
backend
|
||||
.upload_file(&handle, file_name, media_type, content, context)
|
||||
.map_err(|result| RuntimeError::WorkerExecutionRejected {
|
||||
worker_id: worker_ref.worker_id.clone(),
|
||||
operation: result.operation,
|
||||
outcome: result.outcome,
|
||||
message: result.message_or_default(),
|
||||
result,
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete an unsubmitted uploaded file from the owning Worker session.
|
||||
pub fn delete_worker_uploaded_file_scoped(
|
||||
&self,
|
||||
scope: &RuntimeWorkspaceScope,
|
||||
worker_ref: &WorkerRef,
|
||||
artifact_id: &str,
|
||||
) -> Result<(), RuntimeError> {
|
||||
self.ensure_worker_in_workspace(scope, worker_ref)?;
|
||||
self.delete_worker_uploaded_file(worker_ref, artifact_id)
|
||||
}
|
||||
|
||||
pub fn delete_worker_uploaded_file(
|
||||
&self,
|
||||
worker_ref: &WorkerRef,
|
||||
artifact_id: &str,
|
||||
) -> Result<(), RuntimeError> {
|
||||
let (backend, handle) = {
|
||||
let state = self.lock()?;
|
||||
state.ensure_running()?;
|
||||
state.ensure_worker_ref(worker_ref)?;
|
||||
let worker = state.worker(worker_ref)?;
|
||||
match (
|
||||
state.execution_backend.clone(),
|
||||
worker.execution_handle.clone(),
|
||||
) {
|
||||
(Some(backend), Some(handle)) => (backend, handle),
|
||||
_ => {
|
||||
return Err(RuntimeError::WorkerExecutionUnavailable {
|
||||
worker_id: worker_ref.worker_id.clone(),
|
||||
message: "worker has no live execution handle".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
let result = backend.delete_uploaded_file(&handle, artifact_id);
|
||||
if result.is_accepted() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(RuntimeError::WorkerExecutionRejected {
|
||||
worker_id: worker_ref.worker_id.clone(),
|
||||
operation: result.operation,
|
||||
outcome: result.outcome,
|
||||
message: result.message_or_default(),
|
||||
result,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Return live completion entries through a workspace-scoped Runtime authorization context.
|
||||
pub fn worker_completions_scoped(
|
||||
&self,
|
||||
@@ -1387,6 +1519,8 @@ impl Runtime {
|
||||
WorkerExecutionOperation::Spawn
|
||||
| WorkerExecutionOperation::Restore
|
||||
| WorkerExecutionOperation::Input
|
||||
| WorkerExecutionOperation::UploadFile
|
||||
| WorkerExecutionOperation::DeleteUploadedFile
|
||||
| WorkerExecutionOperation::ProtocolMethod => return Ok(()),
|
||||
};
|
||||
if result.is_accepted() {
|
||||
@@ -2402,6 +2536,7 @@ impl RuntimeState {
|
||||
display_name: worker.request.display_name.clone(),
|
||||
profile,
|
||||
repository_id,
|
||||
repository_key: None,
|
||||
working_directory_id,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2025,6 +2025,56 @@ where
|
||||
result
|
||||
}
|
||||
|
||||
fn upload_file(
|
||||
&self,
|
||||
handle: &WorkerExecutionHandle,
|
||||
file_name: &str,
|
||||
media_type: &str,
|
||||
content: &[u8],
|
||||
context: Option<&session_store::UploadedFileUploadContext>,
|
||||
) -> Result<protocol::UploadedFileRef, WorkerExecutionResult> {
|
||||
let (worker, _, _) = self.get_execution(handle).map_err(|mut result| {
|
||||
result.operation = WorkerExecutionOperation::UploadFile;
|
||||
result
|
||||
})?;
|
||||
let uploaded = match context {
|
||||
Some(context) => {
|
||||
worker.upload_file_with_context(file_name, media_type, content, context)
|
||||
}
|
||||
None => worker.upload_file(file_name, media_type, content),
|
||||
};
|
||||
uploaded.map_err(|error| {
|
||||
WorkerExecutionResult::rejected(
|
||||
WorkerExecutionOperation::UploadFile,
|
||||
format!("uploaded_file_rejected: {error}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_uploaded_file(
|
||||
&self,
|
||||
handle: &WorkerExecutionHandle,
|
||||
artifact_id: &str,
|
||||
) -> WorkerExecutionResult {
|
||||
let (worker, _, _) = match self.get_execution(handle) {
|
||||
Ok(execution) => execution,
|
||||
Err(mut result) => {
|
||||
result.operation = WorkerExecutionOperation::DeleteUploadedFile;
|
||||
return result;
|
||||
}
|
||||
};
|
||||
match worker.delete_uploaded_file(artifact_id) {
|
||||
Ok(_) => WorkerExecutionResult::accepted(
|
||||
WorkerExecutionOperation::DeleteUploadedFile,
|
||||
WorkerExecutionRunState::Idle,
|
||||
),
|
||||
Err(error) => WorkerExecutionResult::rejected(
|
||||
WorkerExecutionOperation::DeleteUploadedFile,
|
||||
format!("uploaded_file_delete_rejected: {error}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_method(
|
||||
&self,
|
||||
handle: &WorkerExecutionHandle,
|
||||
@@ -2115,6 +2165,7 @@ where
|
||||
"execution handle does not reference a live Worker",
|
||||
);
|
||||
};
|
||||
let artifact_cleanup = execution.handle.clone();
|
||||
let shutdown = execution.shutdown.clone();
|
||||
let result = self.send_method(
|
||||
WorkerExecutionOperation::Stop,
|
||||
@@ -2134,7 +2185,13 @@ where
|
||||
}
|
||||
Ok(())
|
||||
}) {
|
||||
Ok(()) => result,
|
||||
Ok(()) => match artifact_cleanup.delete_uncommitted_uploaded_files() {
|
||||
Ok(_) => result,
|
||||
Err(error) => WorkerExecutionResult::errored(
|
||||
WorkerExecutionOperation::Stop,
|
||||
format!("uploaded_file_cleanup_failed: {error}"),
|
||||
),
|
||||
},
|
||||
Err(message) => WorkerExecutionResult::errored(WorkerExecutionOperation::Stop, message),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,8 @@ pub trait EmbeddedWorkerMutationDispatcher: Send + Sync {
|
||||
enum RuntimeWorkerMutationTransport {
|
||||
Remote {
|
||||
base_url: String,
|
||||
request_source_signer: RuntimeRequestSourceSigner,
|
||||
request_source_audience: String,
|
||||
},
|
||||
Embedded {
|
||||
dispatcher: Arc<dyn EmbeddedWorkerMutationDispatcher>,
|
||||
@@ -157,10 +159,12 @@ impl RuntimeWorkerMutationForwarder {
|
||||
) -> Self {
|
||||
Self {
|
||||
authority: RuntimeWorkerMutationSourceAuthority::remote(identity),
|
||||
scope,
|
||||
scope: scope.clone(),
|
||||
source_worker_id: source_worker_id.into(),
|
||||
transport: RuntimeWorkerMutationTransport::Remote {
|
||||
base_url: base_url.into().trim_end_matches('/').to_string(),
|
||||
request_source_signer: RuntimeRequestSourceSigner::from_identity(identity),
|
||||
request_source_audience: scope.server_id,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -197,11 +201,18 @@ impl RuntimeWorkerMutationForwarder {
|
||||
)?;
|
||||
match (&self.transport, proof) {
|
||||
(
|
||||
RuntimeWorkerMutationTransport::Remote { base_url },
|
||||
RuntimeWorkerMutationTransport::Remote {
|
||||
base_url,
|
||||
request_source_signer,
|
||||
request_source_audience,
|
||||
},
|
||||
RuntimeOwnedWorkerMutationProof::Remote(token),
|
||||
) => execute_remote_worker_remove_http(RemoteWorkerRemoveHttpRequest {
|
||||
base_url: base_url.clone(),
|
||||
workspace_id: self.scope.workspace_id.clone(),
|
||||
source_worker_id: self.source_worker_id.clone(),
|
||||
request_source_signer: request_source_signer.clone(),
|
||||
request_source_audience: request_source_audience.clone(),
|
||||
token,
|
||||
target_runtime_id: target_runtime_id.to_string(),
|
||||
target_worker_id: target_worker_id.to_string(),
|
||||
@@ -224,6 +235,9 @@ impl RuntimeWorkerMutationForwarder {
|
||||
struct RemoteWorkerRemoveHttpRequest {
|
||||
base_url: String,
|
||||
workspace_id: String,
|
||||
source_worker_id: String,
|
||||
request_source_signer: RuntimeRequestSourceSigner,
|
||||
request_source_audience: String,
|
||||
token: String,
|
||||
target_runtime_id: String,
|
||||
target_worker_id: String,
|
||||
@@ -256,23 +270,35 @@ fn execute_remote_worker_remove_http(
|
||||
fn execute_remote_worker_remove_http_blocking(
|
||||
request: RemoteWorkerRemoveHttpRequest,
|
||||
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> {
|
||||
let url = format!(
|
||||
"{}/api/w/{}/workers/remove",
|
||||
request.base_url, request.workspace_id
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
let path = format!("/api/w/{}/workers/remove", request.workspace_id);
|
||||
let url = format!("{}{}", request.base_url, path);
|
||||
let body = serde_json::to_string(&serde_json::json!({
|
||||
"target_runtime_id": request.target_runtime_id,
|
||||
"target_worker_id": request.target_worker_id,
|
||||
"reason": request.reason,
|
||||
});
|
||||
}))
|
||||
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
|
||||
let request_source_proof = request.request_source_signer.issue(
|
||||
&request.request_source_audience,
|
||||
&request.workspace_id,
|
||||
Some(&request.source_worker_id),
|
||||
WORKSPACE_REQUEST_PERMISSION,
|
||||
"POST",
|
||||
&path,
|
||||
body.as_bytes(),
|
||||
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
|
||||
30,
|
||||
)?;
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let response = client
|
||||
.post(url)
|
||||
.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, request_source_proof)
|
||||
.header(
|
||||
crate::auth::WORKER_MUTATION_SOURCE_PROOF_HEADER,
|
||||
request.token,
|
||||
)
|
||||
.json(&body)
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||
.body(body)
|
||||
.send()
|
||||
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
|
||||
let status = response.status().as_u16();
|
||||
@@ -697,7 +723,8 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::auth::{
|
||||
WorkerMutationSourceExpectation, decode_runtime_request_source_claims,
|
||||
decode_worker_mutation_source_claims, verify_worker_mutation_source_proof,
|
||||
decode_worker_mutation_source_claims, request_body_digest,
|
||||
verify_worker_mutation_source_proof,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -1119,6 +1146,41 @@ mod tests {
|
||||
assert!(request.contains("\"target_worker_id\":\"worker-target\""));
|
||||
assert!(!request.contains("expected_worker_revision"));
|
||||
assert!(request.contains("\"reason\":\"retire obsolete Worker\""));
|
||||
let request_source_token = request
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
line.split_once(':').and_then(|(name, value)| {
|
||||
name.eq_ignore_ascii_case(RUNTIME_REQUEST_SOURCE_PROOF_HEADER)
|
||||
.then(|| value.trim())
|
||||
})
|
||||
})
|
||||
.expect("runtime request source proof header");
|
||||
let request_source_claims =
|
||||
decode_runtime_request_source_claims(request_source_token).unwrap();
|
||||
assert_eq!(request_source_claims.iss, "runtime-a");
|
||||
assert_eq!(request_source_claims.aud, "server-a");
|
||||
assert_eq!(request_source_claims.workspace_id, "workspace-a");
|
||||
assert_eq!(
|
||||
request_source_claims.worker_id.as_deref(),
|
||||
Some("worker-source")
|
||||
);
|
||||
assert_eq!(
|
||||
request_source_claims.permission,
|
||||
WORKSPACE_REQUEST_PERMISSION
|
||||
);
|
||||
assert_eq!(request_source_claims.method, "POST");
|
||||
assert_eq!(
|
||||
request_source_claims.path,
|
||||
"/api/w/workspace-a/workers/remove"
|
||||
);
|
||||
let request_body = request
|
||||
.split_once("\r\n\r\n")
|
||||
.map(|(_, body)| body)
|
||||
.expect("WorkerRemove request body");
|
||||
assert_eq!(
|
||||
request_source_claims.body_digest,
|
||||
request_body_digest(request_body.as_bytes())
|
||||
);
|
||||
let token = request
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
|
||||
@@ -31,7 +31,8 @@ use protocol::{
|
||||
AlertLevel, AlertSource, CommandEvent as ProtocolCommandEvent,
|
||||
CommandSnapshot as ProtocolCommandSnapshot, CommandStatus as ProtocolCommandStatus,
|
||||
CommandStream as ProtocolCommandStream, CommandStreamSlice as ProtocolCommandStreamSlice,
|
||||
ErrorCode, Event, Method, RewindTargetId, RunResult, Segment, TurnResult, WorkerStatus,
|
||||
ErrorCode, Event, Method, RewindTargetId, RunResult, Segment, TurnResult, UploadedFileRef,
|
||||
WorkerStatus,
|
||||
};
|
||||
use workdir::{
|
||||
CommandEvent as WorkdirCommandEvent, CommandSnapshot as WorkdirCommandSnapshot,
|
||||
@@ -55,6 +56,8 @@ pub struct WorkerHandle {
|
||||
/// subsequent commits (Event::Entry) on the receiver.
|
||||
pub sink: SegmentLogSink,
|
||||
spawned_registry: Arc<SpawnedWorkerRegistry>,
|
||||
artifact_store: Arc<dyn Store>,
|
||||
session_id: session_store::SessionId,
|
||||
}
|
||||
|
||||
impl WorkerHandle {
|
||||
@@ -62,6 +65,51 @@ impl WorkerHandle {
|
||||
self.method_tx.send(method).await
|
||||
}
|
||||
|
||||
pub fn upload_file(
|
||||
&self,
|
||||
file_name: &str,
|
||||
media_type: &str,
|
||||
content: &[u8],
|
||||
) -> Result<UploadedFileRef, session_store::StoreError> {
|
||||
self.artifact_store.write_uploaded_file(
|
||||
self.session_id,
|
||||
file_name,
|
||||
media_type,
|
||||
content,
|
||||
session_store::UploadedFileLimits::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn upload_file_with_context(
|
||||
&self,
|
||||
file_name: &str,
|
||||
media_type: &str,
|
||||
content: &[u8],
|
||||
context: &session_store::UploadedFileUploadContext,
|
||||
) -> Result<UploadedFileRef, session_store::StoreError> {
|
||||
self.artifact_store.write_uploaded_file_with_context(
|
||||
self.session_id,
|
||||
file_name,
|
||||
media_type,
|
||||
content,
|
||||
context,
|
||||
session_store::UploadedFileLimits::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn delete_uploaded_file(
|
||||
&self,
|
||||
artifact_id: &str,
|
||||
) -> Result<bool, session_store::StoreError> {
|
||||
self.artifact_store
|
||||
.delete_uploaded_file(self.session_id, artifact_id)
|
||||
}
|
||||
|
||||
pub fn delete_uncommitted_uploaded_files(&self) -> Result<u64, session_store::StoreError> {
|
||||
self.artifact_store
|
||||
.delete_uncommitted_uploaded_files(self.session_id)
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<Event> {
|
||||
self.working_event_tx.subscribe()
|
||||
}
|
||||
@@ -503,6 +551,8 @@ impl WorkerController {
|
||||
runtime_dir.write_manifest(&manifest_toml).await?;
|
||||
runtime_dir.write_status(&shared_state).await?;
|
||||
|
||||
let artifact_store: Arc<dyn Store> = Arc::new(worker.store().clone());
|
||||
let session_id = worker.session_id();
|
||||
let handle = WorkerHandle {
|
||||
method_tx,
|
||||
working_event_tx: working_event_tx.clone(),
|
||||
@@ -512,6 +562,8 @@ impl WorkerController {
|
||||
in_flight: in_flight.clone(),
|
||||
sink: worker.sink(),
|
||||
spawned_registry: spawned_registry.clone(),
|
||||
artifact_store,
|
||||
session_id,
|
||||
};
|
||||
|
||||
let socket_server = match transport {
|
||||
|
||||
@@ -23,8 +23,9 @@ use workdir::{
|
||||
use workspace_api::{
|
||||
WorkingDirectoryCreateRequest as WorkdirCreateRequest,
|
||||
WorkingDirectoryCreateResponse as WorkdirCreateResponse,
|
||||
WorkingDirectoryDetailResponse as WorkdirDetailResponse,
|
||||
WorkingDirectoryListResponse as WorkdirListResponse,
|
||||
WorkingDirectoryRemovalRequest as WorkdirRemovalRequest,
|
||||
WorkingDirectoryRemovalResponse as WorkdirRemovalResponse,
|
||||
};
|
||||
|
||||
use crate::feature::{
|
||||
@@ -51,7 +52,7 @@ const LIST_DESCRIPTION: &str = "List persistent Workdirs in the current Workspac
|
||||
const CREATE_DESCRIPTION: &str = "Materialize a persistent Workdir on a selected Runtime from a Workspace repository and optional selector. This does not change this Worker's attachment; use WorkdirAttach explicitly after creation.";
|
||||
const ATTACH_DESCRIPTION: &str = "Attach this Worker to one existing Workdir. The Backend enforces one active Workdir per Worker and one active Worker per Workdir, then opens an ephemeral operation session.";
|
||||
const DETACH_DESCRIPTION: &str = "Detach this Worker from its active Workdir and release Workdir occupancy. Any ephemeral operation session is closed.";
|
||||
const DELETE_DESCRIPTION: &str = "Delete one persistent Workdir by id through Backend Workspace API authority. Occupied, blocked, or dirty Workdirs requiring confirmation are rejected.";
|
||||
const DELETE_DESCRIPTION: &str = "Request removal of one persistent Workdir by id through durable Backend Workspace authority. The input includes only the Workdir id and a bounded reason. The result reports removed, retained, or attention_required without exposing operation-table or provider internals.";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ManageWorkdirFeature {
|
||||
@@ -422,12 +423,13 @@ impl WorkspaceHttpWorkdirBackend {
|
||||
.as_deref()
|
||||
.map(|value| validate_identity(value, CREATE_TOOL, "runtime_id"))
|
||||
.transpose()?;
|
||||
let repository_id = validate_identity(&input.repository_id, CREATE_TOOL, "repository_id")?;
|
||||
let repository_key =
|
||||
validate_identity(&input.repository_key, CREATE_TOOL, "repository_key")?;
|
||||
let selector = validate_optional_selector(input.selector)?;
|
||||
let workspace_id = encode_path_segment(self.workspace_id()?);
|
||||
let request = WorkdirCreateRequest {
|
||||
runtime_id: runtime_id.map(str::to_string),
|
||||
repository_id: repository_id.to_string(),
|
||||
repository_key: repository_key.to_string(),
|
||||
selector,
|
||||
operation_id: Some(operation_id),
|
||||
};
|
||||
@@ -484,12 +486,21 @@ impl WorkspaceHttpWorkdirBackend {
|
||||
)?;
|
||||
let workspace_id = encode_path_segment(self.workspace_id()?);
|
||||
let workdir_path = encode_path_segment(workdir_id);
|
||||
let response = self.execute_json::<WorkdirDetailResponse>(WorkspaceRequest {
|
||||
method: WorkspaceRequestMethod::Delete,
|
||||
path: format!("/api/w/{workspace_id}/working-directories/{workdir_path}"),
|
||||
body: None,
|
||||
})?;
|
||||
workdir_output(format!("Deleted Workdir {workdir_id}"), &response)
|
||||
let response = self.execute_json::<WorkdirRemovalResponse>(WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Delete,
|
||||
format!("/api/w/{workspace_id}/working-directories/{workdir_path}"),
|
||||
serde_json::to_string(&WorkdirRemovalRequest {
|
||||
reason: validate_delete_reason(&input.reason)?.to_string(),
|
||||
})
|
||||
.map_err(decode_error)?,
|
||||
))?;
|
||||
workdir_output(
|
||||
format!(
|
||||
"Workdir {workdir_id} removal disposition: {:?}",
|
||||
response.disposition
|
||||
),
|
||||
&response,
|
||||
)
|
||||
}
|
||||
|
||||
fn execute_json<T: for<'de> Deserialize<'de>>(
|
||||
@@ -611,6 +622,17 @@ fn validate_identity<'a>(
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn validate_delete_reason(reason: &str) -> Result<&str, ToolError> {
|
||||
let reason = reason.trim();
|
||||
if reason.is_empty() || reason.len() > 500 || reason.chars().any(char::is_control) {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"WorkdirDelete reason must be non-empty, contain no control characters, and be at most 500 bytes"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(reason)
|
||||
}
|
||||
|
||||
fn validate_optional_selector(selector: Option<String>) -> Result<Option<String>, ToolError> {
|
||||
let Some(selector) = selector else {
|
||||
return Ok(None);
|
||||
@@ -661,10 +683,10 @@ fn create_schema() -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["repository_id"],
|
||||
"required": ["repository_key"],
|
||||
"properties": {
|
||||
"runtime_id": {"type": ["string", "null"], "minLength": 1},
|
||||
"repository_id": {"type": "string", "minLength": 1},
|
||||
"repository_key": {"type": "string", "minLength": 1},
|
||||
"selector": {"type": ["string", "null"], "minLength": 1}
|
||||
}
|
||||
})
|
||||
@@ -689,9 +711,10 @@ fn delete_schema() -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["working_directory_id"],
|
||||
"required": ["working_directory_id", "reason"],
|
||||
"properties": {
|
||||
"working_directory_id": {"type": "string", "minLength": 1}
|
||||
"working_directory_id": {"type": "string", "minLength": 1},
|
||||
"reason": {"type": "string", "minLength": 1, "maxLength": 500}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -705,7 +728,7 @@ struct WorkdirListInput {}
|
||||
struct WorkdirCreateInput {
|
||||
#[serde(default)]
|
||||
runtime_id: Option<String>,
|
||||
repository_id: String,
|
||||
repository_key: String,
|
||||
#[serde(default)]
|
||||
selector: Option<String>,
|
||||
}
|
||||
@@ -736,6 +759,7 @@ struct WorkdirAttachmentResponse {
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct WorkdirDeleteInput {
|
||||
working_directory_id: String,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -817,14 +841,14 @@ mod tests {
|
||||
fn workdir_json(id: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"working_directory_id": id,
|
||||
"repository_id": "main",
|
||||
"repository_key": "main",
|
||||
"creation_selector": "refs/heads/main",
|
||||
"creation_ref": "0123456789abcdef",
|
||||
"materializer_kind": "local_git_worktree",
|
||||
"cleanup_target": {
|
||||
"kind": "git_worktree",
|
||||
"working_directory_id": id,
|
||||
"repository_id": "main"
|
||||
"repository_key": "main"
|
||||
},
|
||||
"status": "active",
|
||||
"cleanliness": "clean",
|
||||
@@ -950,7 +974,7 @@ mod tests {
|
||||
#[test]
|
||||
fn schemas_expose_identities_without_paths_or_session_handles() {
|
||||
let create = create_schema();
|
||||
assert_eq!(create["required"], json!(["repository_id"]));
|
||||
assert_eq!(create["required"], json!(["repository_key"]));
|
||||
assert_eq!(
|
||||
create["properties"]["runtime_id"]["type"],
|
||||
json!(["string", "null"])
|
||||
@@ -959,7 +983,10 @@ mod tests {
|
||||
assert!(create["properties"].get("session_id").is_none());
|
||||
assert_eq!(attach_schema()["required"], json!(["workdir_id"]));
|
||||
assert!(attach_schema()["properties"].get("session_id").is_none());
|
||||
assert_eq!(delete_schema()["required"], json!(["working_directory_id"]));
|
||||
assert_eq!(
|
||||
delete_schema()["required"],
|
||||
json!(["working_directory_id", "reason"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -999,15 +1026,9 @@ mod tests {
|
||||
"attached": false
|
||||
})),
|
||||
response(json!({
|
||||
"workspace_id": "workspace/test",
|
||||
"runtime_id": "runtime/one",
|
||||
"item": {
|
||||
"working_directory_id": "wd-created",
|
||||
"repository_id": "main",
|
||||
"materializer_kind": "local_git_worktree",
|
||||
"status": "not_found"
|
||||
},
|
||||
"diagnostics": []
|
||||
"working_directory_id": "wd-created",
|
||||
"disposition": "removed",
|
||||
"retryable": false
|
||||
})),
|
||||
]));
|
||||
let backend = WorkspaceHttpWorkdirBackend::new(client.clone());
|
||||
@@ -1028,7 +1049,7 @@ mod tests {
|
||||
.create(
|
||||
WorkdirCreateInput {
|
||||
runtime_id: Some("runtime/one".to_string()),
|
||||
repository_id: "main".to_string(),
|
||||
repository_key: "main".to_string(),
|
||||
selector: Some("refs/heads/topic".to_string()),
|
||||
},
|
||||
"call-create-1".to_string(),
|
||||
@@ -1052,6 +1073,7 @@ mod tests {
|
||||
backend
|
||||
.delete(WorkdirDeleteInput {
|
||||
working_directory_id: "wd-created".to_string(),
|
||||
reason: "remove stale Workdir".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -1068,7 +1090,7 @@ mod tests {
|
||||
assert_eq!(requests[1].method, WorkspaceRequestMethod::Post);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(requests[1].body.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(body["repository_id"], "main");
|
||||
assert_eq!(body["repository_key"], "main");
|
||||
assert_eq!(body["runtime_id"], "runtime/one");
|
||||
assert_eq!(body["operation_id"], "call-create-1");
|
||||
assert_eq!(body["selector"], "refs/heads/topic");
|
||||
@@ -1087,6 +1109,9 @@ mod tests {
|
||||
"/api/w/workspace%2Ftest/working-directories/wd-created"
|
||||
);
|
||||
assert_eq!(requests[4].method, WorkspaceRequestMethod::Delete);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(requests[4].body.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(body, json!({"reason": "remove stale Workdir"}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1356,7 +1381,7 @@ mod tests {
|
||||
.create(
|
||||
WorkdirCreateInput {
|
||||
runtime_id: None,
|
||||
repository_id: "main".to_string(),
|
||||
repository_key: "main".to_string(),
|
||||
selector: None,
|
||||
},
|
||||
"call-default".to_string(),
|
||||
@@ -1381,7 +1406,7 @@ mod tests {
|
||||
.create(
|
||||
WorkdirCreateInput {
|
||||
runtime_id: Some(" ".to_string()),
|
||||
repository_id: "main".to_string(),
|
||||
repository_key: "main".to_string(),
|
||||
selector: None,
|
||||
},
|
||||
"call-invalid".to_string(),
|
||||
|
||||
@@ -56,7 +56,7 @@ struct TicketInput {
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct OpenMergeRequestInput {
|
||||
ticket: String,
|
||||
repository_id: String,
|
||||
repository_key: String,
|
||||
selector_from: String,
|
||||
selector_to: String,
|
||||
#[serde(default)]
|
||||
@@ -178,7 +178,7 @@ impl Tool for MergeRequestTool {
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("/api/w/{ws}/tickets/{}/merge-request", v.ticket),
|
||||
Some(
|
||||
json!({"repository_id":v.repository_id,"selector_from":v.selector_from,"selector_to":v.selector_to,"summary":v.summary}),
|
||||
json!({"repository_key":v.repository_key,"selector_from":v.selector_from,"selector_to":v.selector_to,"summary":v.summary}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::sync::Arc;
|
||||
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||
use async_trait::async_trait;
|
||||
use protocol::{PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use session_store::{SessionId, Store, StoreError};
|
||||
@@ -75,11 +76,8 @@ where
|
||||
.max_results
|
||||
.unwrap_or(DEFAULT_SEARCH_RESULTS)
|
||||
.clamp(1, MAX_SEARCH_RESULTS);
|
||||
let (_, content) = self
|
||||
.access
|
||||
.store
|
||||
.read_paste_artifact(self.access.session_id, &input.artifact_id)
|
||||
.map_err(tool_store_error)?;
|
||||
let (_, content) =
|
||||
read_artifact_text(&self.access, &input.artifact_id).map_err(tool_store_error)?;
|
||||
let mut matches = Vec::new();
|
||||
let mut truncated = false;
|
||||
let mut byte_offset = 0_u64;
|
||||
@@ -150,11 +148,8 @@ where
|
||||
.max_bytes
|
||||
.unwrap_or(DEFAULT_READ_BYTES)
|
||||
.clamp(4, MAX_READ_BYTES);
|
||||
let (_, content) = self
|
||||
.access
|
||||
.store
|
||||
.read_paste_artifact(self.access.session_id, &input.artifact_id)
|
||||
.map_err(tool_store_error)?;
|
||||
let (_, content) =
|
||||
read_artifact_text(&self.access, &input.artifact_id).map_err(tool_store_error)?;
|
||||
let offset = usize::try_from(offset).map_err(|_| {
|
||||
ToolError::InvalidArgument("offset exceeds the artifact size".to_string())
|
||||
})?;
|
||||
@@ -234,6 +229,47 @@ fn json_output(summary: String, value: &impl Serialize) -> Result<ToolOutput, To
|
||||
})
|
||||
}
|
||||
|
||||
fn read_artifact_text<St: Store + Clone>(
|
||||
access: &ArtifactAccess<St>,
|
||||
artifact_id: &str,
|
||||
) -> Result<(PasteArtifactRef, String), StoreError> {
|
||||
match access
|
||||
.store
|
||||
.read_paste_artifact(access.session_id, artifact_id)
|
||||
{
|
||||
Ok(result) => Ok(result),
|
||||
Err(paste_error) => {
|
||||
let (file, bytes) = match access
|
||||
.store
|
||||
.read_uploaded_file_by_id(access.session_id, artifact_id)
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(_) => return Err(paste_error),
|
||||
};
|
||||
let content =
|
||||
String::from_utf8(bytes).map_err(|_| StoreError::ArtifactIntegrityMismatch)?;
|
||||
let char_count =
|
||||
u64::try_from(content.chars().count()).map_err(|_| StoreError::ArtifactTooLarge)?;
|
||||
let line_count =
|
||||
u64::try_from(content.lines().count()).map_err(|_| StoreError::ArtifactTooLarge)?;
|
||||
Ok((
|
||||
PasteArtifactRef {
|
||||
artifact_id: file.artifact_id,
|
||||
created_at_ms: file.created_at_ms,
|
||||
media_type: PasteArtifactMediaType::TextPlainUtf8,
|
||||
availability: PasteArtifactAvailability::Available,
|
||||
byte_len: file.byte_len,
|
||||
char_count,
|
||||
line_count,
|
||||
sha256: file.sha256,
|
||||
source_entry_id: file.source_entry_id.unwrap_or_default(),
|
||||
},
|
||||
content,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_store_error(error: StoreError) -> ToolError {
|
||||
let message = match error {
|
||||
StoreError::PasteArtifactNotFound(_) => "paste artifact not found",
|
||||
@@ -263,6 +299,66 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_input_artifact_reads_uploaded_text_but_rejects_binary_content() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let store = FsStore::new(temp.path()).unwrap();
|
||||
let owner = new_session_id();
|
||||
let text = store
|
||||
.write_uploaded_file(
|
||||
owner,
|
||||
"notes.md",
|
||||
"text/markdown",
|
||||
b"alpha\nbeta",
|
||||
session_store::UploadedFileLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let read = ReadInputArtifactTool {
|
||||
access: ArtifactAccess {
|
||||
store: store.clone(),
|
||||
session_id: owner,
|
||||
},
|
||||
};
|
||||
let output = read
|
||||
.execute(
|
||||
&serde_json::json!({
|
||||
"artifact_id": text.artifact_id,
|
||||
"offset": 0,
|
||||
"max_bytes": 64
|
||||
})
|
||||
.to_string(),
|
||||
ToolExecutionContext::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let output: serde_json::Value =
|
||||
serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(output["content"], "alpha\nbeta");
|
||||
|
||||
let binary = store
|
||||
.write_uploaded_file(
|
||||
owner,
|
||||
"image.png",
|
||||
"image/png",
|
||||
b"\x89PNG\r\n\x1a\nbody",
|
||||
session_store::UploadedFileLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let error = read
|
||||
.execute(
|
||||
&serde_json::json!({
|
||||
"artifact_id": binary.artifact_id,
|
||||
"offset": 0,
|
||||
"max_bytes": 64
|
||||
})
|
||||
.to_string(),
|
||||
ToolExecutionContext::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("unavailable"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_and_read_are_bounded_and_owner_scoped() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
|
||||
@@ -920,4 +920,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_orchestrator_cleanup_policy_renders_with_common_includes() {
|
||||
let rendered = PromptCatalog::builtins_only()
|
||||
.unwrap()
|
||||
.render_name("role.orchestrator", Value::UNDEFINED)
|
||||
.unwrap();
|
||||
|
||||
assert!(rendered.contains("This policy governs naming only"));
|
||||
assert!(rendered.contains("Coder cleanup is a separate post-completion decision"));
|
||||
assert!(rendered.contains("perform one cleanup pass before ending the orchestration turn"));
|
||||
assert!(rendered.contains("Never predeclare `delete_on_completion`"));
|
||||
assert!(rendered.contains("call `WorkerStop`"));
|
||||
assert!(rendered.contains("call `WorkerRemove`"));
|
||||
assert!(rendered.contains("only then call `WorkdirDelete`"));
|
||||
assert!(rendered.contains("`CurrentAssignment` means unassign and reread"));
|
||||
assert!(!rendered.contains("{% include"));
|
||||
}
|
||||
}
|
||||
|
||||
+108
-6
@@ -3082,7 +3082,32 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
projected_entry_ids: &[SessionHistoryEntryId],
|
||||
one_entry_per_segment: bool,
|
||||
) -> Result<(), WorkerError> {
|
||||
let uploaded_file_count = input
|
||||
.iter()
|
||||
.filter(|segment| matches!(segment, Segment::UploadedFile { .. }))
|
||||
.count();
|
||||
if uploaded_file_count > session_store::DEFAULT_MAX_FILES_PER_SUBMISSION {
|
||||
return Err(WorkerError::Store(StoreError::ArtifactQuotaExceeded));
|
||||
}
|
||||
for (index, segment) in input.iter_mut().enumerate() {
|
||||
if let Segment::UploadedFile { file } = segment {
|
||||
if file.source_entry_id.is_some()
|
||||
|| file.availability != protocol::UploadedFileAvailability::Available
|
||||
{
|
||||
return Err(WorkerError::Store(StoreError::ArtifactIntegrityMismatch));
|
||||
}
|
||||
let entry_index = if one_entry_per_segment { index } else { 0 };
|
||||
let source_entry_id = projected_entry_ids
|
||||
.get(entry_index)
|
||||
.expect("projected input id exists for every uploaded file")
|
||||
.0
|
||||
.clone();
|
||||
*file = self
|
||||
.store
|
||||
.bind_uploaded_file(self.session_id(), file, &source_entry_id)
|
||||
.map_err(WorkerError::Store)?;
|
||||
continue;
|
||||
}
|
||||
if let Segment::PasteArtifact { artifact } = segment {
|
||||
let (stored, _) = self
|
||||
.store
|
||||
@@ -3306,7 +3331,13 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
}),
|
||||
compacted_from: None,
|
||||
};
|
||||
let mut initial_entries = vec![entry.clone()];
|
||||
let mut initial_entries = vec![
|
||||
entry.clone(),
|
||||
LogEntry::InputSegmentsCheckpoint {
|
||||
ts: segment_log::now_millis(),
|
||||
user_segments: self.user_segments.clone(),
|
||||
},
|
||||
];
|
||||
if let Some(checkpoint) =
|
||||
active_run_checkpoint_entry(w.active_run_turn_count(), w.turn_count())
|
||||
{
|
||||
@@ -4270,6 +4301,13 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let retained_user_segments = self
|
||||
.user_segments
|
||||
.iter()
|
||||
.skip(self.user_segments.len().saturating_sub(retained_user_msgs))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Build the SegmentStart entry for the new compacted segment.
|
||||
// Inherits the source Segment's session_id so the compacted
|
||||
// lineage stays grouped under the same Session. Atomically
|
||||
@@ -4295,7 +4333,13 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
at_turn_index: source_turn_count,
|
||||
}),
|
||||
};
|
||||
let mut initial_entries = vec![entry.clone()];
|
||||
let mut initial_entries = vec![
|
||||
entry.clone(),
|
||||
LogEntry::InputSegmentsCheckpoint {
|
||||
ts: segment_log::now_millis(),
|
||||
user_segments: retained_user_segments.clone(),
|
||||
},
|
||||
];
|
||||
if let Some(checkpoint) =
|
||||
active_run_checkpoint_entry(w.active_run_turn_count(), source_turn_count)
|
||||
{
|
||||
@@ -4347,10 +4391,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
// segments; only the user_messages surviving in retained_items
|
||||
// keep them. They are always the trailing K entries of
|
||||
// `self.user_segments` because submissions are appended in order.
|
||||
let drop_n = self.user_segments.len().saturating_sub(retained_user_msgs);
|
||||
if drop_n > 0 {
|
||||
self.user_segments.drain(..drop_n);
|
||||
}
|
||||
self.user_segments = retained_user_segments;
|
||||
|
||||
self.session.replace_history(compacted_history_entries);
|
||||
// Compaction-introduced system messages are part of the new
|
||||
@@ -6471,6 +6512,11 @@ fn preview_segments(segments: &[Segment]) -> String {
|
||||
preview.push_str(&artifact.artifact_id);
|
||||
preview.push(']');
|
||||
}
|
||||
Segment::UploadedFile { file } => {
|
||||
preview.push_str("[Attached file: ");
|
||||
preview.push_str(&file.file_name);
|
||||
preview.push(']');
|
||||
}
|
||||
Segment::FileRef { path } => {
|
||||
preview.push('@');
|
||||
preview.push_str(path);
|
||||
@@ -7873,6 +7919,7 @@ mod build_summary_prompt_tests {
|
||||
fork_entries.as_slice(),
|
||||
[
|
||||
LogEntry::AnnotatedSegmentStart { .. },
|
||||
LogEntry::InputSegmentsCheckpoint { .. },
|
||||
LogEntry::ActiveRunCheckpoint {
|
||||
active_turn_count: 3,
|
||||
total_turn_count: 7,
|
||||
@@ -8049,6 +8096,61 @@ mod build_summary_prompt_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uploaded_file_is_verified_and_bound_to_projected_entry_before_commit() {
|
||||
let (_dir, worker) = rewind_test_worker().await;
|
||||
let reference = worker
|
||||
.store
|
||||
.write_uploaded_file(
|
||||
worker.session_id(),
|
||||
"notes.md",
|
||||
"text/markdown",
|
||||
b"# private body",
|
||||
session_store::UploadedFileLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let entry_id = SessionHistoryEntryId::new();
|
||||
let mut input = vec![Segment::UploadedFile { file: reference }];
|
||||
|
||||
worker
|
||||
.materialize_large_pastes(&mut input, std::slice::from_ref(&entry_id), false)
|
||||
.unwrap();
|
||||
let file = match &input[0] {
|
||||
Segment::UploadedFile { file } => file,
|
||||
other => panic!("expected uploaded file, got {other:?}"),
|
||||
};
|
||||
assert_eq!(file.source_entry_id.as_deref(), Some(entry_id.0.as_str()));
|
||||
assert_eq!(
|
||||
worker
|
||||
.store
|
||||
.read_uploaded_file(worker.session_id(), file)
|
||||
.unwrap(),
|
||||
b"# private body"
|
||||
);
|
||||
assert!(matches!(
|
||||
worker
|
||||
.store
|
||||
.delete_uploaded_file(worker.session_id(), &file.artifact_id),
|
||||
Err(StoreError::ArtifactAlreadyCommitted)
|
||||
));
|
||||
let projected = worker.projected_input_history(&input, None, &[entry_id]);
|
||||
let text = projected[0].item.as_text().unwrap();
|
||||
assert!(text.contains("notes.md"));
|
||||
assert!(text.contains(&file.artifact_id));
|
||||
assert!(!text.contains("private body"));
|
||||
|
||||
let mut forged = input.clone();
|
||||
let Segment::UploadedFile { file } = &mut forged[0] else {
|
||||
unreachable!();
|
||||
};
|
||||
file.source_entry_id = None;
|
||||
file.sha256 = "0".repeat(64);
|
||||
assert!(matches!(
|
||||
worker.materialize_large_pastes(&mut forged, &[SessionHistoryEntryId::new()], false),
|
||||
Err(WorkerError::Store(StoreError::ArtifactIntegrityMismatch))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn large_paste_is_stored_before_compact_history_is_committed() {
|
||||
let (_dir, worker) = rewind_test_worker().await;
|
||||
|
||||
@@ -9,6 +9,7 @@ use agen::llm_client::{ClientError, LlmClient, Request};
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use async_trait::async_trait;
|
||||
use futures::{Stream, StreamExt};
|
||||
use manifest::{ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, ProfileSelector};
|
||||
use session_store::{CombinedStore, FsWorkerStore};
|
||||
use session_store::{FsStore, LogEntry};
|
||||
use workdir::{
|
||||
@@ -248,6 +249,14 @@ async fn make_worker_with_pwd_manifest_and_workspace_context(
|
||||
workspace_context: WorkerWorkspaceContext,
|
||||
) -> (Worker<MockClient, TestStore>, std::path::PathBuf) {
|
||||
let manifest = WorkerManifest::from_toml(manifest_toml).unwrap();
|
||||
make_worker_with_manifest_and_workspace_context(client, manifest, workspace_context).await
|
||||
}
|
||||
|
||||
async fn make_worker_with_manifest_and_workspace_context(
|
||||
client: MockClient,
|
||||
manifest: WorkerManifest,
|
||||
workspace_context: WorkerWorkspaceContext,
|
||||
) -> (Worker<MockClient, TestStore>, std::path::PathBuf) {
|
||||
let store_tmp = tempfile::tempdir().unwrap();
|
||||
let store = CombinedStore::new(
|
||||
FsStore::new(store_tmp.path()).unwrap(),
|
||||
@@ -783,6 +792,37 @@ permission = "write"
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn builtin_orchestrator_exposes_worker_remove_and_workdir_delete() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let resolved = ProfileResolver::new()
|
||||
.with_workspace_base(workspace.path())
|
||||
.resolve(
|
||||
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "orchestrator"),
|
||||
ProfileResolveOptions::with_worker_name("orchestrator-worker"),
|
||||
)
|
||||
.unwrap();
|
||||
let workspace_context =
|
||||
WorkerWorkspaceContext::with_client(None, Arc::new(NoopWorkspaceClient));
|
||||
let client = MockClient::new(simple_text_events());
|
||||
let client_for_assert = client.clone();
|
||||
let (worker, _pwd) = make_worker_with_manifest_and_workspace_context(
|
||||
client,
|
||||
resolved.manifest,
|
||||
workspace_context,
|
||||
)
|
||||
.await;
|
||||
let handle = spawn_controller(worker).await;
|
||||
|
||||
handle.send(Method::run_text("Hello")).await.unwrap();
|
||||
wait_for_status(&handle, WorkerStatus::Idle).await;
|
||||
let request = wait_for_captured_request(&client_for_assert).await;
|
||||
let installed = request_tool_names(&request);
|
||||
|
||||
assert!(installed.iter().any(|name| name == "WorkerRemove"));
|
||||
assert!(installed.iter().any(|name| name == "WorkdirDelete"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_and_sub_worker_features_install_one_canonical_control_surface() {
|
||||
let manifest = r#"
|
||||
|
||||
+350
-24
@@ -6,6 +6,54 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const REPOSITORY_KEY_MIN_LEN: usize = 1;
|
||||
pub const REPOSITORY_KEY_MAX_LEN: usize = 64;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RepositoryKeyError {
|
||||
Length,
|
||||
Character,
|
||||
LeadingHyphen,
|
||||
TrailingHyphen,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RepositoryKeyError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(match self {
|
||||
Self::Length => "must contain between 1 and 64 ASCII bytes",
|
||||
Self::Character => "must contain only lowercase ASCII letters, digits, and hyphens",
|
||||
Self::LeadingHyphen => "must not start with a hyphen",
|
||||
Self::TrailingHyphen => "must not end with a hyphen",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RepositoryKeyError {}
|
||||
|
||||
/// Validate one immutable Workspace-scoped Repository key.
|
||||
///
|
||||
/// Keys are deliberately not normalized: callers must submit the exact canonical
|
||||
/// lowercase ASCII spelling so idempotency and route identity cannot alias.
|
||||
pub fn validate_repository_key(value: &str) -> Result<(), RepositoryKeyError> {
|
||||
let bytes = value.as_bytes();
|
||||
if !(REPOSITORY_KEY_MIN_LEN..=REPOSITORY_KEY_MAX_LEN).contains(&bytes.len()) {
|
||||
return Err(RepositoryKeyError::Length);
|
||||
}
|
||||
if bytes[0] == b'-' {
|
||||
return Err(RepositoryKeyError::LeadingHyphen);
|
||||
}
|
||||
if bytes[bytes.len() - 1] == b'-' {
|
||||
return Err(RepositoryKeyError::TrailingHyphen);
|
||||
}
|
||||
if !bytes
|
||||
.iter()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
|
||||
{
|
||||
return Err(RepositoryKeyError::Character);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Provider-neutral classification of an authoritative Repository source.
|
||||
///
|
||||
/// Local paths remain distinct from network Git transports so callers cannot
|
||||
@@ -70,8 +118,7 @@ pub struct RepositorySource {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CreateWorkspaceRepositoryRequest {
|
||||
pub repository_id: String,
|
||||
pub display_name: String,
|
||||
pub repository_key: String,
|
||||
pub source: String,
|
||||
#[serde(default)]
|
||||
pub default_ref: Option<String>,
|
||||
@@ -80,7 +127,7 @@ pub struct CreateWorkspaceRepositoryRequest {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct CreateWorkspaceRepositoryResponse {
|
||||
pub workspace_id: String,
|
||||
pub repository_id: String,
|
||||
pub repository_key: String,
|
||||
pub replayed: bool,
|
||||
}
|
||||
|
||||
@@ -139,8 +186,7 @@ pub struct WorkspaceCatalogListResponse(pub Vec<WorkspaceSummary>);
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceRepositoryRecord {
|
||||
pub workspace_id: String,
|
||||
pub repository_id: String,
|
||||
pub name: String,
|
||||
pub repository_key: String,
|
||||
pub kind: String,
|
||||
pub provider: Option<String>,
|
||||
pub source: RepositorySource,
|
||||
@@ -222,6 +268,100 @@ pub struct WorkspaceResponse {
|
||||
pub extension_points: WorkspaceExtensionPoints,
|
||||
}
|
||||
|
||||
/// Workspace identity metadata exposed by the current settings resource.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceMetadataSettingsResponse {
|
||||
pub workspace_id: String,
|
||||
pub display_name: String,
|
||||
pub created_at: String,
|
||||
pub revision: String,
|
||||
pub source: String,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
/// Compare-and-swap update for Workspace identity display metadata.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UpdateWorkspaceMetadataRequest {
|
||||
pub display_name: String,
|
||||
pub revision: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceMetadataMutationResponse {
|
||||
pub workspace: WorkspaceMetadataSettingsResponse,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
/// Read-only Profile catalog projected from one active Workspace config revision.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "typescript", ts(optional_fields = nullable))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ProfileSettingsResponse {
|
||||
pub workspace_id: String,
|
||||
pub registry_revision: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(optional, type = "number | null"))]
|
||||
pub config_revision: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tree_digest: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub projection_digest: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_profile: Option<String>,
|
||||
pub profiles: Vec<WorkspaceProfileSummary>,
|
||||
pub sources: Vec<WorkspaceProfileSourceSummary>,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "typescript", ts(optional_fields = nullable))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceProfileSummary {
|
||||
pub profile_id: String,
|
||||
pub selector: String,
|
||||
pub label: String,
|
||||
pub source_kind: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub profile_source_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub editable: bool,
|
||||
pub is_default: bool,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceProfileSourceSummary {
|
||||
pub profile_source_id: String,
|
||||
pub display_path: String,
|
||||
pub kind: String,
|
||||
pub content_type: String,
|
||||
pub content_digest: String,
|
||||
pub provenance: WorkspaceProfileSourceProvenance,
|
||||
pub editable: bool,
|
||||
pub revision: String,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
pub size_bytes: u64,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkspaceProfileSourceProvenance {
|
||||
ProjectProfileSourceTree,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -254,8 +394,7 @@ pub struct GitRepositorySummary {
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RepositorySummary {
|
||||
pub id: String,
|
||||
pub display_name: String,
|
||||
pub repository_key: String,
|
||||
pub kind: String,
|
||||
pub provider: String,
|
||||
pub source: RepositorySource,
|
||||
@@ -316,7 +455,7 @@ pub struct RepositoryDetailResponse {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RepositoryLogResponse {
|
||||
pub workspace_id: String,
|
||||
pub repository_id: String,
|
||||
pub repository_key: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(optional = nullable))]
|
||||
pub default_selector: Option<String>,
|
||||
@@ -394,7 +533,35 @@ impl std::fmt::Display for WorkingDirectoryStatusKind {
|
||||
pub struct WorkingDirectoryCleanupTarget {
|
||||
pub kind: String,
|
||||
pub working_directory_id: String,
|
||||
pub repository_id: String,
|
||||
pub repository_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkingDirectoryRemovalRequest {
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkingDirectoryRemovalDisposition {
|
||||
Removed,
|
||||
Retained,
|
||||
AttentionRequired,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "typescript", ts(optional_fields = nullable))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkingDirectoryRemovalResponse {
|
||||
pub working_directory_id: String,
|
||||
pub disposition: WorkingDirectoryRemovalDisposition,
|
||||
pub retryable: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub failure_category: Option<String>,
|
||||
}
|
||||
|
||||
/// Durable Workspace occupancy projection for one Workdir.
|
||||
@@ -408,6 +575,51 @@ pub struct WorkingDirectoryOccupancy {
|
||||
pub linked_at: String,
|
||||
}
|
||||
|
||||
/// Runtime-internal Workdir cleanup authority. This transport intentionally
|
||||
/// retains the Backend-generated Repository id and is never a Workspace public
|
||||
/// projection.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RuntimeWorkingDirectoryCleanupTarget {
|
||||
pub kind: String,
|
||||
pub working_directory_id: String,
|
||||
pub repository_id: String,
|
||||
}
|
||||
|
||||
/// Runtime-internal Workdir inventory transport. Workspace REST and model-facing
|
||||
/// surfaces must project this through [`WorkingDirectorySummary`] so the UUID is
|
||||
/// replaced with `repository_key`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RuntimeWorkingDirectorySummary {
|
||||
pub working_directory_id: String,
|
||||
pub repository_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub creation_selector: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub creation_ref: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub creation_tree: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_selector: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_ref: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_tree: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub observed_at_epoch_seconds: Option<u64>,
|
||||
pub materializer_kind: WorkingDirectoryMaterializerKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cleanup_target: Option<RuntimeWorkingDirectoryCleanupTarget>,
|
||||
pub status: WorkingDirectoryStatusKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cleanliness: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub primary_worker_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub occupied_by: Option<WorkingDirectoryOccupancy>,
|
||||
}
|
||||
|
||||
/// Public, provider-neutral Workdir inventory projection.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
@@ -415,7 +627,7 @@ pub struct WorkingDirectoryOccupancy {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkingDirectorySummary {
|
||||
pub working_directory_id: String,
|
||||
pub repository_id: String,
|
||||
pub repository_key: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub creation_selector: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -462,7 +674,7 @@ impl WorkingDirectorySummary {
|
||||
pub struct WorkingDirectoryCreateRequest {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub runtime_id: Option<String>,
|
||||
pub repository_id: String,
|
||||
pub repository_key: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub selector: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -1061,7 +1273,7 @@ pub enum RepositoryAccessMode {
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RepositorySshAccessBinding {
|
||||
pub repository_id: String,
|
||||
pub repository_key: String,
|
||||
pub credential_id: String,
|
||||
pub host_trust_id: String,
|
||||
pub access: RepositoryAccessMode,
|
||||
@@ -1096,6 +1308,13 @@ pub fn catalog_typescript() -> String {
|
||||
WorkspaceExtensionPointState::decl(&config),
|
||||
WorkspaceExtensionPoints::decl(&config),
|
||||
WorkspaceResponse::decl(&config),
|
||||
WorkspaceMetadataSettingsResponse::decl(&config),
|
||||
UpdateWorkspaceMetadataRequest::decl(&config),
|
||||
WorkspaceMetadataMutationResponse::decl(&config),
|
||||
ProfileSettingsResponse::decl(&config),
|
||||
WorkspaceProfileSummary::decl(&config),
|
||||
WorkspaceProfileSourceSummary::decl(&config),
|
||||
WorkspaceProfileSourceProvenance::decl(&config),
|
||||
RepositorySourceKind::decl(&config),
|
||||
RepositorySource::decl(&config),
|
||||
RepositoryObservedStatus::decl(&config),
|
||||
@@ -1191,10 +1410,12 @@ mod workdir_typescript_tests {
|
||||
value
|
||||
.chars()
|
||||
.filter_map(|character| match character {
|
||||
'\r' | '\n' | ' ' | '\t' => None,
|
||||
_ => Some(character),
|
||||
character if character.is_whitespace() => None,
|
||||
',' => Some(';'),
|
||||
character => Some(character),
|
||||
})
|
||||
.collect()
|
||||
.collect::<String>()
|
||||
.replace("=|", "=")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1202,6 +1423,27 @@ mod workdir_typescript_tests {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn repository_key_validation_is_canonical_and_bounded() {
|
||||
let max = "a".repeat(64);
|
||||
for valid in ["a", "main", "repo-42", max.as_str()] {
|
||||
assert_eq!(validate_repository_key(valid), Ok(()), "{valid}");
|
||||
}
|
||||
let too_long = "a".repeat(65);
|
||||
for invalid in [
|
||||
"",
|
||||
"-main",
|
||||
"main-",
|
||||
"Main",
|
||||
"main_repo",
|
||||
"main.repo",
|
||||
"日本語",
|
||||
too_long.as_str(),
|
||||
] {
|
||||
assert!(validate_repository_key(invalid).is_err(), "{invalid}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_and_repository_response_shapes_round_trip() {
|
||||
let workspace = serde_json::json!({
|
||||
@@ -1243,8 +1485,7 @@ mod tests {
|
||||
let repositories = serde_json::json!({
|
||||
"workspace_id": "workspace-test",
|
||||
"items": [{
|
||||
"id": "main",
|
||||
"display_name": "main",
|
||||
"repository_key": "main",
|
||||
"kind": "git",
|
||||
"provider": "git",
|
||||
"source": {"kind": "local_path", "uri": "/srv/project"},
|
||||
@@ -1265,7 +1506,7 @@ mod tests {
|
||||
let stale = serde_json::json!({
|
||||
"workspace_id": "workspace-test",
|
||||
"items": [{
|
||||
"repository_id": "main",
|
||||
"repository_key": "main",
|
||||
"display_name": "main",
|
||||
"kind": "git",
|
||||
"provider": "git",
|
||||
@@ -1294,7 +1535,15 @@ mod tests {
|
||||
assert!(output.contains("export type RepositoryListResponse ="));
|
||||
assert!(output.contains("items: Array<RepositorySummary>"));
|
||||
assert!(output.contains("observed_at?: string | null"));
|
||||
assert!(!output.contains("repository_id: string, display_name"));
|
||||
assert!(output.contains("export type WorkspaceMetadataSettingsResponse ="));
|
||||
assert!(output.contains("export type WorkspaceMetadataMutationResponse ="));
|
||||
assert!(output.contains("export type ProfileSettingsResponse ="));
|
||||
assert!(output.contains("config_revision?: number | null"));
|
||||
assert!(output.contains("provenance: WorkspaceProfileSourceProvenance"));
|
||||
assert!(output.contains(
|
||||
"export type WorkspaceProfileSourceProvenance = \"project_profile_source_tree\""
|
||||
));
|
||||
assert!(!output.contains("repository_key: string, display_name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1326,6 +1575,83 @@ mod tests {
|
||||
assert_eq!(decoded, value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_metadata_and_profile_projection_fixtures_round_trip() {
|
||||
let diagnostic = Diagnostic {
|
||||
code: "profile_projection_warning".to_string(),
|
||||
severity: DiagnosticSeverity::Warning,
|
||||
message: "projected from the active config revision".to_string(),
|
||||
};
|
||||
let metadata = WorkspaceMetadataSettingsResponse {
|
||||
workspace_id: "workspace-test".to_string(),
|
||||
display_name: "Test".to_string(),
|
||||
created_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
revision: "sha256:metadata".to_string(),
|
||||
source: "workspace-config".to_string(),
|
||||
diagnostics: vec![diagnostic.clone()],
|
||||
};
|
||||
round_trip(metadata.clone());
|
||||
round_trip(UpdateWorkspaceMetadataRequest {
|
||||
display_name: "Renamed".to_string(),
|
||||
revision: metadata.revision.clone(),
|
||||
});
|
||||
round_trip(WorkspaceMetadataMutationResponse {
|
||||
workspace: metadata,
|
||||
diagnostics: vec![],
|
||||
});
|
||||
|
||||
round_trip(ProfileSettingsResponse {
|
||||
workspace_id: "workspace-test".to_string(),
|
||||
registry_revision: "config-source:7:sha256:tree:sha256:projection".to_string(),
|
||||
config_revision: Some(7),
|
||||
tree_digest: Some("sha256:tree".to_string()),
|
||||
projection_digest: Some("sha256:projection".to_string()),
|
||||
default_profile: Some("workspace:coder".to_string()),
|
||||
profiles: vec![WorkspaceProfileSummary {
|
||||
profile_id: "workspace:coder".to_string(),
|
||||
selector: "workspace:coder".to_string(),
|
||||
label: "Coder".to_string(),
|
||||
source_kind: "project".to_string(),
|
||||
profile_source_id: Some("profile-source-1".to_string()),
|
||||
description: None,
|
||||
editable: true,
|
||||
is_default: true,
|
||||
diagnostics: vec![diagnostic.clone()],
|
||||
}],
|
||||
sources: vec![WorkspaceProfileSourceSummary {
|
||||
profile_source_id: "profile-source-1".to_string(),
|
||||
display_path: "profiles/coder.dcdl".to_string(),
|
||||
kind: "profile".to_string(),
|
||||
content_type: "text/x-decodal".to_string(),
|
||||
content_digest: "sha256:source".to_string(),
|
||||
provenance: WorkspaceProfileSourceProvenance::ProjectProfileSourceTree,
|
||||
editable: false,
|
||||
revision: "config-source:7".to_string(),
|
||||
size_bytes: 128,
|
||||
diagnostics: vec![],
|
||||
}],
|
||||
diagnostics: vec![diagnostic],
|
||||
});
|
||||
|
||||
let absent_optional_fields = serde_json::json!({
|
||||
"workspace_id": "workspace-test",
|
||||
"registry_revision": "builtin",
|
||||
"profiles": [],
|
||||
"sources": [],
|
||||
"diagnostics": []
|
||||
});
|
||||
let decoded: ProfileSettingsResponse =
|
||||
serde_json::from_value(absent_optional_fields.clone()).unwrap();
|
||||
assert_eq!(decoded.config_revision, None);
|
||||
assert_eq!(decoded.tree_digest, None);
|
||||
assert_eq!(decoded.projection_digest, None);
|
||||
assert_eq!(decoded.default_profile, None);
|
||||
assert_eq!(
|
||||
serde_json::to_value(decoded).unwrap(),
|
||||
absent_optional_fields
|
||||
);
|
||||
}
|
||||
|
||||
fn companion_worker() -> WorkspaceWorkerDiscoveryItem {
|
||||
WorkspaceWorkerDiscoveryItem {
|
||||
subject: WorkspaceWorkerSubject::RuntimeWorker {
|
||||
@@ -1474,7 +1800,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn workdir_create_request_preserves_optional_operation_fields() {
|
||||
let payload = serde_json::json!({"repository_id": "main"});
|
||||
let payload = serde_json::json!({"repository_key": "main"});
|
||||
let request = serde_json::from_value::<WorkingDirectoryCreateRequest>(payload)
|
||||
.expect("optional create fields may be absent");
|
||||
|
||||
@@ -1483,13 +1809,13 @@ mod tests {
|
||||
assert_eq!(request.operation_id, None);
|
||||
|
||||
let serialized = serde_json::to_value(request).expect("serialize create request");
|
||||
assert_eq!(serialized, serde_json::json!({"repository_id": "main"}));
|
||||
assert_eq!(serialized, serde_json::json!({"repository_key": "main"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workdir_create_request_rejects_stale_or_incomplete_json() {
|
||||
let stale = serde_json::json!({
|
||||
"repository_id": "main",
|
||||
"repository_key": "main",
|
||||
"selector": "develop",
|
||||
"path": "/tmp/workdir"
|
||||
});
|
||||
@@ -1506,7 +1832,7 @@ mod tests {
|
||||
fn workdir_summary_omits_absent_optional_fields_on_the_wire() {
|
||||
let value = serde_json::to_value(WorkingDirectorySummary {
|
||||
working_directory_id: "workdir-1".into(),
|
||||
repository_id: "main".into(),
|
||||
repository_key: "main".into(),
|
||||
creation_selector: None,
|
||||
creation_ref: None,
|
||||
creation_tree: None,
|
||||
@@ -1550,7 +1876,7 @@ mod tests {
|
||||
"workspace_id": "workspace-test",
|
||||
"items": [{
|
||||
"working_directory_id": "workdir-1",
|
||||
"repository_id": "main",
|
||||
"repository_key": "main",
|
||||
"materializer_kind": "runtime_git_cache",
|
||||
"status": "active",
|
||||
"occupied_by": {
|
||||
|
||||
@@ -918,13 +918,33 @@ impl SqliteWorkspaceAuthority {
|
||||
self.merge_revision_source
|
||||
.resolve_subject_ref(&request.repository_id, selector)
|
||||
});
|
||||
Some(merge_request_summary(request, current_subject_ref))
|
||||
let repository_key = self
|
||||
.store
|
||||
.get_repository(&self.workspace_id, &request.repository_id)?
|
||||
.map(|repository| repository.repository_key)
|
||||
.ok_or_else(|| Error::UnknownRepository(request.repository_id.clone()))?;
|
||||
Some(merge_request_summary(
|
||||
request,
|
||||
repository_key,
|
||||
current_subject_ref,
|
||||
))
|
||||
}
|
||||
Err(MergeRequestError::NotFound) => None,
|
||||
Err(error) => return Err(Error::Store(error.to_string())),
|
||||
};
|
||||
let repository_key = ticket
|
||||
.meta
|
||||
.repository_id
|
||||
.as_deref()
|
||||
.map(|repository_id| {
|
||||
self.store
|
||||
.get_repository(&self.workspace_id, repository_id)?
|
||||
.map(|repository| repository.repository_key)
|
||||
.ok_or_else(|| Error::UnknownRepository(repository_id.to_string()))
|
||||
})
|
||||
.transpose()?;
|
||||
let evidence = ticket_evidence_summary(
|
||||
ticket.meta.repository_id.as_deref(),
|
||||
repository_key.as_deref(),
|
||||
&ticket.events,
|
||||
merge_request.as_ref(),
|
||||
);
|
||||
@@ -980,7 +1000,7 @@ impl SqliteWorkspaceAuthority {
|
||||
item_revision,
|
||||
queued_by: ticket.meta.queued_by,
|
||||
queued_at: ticket.meta.queued_at,
|
||||
repository_id: ticket.meta.repository_id,
|
||||
repository_key,
|
||||
ref_selector: ticket.meta.ref_selector,
|
||||
risk_flags: ticket.meta.risk_flags,
|
||||
body,
|
||||
@@ -1742,6 +1762,7 @@ fn ticket_evidence_event(sequence: usize, event: &TicketEvent) -> TicketEvidence
|
||||
|
||||
pub(crate) fn merge_request_summary(
|
||||
request: MergeRequest,
|
||||
repository_key: String,
|
||||
current_subject_ref: Option<String>,
|
||||
) -> TicketMergeRequestSummary {
|
||||
let latest_review_request = request.thread.iter().rev().find_map(|event| match event {
|
||||
@@ -1790,7 +1811,7 @@ pub(crate) fn merge_request_summary(
|
||||
|
||||
TicketMergeRequestSummary {
|
||||
merge_request_id: request.merge_request_id.clone(),
|
||||
repository_id: request.repository_id.clone(),
|
||||
repository_key,
|
||||
state,
|
||||
review_status,
|
||||
selector_from: request.selector_from.clone(),
|
||||
@@ -1835,7 +1856,7 @@ fn ticket_evidence_summary(
|
||||
let linked_merge_request = merge_request.filter(|request| {
|
||||
request.state == "open"
|
||||
&& ticket_repository_id
|
||||
.is_some_and(|repository_id| repository_id == request.repository_id)
|
||||
.is_some_and(|repository_id| repository_id == request.repository_key)
|
||||
});
|
||||
let has_merge_request = linked_merge_request.is_some();
|
||||
let has_current_subject_ref = linked_merge_request.is_some_and(|request| {
|
||||
@@ -1886,7 +1907,7 @@ fn ticket_evidence_summary(
|
||||
Some(request) if request.state != "open" => missing.push("open_merge_request".to_string()),
|
||||
Some(request)
|
||||
if ticket_repository_id
|
||||
.is_none_or(|repository_id| repository_id != request.repository_id) =>
|
||||
.is_none_or(|repository_id| repository_id != request.repository_key) =>
|
||||
{
|
||||
missing.push("merge_request_repository".to_string())
|
||||
}
|
||||
@@ -2823,6 +2844,7 @@ mod tests {
|
||||
fn merge_request_summary_uses_the_provider_resolved_current_subject() {
|
||||
let approved = merge_request_summary(
|
||||
reviewed_merge_request(ReviewDecision::Approve, false),
|
||||
"main".to_string(),
|
||||
Some("commit-1".to_string()),
|
||||
);
|
||||
assert_eq!(approved.review_status, "approved");
|
||||
@@ -2834,6 +2856,7 @@ mod tests {
|
||||
|
||||
let moved = merge_request_summary(
|
||||
reviewed_merge_request(ReviewDecision::Approve, false),
|
||||
"main".to_string(),
|
||||
Some("commit-2".to_string()),
|
||||
);
|
||||
assert_eq!(moved.review_status, "pending");
|
||||
@@ -2846,6 +2869,7 @@ mod tests {
|
||||
fn ticket_readiness_requires_current_unrevoked_approval_without_a_report() {
|
||||
let approved = merge_request_summary(
|
||||
reviewed_merge_request(ReviewDecision::Approve, false),
|
||||
"main".to_string(),
|
||||
Some("commit-1".to_string()),
|
||||
);
|
||||
let evidence = ticket_evidence_summary(Some("main"), &[], Some(&approved));
|
||||
@@ -2866,6 +2890,7 @@ mod tests {
|
||||
|
||||
let revoked = merge_request_summary(
|
||||
reviewed_merge_request(ReviewDecision::Approve, true),
|
||||
"main".to_string(),
|
||||
Some("commit-1".to_string()),
|
||||
);
|
||||
let evidence = ticket_evidence_summary(Some("main"), &[], Some(&revoked));
|
||||
@@ -2874,6 +2899,7 @@ mod tests {
|
||||
|
||||
let changes = merge_request_summary(
|
||||
reviewed_merge_request(ReviewDecision::RequestChanges, false),
|
||||
"main".to_string(),
|
||||
Some("commit-1".to_string()),
|
||||
);
|
||||
let evidence = ticket_evidence_summary(Some("main"), &[], Some(&changes));
|
||||
@@ -2883,8 +2909,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn ticket_readiness_fails_closed_for_missing_or_closed_current_merge_request() {
|
||||
let unresolved =
|
||||
merge_request_summary(reviewed_merge_request(ReviewDecision::Approve, false), None);
|
||||
let unresolved = merge_request_summary(
|
||||
reviewed_merge_request(ReviewDecision::Approve, false),
|
||||
"main".to_string(),
|
||||
None,
|
||||
);
|
||||
let evidence = ticket_evidence_summary(Some("main"), &[], Some(&unresolved));
|
||||
assert!(!evidence.has_current_subject_ref);
|
||||
assert!(!evidence.has_commit);
|
||||
@@ -2892,7 +2921,11 @@ mod tests {
|
||||
|
||||
let mut closed_request = reviewed_merge_request(ReviewDecision::Approve, false);
|
||||
closed_request.state = MergeRequestState::Closed;
|
||||
let closed = merge_request_summary(closed_request, Some("commit-1".to_string()));
|
||||
let closed = merge_request_summary(
|
||||
closed_request,
|
||||
"main".to_string(),
|
||||
Some("commit-1".to_string()),
|
||||
);
|
||||
let evidence = ticket_evidence_summary(Some("main"), &[], Some(&closed));
|
||||
assert!(!evidence.has_merge_request);
|
||||
assert!(!evidence.complete_for_integration);
|
||||
@@ -2903,6 +2936,7 @@ mod tests {
|
||||
fn ticket_readiness_requires_request_and_approval_after_substantive_rescope() {
|
||||
let approved = merge_request_summary(
|
||||
reviewed_merge_request(ReviewDecision::Approve, false),
|
||||
"main".to_string(),
|
||||
Some("commit-1".to_string()),
|
||||
);
|
||||
let fresh = ticket_evidence_summary(
|
||||
@@ -2946,6 +2980,7 @@ mod tests {
|
||||
fn ticket_query_filters_map_to_current_merge_request_evidence() {
|
||||
let approved_summary = merge_request_summary(
|
||||
reviewed_merge_request(ReviewDecision::Approve, false),
|
||||
"main".to_string(),
|
||||
Some("commit-1".to_string()),
|
||||
);
|
||||
let approved = ticket_evidence_summary(Some("main"), &[], Some(&approved_summary));
|
||||
@@ -2962,6 +2997,7 @@ mod tests {
|
||||
|
||||
let pending_summary = merge_request_summary(
|
||||
reviewed_merge_request(ReviewDecision::Approve, false),
|
||||
"main".to_string(),
|
||||
Some("commit-2".to_string()),
|
||||
);
|
||||
let pending = ticket_evidence_summary(Some("main"), &[], Some(&pending_summary));
|
||||
|
||||
@@ -40,6 +40,7 @@ use worker_runtime::fs_store::FsRuntimeStoreOptions;
|
||||
use worker_runtime::http_server::{
|
||||
RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundleSyncRequest,
|
||||
RuntimeHttpErrorResponse, RuntimeHttpRepositoryAccessResponse, RuntimeHttpSummaryResponse,
|
||||
RuntimeHttpUploadedFileDeleteResponse, RuntimeHttpUploadedFileResponse,
|
||||
RuntimeHttpWorkerCompletionsRequest, RuntimeHttpWorkerCompletionsResponse,
|
||||
RuntimeHttpWorkerDeleteResponse, RuntimeHttpWorkerInputResponse,
|
||||
RuntimeHttpWorkerLifecycleRequest, RuntimeHttpWorkerLifecycleResponse,
|
||||
@@ -313,6 +314,7 @@ impl From<RuntimeSummary> for workspace_api::RuntimeSummary {
|
||||
pub(crate) fn workspace_worker_summary(
|
||||
summary: WorkerSummary,
|
||||
resource_key: String,
|
||||
working_directory: Option<workspace_api::WorkingDirectorySummary>,
|
||||
) -> workspace_api::WorkerSummary {
|
||||
workspace_api::WorkerSummary {
|
||||
runtime_id: summary.worker.runtime_id,
|
||||
@@ -341,7 +343,7 @@ pub(crate) fn workspace_worker_summary(
|
||||
can_stop: summary.capabilities.can_stop,
|
||||
can_spawn_followup: summary.capabilities.can_spawn_followup,
|
||||
},
|
||||
working_directory: summary.working_directory,
|
||||
working_directory,
|
||||
diagnostics: summary.diagnostics.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
@@ -402,10 +404,10 @@ pub struct RuntimeWorkingDirectoryResult {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkerSpawnWorkingDirectoryRequest {
|
||||
/// Safe configured Repository id. The host resolves this id to repository
|
||||
/// authority from server-side config; browser callers cannot provide raw
|
||||
/// source paths or runtime-internal storage paths.
|
||||
pub repository_id: String,
|
||||
/// Safe configured Repository key. The host resolves this key to internal
|
||||
/// Repository authority; browser callers cannot provide Backend UUIDs, raw
|
||||
/// source paths, or Runtime-internal storage paths.
|
||||
pub repository_key: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub selector: Option<String>,
|
||||
}
|
||||
@@ -1040,6 +1042,35 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
|
||||
}
|
||||
}
|
||||
|
||||
fn upload_worker_file(
|
||||
&self,
|
||||
worker_id: &str,
|
||||
_file_name: &str,
|
||||
_media_type: &str,
|
||||
_content: &[u8],
|
||||
_context: Option<&worker_runtime::UploadedFileUploadContext>,
|
||||
) -> Result<protocol::UploadedFileRef, RuntimeRegistryError> {
|
||||
Err(RuntimeRegistryError::RuntimeOperationFailed {
|
||||
runtime_id: self.runtime_id().to_string(),
|
||||
code: "worker_file_upload_unsupported".to_string(),
|
||||
message: format!("runtime does not support file upload for worker `{worker_id}`"),
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_worker_uploaded_file(
|
||||
&self,
|
||||
worker_id: &str,
|
||||
_artifact_id: &str,
|
||||
) -> Result<(), RuntimeRegistryError> {
|
||||
Err(RuntimeRegistryError::RuntimeOperationFailed {
|
||||
runtime_id: self.runtime_id().to_string(),
|
||||
code: "worker_file_delete_unsupported".to_string(),
|
||||
message: format!(
|
||||
"runtime does not support uploaded-file deletion for worker `{worker_id}`"
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn worker_completions(
|
||||
&self,
|
||||
worker_id: &str,
|
||||
@@ -1542,6 +1573,51 @@ impl RuntimeRegistry {
|
||||
Ok(runtime.send_input(worker_id, request))
|
||||
}
|
||||
|
||||
pub fn upload_worker_file(
|
||||
&self,
|
||||
worker: &RuntimeWorkerRef,
|
||||
file_name: &str,
|
||||
media_type: &str,
|
||||
content: &[u8],
|
||||
context: Option<&worker_runtime::UploadedFileUploadContext>,
|
||||
) -> Result<protocol::UploadedFileRef, RuntimeRegistryError> {
|
||||
let runtime_id = worker.runtime_id.as_str();
|
||||
let worker_id = worker.worker_id.as_str();
|
||||
validate_backend_identifier("runtime_id", runtime_id)?;
|
||||
validate_backend_identifier("worker_id", worker_id)?;
|
||||
let runtime = self.runtime(runtime_id)?;
|
||||
let lookup = runtime.worker(worker_id);
|
||||
if lookup.worker.is_none() {
|
||||
return Err(operation_failed_or_unknown_worker(
|
||||
runtime_id,
|
||||
worker_id,
|
||||
lookup.diagnostics,
|
||||
));
|
||||
}
|
||||
runtime.upload_worker_file(worker_id, file_name, media_type, content, context)
|
||||
}
|
||||
|
||||
pub fn delete_worker_uploaded_file(
|
||||
&self,
|
||||
worker: &RuntimeWorkerRef,
|
||||
artifact_id: &str,
|
||||
) -> Result<(), RuntimeRegistryError> {
|
||||
let runtime_id = worker.runtime_id.as_str();
|
||||
let worker_id = worker.worker_id.as_str();
|
||||
validate_backend_identifier("runtime_id", runtime_id)?;
|
||||
validate_backend_identifier("worker_id", worker_id)?;
|
||||
let runtime = self.runtime(runtime_id)?;
|
||||
let lookup = runtime.worker(worker_id);
|
||||
if lookup.worker.is_none() {
|
||||
return Err(operation_failed_or_unknown_worker(
|
||||
runtime_id,
|
||||
worker_id,
|
||||
lookup.diagnostics,
|
||||
));
|
||||
}
|
||||
runtime.delete_worker_uploaded_file(worker_id, artifact_id)
|
||||
}
|
||||
|
||||
pub fn worker_completions(
|
||||
&self,
|
||||
worker: &RuntimeWorkerRef,
|
||||
@@ -2508,6 +2584,57 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
fn upload_worker_file(
|
||||
&self,
|
||||
worker_id: &str,
|
||||
file_name: &str,
|
||||
media_type: &str,
|
||||
content: &[u8],
|
||||
context: Option<&worker_runtime::UploadedFileUploadContext>,
|
||||
) -> Result<protocol::UploadedFileRef, RuntimeRegistryError> {
|
||||
let worker_ref =
|
||||
self.worker_ref(worker_id)
|
||||
.ok_or_else(|| RuntimeRegistryError::UnknownWorker {
|
||||
worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id),
|
||||
})?;
|
||||
let uploaded = match context {
|
||||
Some(context) => self.runtime.upload_worker_file_with_context(
|
||||
&worker_ref,
|
||||
file_name,
|
||||
media_type,
|
||||
content,
|
||||
context,
|
||||
),
|
||||
None => self
|
||||
.runtime
|
||||
.upload_worker_file(&worker_ref, file_name, media_type, content),
|
||||
};
|
||||
uploaded.map_err(|error| RuntimeRegistryError::RuntimeOperationFailed {
|
||||
runtime_id: self.runtime_id.clone(),
|
||||
code: "embedded_worker_file_upload_failed".to_string(),
|
||||
message: error.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_worker_uploaded_file(
|
||||
&self,
|
||||
worker_id: &str,
|
||||
artifact_id: &str,
|
||||
) -> Result<(), RuntimeRegistryError> {
|
||||
let worker_ref =
|
||||
self.worker_ref(worker_id)
|
||||
.ok_or_else(|| RuntimeRegistryError::UnknownWorker {
|
||||
worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id),
|
||||
})?;
|
||||
self.runtime
|
||||
.delete_worker_uploaded_file(&worker_ref, artifact_id)
|
||||
.map_err(|error| RuntimeRegistryError::RuntimeOperationFailed {
|
||||
runtime_id: self.runtime_id.clone(),
|
||||
code: "embedded_worker_file_delete_failed".to_string(),
|
||||
message: error.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn worker_completions(
|
||||
&self,
|
||||
worker_id: &str,
|
||||
@@ -2847,6 +2974,16 @@ impl RemoteWorkerRuntime {
|
||||
self.send_json(path, self.http.post(self.endpoint(path)).json(body))
|
||||
}
|
||||
|
||||
fn post_bytes<T>(&self, path: &str, body: &[u8]) -> Result<T, RuntimeDiagnostic>
|
||||
where
|
||||
T: DeserializeOwned + Send + 'static,
|
||||
{
|
||||
self.send_json(
|
||||
path,
|
||||
self.http.post(self.endpoint(path)).body(body.to_vec()),
|
||||
)
|
||||
}
|
||||
|
||||
fn delete_json<T>(&self, path: &str) -> Result<T, RuntimeDiagnostic>
|
||||
where
|
||||
T: DeserializeOwned + Send + 'static,
|
||||
@@ -3535,6 +3672,58 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
fn upload_worker_file(
|
||||
&self,
|
||||
worker_id: &str,
|
||||
file_name: &str,
|
||||
media_type: &str,
|
||||
content: &[u8],
|
||||
context: Option<&worker_runtime::UploadedFileUploadContext>,
|
||||
) -> Result<protocol::UploadedFileRef, RuntimeRegistryError> {
|
||||
let mut path = format!(
|
||||
"/v1/workers/{}/attachments?file_name={}&media_type={}",
|
||||
url_path_segment_encode(worker_id),
|
||||
url_query_value_encode(file_name),
|
||||
url_query_value_encode(media_type),
|
||||
);
|
||||
if let Some(context) = context {
|
||||
path.push_str(&format!(
|
||||
"&upload_id={}&principal_id={}&workspace_id={}&runtime_id={}&owner_worker_id={}",
|
||||
url_query_value_encode(&context.upload_id),
|
||||
url_query_value_encode(&context.principal_id),
|
||||
url_query_value_encode(&context.workspace_id),
|
||||
url_query_value_encode(&context.runtime_id),
|
||||
url_query_value_encode(&context.worker_id),
|
||||
));
|
||||
}
|
||||
self.post_bytes::<RuntimeHttpUploadedFileResponse>(&path, content)
|
||||
.map(|response| response.file)
|
||||
.map_err(|diagnostic| RuntimeRegistryError::RuntimeOperationFailed {
|
||||
runtime_id: self.runtime_id.clone(),
|
||||
code: diagnostic.code,
|
||||
message: diagnostic.message,
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_worker_uploaded_file(
|
||||
&self,
|
||||
worker_id: &str,
|
||||
artifact_id: &str,
|
||||
) -> Result<(), RuntimeRegistryError> {
|
||||
let path = format!(
|
||||
"/v1/workers/{}/attachments/{}",
|
||||
url_path_segment_encode(worker_id),
|
||||
url_path_segment_encode(artifact_id),
|
||||
);
|
||||
self.delete_json::<RuntimeHttpUploadedFileDeleteResponse>(&path)
|
||||
.map(|_| ())
|
||||
.map_err(|diagnostic| RuntimeRegistryError::RuntimeOperationFailed {
|
||||
runtime_id: self.runtime_id.clone(),
|
||||
code: diagnostic.code,
|
||||
message: diagnostic.message,
|
||||
})
|
||||
}
|
||||
|
||||
fn worker_completions(
|
||||
&self,
|
||||
worker_id: &str,
|
||||
|
||||
@@ -30,6 +30,7 @@ pub mod server;
|
||||
pub mod skills;
|
||||
pub mod store;
|
||||
pub mod workdir_create_operations;
|
||||
mod workdir_removal;
|
||||
pub mod worker_source;
|
||||
pub mod workspace_catalog;
|
||||
mod workspace_subscription;
|
||||
|
||||
@@ -537,7 +537,7 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
|
||||
created_at: workspace.created_at.clone(),
|
||||
display_name: workspace.display_name.clone(),
|
||||
},
|
||||
infer_workspace_root_from_repositories(store.as_ref(), workspace)?,
|
||||
workspace_root_from_server_data(workspace)?,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
@@ -619,34 +619,7 @@ fn append_trusted_runtime_sources(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn infer_workspace_root_from_repositories(
|
||||
store: &SqliteWorkspaceStore,
|
||||
workspace: &WorkspaceRecord,
|
||||
) -> Result<PathBuf, CliError> {
|
||||
let repositories = store
|
||||
.list_repositories(&workspace.workspace_id)
|
||||
.map_err(|error| {
|
||||
CliError(format!(
|
||||
"failed to list repositories from server DB: {error}"
|
||||
))
|
||||
})?;
|
||||
let Some(repository) = repositories
|
||||
.iter()
|
||||
.find(|repository| repository.repository_id == "main")
|
||||
.or_else(|| repositories.first())
|
||||
else {
|
||||
return Err(CliError(format!(
|
||||
"workspace `{}` has no repository records; cannot derive a workspace root",
|
||||
workspace.workspace_id
|
||||
)));
|
||||
};
|
||||
|
||||
if repository.source.kind == workspace_api::RepositorySourceKind::Invalid {
|
||||
return Err(CliError(format!(
|
||||
"repository `{}` has an invalid migrated source and cannot be used by serve",
|
||||
repository.repository_id
|
||||
)));
|
||||
}
|
||||
fn workspace_root_from_server_data(workspace: &WorkspaceRecord) -> Result<PathBuf, CliError> {
|
||||
Ok(ServerConfig::default_workspace_backend_data_root(
|
||||
&workspace.workspace_id,
|
||||
))
|
||||
|
||||
@@ -12,11 +12,15 @@ use worker_runtime::config_bundle::{
|
||||
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor,
|
||||
};
|
||||
use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput};
|
||||
use workspace_api::{
|
||||
Diagnostic, DiagnosticSeverity, ProfileSettingsResponse, UpdateWorkspaceMetadataRequest,
|
||||
WorkspaceMetadataSettingsResponse, WorkspaceProfileSourceProvenance,
|
||||
WorkspaceProfileSourceSummary, WorkspaceProfileSummary,
|
||||
};
|
||||
|
||||
use crate::config_source::{
|
||||
WorkspaceConfigSchemaProvider, WorkspaceConfigState, evaluate_workspace_config_state,
|
||||
};
|
||||
use crate::hosts::{DiagnosticSeverity, RuntimeDiagnostic};
|
||||
use crate::{Error, Result};
|
||||
|
||||
const PROFILE_SCHEMA_SOURCE: &str = r#"{
|
||||
@@ -427,81 +431,6 @@ fn build_virtual_profile_archive(
|
||||
.map_err(|error| profile_validation_error("profile_source_archive_invalid", &error.to_string()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkspaceMetadataSettingsResponse {
|
||||
pub workspace_id: String,
|
||||
pub display_name: String,
|
||||
pub created_at: String,
|
||||
pub revision: String,
|
||||
pub source: String,
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UpdateWorkspaceMetadataRequest {
|
||||
pub display_name: String,
|
||||
pub revision: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkspaceMetadataMutationResponse {
|
||||
pub workspace: WorkspaceMetadataSettingsResponse,
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProfileSettingsResponse {
|
||||
pub workspace_id: String,
|
||||
pub registry_revision: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub config_revision: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tree_digest: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub projection_digest: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_profile: Option<String>,
|
||||
pub profiles: Vec<WorkspaceProfileSummary>,
|
||||
pub sources: Vec<WorkspaceProfileSourceSummary>,
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkspaceProfileSummary {
|
||||
pub profile_id: String,
|
||||
pub selector: String,
|
||||
pub label: String,
|
||||
pub source_kind: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub profile_source_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub editable: bool,
|
||||
pub is_default: bool,
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkspaceProfileSourceSummary {
|
||||
pub profile_source_id: String,
|
||||
pub display_path: String,
|
||||
pub kind: String,
|
||||
pub content_type: String,
|
||||
pub content_digest: String,
|
||||
pub provenance: WorkspaceProfileSourceProvenance,
|
||||
pub editable: bool,
|
||||
pub revision: String,
|
||||
pub size_bytes: u64,
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkspaceProfileSourceProvenance {
|
||||
ProjectProfileSourceTree,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct WorkspaceIdentityFile {
|
||||
@@ -804,8 +733,8 @@ fn diagnostic(
|
||||
code: impl Into<String>,
|
||||
severity: DiagnosticSeverity,
|
||||
message: impl Into<String>,
|
||||
) -> RuntimeDiagnostic {
|
||||
RuntimeDiagnostic {
|
||||
) -> Diagnostic {
|
||||
Diagnostic {
|
||||
code: code.into(),
|
||||
severity,
|
||||
message: message.into(),
|
||||
|
||||
@@ -78,7 +78,7 @@ pub struct TicketDetail {
|
||||
pub item_revision: String,
|
||||
pub queued_by: Option<String>,
|
||||
pub queued_at: Option<String>,
|
||||
pub repository_id: Option<String>,
|
||||
pub repository_key: Option<String>,
|
||||
pub ref_selector: Option<String>,
|
||||
pub risk_flags: Vec<String>,
|
||||
pub body: String,
|
||||
@@ -301,7 +301,7 @@ pub struct TicketActionEligibility {
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct TicketMergeRequestSummary {
|
||||
pub merge_request_id: String,
|
||||
pub repository_id: String,
|
||||
pub repository_key: String,
|
||||
pub state: String,
|
||||
pub review_status: String,
|
||||
pub selector_from: Option<String>,
|
||||
|
||||
@@ -15,6 +15,7 @@ pub type RepositorySelector = String;
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConfiguredRepository {
|
||||
pub id: RepositoryId,
|
||||
pub repository_key: String,
|
||||
pub provider: String,
|
||||
pub source: RepositorySource,
|
||||
pub source_revision: u64,
|
||||
@@ -22,7 +23,6 @@ pub struct ConfiguredRepository {
|
||||
pub observed_status: RepositoryObservedStatus,
|
||||
pub observed_at: Option<String>,
|
||||
pub path: Option<PathBuf>,
|
||||
pub display_name: Option<String>,
|
||||
pub default_selector: Option<RepositorySelector>,
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ pub struct RepositoryListProjection {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RepositoryLogRead {
|
||||
pub repository_id: RepositoryId,
|
||||
pub repository_key: String,
|
||||
pub default_selector: Option<RepositorySelector>,
|
||||
pub limit: usize,
|
||||
pub commits: Vec<GitCommitSummary>,
|
||||
@@ -104,14 +104,28 @@ impl RepositoryRegistryReader {
|
||||
Ok(self.summary_for_config(repository))
|
||||
}
|
||||
|
||||
pub fn summary_by_key(
|
||||
&self,
|
||||
repository_key: &str,
|
||||
) -> Result<RepositorySummary, RepositoryLookupError> {
|
||||
let repository = self.find_by_key(repository_key).ok_or_else(|| {
|
||||
RepositoryLookupError::UnknownRepository {
|
||||
id: repository_key.to_string(),
|
||||
}
|
||||
})?;
|
||||
Ok(self.summary_for_config(repository))
|
||||
}
|
||||
|
||||
pub fn recent_log(
|
||||
&self,
|
||||
id: &str,
|
||||
repository_key: &str,
|
||||
limit: Option<usize>,
|
||||
) -> Result<RepositoryLogRead, RepositoryLookupError> {
|
||||
let repository = self
|
||||
.find(id)
|
||||
.ok_or_else(|| RepositoryLookupError::UnknownRepository { id: id.to_string() })?;
|
||||
let repository = self.find_by_key(repository_key).ok_or_else(|| {
|
||||
RepositoryLookupError::UnknownRepository {
|
||||
id: repository_key.to_string(),
|
||||
}
|
||||
})?;
|
||||
if repository.provider != "git" {
|
||||
return Err(RepositoryLookupError::UnsupportedProvider {
|
||||
id: repository.id.clone(),
|
||||
@@ -134,7 +148,7 @@ impl RepositoryRegistryReader {
|
||||
};
|
||||
|
||||
Ok(RepositoryLogRead {
|
||||
repository_id: repository.id.clone(),
|
||||
repository_key: repository.repository_key.clone(),
|
||||
default_selector: repository.default_selector.clone(),
|
||||
limit,
|
||||
commits,
|
||||
@@ -260,6 +274,12 @@ impl RepositoryRegistryReader {
|
||||
Ok(repository)
|
||||
}
|
||||
|
||||
fn find_by_key(&self, repository_key: &str) -> Option<&ConfiguredRepository> {
|
||||
self.repositories
|
||||
.iter()
|
||||
.find(|repository| repository.repository_key == repository_key)
|
||||
}
|
||||
|
||||
fn find(&self, id: &str) -> Option<&ConfiguredRepository> {
|
||||
self.repositories
|
||||
.iter()
|
||||
@@ -267,10 +287,6 @@ impl RepositoryRegistryReader {
|
||||
}
|
||||
|
||||
fn summary_for_config(&self, repository: &ConfiguredRepository) -> RepositorySummary {
|
||||
let display_name = repository
|
||||
.display_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| repository.id.clone());
|
||||
let mut diagnostics = Vec::new();
|
||||
if repository.source.kind == workspace_api::RepositorySourceKind::Http {
|
||||
diagnostics.push(RepositoryDiagnostic {
|
||||
@@ -314,8 +330,7 @@ impl RepositoryRegistryReader {
|
||||
};
|
||||
|
||||
RepositorySummary {
|
||||
id: repository.id.clone(),
|
||||
display_name,
|
||||
repository_key: repository.repository_key.clone(),
|
||||
kind: repository.provider.clone(),
|
||||
provider: repository.provider.clone(),
|
||||
source: repository.source.clone(),
|
||||
@@ -600,7 +615,7 @@ mod tests {
|
||||
};
|
||||
let reader = RepositoryRegistryReader::new(vec![ConfiguredRepository {
|
||||
id: "remote".into(),
|
||||
display_name: Some("Remote".into()),
|
||||
repository_key: "remote".into(),
|
||||
provider: "git".into(),
|
||||
source_fingerprint: crate::repository_source::repository_source_fingerprint(&source),
|
||||
source,
|
||||
@@ -724,7 +739,7 @@ mod tests {
|
||||
};
|
||||
let reader = RepositoryRegistryReader::new(vec![ConfiguredRepository {
|
||||
id: "main".into(),
|
||||
display_name: Some("Main".into()),
|
||||
repository_key: "main".into(),
|
||||
provider: "git".into(),
|
||||
source_fingerprint: crate::repository_source::repository_source_fingerprint(
|
||||
&source_descriptor,
|
||||
|
||||
@@ -146,16 +146,17 @@ fn project_repository_access_evaluation(
|
||||
Error::InvalidInput(format!("invalid Repository access config: {error}"))
|
||||
})?;
|
||||
let mut bindings = Vec::with_capacity(config.repository_access.len());
|
||||
for (repository_id, access) in config.repository_access {
|
||||
validate_identifier("repository_id", &repository_id)?;
|
||||
for (repository_key, access) in config.repository_access {
|
||||
workspace_api::validate_repository_key(&repository_key)
|
||||
.map_err(|error| Error::InvalidInput(format!("invalid Repository key: {error}")))?;
|
||||
validate_identifier("credential_id", &access.ssh.credential)?;
|
||||
validate_identifier("host_trust_id", &access.ssh.host_trust)?;
|
||||
let repository = store
|
||||
.get_repository(workspace_id, &repository_id)?
|
||||
.ok_or_else(|| Error::InvalidInput(format!("unknown Repository `{repository_id}`")))?;
|
||||
.get_repository_by_key(workspace_id, &repository_key)?
|
||||
.ok_or_else(|| Error::InvalidInput(format!("unknown Repository `{repository_key}`")))?;
|
||||
if repository.source.kind != workspace_api::RepositorySourceKind::Ssh {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"Repository `{repository_id}` is not an ssh:// Repository"
|
||||
"Repository `{repository_key}` is not an ssh:// Repository"
|
||||
)));
|
||||
}
|
||||
let credential = secrets
|
||||
@@ -182,34 +183,34 @@ fn project_repository_access_evaluation(
|
||||
})?;
|
||||
let uri = url::Url::parse(&repository.source.uri).map_err(|_| {
|
||||
Error::InvalidInput(format!(
|
||||
"Repository `{repository_id}` has an invalid SSH URI"
|
||||
"Repository `{repository_key}` has an invalid SSH URI"
|
||||
))
|
||||
})?;
|
||||
if uri.scheme() != "ssh" || uri.username().is_empty() || uri.password().is_some() {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"Repository `{repository_id}` must use ssh://user@host[:port]/path without credentials"
|
||||
"Repository `{repository_key}` must use ssh://user@host[:port]/path without credentials"
|
||||
)));
|
||||
}
|
||||
let hostname = uri.host_str().ok_or_else(|| {
|
||||
Error::InvalidInput(format!(
|
||||
"Repository `{repository_id}` SSH URI has no hostname"
|
||||
"Repository `{repository_key}` SSH URI has no hostname"
|
||||
))
|
||||
})?;
|
||||
let port = uri.port().unwrap_or(22);
|
||||
if hostname != host_trust.hostname || port != host_trust.port {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"Repository `{repository_id}` SSH host does not match host trust `{}`",
|
||||
"Repository `{repository_key}` SSH host does not match host trust `{}`",
|
||||
access.ssh.host_trust
|
||||
)));
|
||||
}
|
||||
bindings.push(RepositorySshAccessBinding {
|
||||
repository_id,
|
||||
repository_key,
|
||||
credential_id: access.ssh.credential,
|
||||
host_trust_id: access.ssh.host_trust,
|
||||
access: access.ssh.access,
|
||||
});
|
||||
}
|
||||
bindings.sort_by(|left, right| left.repository_id.cmp(&right.repository_id));
|
||||
bindings.sort_by(|left, right| left.repository_key.cmp(&right.repository_key));
|
||||
Ok(RepositoryAccessProjection {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
config_revision,
|
||||
@@ -1441,7 +1442,7 @@ fn credential_references(
|
||||
.bindings
|
||||
.iter()
|
||||
.filter(|binding| binding.credential_id == credential_id)
|
||||
.map(|binding| binding.repository_id.clone())
|
||||
.map(|binding| binding.repository_key.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -1453,7 +1454,7 @@ fn host_trust_references(
|
||||
.bindings
|
||||
.iter()
|
||||
.filter(|binding| binding.host_trust_id == host_trust_id)
|
||||
.map(|binding| binding.repository_id.clone())
|
||||
.map(|binding| binding.repository_key.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -1846,7 +1847,7 @@ mod tests {
|
||||
.upsert_repository(&RepositoryRecord {
|
||||
workspace_id: "workspace-a".to_string(),
|
||||
repository_id: "remote".to_string(),
|
||||
name: "Remote".to_string(),
|
||||
repository_key: "remote".to_string(),
|
||||
kind: "git".to_string(),
|
||||
provider: Some("git".to_string()),
|
||||
source,
|
||||
@@ -1912,7 +1913,7 @@ mod tests {
|
||||
let projection =
|
||||
project_repository_access_state(&*store, &service, "workspace-a", &state).unwrap();
|
||||
assert_eq!(projection.bindings.len(), 1);
|
||||
assert_eq!(projection.bindings[0].repository_id, "remote");
|
||||
assert_eq!(projection.bindings[0].repository_key, "remote");
|
||||
assert_eq!(
|
||||
projection.bindings[0].access,
|
||||
RepositoryAccessMode::ReadOnly
|
||||
@@ -1999,7 +2000,7 @@ mod tests {
|
||||
config_revision: 3,
|
||||
projection_digest: "sha256:test".to_string(),
|
||||
bindings: vec![RepositorySshAccessBinding {
|
||||
repository_id: "main".to_string(),
|
||||
repository_key: "main".to_string(),
|
||||
credential_id: "deploy".to_string(),
|
||||
host_trust_id: "host".to_string(),
|
||||
access: RepositoryAccessMode::ReadOnly,
|
||||
|
||||
+2056
-463
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
use rusqlite::{OptionalExtension, TransactionBehavior, params};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::store::WorkdirCreateOperationRecord;
|
||||
@@ -103,6 +103,65 @@ impl SqliteWorkspaceStore {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn begin_failed_workdir_create_retry(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
operation_id: &str,
|
||||
request_fingerprint: &str,
|
||||
updated_at: &str,
|
||||
) -> Result<WorkdirCreateOperationRecord> {
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let operation = read_workdir_create_operation(&tx, workspace_id, operation_id)?
|
||||
.ok_or_else(|| {
|
||||
Error::RegistryInconsistency(format!(
|
||||
"Workdir create operation `{operation_id}` disappeared before retry"
|
||||
))
|
||||
})?;
|
||||
if operation.request_fingerprint != request_fingerprint {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"Workdir create operation `{operation_id}` was reused with different input"
|
||||
)));
|
||||
}
|
||||
if operation.state != "failed" {
|
||||
return Err(Error::WorkdirAttachmentConflict(format!(
|
||||
"Workdir create operation `{operation_id}` is not a failed retry"
|
||||
)));
|
||||
}
|
||||
let removal_pending: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM workdir_removal_operations WHERE workspace_id=?1 AND workdir_id=?2 AND state='pending')",
|
||||
params![workspace_id, operation.working_directory_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if removal_pending {
|
||||
return Err(Error::WorkdirAttachmentConflict(format!(
|
||||
"Workdir {} has a pending durable removal operation",
|
||||
operation.working_directory_id
|
||||
)));
|
||||
}
|
||||
let changed = tx.execute(
|
||||
r#"UPDATE workdir_create_operations
|
||||
SET state='pending', failure=NULL, updated_at=?1
|
||||
WHERE workspace_id=?2 AND operation_id=?3
|
||||
AND request_fingerprint=?4 AND state='failed'"#,
|
||||
params![updated_at, workspace_id, operation_id, request_fingerprint],
|
||||
)?;
|
||||
if changed != 1 {
|
||||
return Err(Error::WorkdirAttachmentConflict(format!(
|
||||
"Workdir create operation `{operation_id}` retry was claimed concurrently"
|
||||
)));
|
||||
}
|
||||
let updated = read_workdir_create_operation(&tx, workspace_id, operation_id)?
|
||||
.ok_or_else(|| {
|
||||
Error::RegistryInconsistency(format!(
|
||||
"Workdir create operation `{operation_id}` disappeared after retry claim"
|
||||
))
|
||||
})?;
|
||||
tx.commit()?;
|
||||
Ok(updated)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn bind_workdir_create_repository_access(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -310,7 +369,7 @@ mod tests {
|
||||
.upsert_repository(&RepositoryRecord {
|
||||
workspace_id: "workspace".to_string(),
|
||||
repository_id: "main".to_string(),
|
||||
name: "main".to_string(),
|
||||
repository_key: "main".to_string(),
|
||||
kind: "git".to_string(),
|
||||
provider: Some("git".to_string()),
|
||||
source: workspace_api::RepositorySource {
|
||||
@@ -406,11 +465,32 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(replayed, bound);
|
||||
assert_eq!(replayed.source_uri.as_deref(), Some("/tmp/repo"));
|
||||
let failed = store
|
||||
.finish_workdir_create_operation(
|
||||
"workspace",
|
||||
"call-1",
|
||||
&record.request_fingerprint,
|
||||
false,
|
||||
Some("provider failed"),
|
||||
"2026-08-24T00:00:03Z",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(failed.state, "failed");
|
||||
let retry = store
|
||||
.begin_failed_workdir_create_retry(
|
||||
"workspace",
|
||||
"call-1",
|
||||
&record.request_fingerprint,
|
||||
"2026-08-24T00:00:04Z",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(retry.state, "pending");
|
||||
assert_eq!(retry.failure, None);
|
||||
assert_eq!(
|
||||
store
|
||||
.load_workdir_create_operation("workspace", "call-1")
|
||||
.unwrap(),
|
||||
Some(bound.clone())
|
||||
Some(retry.clone())
|
||||
);
|
||||
let mut changed_input = record.clone();
|
||||
changed_input.request_fingerprint =
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,17 +13,15 @@ use crate::store::{
|
||||
};
|
||||
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 repository_key: String,
|
||||
pub uri: String,
|
||||
#[serde(default)]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub default_ref: Option<String>,
|
||||
}
|
||||
|
||||
@@ -102,14 +100,9 @@ impl WorkspaceCatalogService {
|
||||
normalize_required("display_name", request.display_name, MAX_DISPLAY_NAME_BYTES)?;
|
||||
let repository_source = validate_repository_source(&request.repository.uri)?;
|
||||
let repository_uri = repository_source.uri.clone();
|
||||
let repository_name = request
|
||||
.repository
|
||||
.display_name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("Main repository")
|
||||
.to_string();
|
||||
workspace_api::validate_repository_key(&request.repository.repository_key)
|
||||
.map_err(|error| Error::InvalidInput(format!("invalid Repository key: {error}")))?;
|
||||
let repository_key = request.repository.repository_key.clone();
|
||||
let default_ref = request
|
||||
.repository
|
||||
.default_ref
|
||||
@@ -132,8 +125,8 @@ impl WorkspaceCatalogService {
|
||||
requested_workspace_id.as_deref(),
|
||||
&display_name,
|
||||
Some(&owner_account_id),
|
||||
&repository_key,
|
||||
&repository_uri,
|
||||
&repository_name,
|
||||
&default_ref,
|
||||
);
|
||||
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
|
||||
@@ -152,8 +145,8 @@ impl WorkspaceCatalogService {
|
||||
},
|
||||
repository: RepositoryRecord {
|
||||
workspace_id,
|
||||
repository_id: DEFAULT_REPOSITORY_ID.to_string(),
|
||||
name: repository_name,
|
||||
repository_id: Uuid::now_v7().to_string(),
|
||||
repository_key: repository_key.clone(),
|
||||
kind: "git".to_string(),
|
||||
provider: Some("git".to_string()),
|
||||
source: repository_source.clone(),
|
||||
@@ -194,8 +187,8 @@ fn workspace_create_fingerprint(
|
||||
requested_workspace_id: Option<&str>,
|
||||
display_name: &str,
|
||||
owner_account_id: Option<&str>,
|
||||
repository_key: &str,
|
||||
repository_uri: &str,
|
||||
repository_name: &str,
|
||||
default_ref: &str,
|
||||
) -> String {
|
||||
let payload = serde_json::json!({
|
||||
@@ -203,9 +196,8 @@ fn workspace_create_fingerprint(
|
||||
"display_name": display_name,
|
||||
"owner_account_id": owner_account_id,
|
||||
"repository": {
|
||||
"repository_id": DEFAULT_REPOSITORY_ID,
|
||||
"repository_key": repository_key,
|
||||
"uri": repository_uri,
|
||||
"display_name": repository_name,
|
||||
"default_ref": default_ref,
|
||||
"kind": "git",
|
||||
}
|
||||
@@ -258,7 +250,7 @@ mod tests {
|
||||
display_name: "Workspace A".to_string(),
|
||||
repository: InitialRepositoryIntent {
|
||||
uri: repository.path().display().to_string(),
|
||||
display_name: None,
|
||||
repository_key: "main".to_string(),
|
||||
default_ref: None,
|
||||
},
|
||||
};
|
||||
@@ -302,7 +294,7 @@ mod tests {
|
||||
display_name: "Workspace A".to_string(),
|
||||
repository: InitialRepositoryIntent {
|
||||
uri: repository.path().display().to_string(),
|
||||
display_name: None,
|
||||
repository_key: "main".to_string(),
|
||||
default_ref: None,
|
||||
},
|
||||
};
|
||||
@@ -355,7 +347,7 @@ mod tests {
|
||||
display_name: "Organization Workspace".to_string(),
|
||||
repository: InitialRepositoryIntent {
|
||||
uri: repository.path().display().to_string(),
|
||||
display_name: None,
|
||||
repository_key: "main".to_string(),
|
||||
default_ref: None,
|
||||
},
|
||||
},
|
||||
@@ -391,7 +383,7 @@ mod tests {
|
||||
display_name: "Owner A Workspace".to_string(),
|
||||
repository: InitialRepositoryIntent {
|
||||
uri: repository_a.path().display().to_string(),
|
||||
display_name: None,
|
||||
repository_key: "main".to_string(),
|
||||
default_ref: None,
|
||||
},
|
||||
},
|
||||
@@ -405,7 +397,7 @@ mod tests {
|
||||
display_name: "Owner B Workspace".to_string(),
|
||||
repository: InitialRepositoryIntent {
|
||||
uri: repository_b.path().display().to_string(),
|
||||
display_name: None,
|
||||
repository_key: "main".to_string(),
|
||||
default_ref: None,
|
||||
},
|
||||
},
|
||||
@@ -441,7 +433,7 @@ mod tests {
|
||||
display_name: "Remote Workspace".to_string(),
|
||||
repository: InitialRepositoryIntent {
|
||||
uri: "ssh://git@example.test/org/repository.git".to_string(),
|
||||
display_name: Some("Remote Repository".to_string()),
|
||||
repository_key: "remote".to_string(),
|
||||
default_ref: Some("main".to_string()),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -408,6 +408,9 @@ async fn run_workspace_workers(
|
||||
continue;
|
||||
};
|
||||
worker.resource_key = Some(resource_key);
|
||||
if !project_repository_key(&api, &mut worker) {
|
||||
continue;
|
||||
}
|
||||
let worker_ref = RuntimeWorkerRef::new(&runtime_id, worker.worker_id.as_str());
|
||||
let revision = next_revision(&mut revisions, &worker_ref);
|
||||
worker.subject_revision = revision;
|
||||
@@ -507,11 +510,28 @@ fn install_snapshot(
|
||||
continue;
|
||||
};
|
||||
worker.resource_key = Some(resource_key);
|
||||
if !project_repository_key(api, &mut worker) {
|
||||
continue;
|
||||
}
|
||||
projected.insert(worker.worker_id.to_string(), worker);
|
||||
}
|
||||
workers.insert(runtime_id.to_string(), projected);
|
||||
}
|
||||
|
||||
fn project_repository_key(api: &WorkspaceApi, worker: &mut SubscriptionWorker) -> bool {
|
||||
let Some(repository_id) = worker.repository_id.take() else {
|
||||
return true;
|
||||
};
|
||||
let Ok(Some(repository)) = api
|
||||
.store
|
||||
.get_repository(&api.config.workspace_id, &repository_id)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
worker.repository_key = Some(repository.repository_key);
|
||||
true
|
||||
}
|
||||
|
||||
async fn send_event(
|
||||
outbound: &mpsc::Sender<WsMessage>,
|
||||
subscription_id: &SubscriptionId,
|
||||
|
||||
@@ -4,11 +4,15 @@ pkgs.mkShell {
|
||||
nixfmt
|
||||
deno
|
||||
git
|
||||
playwright-driver.browsers
|
||||
rustc
|
||||
cargo
|
||||
pkgs.sccache
|
||||
];
|
||||
|
||||
PLAYWRIGHT_BROWSERS_PATH = "${pkgs.playwright-driver.browsers}";
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
|
||||
|
||||
# sccache is additive to Cargo's shared build-dir, so keep its disk usage bounded.
|
||||
RUSTC_WRAPPER = "${pkgs.sccache}/bin/sccache";
|
||||
SCCACHE_CACHE_SIZE = "5G";
|
||||
|
||||
+18
-17
@@ -7,23 +7,24 @@ It is not a dumping ground for external research, old plans, API inventories, or
|
||||
## Reading order
|
||||
|
||||
1. [`design/overview.md`](design/overview.md) — the system map.
|
||||
2. [`design/context-history.md`](design/context-history.md) — the highest-risk invariant: inputs that affect the model must be committed to history before they enter context.
|
||||
3. [`design/worker-session-state.md`](design/worker-session-state.md) — Worker identity, replayable session logs, current metadata, and live process hints.
|
||||
4. [`design/session-observation.md`](design/session-observation.md) — common session captures, `SessionEntryRef`, Memory evidence, and host-authorized Worker observation.
|
||||
5. [`design/flow-state-graph.md`](design/flow-state-graph.md) — Workspace Flow sources, immutable revisions, transition attempts, and bounded internal verification.
|
||||
6. [`design/profiles-manifests-prompts.md`](design/profiles-manifests-prompts.md) — reusable Profiles, resolved Manifests, and prompt resources.
|
||||
7. [`design/tool-permissions-scope.md`](design/tool-permissions-scope.md) — tool policy and filesystem scope.
|
||||
8. [`design/plugin-packages.md`](design/plugin-packages.md) — plugin package distribution, discovery, and enablement boundaries.
|
||||
9. [`development/plugin-development.md`](development/plugin-development.md) — how to build, package, enable, and inspect Yoi Plugins.
|
||||
10. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory and audit records.
|
||||
11. [`design/workspace-kanban-orchestrator-runtime.md`](design/workspace-kanban-orchestrator-runtime.md) — how Kanban operations become durable orchestration events and backend-internal routing decisions.
|
||||
12. [`design/workspace-runtime-docker.md`](design/workspace-runtime-docker.md) — the WebUI / Backend / Runtime split, Docker image layout, worker launch path, and workdir materialization boundary.
|
||||
13. [`development/server-runtime-auth.md`](development/server-runtime-auth.md) — manual Workspace Server / Runtime public-key exchange and authenticated Runtime startup checks.
|
||||
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.
|
||||
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.
|
||||
18. [`design/standalone-agent-host.md`](design/standalone-agent-host.md) — in-process standalone Worker host の依存方向、authority、lifecycle、非目標。
|
||||
2. [`design/durable-operations.md`](design/durable-operations.md) — cross-domain operation identity, checkpoints, retries, child operations, and disposition, including durable Workdir removal.
|
||||
3. [`design/context-history.md`](design/context-history.md) — the highest-risk invariant: inputs that affect the model must be committed to history before they enter context.
|
||||
4. [`design/worker-session-state.md`](design/worker-session-state.md) — Worker identity, replayable session logs, current metadata, and live process hints.
|
||||
5. [`design/session-observation.md`](design/session-observation.md) — common session captures, `SessionEntryRef`, Memory evidence, and host-authorized Worker observation.
|
||||
6. [`design/flow-state-graph.md`](design/flow-state-graph.md) — Workspace Flow sources, immutable revisions, transition attempts, and bounded internal verification.
|
||||
7. [`design/profiles-manifests-prompts.md`](design/profiles-manifests-prompts.md) — reusable Profiles, resolved Manifests, and prompt resources.
|
||||
8. [`design/tool-permissions-scope.md`](design/tool-permissions-scope.md) — tool policy and filesystem scope.
|
||||
9. [`design/plugin-packages.md`](design/plugin-packages.md) — plugin package distribution, discovery, and enablement boundaries.
|
||||
10. [`development/plugin-development.md`](development/plugin-development.md) — how to build, package, enable, and inspect Yoi Plugins.
|
||||
11. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory and audit records.
|
||||
12. [`design/workspace-kanban-orchestrator-runtime.md`](design/workspace-kanban-orchestrator-runtime.md) — how Kanban operations become durable orchestration events and backend-internal routing decisions.
|
||||
13. [`design/workspace-runtime-docker.md`](design/workspace-runtime-docker.md) — the WebUI / Backend / Runtime split, Docker image layout, worker launch path, and workdir materialization boundary.
|
||||
14. [`development/server-runtime-auth.md`](development/server-runtime-auth.md) — manual Workspace Server / Runtime public-key exchange and authenticated Runtime startup checks.
|
||||
15. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed.
|
||||
16. [`development/rust-testing-strategy.md`](development/rust-testing-strategy.md) — what Yoi Rust tests should prove, where they belong, and how to name them.
|
||||
17. [`development/validation.md`](development/validation.md) — how to check changes.
|
||||
18. [`development/workspace-schema-migrations.md`](development/workspace-schema-migrations.md) — how to preflight, apply, verify, and roll back control-plane SQLite schema changes.
|
||||
19. [`design/standalone-agent-host.md`](design/standalone-agent-host.md) — in-process standalone Worker host の依存方向、authority、lifecycle、非目標。
|
||||
|
||||
## What belongs here
|
||||
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
# Durable operation design
|
||||
|
||||
Durable operation records make retries converge on the same authorized intent
|
||||
and preserve the facts needed to explain an externally visible result. They are
|
||||
not execution traces and must not mirror every Rust function or implementation
|
||||
step as persisted state.
|
||||
|
||||
This document defines the cross-domain rules for operation identity, state,
|
||||
checkpoints, child operations, failure evidence, and terminal disposition.
|
||||
Domain code may use different records where its atomicity boundary differs, but
|
||||
it should classify the operation before choosing a schema.
|
||||
|
||||
## Core rule
|
||||
|
||||
Persist authority and non-reconstructable facts, not control flow.
|
||||
|
||||
A value belongs in durable operation state only when at least one of the
|
||||
following is true:
|
||||
|
||||
- it identifies the caller's stable intent and detects conflicting reuse;
|
||||
- it freezes authority or configuration that an exact retry must continue to
|
||||
use;
|
||||
- it binds a preallocated or created resource to the operation;
|
||||
- it records an externally visible side effect that cannot be safely derived or
|
||||
repeated;
|
||||
- it records the final domain result or disposition;
|
||||
- it provides bounded evidence needed for retry, reconciliation, or audit.
|
||||
|
||||
A local step does not become durable merely because it occurs before or after
|
||||
another function call. If current authority can be reread or the step can be
|
||||
repeated safely, derive or repeat it instead of adding a stage.
|
||||
|
||||
## Resilience boundary
|
||||
|
||||
These rules cover normal product retry and recovery boundaries: duplicate
|
||||
requests, returned errors, timeouts, known partial-completion outcomes, process
|
||||
restart from the last committed facts, and retries of provider operations with
|
||||
an explicit idempotency or observation contract.
|
||||
|
||||
They do not require the system to survive an unexpected stop at every point
|
||||
while Rust code is executing. A panic, abort, process kill, machine loss, or
|
||||
power failure may occur between an external side effect and its next durable
|
||||
checkpoint. Yoi does not attempt to close every such instruction-level crash
|
||||
window by writing a stage before and after every `await`, function, or provider
|
||||
call.
|
||||
|
||||
Consequently:
|
||||
|
||||
- do not claim general exactly-once execution;
|
||||
- do not introduce write-ahead stages solely to model arbitrary Rust
|
||||
control-flow interruption;
|
||||
- rely on SQLite transaction atomicity for work inside one database transaction;
|
||||
- prefer provider idempotency, compare-and-swap, stable resource identity, and
|
||||
authoritative observation for external effects;
|
||||
- when an uncovered crash window cannot be reconciled automatically, retain the
|
||||
last committed facts and surface an `unknown` or attention-required
|
||||
disposition rather than guessing that the side effect did or did not happen.
|
||||
|
||||
A domain may require a stronger crash-consistency contract for a specific
|
||||
destructive or security-sensitive effect. That requirement must be explicit and
|
||||
must define the provider protocol, checkpoint ordering, replay behavior, and
|
||||
reconciliation evidence. It is not implied by calling a record a durable
|
||||
operation.
|
||||
|
||||
## Classify the record before adding state
|
||||
|
||||
Not every record containing an `operation_id` is a state machine. Use one of the
|
||||
following shapes.
|
||||
|
||||
### Atomic idempotency ledger
|
||||
|
||||
Use an idempotency ledger when all authoritative mutations and result recording
|
||||
commit in one database transaction.
|
||||
|
||||
The record normally contains:
|
||||
|
||||
- Workspace and operation identity;
|
||||
- a fingerprint of stable caller intent;
|
||||
- the created resource or result identity;
|
||||
- the committed revision and timestamp where relevant.
|
||||
|
||||
It does not need `pending`, `executing`, or intermediate stages. An exact retry
|
||||
returns the recorded result. Reusing the same operation identity with a
|
||||
different fingerprint fails.
|
||||
|
||||
Repository secret mutation results, Workspace resource creation results, and
|
||||
transactionally appended domain events are examples of this shape.
|
||||
|
||||
### Reservation
|
||||
|
||||
Use a reservation when an identity or exclusive right must exist before a later
|
||||
binding can complete.
|
||||
|
||||
Persist factual transitions such as:
|
||||
|
||||
- the reserved resource identity;
|
||||
- the immutable request fingerprint and authority snapshot;
|
||||
- the concrete resource or assignment bound to the reservation;
|
||||
- reservation expiry or release evidence when the contract requires it.
|
||||
|
||||
Do not model internal dispatch, validation, construction, or callback steps as
|
||||
reservation states. A nullable result binding or a small `reserved | created`
|
||||
state can be sufficient when those values correspond to real authority facts.
|
||||
|
||||
### Durable side-effect operation
|
||||
|
||||
Use a durable side-effect operation when work crosses a database/provider
|
||||
boundary and a retry needs durable intent or result evidence.
|
||||
|
||||
The default lifecycle is deliberately small:
|
||||
|
||||
```text
|
||||
pending -> completed
|
||||
pending -> failed
|
||||
failed -> pending # only when the domain explicitly permits retry
|
||||
```
|
||||
|
||||
Existing code may use `succeeded` for the successful terminal value; new naming
|
||||
should prefer `completed`. Do not rewrite applied migrations or historical audit
|
||||
text only to normalize that word.
|
||||
|
||||
The operation should contain:
|
||||
|
||||
- stable operation identity and request fingerprint;
|
||||
- immutable resolved authority needed by an exact retry;
|
||||
- preallocated resource identity where it prevents duplicate creation;
|
||||
- only the necessary irreversible checkpoints;
|
||||
- bounded failure evidence;
|
||||
- the final result and domain disposition.
|
||||
|
||||
`pending` means that the intent remains open and current authority must be
|
||||
reread before progress. It does not identify which Rust function should execute
|
||||
next. `failed` records the latest terminal attempt outcome; retryability is an
|
||||
explicit domain rule, not something inferred from the word. `completed` means
|
||||
the operation's required result and evidence are durably committed.
|
||||
|
||||
### Parent workflow
|
||||
|
||||
A parent workflow coordinates domain operations but does not duplicate their
|
||||
lifecycle.
|
||||
|
||||
Persist:
|
||||
|
||||
- the parent intent and fencing authority;
|
||||
- stable child operation identities;
|
||||
- the final workflow result or disposition;
|
||||
- bounded attention or decision evidence.
|
||||
|
||||
Read child state from the child authority. Do not copy child states, provider
|
||||
stages, Worker status, attachment status, or Workdir status into a second parent
|
||||
state machine. A parent cleanup workflow will often need only
|
||||
`pending | completed`; child failure remains on the child operation and appears
|
||||
in the parent as current attention metadata.
|
||||
|
||||
Creating or binding a child must itself be idempotent. Prefer a deterministic
|
||||
child operation identity or persist the child reference atomically with the
|
||||
parent decision so a retry cannot create siblings for one intent.
|
||||
|
||||
## Checkpoint rules
|
||||
|
||||
A checkpoint records a fact that changes retry semantics. It is not a progress
|
||||
notification.
|
||||
|
||||
Add a checkpoint only when all of the following hold:
|
||||
|
||||
1. A side effect may already have occurred outside the current transaction.
|
||||
2. Current authority cannot derive the fact reliably enough for safe retry, or
|
||||
repeating the effect is not safe under the provider contract.
|
||||
3. The retry algorithm changes after the fact is committed.
|
||||
4. Tests can exercise behavior before and after the checkpoint.
|
||||
|
||||
Prefer factual fields over stage names:
|
||||
|
||||
- `provider_deleted_at` is evidence that provider deletion succeeded;
|
||||
- `child_operation_id` binds delegated work;
|
||||
- `result_revision` identifies the committed result;
|
||||
- `target_ref_after` records verified merge evidence.
|
||||
|
||||
Avoid fields such as `validating`, `closing_session`, `detaching`,
|
||||
`deleting_registry`, or `finalizing`. Those names describe code location, not
|
||||
durable authority. If those steps are safe to rerun or their result can be read
|
||||
from Worker, attachment, Workdir, repository, or provider authority, they are
|
||||
not checkpoints.
|
||||
|
||||
A checkpoint must never claim more than the authority that produced it. For
|
||||
example, sending a provider request is not proof that provider deletion
|
||||
completed, and receiving a Worker notification is not proof that a Ticket or
|
||||
cleanup workflow completed.
|
||||
|
||||
## State, failure, blockers, and disposition are separate
|
||||
|
||||
Do not overload one enum with unrelated dimensions.
|
||||
|
||||
- **Operation state** says whether the intent is open, completed, or has a
|
||||
recorded failed attempt.
|
||||
- **Failure evidence** records a bounded category, timestamp, and safe
|
||||
diagnostic detail for the latest failure.
|
||||
- **Blockers and eligibility** are normally derived by rereading current
|
||||
authority. Persist them only as audit or attention evidence, not as a
|
||||
substitute for live validation.
|
||||
- **Disposition** records what the domain decided to retain, delete, release,
|
||||
tombstone, abandon, or leave unknown.
|
||||
- **Attention metadata** explains why automated progress currently cannot
|
||||
continue and what authority must change.
|
||||
|
||||
Values such as `blocked`, `executing`, `stale`, `dirty`, `retained`, and
|
||||
`deleted` therefore do not all belong in one operation-state enum. Some are
|
||||
derived conditions, some describe transient execution, and some are domain
|
||||
results.
|
||||
|
||||
Before every retry or side effect, reread live authority and revalidate its
|
||||
fence. A previously recorded blocker does not prove that the operation remains
|
||||
blocked, and a previously unblocked operation does not retain permission after
|
||||
assignment, ownership, revision, or attachment authority changes.
|
||||
|
||||
## Identity and fingerprinting
|
||||
|
||||
Every externally retryable operation has a stable identity in its owning
|
||||
Workspace or authority scope. The operation fingerprint represents stable caller
|
||||
intent, not generated results or mutable observations.
|
||||
|
||||
Include inputs whose change would mean a different requested operation. Exclude:
|
||||
|
||||
- generated resource IDs when the Server allocates and persists them as the
|
||||
result;
|
||||
- timestamps assigned by the Server;
|
||||
- retry counters and diagnostics;
|
||||
- current provider observations that are expected to change;
|
||||
- secret bytes and credential material.
|
||||
|
||||
Resolved authority snapshots may be stored separately from the caller
|
||||
fingerprint. An exact retry uses the persisted snapshot where replay convergence
|
||||
requires it; a new operation resolves current authority. Unknown, foreign, or
|
||||
conflicting operation identity fails closed.
|
||||
|
||||
## Transactions and external providers
|
||||
|
||||
Keep database work in one transaction whenever the owning authority and result
|
||||
live in the same database. Do not create a durable operation merely to split a
|
||||
transaction that can remain atomic.
|
||||
|
||||
When an external provider is involved:
|
||||
|
||||
1. reserve stable intent and identity if retry needs them;
|
||||
2. invoke the provider with the strongest available idempotency, expected-old
|
||||
revision, or stable resource key;
|
||||
3. verify the provider result through authoritative response or observation;
|
||||
4. commit only the checkpoint or result evidence that changes retry behavior;
|
||||
5. on retry, reread both the operation and current domain/provider authority
|
||||
before acting.
|
||||
|
||||
Compensation is a domain operation, not an invisible `finally` block. If
|
||||
compensation has its own external side effects or retry lifecycle, give it a
|
||||
stable child operation identity rather than expanding the parent into a list of
|
||||
cleanup stages.
|
||||
|
||||
## Workdir removal application
|
||||
|
||||
Workdir removal is one durable side-effect operation in the Workspace Server
|
||||
DB. It binds the Workspace, Workdir, owning Runtime,
|
||||
Repository/materialization identity, source actor, stable intent fingerprint,
|
||||
lifecycle, retry metadata, and bounded result. Runtime URL, provider handle,
|
||||
host path, credentials, and caller-selected Runtime are not operation inputs.
|
||||
|
||||
A durable one-pending-operation constraint plus an atomic attempt claim prevents
|
||||
concurrent callers from entering the provider side effect for the same Workdir;
|
||||
the in-process resource lock is an additional serialization layer, not the sole
|
||||
authority. Each active attempt persists the Server process ID and process-start
|
||||
marker. Recovery reclaims only an owner proven missing or replaced; a live or
|
||||
unobservable owner is never stolen. The reclaim transaction compare-and-set
|
||||
checks the exact proved owner snapshot and attempt count so stale orphan proof
|
||||
cannot overwrite a newer live claim.
|
||||
|
||||
Each attempt:
|
||||
|
||||
1. resolves or revalidates the persisted same-Workspace Workdir, Runtime,
|
||||
Repository, and materialization identity;
|
||||
2. checks current attachments, attachment reservations, current assignment
|
||||
occupancy, retention/cleanup holds, and pending materialization authority; a
|
||||
failed Workdir-create retry must atomically return to `pending` before
|
||||
provider work and is rejected while removal is pending;
|
||||
3. retains dirty, occupied, blocked, or otherwise unknown Workdirs without
|
||||
detaching a Worker or forcing deletion;
|
||||
4. observes the owning Runtime/provider and calls its existing Workdir cleanup
|
||||
only for an eligible clean Workdir;
|
||||
5. treats only successful provider cleanup or exact
|
||||
`working_directory_not_found` as removal evidence;
|
||||
6. deletes the Backend Workdir registry row and commits the operation's
|
||||
`completed`/`removed` result in one SQLite transaction.
|
||||
|
||||
A provider error leaves the registry intact and records a bounded
|
||||
`attention_required` result with explicit retryability. Startup recovery lists
|
||||
`pending` and retryable `failed` operations, then executes this same path after
|
||||
rereading live authority. `WorkdirDelete`, Workspace REST removal, Runtime
|
||||
cleanup execution, and recovery must not maintain separate inline
|
||||
provider-delete paths.
|
||||
|
||||
The public request contains only `working_directory_id` plus a bounded reason.
|
||||
The public result contains only the Workdir ID,
|
||||
`removed | retained | attention_required`, retryability, and an optional bounded
|
||||
failure category. Internal operation identifiers, checkpoints, provider paths,
|
||||
and credentials are not public DTO fields.
|
||||
|
||||
## Diagnostics and audit
|
||||
|
||||
Persist bounded error categories and identifiers needed to investigate or retry.
|
||||
Do not persist credentials, provider handles, raw command output, raw prompts,
|
||||
full session transcripts, or host paths in ordinary operation diagnostics.
|
||||
|
||||
Attempt counts, last-attempt timestamps, and safe provider categories may help
|
||||
operations, but they are telemetry and evidence rather than lifecycle authority.
|
||||
Logs may describe detailed execution stages; the durable record should remain
|
||||
centered on intent, checkpoints, result, and disposition.
|
||||
|
||||
## Applying this rule
|
||||
|
||||
For a new or materially changed operation:
|
||||
|
||||
1. identify the owning authority and transaction boundary;
|
||||
2. classify it as an atomic ledger, reservation, durable side-effect operation,
|
||||
or parent workflow;
|
||||
3. define stable identity, fingerprint, and exact-retry behavior;
|
||||
4. list external side effects and decide which are idempotent or authoritatively
|
||||
observable;
|
||||
5. add only checkpoints that change retry behavior;
|
||||
6. keep child operation state in the child authority;
|
||||
7. separate failure, blocker, attention, and disposition from lifecycle state;
|
||||
8. state the unsupported crash windows honestly;
|
||||
9. test fingerprint conflict, exact retry, authority revalidation, checkpoint
|
||||
replay, and result/disposition projection as applicable.
|
||||
|
||||
Existing operation schemas need not be rewritten solely for vocabulary
|
||||
consistency. When an operation is changed for functional reasons, use this
|
||||
classification to remove derived or control-flow stages rather than adding
|
||||
another special-case lifecycle.
|
||||
@@ -23,3 +23,7 @@ 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.
|
||||
|
||||
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.
|
||||
|
||||
Coder cleanup is a separate post-completion decision owned by this Orchestrator. Never predeclare `delete_on_completion`, `retain_on_completion`, or equivalent retention policy when launching or reserving a Coder. After `CompleteMergeRequest` and Ticket completion, perform one cleanup pass before ending the orchestration turn: reread the current Ticket and `WorkerList`, verify completion is authoritative and the Coder has no current Ticket assignment, then inspect the Coder Worker, Workdir attachment and occupancy, repository cleanliness, ownership, provider availability, and any other current use of that Workdir. For Worker control, use the exact subject returned by `WorkerList`. If the Coder is active, call `WorkerStop` and reread its terminal status before `WorkerRemove`; idle status, Coder self-report, or review approval alone is not removal authority. Retain an existing or still-needed Workdir. Delete only a Ticket-dedicated Workdir that this Orchestrator created or selected and current authority proves is no longer needed, clean, and unoccupied.
|
||||
|
||||
For Ticket-dedicated cleanup, preserve this guarded order: stop the Coder if needed and confirm it is terminal; unassign it through the available orchestration authority; call `WorkerRemove`; use `WorkdirList` to reread the actual Workdir and confirm attachment release, clean state, and no occupancy; only then call `WorkdirDelete`. Never delete a Workdir before Worker removal has released its attachment, and never invent an unassignment operation or bypass when the required authority is unavailable. `CurrentAssignment` means unassign and reread before retrying `WorkerRemove`. Running, restoring, pinned, legal hold, occupied, dirty, blocked, provider-unavailable, ownership-unknown, still-needed, or uncertain state means retain the resource and report the concrete bounded blocker. If stop, unassign, removal, attachment release, or deletion reports a partial failure, do not infer success or advance to the next step; reread current Ticket, assignment, Worker, and Workdir authority before a bounded safe retry. Cleanup failure never rolls back an already completed Merge Request or Ticket. Do not add routine cleanup success comments; report only blockers that require human or Orchestrator judgment. Never force removal, discard changes, or retry from stale assumptions.
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# Web UX inspection workbench
|
||||
|
||||
`tools/web-ux` is a development-only Playwright workbench for repeatable visual inspection of the
|
||||
real Web Workspace. It does not add a Yoi product Skill, Flow, Runtime capability, or browser
|
||||
automation route.
|
||||
|
||||
The workbench produces a **review context bundle** rather than treating a screenshot as evidence by
|
||||
itself. Every capture records the persona, route, viewport, theme, intended user goal, expected data
|
||||
state, sanitized document URL/status, console/page/request failures, screenshot hashes, an
|
||||
accessibility snapshot, source revision, and browser version.
|
||||
|
||||
## Environment
|
||||
|
||||
Enter the repository dev shell. The shell supplies the Nix-pinned Chromium build and sets
|
||||
`PLAYWRIGHT_BROWSERS_PATH`; Playwright does not download a browser at runtime.
|
||||
|
||||
```sh
|
||||
nix develop
|
||||
cd tools/web-ux
|
||||
deno task check
|
||||
deno task test
|
||||
deno task test:browser
|
||||
```
|
||||
|
||||
`test:browser` starts a deterministic fixture server owned by the test, captures distinct owner and
|
||||
non-owner contexts, verifies the review bundle, and proves server/browser cleanup. It must run
|
||||
inside `nix develop` so it uses the pinned browser.
|
||||
|
||||
The npm Playwright version in `deno.json` must match `pkgs.playwright-driver.version` in the pinned
|
||||
Nixpkgs input. Update both as one toolchain change.
|
||||
|
||||
## Scenario contract
|
||||
|
||||
Scenarios are reviewed JSON files under `scenarios/`. A scenario fixes:
|
||||
|
||||
- personas and whether each uses an isolated anonymous context or a local Playwright storage-state
|
||||
file;
|
||||
- explicit routes and user goals;
|
||||
- expected data state, viewports, theme, locale, timezone, and reduced-motion mode;
|
||||
- an explicit readiness condition for every route and optional interaction/capture-point conditions;
|
||||
- selectors and exact environment-derived text that must be redacted;
|
||||
- optional processes owned by the capture command, including an HTTP readiness URL.
|
||||
|
||||
`${UPPER_CASE_ENV}` values are expanded at runtime. URLs with embedded credentials are rejected.
|
||||
Route readiness is bounded and retried twice; it never relies on a fixed sleep. `network-idle` is
|
||||
available but should be used only for screens whose contract actually reaches idle. Prefer a stable
|
||||
screen-owned selector.
|
||||
|
||||
`workspace-control-plane.json` expects:
|
||||
|
||||
```sh
|
||||
export WEB_UX_BASE_URL='http://127.0.0.1:5173'
|
||||
export WORKSPACE_ID='<workspace-id>'
|
||||
export XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}"
|
||||
```
|
||||
|
||||
## Authentication fixtures
|
||||
|
||||
Authentication state is local sensitive material stored under `$XDG_STATE_HOME/yoi/web-ux/auth/`,
|
||||
outside the Repository and Workdir. Files are written with mode `0600`, state contents are never
|
||||
copied into a review bundle, and the CLI never prints cookies or credentials. Each profile has a
|
||||
sidecar binding it to the exact persona and base URL origin with a 12-hour default expiry. Capture
|
||||
fails explicitly when metadata is missing, the origin differs, or the profile has expired; it never
|
||||
silently reuses or refreshes that state.
|
||||
|
||||
For an interactive Passkey/browser login:
|
||||
|
||||
```sh
|
||||
deno task web-ux auth \
|
||||
--scenario scenarios/workspace-control-plane.json \
|
||||
--persona owner
|
||||
```
|
||||
|
||||
The command opens Chromium at the configured login route, waits up to five minutes for the
|
||||
scenario's success URL, saves `storageState`, and closes the browser in `finally`. Repeat for
|
||||
`non-owner` using a real account with that permission projection.
|
||||
|
||||
A test fixture may already provide Playwright-compatible `{ cookies, origins }` state. Import it
|
||||
without putting its value on the command line:
|
||||
|
||||
```sh
|
||||
deno task web-ux auth \
|
||||
--scenario scenarios/workspace-control-plane.json \
|
||||
--persona owner \
|
||||
--import-state /private/path/owner-state.json \
|
||||
--expires-in-hours 8
|
||||
```
|
||||
|
||||
Delete both the profile and its metadata when it is no longer needed:
|
||||
|
||||
```sh
|
||||
deno task web-ux auth \
|
||||
--scenario scenarios/workspace-control-plane.json \
|
||||
--persona owner \
|
||||
--delete
|
||||
```
|
||||
|
||||
Do not place passwords, bearer tokens, private keys, WebAuthn material, or inline cookies in a
|
||||
scenario, process arguments, a Repository URL, or `redact.text`. `redact.text` is only a final
|
||||
defense for a secret already supplied through an environment-owned fixture; it is not a credential
|
||||
transport.
|
||||
|
||||
## Capture and inspect
|
||||
|
||||
Capture a stable multi-persona bundle:
|
||||
|
||||
```sh
|
||||
deno task web-ux capture \
|
||||
--scenario scenarios/workspace-control-plane.json \
|
||||
--output ../../target/web-ux \
|
||||
--run-id before-change
|
||||
```
|
||||
|
||||
Use filters for a bounded feedback loop:
|
||||
|
||||
```sh
|
||||
deno task web-ux capture \
|
||||
--scenario scenarios/workspace-control-plane.json \
|
||||
--output ../../target/web-ux \
|
||||
--run-id ticket-list-after \
|
||||
--personas owner,non-owner \
|
||||
--routes tickets \
|
||||
--viewports desktop
|
||||
```
|
||||
|
||||
The command exits `2` when it produced evidence but observed UI/tool errors, and exits `1` when
|
||||
capture itself failed. It continues other route/persona captures after a bounded route failure.
|
||||
Inspect:
|
||||
|
||||
- `review-context.json` for the exact context, hashes, HTTP status, retained/truncated diagnostic
|
||||
counts, route and capture-point readiness, and the redacted interaction sequence;
|
||||
- `contact-sheet.png` through its manifest `workdirPath` with an image-capable reviewer for
|
||||
composition, hierarchy, density, clipping, empty/error states, and permission-specific
|
||||
affordances;
|
||||
- each `accessibility.md` through its manifest `workdirPath` for landmark/name/state evidence that a
|
||||
screenshot cannot prove;
|
||||
- `process-logs/` when the scenario owns a server process. Each stdout/stderr stream is redacted,
|
||||
capped at 1 MiB, and paired with truncation metadata.
|
||||
|
||||
The implementing agent must inspect the actual contact sheet (for example with `ViewImage`), record
|
||||
concrete findings, fix them, recapture under the same persona/route/viewport filters, and inspect
|
||||
the new evidence. Playwright success alone is not visual acceptance.
|
||||
|
||||
## Compare before and after
|
||||
|
||||
```sh
|
||||
deno task web-ux compare \
|
||||
--before ../../target/web-ux/before-change/review-context.json \
|
||||
--after ../../target/web-ux/after-change/review-context.json \
|
||||
--output ../../target/web-ux/before-vs-after
|
||||
```
|
||||
|
||||
`comparison.html` and `comparison.png` show before, after, and pixel diff side by side.
|
||||
`comparison.json` records changed-pixel counts, dimension mismatches, unmatched capture keys, and
|
||||
diff hashes. Pixel differences are orientation evidence, not a correctness verdict; explain expected
|
||||
animation/font/data changes and inspect the actual UI.
|
||||
|
||||
Capture keys are stable across runs: `persona / route / viewport / capture-point`. Keep those
|
||||
identities unchanged when comparing the same user task.
|
||||
|
||||
## Process and artifact cleanup
|
||||
|
||||
The capture command owns only processes declared in its scenario. It starts them without a shell,
|
||||
records bounded/redacted output, and terminates the process and descendants on success, capture
|
||||
failure, or interruption observed by the command. It never stops an existing Yoi Server or Runtime
|
||||
that it did not start.
|
||||
|
||||
Old complete review bundles can be removed without touching auth state or arbitrary directories:
|
||||
|
||||
```sh
|
||||
deno task web-ux cleanup --output ../../target/web-ux --keep 5 --older-than-days 14 --dry-run
|
||||
deno task web-ux cleanup --output ../../target/web-ux --keep 5 --older-than-days 14
|
||||
```
|
||||
|
||||
Cleanup recognizes only directories containing `review-context.json`. The repository `target/` tree
|
||||
is ignored by Git, while authentication state remains outside the repository. `capture` defaults to
|
||||
`target/web-ux` when `--output` is omitted. Keep a bundle outside Git or publish it through the
|
||||
approved immutable artifact channel when durable review evidence is required.
|
||||
|
||||
## Adding a scenario
|
||||
|
||||
1. Name the concrete user task and expected data state; do not write “looks correct”.
|
||||
2. Use the smallest persona/route/viewport matrix that proves the intended contract, including
|
||||
owner/non-owner/anonymous boundaries when permissions affect composition.
|
||||
3. Choose a screen-owned readiness selector or response. Avoid arbitrary sleeps.
|
||||
4. Add capture points only for meaningful visual states (initial, expanded detail, error, empty, and
|
||||
so on).
|
||||
5. Mark sensitive DOM regions with `[data-web-ux-redact]` or scenario selectors; never use review
|
||||
artifacts to transport secrets.
|
||||
6. Run `deno task check`, `deno task test`, one real capture, and inspect `contact-sheet.png` plus
|
||||
`review-context.json`.
|
||||
@@ -0,0 +1,133 @@
|
||||
import { assertEquals, assertRejects } from "@std/assert";
|
||||
import { join } from "@std/path";
|
||||
import { writeAuthMetadata } from "../src/auth_state.ts";
|
||||
import { capture } from "../src/capture.ts";
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
||||
const port = (listener.addr as Deno.NetAddr).port;
|
||||
listener.close();
|
||||
return port;
|
||||
}
|
||||
|
||||
Deno.test("browser smoke captures distinct owner and non-owner evidence and cleans its server", async () => {
|
||||
const directory = await Deno.makeTempDir();
|
||||
const previousSecret = Deno.env.get("WEB_UX_FIXTURE_SECRET");
|
||||
const fixtureSecret = "fixture-canary-secret";
|
||||
Deno.env.set("WEB_UX_FIXTURE_SECRET", fixtureSecret);
|
||||
const port = await freePort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
try {
|
||||
const authDirectory = join(directory, "auth");
|
||||
await Deno.mkdir(authDirectory);
|
||||
for (const persona of ["owner", "non-owner"]) {
|
||||
const storageState = join(authDirectory, `${persona}.json`);
|
||||
await Deno.writeTextFile(
|
||||
storageState,
|
||||
JSON.stringify({
|
||||
cookies: [{
|
||||
name: "persona",
|
||||
value: persona,
|
||||
domain: "127.0.0.1",
|
||||
path: "/",
|
||||
expires: -1,
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: "Lax",
|
||||
}],
|
||||
origins: [],
|
||||
}),
|
||||
);
|
||||
await writeAuthMetadata(storageState, persona, baseUrl, 1);
|
||||
}
|
||||
const scenarioPath = join(directory, "scenario.json");
|
||||
await Deno.writeTextFile(
|
||||
scenarioPath,
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "browser-smoke",
|
||||
title: "Browser smoke",
|
||||
baseUrl,
|
||||
redact: {
|
||||
selectors: ["[data-web-ux-redact]"],
|
||||
text: ["${WEB_UX_FIXTURE_SECRET}"],
|
||||
},
|
||||
personas: [
|
||||
{ id: "owner", label: "Owner", auth: { kind: "storage-state", path: "auth/owner.json" } },
|
||||
{
|
||||
id: "non-owner",
|
||||
label: "Non-owner",
|
||||
auth: { kind: "storage-state", path: "auth/non-owner.json" },
|
||||
},
|
||||
],
|
||||
viewports: [{ label: "desktop", width: 1000, height: 700 }],
|
||||
routes: [{
|
||||
id: "repositories",
|
||||
label: "Repositories",
|
||||
path: "/screen",
|
||||
goal: "Verify permission-specific composition",
|
||||
dataState: "Deterministic fixture repository",
|
||||
ready: { kind: "selector", selector: "main" },
|
||||
capturePoints: [{
|
||||
id: "initial",
|
||||
label: "Initial",
|
||||
interaction: [{
|
||||
action: "wait",
|
||||
ready: { kind: "selector", selector: "h1" },
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
processes: [{
|
||||
id: "fixture-server",
|
||||
command: Deno.execPath(),
|
||||
args: [
|
||||
"run",
|
||||
"--allow-env",
|
||||
"--allow-net",
|
||||
join(Deno.cwd(), "browser-tests/fixture_server.ts"),
|
||||
String(port),
|
||||
],
|
||||
env: { WEB_UX_FIXTURE_SECRET: "${WEB_UX_FIXTURE_SECRET}" },
|
||||
readyUrl: `${baseUrl}/health`,
|
||||
}],
|
||||
}),
|
||||
);
|
||||
const manifest = await capture({
|
||||
scenarioPath,
|
||||
outputDirectory: join(directory, "artifacts"),
|
||||
runId: "multi-persona",
|
||||
});
|
||||
assertEquals(manifest.status, "completed-with-errors");
|
||||
assertEquals(manifest.captures.map((item) => item.persona.id), ["owner", "non-owner"]);
|
||||
assertEquals(manifest.captures.every((item) => item.screenshots.length === 1), true);
|
||||
assertEquals(manifest.captures[0].route.ready.kind, "selector");
|
||||
assertEquals(manifest.captures[0].interactions[0].action, "wait");
|
||||
assertEquals(manifest.captures[0].errorSummary, {
|
||||
observed: 150,
|
||||
retained: 100,
|
||||
truncated: true,
|
||||
limit: 100,
|
||||
});
|
||||
assertEquals(manifest.contactSheet.png?.bundlePath, "contact-sheet.png");
|
||||
const runDirectory = join(directory, "artifacts", "multi-persona");
|
||||
const reviewContext = await Deno.readTextFile(join(runDirectory, "review-context.json"));
|
||||
assertEquals(reviewContext.includes('"cookies"'), false);
|
||||
assertEquals(reviewContext.includes(fixtureSecret), false);
|
||||
const processLog = await Deno.readTextFile(
|
||||
join(runDirectory, "process-logs", "fixture-server.stdout.log"),
|
||||
);
|
||||
assertEquals(processLog.includes(fixtureSecret), false);
|
||||
if (Deno.build.os !== "windows") {
|
||||
const screenshot = join(runDirectory, manifest.captures[0].screenshots[0].bundlePath);
|
||||
assertEquals((await Deno.stat(screenshot)).mode! & 0o777, 0o600);
|
||||
}
|
||||
await assertRejects(
|
||||
() => fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(500) }),
|
||||
TypeError,
|
||||
);
|
||||
} finally {
|
||||
if (previousSecret === undefined) Deno.env.delete("WEB_UX_FIXTURE_SECRET");
|
||||
else Deno.env.set("WEB_UX_FIXTURE_SECRET", previousSecret);
|
||||
await Deno.remove(directory, { recursive: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
const port = Number(Deno.args[0]);
|
||||
if (!Number.isInteger(port) || port <= 0) throw new Error("port is required");
|
||||
|
||||
const canary = Deno.env.get("WEB_UX_FIXTURE_SECRET") ?? "";
|
||||
console.log(`Authorization: Bearer ${canary}`);
|
||||
|
||||
Deno.serve({ hostname: "127.0.0.1", port }, (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/health") return new Response("ok");
|
||||
const cookie = request.headers.get("cookie") ?? "";
|
||||
const owner = cookie.includes("persona=owner");
|
||||
const title = owner ? "Owner repository settings" : "Repository settings";
|
||||
const action = owner
|
||||
? '<button type="button">Add repository</button>'
|
||||
: '<p role="note">Ask a Workspace owner to change repository access.</p>';
|
||||
return new Response(
|
||||
`<!doctype html><html><head><title>${title}</title><style>body{font:16px system-ui;margin:0}main{max-width:800px;margin:40px auto}header{border-bottom:1px solid #ccc;padding:16px}section{border:1px solid #ccc;padding:20px}button{background:#06c;color:white;padding:10px 20px}</style></head><body><header>Workspace</header><main><h1>${title}</h1><section><h2>main</h2><p>SSH repository access is configured.</p>${action}<span data-web-ux-redact>${canary}</span><script>for(let index=0;index<150;index++)console.error('fixture error '+index)</script></section></main></body></html>`,
|
||||
{ headers: { "content-type": "text/html; charset=utf-8" } },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env -S deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys
|
||||
import { dirname, fromFileUrl, resolve } from "@std/path";
|
||||
import { authenticate, cleanup } from "./src/lifecycle.ts";
|
||||
import { capture, describeCapture } from "./src/capture.ts";
|
||||
import { compare } from "./src/compare.ts";
|
||||
|
||||
const DEFAULT_OUTPUT = resolve(dirname(fromFileUrl(import.meta.url)), "../..", "target/web-ux");
|
||||
|
||||
const HELP = `Web UX inspection workbench
|
||||
|
||||
Usage:
|
||||
deno task web-ux auth --scenario <file> --persona <id> [--base-url <url>] [--import-state <file>] [--expires-in-hours <hours>] [--headless]
|
||||
deno task web-ux auth --scenario <file> --persona <id> --delete
|
||||
deno task web-ux capture --scenario <file> [--output <directory>] [--base-url <url>] [--run-id <id>] [--personas <ids>] [--routes <ids>] [--viewports <ids>] [--headed]
|
||||
deno task web-ux compare --before <review-context.json> --after <review-context.json> --output <directory> [--threshold <0..1>]
|
||||
deno task web-ux cleanup --output <directory> [--keep <count>] [--older-than-days <days>] [--dry-run]
|
||||
|
||||
Comma-separate persona, route, and viewport ids. Auth state is local, mode 0600, and must not be committed.
|
||||
`;
|
||||
|
||||
type Arguments = { command: string; values: Map<string, string[]>; flags: Set<string> };
|
||||
|
||||
export function parseArguments(args: string[]): Arguments {
|
||||
const command = args.shift() ?? "help";
|
||||
const values = new Map<string, string[]>();
|
||||
const flags = new Set<string>();
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
const token = args[index];
|
||||
if (!token.startsWith("--")) throw new Error(`unexpected argument: ${token}`);
|
||||
const name = token.slice(2);
|
||||
const next = args[index + 1];
|
||||
if (next === undefined || next.startsWith("--")) {
|
||||
flags.add(name);
|
||||
} else {
|
||||
const items = values.get(name) ?? [];
|
||||
items.push(next);
|
||||
values.set(name, items);
|
||||
index++;
|
||||
}
|
||||
}
|
||||
return { command, values, flags };
|
||||
}
|
||||
|
||||
function optional(args: Arguments, name: string): string | undefined {
|
||||
const values = args.values.get(name);
|
||||
if (!values) return undefined;
|
||||
if (values.length !== 1) throw new Error(`--${name} must be specified once`);
|
||||
return values[0];
|
||||
}
|
||||
|
||||
function required(args: Arguments, name: string): string {
|
||||
const value = optional(args, name);
|
||||
if (!value) throw new Error(`--${name} is required`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(args: Arguments, name: string, fallback?: number): number | undefined {
|
||||
const value = optional(args, name);
|
||||
if (value === undefined) return fallback;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
throw new Error(`--${name} must be a non-negative integer`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function list(args: Arguments, name: string): string[] | undefined {
|
||||
const value = optional(args, name);
|
||||
return value?.split(",").map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function rejectUnknown(args: Arguments, allowedValues: string[], allowedFlags: string[]): void {
|
||||
for (const name of args.values.keys()) {
|
||||
if (!allowedValues.includes(name)) throw new Error(`unsupported option: --${name}`);
|
||||
}
|
||||
for (const name of args.flags) {
|
||||
if (!allowedFlags.includes(name)) throw new Error(`unsupported flag: --${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function main(rawArgs: string[]): Promise<number> {
|
||||
const args = parseArguments([...rawArgs]);
|
||||
if (args.command === "help" || args.flags.has("help")) {
|
||||
console.log(HELP);
|
||||
return 0;
|
||||
}
|
||||
if (args.command === "auth") {
|
||||
rejectUnknown(
|
||||
args,
|
||||
["scenario", "persona", "base-url", "import-state", "timeout-ms", "expires-in-hours"],
|
||||
["headless", "delete"],
|
||||
);
|
||||
const deleting = args.flags.has("delete");
|
||||
if (deleting && optional(args, "import-state")) {
|
||||
throw new Error("--delete cannot be combined with --import-state");
|
||||
}
|
||||
const path = await authenticate({
|
||||
scenarioPath: required(args, "scenario"),
|
||||
personaId: required(args, "persona"),
|
||||
baseUrl: optional(args, "base-url"),
|
||||
importState: optional(args, "import-state"),
|
||||
timeoutMs: integer(args, "timeout-ms"),
|
||||
expiresInHours: integer(args, "expires-in-hours"),
|
||||
delete: deleting,
|
||||
headless: args.flags.has("headless"),
|
||||
});
|
||||
console.log(`auth state ${deleting ? "deleted" : "saved"}: ${path}`);
|
||||
return 0;
|
||||
}
|
||||
if (args.command === "capture") {
|
||||
rejectUnknown(args, [
|
||||
"scenario",
|
||||
"output",
|
||||
"base-url",
|
||||
"run-id",
|
||||
"personas",
|
||||
"routes",
|
||||
"viewports",
|
||||
], ["headed"]);
|
||||
const outputDirectory = optional(args, "output") ?? DEFAULT_OUTPUT;
|
||||
const manifest = await capture({
|
||||
scenarioPath: required(args, "scenario"),
|
||||
outputDirectory,
|
||||
baseUrl: optional(args, "base-url"),
|
||||
runId: optional(args, "run-id"),
|
||||
personas: list(args, "personas"),
|
||||
routes: list(args, "routes"),
|
||||
viewports: list(args, "viewports"),
|
||||
headed: args.flags.has("headed"),
|
||||
});
|
||||
console.log(describeCapture(manifest, outputDirectory));
|
||||
return manifest.status === "completed" ? 0 : 2;
|
||||
}
|
||||
if (args.command === "compare") {
|
||||
rejectUnknown(args, ["before", "after", "output", "threshold"], []);
|
||||
const thresholdValue = optional(args, "threshold");
|
||||
const threshold = thresholdValue === undefined ? undefined : Number(thresholdValue);
|
||||
if (
|
||||
threshold !== undefined && (!Number.isFinite(threshold) || threshold < 0 || threshold > 1)
|
||||
) {
|
||||
throw new Error("--threshold must be between 0 and 1");
|
||||
}
|
||||
const report = await compare({
|
||||
before: required(args, "before"),
|
||||
after: required(args, "after"),
|
||||
outputDirectory: required(args, "output"),
|
||||
threshold,
|
||||
});
|
||||
console.log(`comparison saved: ${report}`);
|
||||
return 0;
|
||||
}
|
||||
if (args.command === "cleanup") {
|
||||
rejectUnknown(args, ["output", "keep", "older-than-days"], ["dry-run"]);
|
||||
const removed = await cleanup({
|
||||
outputDirectory: required(args, "output"),
|
||||
keep: integer(args, "keep", 5)!,
|
||||
olderThanDays: integer(args, "older-than-days"),
|
||||
dryRun: args.flags.has("dry-run"),
|
||||
});
|
||||
for (const path of removed) {
|
||||
console.log(`${args.flags.has("dry-run") ? "would remove" : "removed"}: ${path}`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
throw new Error(`unknown command: ${args.command}\n\n${HELP}`);
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
try {
|
||||
Deno.exit(await main(Deno.args));
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
Deno.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"lock": true,
|
||||
"imports": {
|
||||
"@std/assert": "jsr:@std/assert@1.0.19",
|
||||
"@std/path": "jsr:@std/path@1.1.4",
|
||||
"pixelmatch": "npm:pixelmatch@7.1.0",
|
||||
"playwright": "npm:playwright@1.59.1",
|
||||
"pngjs": "npm:pngjs@7.0.0"
|
||||
},
|
||||
"tasks": {
|
||||
"web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts",
|
||||
"check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts",
|
||||
"test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests",
|
||||
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts"
|
||||
},
|
||||
"fmt": {
|
||||
"lineWidth": 100
|
||||
}
|
||||
}
|
||||
Generated
+68
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"version": "5",
|
||||
"specifiers": {
|
||||
"jsr:@std/assert@1.0.19": "1.0.19",
|
||||
"jsr:@std/internal@^1.0.12": "1.0.14",
|
||||
"jsr:@std/path@1.1.4": "1.1.4",
|
||||
"npm:pixelmatch@7.1.0": "7.1.0",
|
||||
"npm:playwright@1.59.1": "1.59.1",
|
||||
"npm:pngjs@7.0.0": "7.0.0"
|
||||
},
|
||||
"jsr": {
|
||||
"@std/assert@1.0.19": {
|
||||
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
|
||||
"dependencies": [
|
||||
"jsr:@std/internal"
|
||||
]
|
||||
},
|
||||
"@std/internal@1.0.14": {
|
||||
"integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7"
|
||||
},
|
||||
"@std/path@1.1.4": {
|
||||
"integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5",
|
||||
"dependencies": [
|
||||
"jsr:@std/internal"
|
||||
]
|
||||
}
|
||||
},
|
||||
"npm": {
|
||||
"fsevents@2.3.2": {
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"os": ["darwin"],
|
||||
"scripts": true
|
||||
},
|
||||
"pixelmatch@7.1.0": {
|
||||
"integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==",
|
||||
"dependencies": [
|
||||
"pngjs"
|
||||
],
|
||||
"bin": true
|
||||
},
|
||||
"playwright-core@1.59.1": {
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"bin": true
|
||||
},
|
||||
"playwright@1.59.1": {
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"dependencies": [
|
||||
"playwright-core"
|
||||
],
|
||||
"optionalDependencies": [
|
||||
"fsevents"
|
||||
],
|
||||
"bin": true
|
||||
},
|
||||
"pngjs@7.0.0": {
|
||||
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"dependencies": [
|
||||
"jsr:@std/assert@1.0.19",
|
||||
"jsr:@std/path@1.1.4",
|
||||
"npm:pixelmatch@7.1.0",
|
||||
"npm:playwright@1.59.1",
|
||||
"npm:pngjs@7.0.0"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "anonymous-entry",
|
||||
"title": "Anonymous entry and authentication review",
|
||||
"baseUrl": "${WEB_UX_BASE_URL}",
|
||||
"locale": "en-US",
|
||||
"timezone": "UTC",
|
||||
"colorScheme": "light",
|
||||
"reducedMotion": "reduce",
|
||||
"personas": [
|
||||
{ "id": "anonymous", "label": "Anonymous visitor", "auth": { "kind": "anonymous" } }
|
||||
],
|
||||
"viewports": [
|
||||
{ "label": "desktop", "width": 1440, "height": 1000, "deviceScaleFactor": 1 },
|
||||
{ "label": "mobile", "width": 390, "height": 844, "deviceScaleFactor": 1 }
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"id": "entry",
|
||||
"label": "Authentication entry",
|
||||
"path": "/",
|
||||
"goal": "Understand the product and begin authentication without seeing Workspace-private content.",
|
||||
"dataState": "Fresh browser context with no cookies, local storage, or session state.",
|
||||
"ready": { "kind": "network-idle", "timeoutMs": 15000 },
|
||||
"capturePoints": [{ "id": "initial", "label": "Anonymous entry", "fullPage": true }]
|
||||
},
|
||||
{
|
||||
"id": "account",
|
||||
"label": "Account entry",
|
||||
"path": "/account",
|
||||
"goal": "Understand current authentication state and the available account action without Workspace-private content.",
|
||||
"dataState": "Fresh browser context with no cookies, local storage, or session state.",
|
||||
"ready": { "kind": "network-idle", "timeoutMs": 15000 },
|
||||
"capturePoints": [{ "id": "initial", "label": "Anonymous account screen", "fullPage": true }]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "workspace-control-plane",
|
||||
"title": "Workspace control-plane owner and non-owner review",
|
||||
"baseUrl": "${WEB_UX_BASE_URL}",
|
||||
"locale": "en-US",
|
||||
"timezone": "UTC",
|
||||
"colorScheme": "light",
|
||||
"reducedMotion": "reduce",
|
||||
"redact": {
|
||||
"selectors": ["[data-web-ux-redact]", "input[type=password]"],
|
||||
"text": []
|
||||
},
|
||||
"personas": [
|
||||
{
|
||||
"id": "owner",
|
||||
"label": "Workspace owner",
|
||||
"auth": { "kind": "storage-state", "path": "${XDG_STATE_HOME}/yoi/web-ux/auth/owner.json" },
|
||||
"login": { "path": "/", "successUrl": "/w/" }
|
||||
},
|
||||
{
|
||||
"id": "non-owner",
|
||||
"label": "Authenticated non-owner",
|
||||
"auth": {
|
||||
"kind": "storage-state",
|
||||
"path": "${XDG_STATE_HOME}/yoi/web-ux/auth/non-owner.json"
|
||||
},
|
||||
"login": { "path": "/", "successUrl": "/w/" }
|
||||
}
|
||||
],
|
||||
"viewports": [
|
||||
{ "label": "desktop", "width": 1440, "height": 1000, "deviceScaleFactor": 1 },
|
||||
{ "label": "narrow", "width": 900, "height": 900, "deviceScaleFactor": 1 }
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"id": "workspace-home",
|
||||
"label": "Workspace overview",
|
||||
"path": "/w/${WORKSPACE_ID}",
|
||||
"goal": "Orient the user and expose the highest-value Workspace actions without internal authority noise.",
|
||||
"dataState": "Dogfood Workspace with current Runtime and Ticket data.",
|
||||
"ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 },
|
||||
"capturePoints": [{ "id": "initial", "label": "Initial viewport", "fullPage": true }]
|
||||
},
|
||||
{
|
||||
"id": "tickets",
|
||||
"label": "Ticket lanes",
|
||||
"path": "/w/${WORKSPACE_ID}/tickets",
|
||||
"goal": "Scan actionable Ticket lanes and reach the primary authoring action in the initial viewport.",
|
||||
"dataState": "Planning, ready, queued, in-progress, and completed Tickets from the selected Workspace.",
|
||||
"ready": { "kind": "selector", "selector": "main", "timeoutMs": 30000 },
|
||||
"capturePoints": [{ "id": "initial", "label": "Loaded Ticket lanes", "fullPage": true }]
|
||||
},
|
||||
{
|
||||
"id": "workers",
|
||||
"label": "Workers",
|
||||
"path": "/w/${WORKSPACE_ID}/workers",
|
||||
"goal": "Find current Worker state and the new-Worker action without exposing transport internals as the primary content.",
|
||||
"dataState": "Current Workspace Worker projection with mixed lifecycle states.",
|
||||
"ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 },
|
||||
"capturePoints": [{ "id": "initial", "label": "Worker list", "fullPage": true }]
|
||||
},
|
||||
{
|
||||
"id": "settings",
|
||||
"label": "Workspace settings",
|
||||
"path": "/w/${WORKSPACE_ID}/settings",
|
||||
"goal": "Reach the relevant Workspace settings area without presenting owner-only destinations as usable actions to a non-owner.",
|
||||
"dataState": "Current permission projection for the selected Workspace.",
|
||||
"ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 },
|
||||
"capturePoints": [{ "id": "initial", "label": "Workspace settings", "fullPage": true }]
|
||||
},
|
||||
{
|
||||
"id": "repositories",
|
||||
"label": "Repository settings",
|
||||
"path": "/w/${WORKSPACE_ID}/settings/repositories",
|
||||
"goal": "Review repository access as an owner and verify non-owner composition does not expose unusable owner actions.",
|
||||
"dataState": "Workspace repository catalog projected through current permissions.",
|
||||
"ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 },
|
||||
"capturePoints": [{ "id": "initial", "label": "Repository settings", "fullPage": true }]
|
||||
},
|
||||
{
|
||||
"id": "repository-access",
|
||||
"label": "Repository access",
|
||||
"path": "/w/${WORKSPACE_ID}/settings/repository-access",
|
||||
"goal": "Review credential and host-trust bindings as an owner and verify non-owner composition fails closed without secret material.",
|
||||
"dataState": "Configured repository access bindings projected without credential bytes.",
|
||||
"ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 },
|
||||
"capturePoints": [
|
||||
{ "id": "initial", "label": "Repository access settings", "fullPage": true }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { dirname, relative, resolve } from "@std/path";
|
||||
|
||||
const SECRET_PATTERNS: RegExp[] = [
|
||||
/\b(authorization|cookie|set-cookie|x-csrf-token)\b\s*[:=]\s*[^\s,;]+/gi,
|
||||
/\b(bearer)\s+[a-z0-9._~+\/-]+=*/gi,
|
||||
/\b(session|token|credential|password|passkey|private[_ -]?key)\b\s*[:=]\s*["']?[^\s,"'};]+/gi,
|
||||
];
|
||||
|
||||
export function redactText(value: string, exactSecrets: string[] = []): string {
|
||||
let result = value;
|
||||
for (const secret of exactSecrets) {
|
||||
if (secret) result = result.replaceAll(secret, "[REDACTED]");
|
||||
}
|
||||
for (const pattern of SECRET_PATTERNS) result = result.replaceAll(pattern, "$1=[REDACTED]");
|
||||
return result;
|
||||
}
|
||||
|
||||
export function bounded(value: string, maximum = 1000): string {
|
||||
const normalized = value.replaceAll(/\s+/g, " ").trim();
|
||||
return normalized.length <= maximum ? normalized : `${normalized.slice(0, maximum - 1)}…`;
|
||||
}
|
||||
|
||||
export function safeUrl(value: string, baseUrl?: string): string {
|
||||
try {
|
||||
const url = new URL(value, baseUrl);
|
||||
url.username = "";
|
||||
url.password = "";
|
||||
for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, "[REDACTED]");
|
||||
url.hash = "";
|
||||
return url.toString();
|
||||
} catch {
|
||||
return "[invalid-url]";
|
||||
}
|
||||
}
|
||||
|
||||
export function assertBundleIsSecretFree(serialized: string, exactSecrets: string[] = []): void {
|
||||
const lower = serialized.toLowerCase();
|
||||
for (const forbidden of ["authorization:", "set-cookie:", "cookie:", "bearer "]) {
|
||||
if (lower.includes(forbidden)) {
|
||||
throw new Error(`review bundle contains forbidden secret marker: ${forbidden}`);
|
||||
}
|
||||
}
|
||||
for (const secret of exactSecrets) {
|
||||
if (secret && serialized.includes(secret)) {
|
||||
throw new Error("review bundle contains configured secret text");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensurePrivateDirectory(path: string): Promise<void> {
|
||||
await Deno.mkdir(path, { recursive: true, mode: 0o700 });
|
||||
if (Deno.build.os !== "windows") await Deno.chmod(path, 0o700);
|
||||
}
|
||||
|
||||
export async function makePrivate(path: string): Promise<void> {
|
||||
if (Deno.build.os !== "windows") await Deno.chmod(path, 0o600);
|
||||
}
|
||||
|
||||
export async function writePrivateJson(path: string, value: unknown): Promise<void> {
|
||||
await ensurePrivateDirectory(dirname(path));
|
||||
await Deno.writeTextFile(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
||||
if (Deno.build.os !== "windows") await Deno.chmod(path, 0o600);
|
||||
}
|
||||
|
||||
export function workdirLogicalPath(repositoryRoot: string, path: string): string | null {
|
||||
const absolute = resolve(path);
|
||||
const logical = relative(repositoryRoot, absolute);
|
||||
if (logical === "" || (!logical.startsWith("..") && !logical.startsWith("/"))) {
|
||||
return logical || ".";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const TEXT_ARTIFACT_EXTENSIONS = [".json", ".md", ".html", ".log", ".txt"];
|
||||
|
||||
async function artifactFiles(root: string): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
const visit = async (directory: string) => {
|
||||
for await (const entry of Deno.readDir(directory)) {
|
||||
const path = resolve(directory, entry.name);
|
||||
if (entry.isDirectory) await visit(path);
|
||||
else if (entry.isFile) files.push(path);
|
||||
}
|
||||
};
|
||||
await visit(root);
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function assertReviewBundleIsSecretFree(
|
||||
root: string,
|
||||
exactSecrets: string[] = [],
|
||||
): Promise<void> {
|
||||
const secrets = exactSecrets.filter(Boolean);
|
||||
const overlapLength = Math.max(256, ...secrets.map((secret) => secret.length + 1));
|
||||
for (const path of await artifactFiles(root)) {
|
||||
const isText = TEXT_ARTIFACT_EXTENSIONS.some((extension) => path.endsWith(extension));
|
||||
const file = await Deno.open(path, { read: true });
|
||||
const decoder = new TextDecoder();
|
||||
let overlap = "";
|
||||
try {
|
||||
const buffer = new Uint8Array(64 * 1024);
|
||||
while (true) {
|
||||
const count = await file.read(buffer);
|
||||
if (count === null) break;
|
||||
const content = overlap + decoder.decode(buffer.subarray(0, count), { stream: true });
|
||||
for (const secret of secrets) {
|
||||
if (content.includes(secret)) {
|
||||
throw new Error(
|
||||
`review bundle artifact contains configured secret text: ${relative(root, path)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (isText) assertBundleIsSecretFree(content, secrets);
|
||||
overlap = content.slice(-overlapLength);
|
||||
}
|
||||
const final = overlap + decoder.decode();
|
||||
for (const secret of secrets) {
|
||||
if (final.includes(secret)) {
|
||||
throw new Error(
|
||||
`review bundle artifact contains configured secret text: ${relative(root, path)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (isText) assertBundleIsSecretFree(final, secrets);
|
||||
} finally {
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function sha256File(path: string): Promise<string> {
|
||||
const bytes = await Deno.readFile(path);
|
||||
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
||||
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { writePrivateJson } from "./artifacts.ts";
|
||||
|
||||
export type AuthStateMetadata = {
|
||||
schemaVersion: 1;
|
||||
personaId: string;
|
||||
baseOrigin: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export function authMetadataPath(storageStatePath: string): string {
|
||||
return `${storageStatePath}.meta.json`;
|
||||
}
|
||||
|
||||
function baseOrigin(baseUrl: string): string {
|
||||
return new URL(baseUrl).origin;
|
||||
}
|
||||
|
||||
export async function writeAuthMetadata(
|
||||
storageStatePath: string,
|
||||
personaId: string,
|
||||
baseUrl: string,
|
||||
expiresInHours: number,
|
||||
): Promise<void> {
|
||||
if (!Number.isFinite(expiresInHours) || expiresInHours <= 0) {
|
||||
throw new Error("auth state expiry must be a positive number of hours");
|
||||
}
|
||||
const createdAt = new Date();
|
||||
const metadata: AuthStateMetadata = {
|
||||
schemaVersion: 1,
|
||||
personaId,
|
||||
baseOrigin: baseOrigin(baseUrl),
|
||||
createdAt: createdAt.toISOString(),
|
||||
expiresAt: new Date(createdAt.getTime() + expiresInHours * 60 * 60 * 1000).toISOString(),
|
||||
};
|
||||
await writePrivateJson(authMetadataPath(storageStatePath), metadata);
|
||||
}
|
||||
|
||||
export async function validateAuthState(
|
||||
storageStatePath: string,
|
||||
personaId: string,
|
||||
baseUrl: string,
|
||||
now = new Date(),
|
||||
): Promise<void> {
|
||||
await Deno.stat(storageStatePath);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(await Deno.readTextFile(authMetadataPath(storageStatePath)));
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`auth state metadata is missing or invalid for ${personaId}; run the auth command again: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error(`auth state metadata is invalid for ${personaId}`);
|
||||
}
|
||||
const metadata = parsed as Partial<AuthStateMetadata>;
|
||||
if (metadata.schemaVersion !== 1 || metadata.personaId !== personaId) {
|
||||
throw new Error(`auth state metadata does not match persona ${personaId}`);
|
||||
}
|
||||
if (metadata.baseOrigin !== baseOrigin(baseUrl)) {
|
||||
throw new Error(
|
||||
`auth state for ${personaId} belongs to ${metadata.baseOrigin ?? "an unknown origin"}, not ${
|
||||
baseOrigin(baseUrl)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
const expiresAt = Date.parse(metadata.expiresAt ?? "");
|
||||
if (!Number.isFinite(expiresAt)) throw new Error(`auth state expiry is invalid for ${personaId}`);
|
||||
if (expiresAt <= now.getTime()) {
|
||||
throw new Error(
|
||||
`auth state expired for ${personaId} at ${metadata.expiresAt}; run the auth command again`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAuthState(storageStatePath: string): Promise<void> {
|
||||
for (const path of [storageStatePath, authMetadataPath(storageStatePath)]) {
|
||||
try {
|
||||
await Deno.remove(path);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Deno.errors.NotFound)) throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
import { basename, dirname, join, relative, resolve } from "@std/path";
|
||||
import { type Browser, chromium, type Page, type Response } from "playwright";
|
||||
import { validateAuthState } from "./auth_state.ts";
|
||||
import {
|
||||
assertBundleIsSecretFree,
|
||||
assertReviewBundleIsSecretFree,
|
||||
bounded,
|
||||
ensurePrivateDirectory,
|
||||
makePrivate,
|
||||
redactText,
|
||||
safeUrl,
|
||||
sha256File,
|
||||
workdirLogicalPath,
|
||||
} from "./artifacts.ts";
|
||||
import { type RunningProcess, startOwnedProcesses, stopOwnedProcesses } from "./processes.ts";
|
||||
import {
|
||||
interpolateEnvironment,
|
||||
loadScenario,
|
||||
resolveScenarioPath,
|
||||
validateBaseUrl,
|
||||
} from "./scenario.ts";
|
||||
import type {
|
||||
CaptureError,
|
||||
CaptureEvidence,
|
||||
CapturePoint,
|
||||
DiagnosticSummary,
|
||||
Interaction,
|
||||
InteractionEvidence,
|
||||
Persona,
|
||||
ReadyCondition,
|
||||
ReviewContext,
|
||||
RouteScenario,
|
||||
Scenario,
|
||||
ScreenshotEvidence,
|
||||
Viewport,
|
||||
} from "./types.ts";
|
||||
|
||||
export type CaptureOptions = {
|
||||
scenarioPath: string;
|
||||
outputDirectory: string;
|
||||
baseUrl?: string;
|
||||
runId?: string;
|
||||
personas?: string[];
|
||||
routes?: string[];
|
||||
viewports?: string[];
|
||||
headed?: boolean;
|
||||
};
|
||||
|
||||
type SourceState = { revision: string | null; dirty: boolean | null };
|
||||
type ErrorCollector = { errors: CaptureError[]; observed: number; limit: number };
|
||||
|
||||
const CAPTURE_ERROR_LIMIT = 100;
|
||||
|
||||
function recordError(collector: ErrorCollector, error: CaptureError): void {
|
||||
collector.observed++;
|
||||
if (collector.errors.length < collector.limit) collector.errors.push(error);
|
||||
}
|
||||
|
||||
function errorSummary(collector: ErrorCollector): DiagnosticSummary {
|
||||
return {
|
||||
observed: collector.observed,
|
||||
retained: collector.errors.length,
|
||||
truncated: collector.observed > collector.errors.length,
|
||||
limit: collector.limit,
|
||||
};
|
||||
}
|
||||
|
||||
function interactionEvidence(interaction: Interaction): InteractionEvidence {
|
||||
if (interaction.action === "wait") return { action: "wait", ready: interaction.ready };
|
||||
if (interaction.action === "click") return { action: "click", selector: interaction.selector };
|
||||
if (interaction.action === "fill") {
|
||||
return { action: "fill", selector: interaction.selector, value: "[REDACTED]" };
|
||||
}
|
||||
return { action: "press", selector: interaction.selector, key: interaction.key };
|
||||
}
|
||||
|
||||
function slug(value: string): string {
|
||||
return value.replaceAll(/[^a-zA-Z0-9.-]+/g, "-").replaceAll(/^-+|-+$/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
function viewportId(viewport: Viewport): string {
|
||||
return viewport.label ?? `${viewport.width}x${viewport.height}`;
|
||||
}
|
||||
|
||||
function timestampId(): string {
|
||||
return new Date().toISOString().replaceAll(/[:.]/g, "-");
|
||||
}
|
||||
|
||||
async function sourceState(): Promise<SourceState> {
|
||||
try {
|
||||
const [revision, status] = await Promise.all([
|
||||
new Deno.Command("git", { args: ["rev-parse", "HEAD"], stdout: "piped", stderr: "null" })
|
||||
.output(),
|
||||
new Deno.Command("git", { args: ["status", "--porcelain"], stdout: "piped", stderr: "null" })
|
||||
.output(),
|
||||
]);
|
||||
return {
|
||||
revision: revision.success ? new TextDecoder().decode(revision.stdout).trim() : null,
|
||||
dirty: status.success ? new TextDecoder().decode(status.stdout).trim().length > 0 : null,
|
||||
};
|
||||
} catch {
|
||||
return { revision: null, dirty: null };
|
||||
}
|
||||
}
|
||||
|
||||
async function repositoryRoot(): Promise<string> {
|
||||
try {
|
||||
const result = await new Deno.Command("git", {
|
||||
args: ["rev-parse", "--show-toplevel"],
|
||||
stdout: "piped",
|
||||
stderr: "null",
|
||||
}).output();
|
||||
if (result.success) return resolve(new TextDecoder().decode(result.stdout).trim());
|
||||
} catch {
|
||||
// Fall back to the invocation directory outside a Git checkout.
|
||||
}
|
||||
return resolve(Deno.cwd());
|
||||
}
|
||||
|
||||
function selectById<T extends { id: string }>(
|
||||
values: T[],
|
||||
requested: string[] | undefined,
|
||||
kind: string,
|
||||
): T[] {
|
||||
if (!requested || requested.length === 0) return values;
|
||||
const requestedSet = new Set(requested);
|
||||
const selected = values.filter((value) => requestedSet.has(value.id));
|
||||
const missing = [...requestedSet].filter((id) => !selected.some((value) => value.id === id));
|
||||
if (missing.length > 0) throw new Error(`unknown ${kind}: ${missing.join(", ")}`);
|
||||
return selected;
|
||||
}
|
||||
|
||||
function selectViewports(values: Viewport[], requested: string[] | undefined): Viewport[] {
|
||||
if (!requested || requested.length === 0) return values;
|
||||
const requestedSet = new Set(requested);
|
||||
const selected = values.filter((value) => requestedSet.has(viewportId(value)));
|
||||
const missing = [...requestedSet].filter((id) =>
|
||||
!selected.some((value) => viewportId(value) === id)
|
||||
);
|
||||
if (missing.length > 0) throw new Error(`unknown viewports: ${missing.join(", ")}`);
|
||||
return selected;
|
||||
}
|
||||
|
||||
function responseMatches(
|
||||
response: Response,
|
||||
ready: Extract<ReadyCondition, { kind: "response" }>,
|
||||
): boolean {
|
||||
const pattern = new RegExp(ready.urlPattern);
|
||||
return pattern.test(response.url()) &&
|
||||
(ready.status === undefined || response.status() === ready.status);
|
||||
}
|
||||
|
||||
async function waitReady(
|
||||
page: Page,
|
||||
ready: ReadyCondition,
|
||||
navigation?: Response | null,
|
||||
): Promise<void> {
|
||||
const timeout = ready.timeoutMs ?? 15_000;
|
||||
if (ready.kind === "selector") {
|
||||
await page.locator(ready.selector).first().waitFor({ state: "visible", timeout });
|
||||
return;
|
||||
}
|
||||
if (ready.kind === "network-idle") {
|
||||
await page.waitForLoadState("networkidle", { timeout });
|
||||
return;
|
||||
}
|
||||
if (navigation && responseMatches(navigation, ready)) return;
|
||||
await page.waitForResponse((response) => responseMatches(response, ready), { timeout });
|
||||
}
|
||||
|
||||
async function performInteraction(page: Page, interaction: Interaction): Promise<void> {
|
||||
if (interaction.action === "wait") return await waitReady(page, interaction.ready);
|
||||
const locator = page.locator(interaction.selector).first();
|
||||
const timeout = interaction.timeoutMs ?? 10_000;
|
||||
if (interaction.action === "click") return await locator.click({ timeout });
|
||||
if (interaction.action === "fill") {
|
||||
return await locator.fill(interpolateEnvironment(interaction.value), { timeout });
|
||||
}
|
||||
await locator.press(interaction.key, { timeout });
|
||||
}
|
||||
|
||||
async function retry<T>(label: string, operation: () => Promise<T>): Promise<T> {
|
||||
let last: unknown;
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
last = error;
|
||||
if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`${label} failed after 2 attempts: ${last instanceof Error ? last.message : String(last)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function hideRedactedSelectors(page: Page, selectors: string[]): Promise<void> {
|
||||
if (selectors.length === 0) return;
|
||||
const escaped = selectors.join(",\n");
|
||||
await page.addStyleTag({ content: `${escaped} { visibility: hidden !important; }` });
|
||||
}
|
||||
|
||||
export function isVisibleUiErrorText(content: string): boolean {
|
||||
return /\b(error|failed|unauthorized|forbidden|not found)\b/i.test(content);
|
||||
}
|
||||
|
||||
async function collectVisibleUiErrors(
|
||||
page: Page,
|
||||
collector: ErrorCollector,
|
||||
secrets: string[],
|
||||
): Promise<void> {
|
||||
const alerts = page.locator('[role="alert"], [aria-live="assertive"]');
|
||||
for (let index = 0; index < await alerts.count(); index++) {
|
||||
const alert = alerts.nth(index);
|
||||
if (!await alert.isVisible().catch(() => false)) continue;
|
||||
const content = (await alert.innerText().catch(() => "")).trim();
|
||||
if (!isVisibleUiErrorText(content)) continue;
|
||||
const message = `visible UI error: ${bounded(redactText(content, secrets), 500)}`;
|
||||
if (
|
||||
!collector.errors.some((error) => error.kind === "document" && error.message === message)
|
||||
) {
|
||||
recordError(collector, { kind: "document", message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function capturePoint(
|
||||
page: Page,
|
||||
runDirectory: string,
|
||||
repositoryRoot: string,
|
||||
persona: Persona,
|
||||
route: RouteScenario,
|
||||
viewport: Viewport,
|
||||
point: CapturePoint,
|
||||
documentResponse: Response | null,
|
||||
collector: ErrorCollector,
|
||||
executedInteractions: InteractionEvidence[],
|
||||
scenario: Scenario,
|
||||
): Promise<CaptureEvidence> {
|
||||
const startedAt = new Date().toISOString();
|
||||
for (const interaction of point.interaction ?? []) {
|
||||
await performInteraction(page, interaction);
|
||||
executedInteractions.push(interactionEvidence(interaction));
|
||||
}
|
||||
if (point.ready) await waitReady(page, point.ready);
|
||||
await hideRedactedSelectors(page, scenario.redact?.selectors ?? []);
|
||||
await collectVisibleUiErrors(page, collector, scenario.redact?.text ?? []);
|
||||
const directory = join(
|
||||
runDirectory,
|
||||
"captures",
|
||||
persona.id,
|
||||
route.id,
|
||||
viewportId(viewport),
|
||||
point.id,
|
||||
);
|
||||
await ensurePrivateDirectory(directory);
|
||||
const viewportScreenshot = join(directory, "viewport.png");
|
||||
await page.screenshot({ path: viewportScreenshot, fullPage: false, animations: "disabled" });
|
||||
await makePrivate(viewportScreenshot);
|
||||
const screenshots: ScreenshotEvidence[] = [{
|
||||
kind: "viewport",
|
||||
bundlePath: relative(runDirectory, viewportScreenshot),
|
||||
workdirPath: workdirLogicalPath(repositoryRoot, viewportScreenshot),
|
||||
sha256: await sha256File(viewportScreenshot),
|
||||
}];
|
||||
if (point.fullPage) {
|
||||
const fullPageScreenshot = join(directory, "full-page.png");
|
||||
await page.screenshot({ path: fullPageScreenshot, fullPage: true, animations: "disabled" });
|
||||
await makePrivate(fullPageScreenshot);
|
||||
screenshots.push({
|
||||
kind: "full-page",
|
||||
bundlePath: relative(runDirectory, fullPageScreenshot),
|
||||
workdirPath: workdirLogicalPath(repositoryRoot, fullPageScreenshot),
|
||||
sha256: await sha256File(fullPageScreenshot),
|
||||
});
|
||||
}
|
||||
let snapshot: { bundlePath: string; workdirPath: string | null } | null = null;
|
||||
try {
|
||||
const accessibility = await page.locator("body").ariaSnapshot({ timeout: 5_000 });
|
||||
const redacted = redactText(accessibility, scenario.redact?.text ?? []);
|
||||
const target = join(directory, "accessibility.md");
|
||||
await Deno.writeTextFile(target, redacted, { mode: 0o600 });
|
||||
snapshot = {
|
||||
bundlePath: relative(runDirectory, target),
|
||||
workdirPath: workdirLogicalPath(repositoryRoot, target),
|
||||
};
|
||||
} catch (error) {
|
||||
recordError(collector, {
|
||||
kind: "tool",
|
||||
message: `accessibility snapshot failed: ${
|
||||
bounded(error instanceof Error ? error.message : String(error))
|
||||
}`,
|
||||
});
|
||||
}
|
||||
return {
|
||||
persona: { id: persona.id, label: persona.label },
|
||||
route: {
|
||||
id: route.id,
|
||||
path: route.path,
|
||||
goal: route.goal,
|
||||
dataState: route.dataState,
|
||||
ready: route.ready,
|
||||
},
|
||||
viewport,
|
||||
theme: scenario.colorScheme ?? "light",
|
||||
capturePoint: { id: point.id, label: point.label, ready: point.ready ?? null },
|
||||
interactions: [...executedInteractions],
|
||||
document: { url: safeUrl(page.url()), status: documentResponse?.status() ?? null },
|
||||
screenshots,
|
||||
snapshot,
|
||||
errors: [...collector.errors],
|
||||
errorSummary: errorSummary(collector),
|
||||
startedAt,
|
||||
finishedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function screenshotDataUrl(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return `data:image/png;base64,${btoa(binary)}`;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll(
|
||||
'"',
|
||||
""",
|
||||
);
|
||||
}
|
||||
|
||||
async function createContactSheet(
|
||||
browser: Browser,
|
||||
runDirectory: string,
|
||||
repositoryRoot: string,
|
||||
captures: CaptureEvidence[],
|
||||
): Promise<{
|
||||
html: { bundlePath: string; workdirPath: string | null } | null;
|
||||
png: { bundlePath: string; workdirPath: string | null } | null;
|
||||
}> {
|
||||
const cells: string[] = [];
|
||||
for (const capture of captures) {
|
||||
const screenshot = capture.screenshots.find((item) => item.kind === "viewport") ??
|
||||
capture.screenshots[0];
|
||||
if (!screenshot) continue;
|
||||
const bytes = await Deno.readFile(join(runDirectory, screenshot.bundlePath));
|
||||
cells.push(
|
||||
`<figure><img src="${screenshotDataUrl(bytes)}"><figcaption><strong>${
|
||||
escapeHtml(capture.persona.label)
|
||||
} · ${escapeHtml(capture.route.id)}</strong><br>${
|
||||
escapeHtml(viewportId(capture.viewport))
|
||||
} · ${escapeHtml(capture.capturePoint.label)}<br><small>${
|
||||
escapeHtml(capture.route.dataState)
|
||||
}</small>${
|
||||
capture.errors.length > 0
|
||||
? `<br><strong class="errors">${capture.errors.length} captured error(s)</strong>`
|
||||
: ""
|
||||
}</figcaption></figure>`,
|
||||
);
|
||||
}
|
||||
if (cells.length === 0) return { html: null, png: null };
|
||||
const html =
|
||||
`<!doctype html><meta charset="utf-8"><title>Web UX review contact sheet</title><style>body{margin:0;padding:20px;background:#e8e8e8;color:#111;font:14px system-ui}main{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}figure{margin:0;background:white;border:1px solid #aaa;padding:10px;box-shadow:0 2px 8px #0002}img{width:100%;height:auto;display:block;border:1px solid #ddd}figcaption{padding-top:8px;line-height:1.45}small{color:#555}.errors{color:#b42318}</style><main>${
|
||||
cells.join("")
|
||||
}</main>`;
|
||||
const htmlPath = join(runDirectory, "contact-sheet.html");
|
||||
const pngPath = join(runDirectory, "contact-sheet.png");
|
||||
await Deno.writeTextFile(htmlPath, html, { mode: 0o600 });
|
||||
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
|
||||
try {
|
||||
await page.setContent(html, { waitUntil: "load" });
|
||||
await page.screenshot({ path: pngPath, fullPage: true, animations: "disabled" });
|
||||
await makePrivate(pngPath);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
return {
|
||||
html: {
|
||||
bundlePath: relative(runDirectory, htmlPath),
|
||||
workdirPath: workdirLogicalPath(repositoryRoot, htmlPath),
|
||||
},
|
||||
png: {
|
||||
bundlePath: relative(runDirectory, pngPath),
|
||||
workdirPath: workdirLogicalPath(repositoryRoot, pngPath),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function capture(options: CaptureOptions): Promise<ReviewContext> {
|
||||
const scenarioPath = resolve(options.scenarioPath);
|
||||
const repository = await repositoryRoot();
|
||||
const scenario = await loadScenario(scenarioPath);
|
||||
const baseUrl = validateBaseUrl(
|
||||
interpolateEnvironment(
|
||||
options.baseUrl ?? Deno.env.get("WEB_UX_BASE_URL") ?? scenario.baseUrl ?? "",
|
||||
),
|
||||
);
|
||||
const personas = selectById(scenario.personas, options.personas, "personas");
|
||||
const routes = selectById(scenario.routes, options.routes, "routes");
|
||||
const viewports = selectViewports(scenario.viewports, options.viewports);
|
||||
const runId = slug(options.runId ?? `${scenario.id}-${timestampId()}`);
|
||||
const runDirectory = resolve(options.outputDirectory, runId);
|
||||
try {
|
||||
await Deno.stat(runDirectory);
|
||||
throw new Error(`run directory already exists: ${runDirectory}`);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Deno.errors.NotFound)) throw error;
|
||||
}
|
||||
await ensurePrivateDirectory(runDirectory);
|
||||
const secrets = scenario.redact?.text ?? [];
|
||||
let browser: Browser | null = null;
|
||||
let processes: RunningProcess[] = [];
|
||||
const captures: CaptureEvidence[] = [];
|
||||
const globalCollector: ErrorCollector = {
|
||||
errors: [],
|
||||
observed: 0,
|
||||
limit: CAPTURE_ERROR_LIMIT,
|
||||
};
|
||||
const diagnostics = globalCollector.errors;
|
||||
let contactSheet: ReviewContext["contactSheet"] = { html: null, png: null };
|
||||
let browserVersion = "unknown";
|
||||
let status: ReviewContext["status"] = "completed";
|
||||
try {
|
||||
processes = await startOwnedProcesses(
|
||||
scenario.processes ?? [],
|
||||
scenarioPath,
|
||||
join(runDirectory, "process-logs"),
|
||||
secrets,
|
||||
);
|
||||
browser = await chromium.launch({ headless: !options.headed });
|
||||
browserVersion = browser.version();
|
||||
for (const persona of personas) {
|
||||
const storageState = persona.auth.kind === "storage-state"
|
||||
? resolveScenarioPath(scenarioPath, persona.auth.path)
|
||||
: undefined;
|
||||
if (storageState) await validateAuthState(storageState, persona.id, baseUrl);
|
||||
for (const viewport of viewports) {
|
||||
const context = await browser.newContext({
|
||||
storageState,
|
||||
viewport: { width: viewport.width, height: viewport.height },
|
||||
deviceScaleFactor: viewport.deviceScaleFactor ?? 1,
|
||||
locale: scenario.locale,
|
||||
timezoneId: scenario.timezone,
|
||||
colorScheme: scenario.colorScheme,
|
||||
reducedMotion: scenario.reducedMotion,
|
||||
});
|
||||
try {
|
||||
for (const route of routes) {
|
||||
const routeCollector: ErrorCollector = {
|
||||
errors: [],
|
||||
observed: 0,
|
||||
limit: CAPTURE_ERROR_LIMIT,
|
||||
};
|
||||
const routeErrors = routeCollector.errors;
|
||||
const executedInteractions: InteractionEvidence[] = [];
|
||||
const page = await context.newPage();
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
recordError(routeCollector, {
|
||||
kind: "console",
|
||||
message: bounded(redactText(message.text(), secrets)),
|
||||
});
|
||||
}
|
||||
});
|
||||
page.on(
|
||||
"pageerror",
|
||||
(error) =>
|
||||
recordError(routeCollector, {
|
||||
kind: "page",
|
||||
message: bounded(redactText(error.message, secrets)),
|
||||
}),
|
||||
);
|
||||
page.on(
|
||||
"requestfailed",
|
||||
(request) =>
|
||||
recordError(routeCollector, {
|
||||
kind: "request",
|
||||
message: bounded(
|
||||
redactText(request.failure()?.errorText ?? "request failed", secrets),
|
||||
),
|
||||
url: safeUrl(request.url()),
|
||||
}),
|
||||
);
|
||||
page.on("response", (response) => {
|
||||
if (response.status() >= 400) {
|
||||
recordError(routeCollector, {
|
||||
kind: "request",
|
||||
message: `HTTP ${response.status()}`,
|
||||
url: safeUrl(response.url()),
|
||||
status: response.status(),
|
||||
});
|
||||
}
|
||||
});
|
||||
try {
|
||||
const routePath = interpolateEnvironment(route.path);
|
||||
const targetUrl = new URL(routePath, `${baseUrl}/`).toString();
|
||||
const response = await retry(`navigate ${route.id}`, async () => {
|
||||
const ready = route.ready;
|
||||
const responseReady = ready.kind === "response"
|
||||
? page.waitForResponse(
|
||||
(candidate) => responseMatches(candidate, ready),
|
||||
{ timeout: ready.timeoutMs ?? 15_000 },
|
||||
)
|
||||
: null;
|
||||
try {
|
||||
const navigation = await page.goto(targetUrl, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 20_000,
|
||||
});
|
||||
if (responseReady) await responseReady;
|
||||
else await waitReady(page, ready, navigation);
|
||||
return navigation;
|
||||
} catch (error) {
|
||||
responseReady?.catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
if (response && response.status() >= 400) {
|
||||
recordError(routeCollector, {
|
||||
kind: "document",
|
||||
message: `document returned HTTP ${response.status()}`,
|
||||
url: safeUrl(response.url()),
|
||||
status: response.status(),
|
||||
});
|
||||
}
|
||||
for (const point of route.capturePoints) {
|
||||
captures.push(
|
||||
await capturePoint(
|
||||
page,
|
||||
runDirectory,
|
||||
repository,
|
||||
persona,
|
||||
route,
|
||||
viewport,
|
||||
point,
|
||||
response,
|
||||
routeCollector,
|
||||
executedInteractions,
|
||||
scenario,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
status = "completed-with-errors";
|
||||
recordError(routeCollector, {
|
||||
kind: "tool",
|
||||
message: bounded(
|
||||
redactText(error instanceof Error ? error.message : String(error), secrets),
|
||||
),
|
||||
});
|
||||
captures.push({
|
||||
persona: { id: persona.id, label: persona.label },
|
||||
route: {
|
||||
id: route.id,
|
||||
path: route.path,
|
||||
goal: route.goal,
|
||||
dataState: route.dataState,
|
||||
ready: route.ready,
|
||||
},
|
||||
viewport,
|
||||
theme: scenario.colorScheme ?? "light",
|
||||
capturePoint: { id: "failed", label: "Capture failed", ready: null },
|
||||
interactions: [...executedInteractions],
|
||||
document: { url: safeUrl(page.url()), status: null },
|
||||
screenshots: [],
|
||||
snapshot: null,
|
||||
errors: [...routeErrors],
|
||||
errorSummary: errorSummary(routeCollector),
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: new Date().toISOString(),
|
||||
});
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
contactSheet = await createContactSheet(browser, runDirectory, repository, captures);
|
||||
if (captures.some((capture) => capture.errors.length > 0)) status = "completed-with-errors";
|
||||
} catch (error) {
|
||||
status = "failed";
|
||||
recordError(globalCollector, {
|
||||
kind: "tool",
|
||||
message: bounded(redactText(error instanceof Error ? error.message : String(error), secrets)),
|
||||
});
|
||||
} finally {
|
||||
if (browser) {
|
||||
await browser.close().catch((error) =>
|
||||
recordError(globalCollector, {
|
||||
kind: "tool",
|
||||
message: `browser cleanup failed: ${bounded(String(error))}`,
|
||||
})
|
||||
);
|
||||
}
|
||||
for (const error of await stopOwnedProcesses(processes)) recordError(globalCollector, error);
|
||||
}
|
||||
if (diagnostics.length > 0 && status === "completed") status = "completed-with-errors";
|
||||
const manifest: ReviewContext = {
|
||||
schemaVersion: 1,
|
||||
runId,
|
||||
scenario: {
|
||||
id: scenario.id,
|
||||
title: scenario.title,
|
||||
sourcePath: workdirLogicalPath(repository, scenarioPath),
|
||||
},
|
||||
source: await sourceState(),
|
||||
baseUrl: safeUrl(baseUrl),
|
||||
browser: { name: "chromium", version: browserVersion },
|
||||
createdAt: new Date().toISOString(),
|
||||
status,
|
||||
filters: {
|
||||
personas: personas.map((item) => item.id),
|
||||
routes: routes.map((item) => item.id),
|
||||
viewports: viewports.map(viewportId),
|
||||
},
|
||||
captures,
|
||||
contactSheet,
|
||||
diagnostics,
|
||||
diagnosticSummary: errorSummary(globalCollector),
|
||||
};
|
||||
const serialized = `${JSON.stringify(manifest, null, 2)}\n`;
|
||||
assertBundleIsSecretFree(serialized, secrets);
|
||||
await Deno.writeTextFile(join(runDirectory, "review-context.json"), serialized, { mode: 0o600 });
|
||||
await assertReviewBundleIsSecretFree(runDirectory, secrets);
|
||||
if (status === "failed") {
|
||||
throw new Error(`capture failed; inspect ${join(runDirectory, "review-context.json")}`);
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export function describeCapture(manifest: ReviewContext, outputDirectory: string): string {
|
||||
return `${manifest.status}: ${manifest.captures.length} capture(s); ${
|
||||
join(outputDirectory, manifest.runId, "review-context.json")
|
||||
}`;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { basename, dirname, join, relative, resolve } from "@std/path";
|
||||
import { chromium } from "playwright";
|
||||
import pixelmatch from "pixelmatch";
|
||||
import { PNG } from "pngjs";
|
||||
import { ensurePrivateDirectory, makePrivate, sha256File } from "./artifacts.ts";
|
||||
import type { CaptureEvidence, ReviewContext } from "./types.ts";
|
||||
|
||||
export type CompareOptions = {
|
||||
before: string;
|
||||
after: string;
|
||||
outputDirectory: string;
|
||||
threshold?: number;
|
||||
};
|
||||
|
||||
type Pair = {
|
||||
key: string;
|
||||
before: CaptureEvidence;
|
||||
after: CaptureEvidence;
|
||||
beforePath: string;
|
||||
afterPath: string;
|
||||
diffPath: string;
|
||||
changedPixels: number;
|
||||
totalPixels: number;
|
||||
dimensionMismatch: boolean;
|
||||
};
|
||||
|
||||
function key(capture: CaptureEvidence): string {
|
||||
const viewport = capture.viewport.label ?? `${capture.viewport.width}x${capture.viewport.height}`;
|
||||
return [capture.persona.id, capture.route.id, viewport, capture.capturePoint.id].join("/");
|
||||
}
|
||||
|
||||
async function readManifest(path: string): Promise<ReviewContext> {
|
||||
const parsed = JSON.parse(await Deno.readTextFile(path));
|
||||
if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.captures)) {
|
||||
throw new Error(`not a web-ux review context: ${path}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function viewportScreenshot(capture: CaptureEvidence): string | null {
|
||||
return capture.screenshots.find((item) => item.kind === "viewport")?.bundlePath ??
|
||||
capture.screenshots[0]?.bundlePath ?? null;
|
||||
}
|
||||
|
||||
function dataUrl(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return `data:image/png;base64,${btoa(binary)}`;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll(
|
||||
'"',
|
||||
""",
|
||||
);
|
||||
}
|
||||
|
||||
export async function compare(options: CompareOptions): Promise<string> {
|
||||
const beforeManifestPath = resolve(options.before);
|
||||
const afterManifestPath = resolve(options.after);
|
||||
const [before, after] = await Promise.all([
|
||||
readManifest(beforeManifestPath),
|
||||
readManifest(afterManifestPath),
|
||||
]);
|
||||
const outputDirectory = resolve(options.outputDirectory);
|
||||
await ensurePrivateDirectory(outputDirectory);
|
||||
const afterByKey = new Map(after.captures.map((capture) => [key(capture), capture]));
|
||||
const pairs: Pair[] = [];
|
||||
const unmatchedBefore: string[] = [];
|
||||
for (const earlier of before.captures) {
|
||||
const later = afterByKey.get(key(earlier));
|
||||
const earlierScreenshot = viewportScreenshot(earlier);
|
||||
const laterScreenshot = later ? viewportScreenshot(later) : null;
|
||||
if (!later || !earlierScreenshot || !laterScreenshot) {
|
||||
unmatchedBefore.push(key(earlier));
|
||||
continue;
|
||||
}
|
||||
afterByKey.delete(key(earlier));
|
||||
const beforePath = resolve(dirname(beforeManifestPath), earlierScreenshot);
|
||||
const afterPath = resolve(dirname(afterManifestPath), laterScreenshot);
|
||||
const [beforeImage, afterImage] = [
|
||||
PNG.sync.read(Buffer.from(Deno.readFileSync(beforePath))),
|
||||
PNG.sync.read(Buffer.from(Deno.readFileSync(afterPath))),
|
||||
];
|
||||
const dimensionMismatch = beforeImage.width !== afterImage.width ||
|
||||
beforeImage.height !== afterImage.height;
|
||||
const width = Math.max(beforeImage.width, afterImage.width);
|
||||
const height = Math.max(beforeImage.height, afterImage.height);
|
||||
const diff = new PNG({ width, height, fill: true });
|
||||
let changedPixels = width * height;
|
||||
if (!dimensionMismatch) {
|
||||
changedPixels = pixelmatch(beforeImage.data, afterImage.data, diff.data, width, height, {
|
||||
threshold: options.threshold ?? 0.1,
|
||||
includeAA: false,
|
||||
});
|
||||
}
|
||||
const diffPath = join(outputDirectory, "diffs", `${key(earlier).replaceAll("/", "--")}.png`);
|
||||
await ensurePrivateDirectory(dirname(diffPath));
|
||||
await Deno.writeFile(diffPath, PNG.sync.write(diff), { mode: 0o600 });
|
||||
await makePrivate(diffPath);
|
||||
pairs.push({
|
||||
key: key(earlier),
|
||||
before: earlier,
|
||||
after: later,
|
||||
beforePath,
|
||||
afterPath,
|
||||
diffPath,
|
||||
changedPixels,
|
||||
totalPixels: width * height,
|
||||
dimensionMismatch,
|
||||
});
|
||||
}
|
||||
const cells: string[] = [];
|
||||
for (const pair of pairs) {
|
||||
const [beforeBytes, afterBytes, diffBytes] = await Promise.all([
|
||||
Deno.readFile(pair.beforePath),
|
||||
Deno.readFile(pair.afterPath),
|
||||
Deno.readFile(pair.diffPath),
|
||||
]);
|
||||
cells.push(
|
||||
`<section><h2>${escapeHtml(pair.key)}</h2><p>${
|
||||
pair.dimensionMismatch
|
||||
? "dimension mismatch"
|
||||
: `${pair.changedPixels} / ${pair.totalPixels} pixels changed`
|
||||
}</p><div class="row"><figure><img src="${
|
||||
dataUrl(beforeBytes)
|
||||
}"><figcaption>before</figcaption></figure><figure><img src="${
|
||||
dataUrl(afterBytes)
|
||||
}"><figcaption>after</figcaption></figure><figure><img src="${
|
||||
dataUrl(diffBytes)
|
||||
}"><figcaption>diff</figcaption></figure></div></section>`,
|
||||
);
|
||||
}
|
||||
const html =
|
||||
`<!doctype html><meta charset="utf-8"><title>Web UX comparison</title><style>body{margin:0;padding:20px;background:#e8e8e8;color:#111;font:14px system-ui}section{background:white;border:1px solid #aaa;margin:0 0 24px;padding:12px}.row{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}figure{margin:0}img{width:100%;height:auto;border:1px solid #ddd}figcaption{text-align:center;padding:6px}h2{font-size:16px;margin:0}p{color:#555}</style>${
|
||||
cells.join("")
|
||||
}`;
|
||||
const htmlPath = join(outputDirectory, "comparison.html");
|
||||
const pngPath = join(outputDirectory, "comparison.png");
|
||||
await Deno.writeTextFile(htmlPath, html, { mode: 0o600 });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1800, height: 1000 } });
|
||||
await page.setContent(html, { waitUntil: "load" });
|
||||
await page.screenshot({ path: pngPath, fullPage: true, animations: "disabled" });
|
||||
await makePrivate(pngPath);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
before: beforeManifestPath,
|
||||
after: afterManifestPath,
|
||||
createdAt: new Date().toISOString(),
|
||||
threshold: options.threshold ?? 0.1,
|
||||
pairs: await Promise.all(pairs.map(async (pair) => ({
|
||||
key: pair.key,
|
||||
changedPixels: pair.changedPixels,
|
||||
totalPixels: pair.totalPixels,
|
||||
changedRatio: pair.totalPixels === 0 ? 0 : pair.changedPixels / pair.totalPixels,
|
||||
dimensionMismatch: pair.dimensionMismatch,
|
||||
diff: relative(outputDirectory, pair.diffPath),
|
||||
diffSha256: await sha256File(pair.diffPath),
|
||||
}))),
|
||||
unmatchedBefore,
|
||||
unmatchedAfter: [...afterByKey.keys()],
|
||||
contactSheet: { html: basename(htmlPath), png: basename(pngPath) },
|
||||
};
|
||||
const reportPath = join(outputDirectory, "comparison.json");
|
||||
await Deno.writeTextFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
|
||||
return reportPath;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { dirname, resolve } from "@std/path";
|
||||
import { chromium } from "playwright";
|
||||
import { ensurePrivateDirectory, makePrivate, writePrivateJson } from "./artifacts.ts";
|
||||
import { deleteAuthState, writeAuthMetadata } from "./auth_state.ts";
|
||||
import {
|
||||
interpolateEnvironment,
|
||||
loadScenario,
|
||||
resolveScenarioPath,
|
||||
validateBaseUrl,
|
||||
} from "./scenario.ts";
|
||||
|
||||
export type AuthOptions = {
|
||||
scenarioPath: string;
|
||||
personaId: string;
|
||||
baseUrl?: string;
|
||||
importState?: string;
|
||||
timeoutMs?: number;
|
||||
expiresInHours?: number;
|
||||
delete?: boolean;
|
||||
headless?: boolean;
|
||||
};
|
||||
|
||||
function validateStorageState(value: unknown): { cookies: unknown[]; origins: unknown[] } {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new Error("storage state must be an object");
|
||||
}
|
||||
const source = value as Record<string, unknown>;
|
||||
if (!Array.isArray(source.cookies) || !Array.isArray(source.origins)) {
|
||||
throw new Error("storage state must contain cookies and origins arrays");
|
||||
}
|
||||
return { cookies: source.cookies, origins: source.origins };
|
||||
}
|
||||
|
||||
export async function authenticate(options: AuthOptions): Promise<string> {
|
||||
const scenarioPath = resolve(options.scenarioPath);
|
||||
const scenario = await loadScenario(scenarioPath);
|
||||
const persona = scenario.personas.find((candidate) => candidate.id === options.personaId);
|
||||
if (!persona) throw new Error(`unknown persona: ${options.personaId}`);
|
||||
if (persona.auth.kind !== "storage-state") {
|
||||
throw new Error(`persona ${persona.id} is anonymous and has no auth state`);
|
||||
}
|
||||
const outputPath = resolveScenarioPath(scenarioPath, persona.auth.path);
|
||||
if (options.delete) {
|
||||
await deleteAuthState(outputPath);
|
||||
return outputPath;
|
||||
}
|
||||
await ensurePrivateDirectory(dirname(outputPath));
|
||||
const baseUrl = validateBaseUrl(
|
||||
interpolateEnvironment(
|
||||
options.baseUrl ?? Deno.env.get("WEB_UX_BASE_URL") ?? scenario.baseUrl ?? "",
|
||||
),
|
||||
);
|
||||
const expiresInHours = options.expiresInHours ?? 12;
|
||||
if (options.importState) {
|
||||
const imported = validateStorageState(
|
||||
JSON.parse(await Deno.readTextFile(resolve(options.importState))),
|
||||
);
|
||||
await writePrivateJson(outputPath, imported);
|
||||
await writeAuthMetadata(outputPath, persona.id, baseUrl, expiresInHours);
|
||||
return outputPath;
|
||||
}
|
||||
if (!persona.login) {
|
||||
throw new Error(`persona ${persona.id} needs login configuration or --import-state`);
|
||||
}
|
||||
const browser = await chromium.launch({ headless: options.headless ?? false });
|
||||
try {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const loginUrl = new URL(interpolateEnvironment(persona.login.path ?? "/"), `${baseUrl}/`)
|
||||
.toString();
|
||||
await page.goto(loginUrl, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
||||
const success = new RegExp(interpolateEnvironment(persona.login.successUrl));
|
||||
if (!success.test(page.url())) {
|
||||
await page.waitForURL((url) => success.test(url.toString()), {
|
||||
timeout: options.timeoutMs ?? 300_000,
|
||||
});
|
||||
}
|
||||
await context.storageState({ path: outputPath });
|
||||
await makePrivate(outputPath);
|
||||
await writeAuthMetadata(outputPath, persona.id, baseUrl, expiresInHours);
|
||||
return outputPath;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
export type CleanupOptions = {
|
||||
outputDirectory: string;
|
||||
keep: number;
|
||||
olderThanDays?: number;
|
||||
dryRun?: boolean;
|
||||
};
|
||||
|
||||
export async function cleanup(options: CleanupOptions): Promise<string[]> {
|
||||
const outputDirectory = resolve(options.outputDirectory);
|
||||
const candidates: { path: string; modified: number }[] = [];
|
||||
try {
|
||||
for await (const entry of Deno.readDir(outputDirectory)) {
|
||||
if (!entry.isDirectory) continue;
|
||||
const path = resolve(outputDirectory, entry.name);
|
||||
try {
|
||||
await Deno.stat(resolve(path, "review-context.json"));
|
||||
const stat = await Deno.stat(path);
|
||||
candidates.push({ path, modified: stat.mtime?.getTime() ?? 0 });
|
||||
} catch {
|
||||
// Only complete review bundle directories are owned by cleanup.
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Deno.errors.NotFound) return [];
|
||||
throw error;
|
||||
}
|
||||
candidates.sort((left, right) => right.modified - left.modified);
|
||||
const cutoff = options.olderThanDays === undefined
|
||||
? Number.POSITIVE_INFINITY
|
||||
: Date.now() - options.olderThanDays * 24 * 60 * 60 * 1000;
|
||||
const removed: string[] = [];
|
||||
for (const [index, candidate] of candidates.entries()) {
|
||||
if (index < options.keep || candidate.modified > cutoff) continue;
|
||||
removed.push(candidate.path);
|
||||
if (!options.dryRun) await Deno.remove(candidate.path, { recursive: true });
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { dirname, isAbsolute, resolve } from "@std/path";
|
||||
import { bounded, redactText, writePrivateJson } from "./artifacts.ts";
|
||||
import type { CaptureError, OwnedProcess } from "./types.ts";
|
||||
|
||||
export const PROCESS_LOG_BYTE_LIMIT = 1024 * 1024;
|
||||
const PROCESS_STOP_TIMEOUT_MS = 3_000;
|
||||
|
||||
export type RunningProcess = {
|
||||
id: string;
|
||||
pid: number;
|
||||
child: Deno.ChildProcess;
|
||||
status: Promise<Deno.CommandStatus>;
|
||||
output: Promise<void>;
|
||||
};
|
||||
|
||||
async function appendOutput(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
destination: string,
|
||||
secrets: string[],
|
||||
): Promise<void> {
|
||||
const file = await Deno.open(destination, {
|
||||
create: true,
|
||||
append: true,
|
||||
write: true,
|
||||
mode: 0o600,
|
||||
});
|
||||
const encoder = new TextEncoder();
|
||||
const overlapCharacters = Math.max(512, ...secrets.map((secret) => secret.length + 128));
|
||||
let pending = "";
|
||||
let bytesObserved = 0;
|
||||
let bytesWritten = 0;
|
||||
let truncated = false;
|
||||
const writeRedacted = async (value: string) => {
|
||||
const encoded = encoder.encode(redactText(value, secrets));
|
||||
const remaining = Math.max(0, PROCESS_LOG_BYTE_LIMIT - bytesWritten);
|
||||
if (encoded.length > remaining) truncated = true;
|
||||
if (remaining > 0) {
|
||||
const output = encoded.subarray(0, remaining);
|
||||
await file.write(output);
|
||||
bytesWritten += output.length;
|
||||
}
|
||||
};
|
||||
try {
|
||||
const reader = stream.pipeThrough(new TextDecoderStream()).getReader();
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
bytesObserved += encoder.encode(value).length;
|
||||
pending += value;
|
||||
if (pending.length > overlapCharacters * 2) {
|
||||
const splitAt = pending.length - overlapCharacters;
|
||||
await writeRedacted(pending.slice(0, splitAt));
|
||||
pending = pending.slice(splitAt);
|
||||
}
|
||||
}
|
||||
await writeRedacted(pending);
|
||||
} finally {
|
||||
file.close();
|
||||
await writePrivateJson(`${destination}.meta.json`, {
|
||||
schemaVersion: 1,
|
||||
byteLimit: PROCESS_LOG_BYTE_LIMIT,
|
||||
bytesObserved,
|
||||
bytesWritten,
|
||||
truncated,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForReady(url: string, timeoutMs: number): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError = "not attempted";
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(2_000) });
|
||||
const status = response.status;
|
||||
await response.body?.cancel();
|
||||
if (status < 500) return;
|
||||
lastError = `HTTP ${status}`;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
throw new Error(`process readiness timed out for ${url}: ${bounded(lastError, 200)}`);
|
||||
}
|
||||
|
||||
export async function startOwnedProcesses(
|
||||
specifications: OwnedProcess[],
|
||||
scenarioPath: string,
|
||||
logsDirectory: string,
|
||||
secrets: string[],
|
||||
): Promise<RunningProcess[]> {
|
||||
const running: RunningProcess[] = [];
|
||||
await Deno.mkdir(logsDirectory, { recursive: true });
|
||||
try {
|
||||
for (const specification of specifications) {
|
||||
const cwd = specification.cwd === undefined
|
||||
? dirname(resolve(scenarioPath))
|
||||
: isAbsolute(specification.cwd)
|
||||
? specification.cwd
|
||||
: resolve(dirname(scenarioPath), specification.cwd);
|
||||
const child = new Deno.Command(specification.command, {
|
||||
args: specification.args ?? [],
|
||||
cwd,
|
||||
env: specification.env,
|
||||
stdin: "null",
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
}).spawn();
|
||||
const stdout = appendOutput(
|
||||
child.stdout,
|
||||
`${logsDirectory}/${specification.id}.stdout.log`,
|
||||
secrets,
|
||||
);
|
||||
const stderr = appendOutput(
|
||||
child.stderr,
|
||||
`${logsDirectory}/${specification.id}.stderr.log`,
|
||||
secrets,
|
||||
);
|
||||
const status = child.status;
|
||||
const process = {
|
||||
id: specification.id,
|
||||
pid: child.pid,
|
||||
child,
|
||||
status,
|
||||
output: Promise.all([stdout, stderr]).then(() => undefined),
|
||||
};
|
||||
running.push(process);
|
||||
if (specification.readyUrl) {
|
||||
await Promise.race([
|
||||
waitForReady(specification.readyUrl, specification.readyTimeoutMs ?? 30_000),
|
||||
status.then((status) => {
|
||||
throw new Error(
|
||||
`owned process ${specification.id} exited before readiness: ${status.code}`,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
return running;
|
||||
} catch (error) {
|
||||
await stopOwnedProcesses(running);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function descendantPids(parentPid: number): Promise<number[]> {
|
||||
if (Deno.build.os === "windows") return [];
|
||||
try {
|
||||
const result = await new Deno.Command("ps", {
|
||||
args: ["-eo", "pid=,ppid="],
|
||||
stdout: "piped",
|
||||
stderr: "null",
|
||||
}).output();
|
||||
if (!result.success) return [];
|
||||
const rows = new TextDecoder().decode(result.stdout).trim().split("\n").map((line) =>
|
||||
line.trim().split(/\s+/).map(Number)
|
||||
);
|
||||
const descendants: number[] = [];
|
||||
const queue = [parentPid];
|
||||
while (queue.length > 0) {
|
||||
const parent = queue.shift()!;
|
||||
for (const [pid, ppid] of rows) {
|
||||
if (ppid === parent && !descendants.includes(pid)) {
|
||||
descendants.push(pid);
|
||||
queue.push(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
return descendants.reverse();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function tryKill(pid: number, signal: Deno.Signal): void {
|
||||
try {
|
||||
Deno.kill(pid, signal);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Deno.errors.NotFound)) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function livePids(pids: number[]): Promise<number[]> {
|
||||
if (Deno.build.os === "windows") return [];
|
||||
if (pids.length === 0) return [];
|
||||
try {
|
||||
const result = await new Deno.Command("ps", {
|
||||
args: ["-o", "pid=", "-p", pids.join(",")],
|
||||
stdout: "piped",
|
||||
stderr: "null",
|
||||
}).output();
|
||||
if (!result.success && result.code !== 1) return pids;
|
||||
const live = new Set(
|
||||
new TextDecoder().decode(result.stdout).trim().split(/\s+/).map(Number).filter(
|
||||
Number.isFinite,
|
||||
),
|
||||
);
|
||||
return pids.filter((pid) => live.has(pid));
|
||||
} catch {
|
||||
return pids;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForPidsToExit(pids: number[], timeoutMs: number): Promise<number[]> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let live = await livePids(pids);
|
||||
while (live.length > 0 && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
live = await livePids(live);
|
||||
}
|
||||
return live;
|
||||
}
|
||||
|
||||
export async function stopOwnedProcesses(processes: RunningProcess[]): Promise<CaptureError[]> {
|
||||
const diagnostics: CaptureError[] = [];
|
||||
for (const process of [...processes].reverse()) {
|
||||
try {
|
||||
const descendants = await descendantPids(process.pid);
|
||||
tryKill(process.pid, "SIGTERM");
|
||||
for (const pid of descendants) tryKill(pid, "SIGTERM");
|
||||
let timer: number | undefined;
|
||||
const [parentExited, liveDescendants] = await Promise.all([
|
||||
Promise.race([
|
||||
process.status.then(() => true),
|
||||
new Promise<boolean>((resolve) => {
|
||||
timer = setTimeout(() => resolve(false), PROCESS_STOP_TIMEOUT_MS);
|
||||
}),
|
||||
]).finally(() => clearTimeout(timer)),
|
||||
waitForPidsToExit(descendants, PROCESS_STOP_TIMEOUT_MS),
|
||||
]);
|
||||
if (!parentExited || liveDescendants.length > 0) {
|
||||
const lateDescendants = await descendantPids(process.pid);
|
||||
const forceTargets = [...new Set([...liveDescendants, ...lateDescendants])];
|
||||
for (const pid of forceTargets) tryKill(pid, "SIGKILL");
|
||||
tryKill(process.pid, "SIGKILL");
|
||||
await process.status;
|
||||
const survivors = await waitForPidsToExit(forceTargets, 1_000);
|
||||
if (survivors.length > 0) {
|
||||
throw new Error(`descendant processes did not exit: ${survivors.join(",")}`);
|
||||
}
|
||||
}
|
||||
await process.output;
|
||||
} catch (error) {
|
||||
diagnostics.push({
|
||||
kind: "tool",
|
||||
message: `failed to clean process ${process.id}: ${
|
||||
bounded(error instanceof Error ? error.message : String(error), 500)
|
||||
}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return diagnostics;
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { isAbsolute, join, resolve } from "@std/path";
|
||||
import type {
|
||||
CapturePoint,
|
||||
Persona,
|
||||
ReadyCondition,
|
||||
RouteScenario,
|
||||
Scenario,
|
||||
Viewport,
|
||||
} from "./types.ts";
|
||||
|
||||
function record(value: unknown, at: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new Error(`${at} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function text(value: unknown, at: string): string {
|
||||
if (typeof value !== "string" || value.trim() === "") throw new Error(`${at} must be text`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, at: string): number {
|
||||
if (!Number.isInteger(value) || (value as number) <= 0) {
|
||||
throw new Error(`${at} must be a positive integer`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function identifier(value: unknown, at: string): string {
|
||||
const result = text(value, at);
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(result)) {
|
||||
throw new Error(`${at} must contain lowercase ASCII letters, digits, or hyphens`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function stringArray(value: unknown, at: string): string[] {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value)) throw new Error(`${at} must be an array`);
|
||||
return value.map((item, index) => text(item, `${at}[${index}]`));
|
||||
}
|
||||
|
||||
function parseReady(value: unknown, at: string): ReadyCondition {
|
||||
const source = record(value, at);
|
||||
const kind = text(source.kind, `${at}.kind`);
|
||||
const timeoutMs = source.timeoutMs === undefined
|
||||
? undefined
|
||||
: positiveInteger(source.timeoutMs, `${at}.timeoutMs`);
|
||||
if (kind === "selector") {
|
||||
return { kind, selector: text(source.selector, `${at}.selector`), timeoutMs };
|
||||
}
|
||||
if (kind === "response") {
|
||||
const status = source.status === undefined
|
||||
? undefined
|
||||
: positiveInteger(source.status, `${at}.status`);
|
||||
return { kind, urlPattern: text(source.urlPattern, `${at}.urlPattern`), status, timeoutMs };
|
||||
}
|
||||
if (kind === "network-idle") return { kind, timeoutMs };
|
||||
throw new Error(`${at}.kind is unsupported: ${kind}`);
|
||||
}
|
||||
|
||||
function parseCapturePoint(value: unknown, at: string): CapturePoint {
|
||||
const source = record(value, at);
|
||||
const result: CapturePoint = {
|
||||
id: identifier(source.id, `${at}.id`),
|
||||
label: text(source.label, `${at}.label`),
|
||||
fullPage: source.fullPage === undefined ? false : Boolean(source.fullPage),
|
||||
};
|
||||
if (source.ready !== undefined) result.ready = parseReady(source.ready, `${at}.ready`);
|
||||
if (source.interaction !== undefined) {
|
||||
if (!Array.isArray(source.interaction)) throw new Error(`${at}.interaction must be an array`);
|
||||
if (source.interaction.length > 20) {
|
||||
throw new Error(`${at}.interaction must not exceed 20 items`);
|
||||
}
|
||||
result.interaction = source.interaction.map((raw, index) => {
|
||||
const action = record(raw, `${at}.interaction[${index}]`);
|
||||
const name = text(action.action, `${at}.interaction[${index}].action`);
|
||||
if (name === "wait") {
|
||||
return {
|
||||
action: name,
|
||||
ready: parseReady(action.ready, `${at}.interaction[${index}].ready`),
|
||||
};
|
||||
}
|
||||
const selector = text(action.selector, `${at}.interaction[${index}].selector`);
|
||||
const timeoutMs = action.timeoutMs === undefined
|
||||
? undefined
|
||||
: positiveInteger(action.timeoutMs, `${at}.interaction[${index}].timeoutMs`);
|
||||
if (name === "click") return { action: name, selector, timeoutMs };
|
||||
if (name === "fill") {
|
||||
return {
|
||||
action: name,
|
||||
selector,
|
||||
value: text(action.value, `${at}.interaction[${index}].value`),
|
||||
timeoutMs,
|
||||
};
|
||||
}
|
||||
if (name === "press") {
|
||||
return {
|
||||
action: name,
|
||||
selector,
|
||||
key: text(action.key, `${at}.interaction[${index}].key`),
|
||||
timeoutMs,
|
||||
};
|
||||
}
|
||||
throw new Error(`${at}.interaction[${index}].action is unsupported: ${name}`);
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parsePersona(value: unknown, at: string): Persona {
|
||||
const source = record(value, at);
|
||||
const auth = record(source.auth, `${at}.auth`);
|
||||
const kind = text(auth.kind, `${at}.auth.kind`);
|
||||
const persona: Persona = {
|
||||
id: identifier(source.id, `${at}.id`),
|
||||
label: text(source.label, `${at}.label`),
|
||||
auth: kind === "anonymous"
|
||||
? { kind }
|
||||
: kind === "storage-state"
|
||||
? { kind, path: text(auth.path, `${at}.auth.path`) }
|
||||
: (() => {
|
||||
throw new Error(`${at}.auth.kind is unsupported: ${kind}`);
|
||||
})(),
|
||||
};
|
||||
if (source.login !== undefined) {
|
||||
const login = record(source.login, `${at}.login`);
|
||||
persona.login = {
|
||||
path: login.path === undefined ? "/" : text(login.path, `${at}.login.path`),
|
||||
successUrl: text(login.successUrl, `${at}.login.successUrl`),
|
||||
};
|
||||
}
|
||||
return persona;
|
||||
}
|
||||
|
||||
function parseViewport(value: unknown, at: string): Viewport {
|
||||
const source = record(value, at);
|
||||
return {
|
||||
width: positiveInteger(source.width, `${at}.width`),
|
||||
height: positiveInteger(source.height, `${at}.height`),
|
||||
label: source.label === undefined ? undefined : identifier(source.label, `${at}.label`),
|
||||
deviceScaleFactor: source.deviceScaleFactor === undefined
|
||||
? 1
|
||||
: positiveInteger(source.deviceScaleFactor, `${at}.deviceScaleFactor`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseRoute(value: unknown, at: string): RouteScenario {
|
||||
const source = record(value, at);
|
||||
if (!Array.isArray(source.capturePoints) || source.capturePoints.length === 0) {
|
||||
throw new Error(`${at}.capturePoints must have at least one item`);
|
||||
}
|
||||
if (source.capturePoints.length > 12) {
|
||||
throw new Error(`${at}.capturePoints must not exceed 12 items`);
|
||||
}
|
||||
return {
|
||||
id: identifier(source.id, `${at}.id`),
|
||||
label: text(source.label, `${at}.label`),
|
||||
path: text(source.path, `${at}.path`),
|
||||
goal: text(source.goal, `${at}.goal`),
|
||||
dataState: text(source.dataState, `${at}.dataState`),
|
||||
ready: parseReady(source.ready, `${at}.ready`),
|
||||
capturePoints: source.capturePoints.map((point, index) =>
|
||||
parseCapturePoint(point, `${at}.capturePoints[${index}]`)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueIds(values: { id: string }[], at: string): void {
|
||||
const seen = new Set<string>();
|
||||
for (const value of values) {
|
||||
if (seen.has(value.id)) throw new Error(`${at} contains duplicate id: ${value.id}`);
|
||||
seen.add(value.id);
|
||||
}
|
||||
}
|
||||
|
||||
export function interpolateEnvironment(value: string, environment = Deno.env.toObject()): string {
|
||||
return value.replaceAll(/\$\{([A-Z][A-Z0-9_]*)\}/g, (_match, name: string) => {
|
||||
const replacement = environment[name];
|
||||
if (replacement === undefined) {
|
||||
throw new Error(`required environment variable is missing: ${name}`);
|
||||
}
|
||||
return replacement;
|
||||
});
|
||||
}
|
||||
|
||||
export function validateBaseUrl(value: string): string {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error("base URL must use http or https");
|
||||
}
|
||||
if (url.username || url.password) throw new Error("base URL must not contain credentials");
|
||||
url.pathname = url.pathname.replace(/\/$/, "");
|
||||
return url.toString().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
export function resolveScenarioPath(sourcePath: string, value: string): string {
|
||||
const expanded = interpolateEnvironment(value);
|
||||
return isAbsolute(expanded) ? expanded : resolve(join(sourcePath, "..", expanded));
|
||||
}
|
||||
|
||||
export async function loadScenario(sourcePath: string): Promise<Scenario> {
|
||||
const parsed = JSON.parse(await Deno.readTextFile(sourcePath));
|
||||
const source = record(parsed, "scenario");
|
||||
if (source.schemaVersion !== 1) throw new Error("scenario.schemaVersion must equal 1");
|
||||
if (!Array.isArray(source.personas) || source.personas.length === 0) {
|
||||
throw new Error("scenario.personas must have at least one item");
|
||||
}
|
||||
if (source.personas.length > 8) throw new Error("scenario.personas must not exceed 8 items");
|
||||
if (!Array.isArray(source.viewports) || source.viewports.length === 0) {
|
||||
throw new Error("scenario.viewports must have at least one item");
|
||||
}
|
||||
if (source.viewports.length > 8) throw new Error("scenario.viewports must not exceed 8 items");
|
||||
if (!Array.isArray(source.routes) || source.routes.length === 0) {
|
||||
throw new Error("scenario.routes must have at least one item");
|
||||
}
|
||||
if (source.routes.length > 40) throw new Error("scenario.routes must not exceed 40 items");
|
||||
const personas = source.personas.map((value, index) =>
|
||||
parsePersona(value, `scenario.personas[${index}]`)
|
||||
);
|
||||
const routes = source.routes.map((value, index) =>
|
||||
parseRoute(value, `scenario.routes[${index}]`)
|
||||
);
|
||||
const scenario: Scenario = {
|
||||
schemaVersion: 1,
|
||||
id: identifier(source.id, "scenario.id"),
|
||||
title: text(source.title, "scenario.title"),
|
||||
baseUrl: source.baseUrl === undefined ? undefined : text(source.baseUrl, "scenario.baseUrl"),
|
||||
locale: source.locale === undefined ? "en-US" : text(source.locale, "scenario.locale"),
|
||||
timezone: source.timezone === undefined ? "UTC" : text(source.timezone, "scenario.timezone"),
|
||||
colorScheme: source.colorScheme === "dark" ? "dark" : "light",
|
||||
reducedMotion: source.reducedMotion === "no-preference" ? "no-preference" : "reduce",
|
||||
redact: source.redact === undefined ? undefined : (() => {
|
||||
const redact = record(source.redact, "scenario.redact");
|
||||
return {
|
||||
selectors: stringArray(redact.selectors, "scenario.redact.selectors"),
|
||||
text: stringArray(redact.text, "scenario.redact.text").map((value) =>
|
||||
interpolateEnvironment(value)
|
||||
),
|
||||
};
|
||||
})(),
|
||||
personas,
|
||||
viewports: source.viewports.map((value, index) =>
|
||||
parseViewport(value, `scenario.viewports[${index}]`)
|
||||
),
|
||||
routes,
|
||||
};
|
||||
if (source.processes !== undefined) {
|
||||
if (!Array.isArray(source.processes)) throw new Error("scenario.processes must be an array");
|
||||
if (source.processes.length > 8) throw new Error("scenario.processes must not exceed 8 items");
|
||||
scenario.processes = source.processes.map((value, index) => {
|
||||
const at = `scenario.processes[${index}]`;
|
||||
const process = record(value, at);
|
||||
const env = process.env === undefined ? undefined : record(process.env, `${at}.env`);
|
||||
return {
|
||||
id: identifier(process.id, `${at}.id`),
|
||||
command: text(process.command, `${at}.command`),
|
||||
args: stringArray(process.args, `${at}.args`),
|
||||
cwd: process.cwd === undefined ? undefined : text(process.cwd, `${at}.cwd`),
|
||||
env: env === undefined ? undefined : Object.fromEntries(
|
||||
Object.entries(env).map((
|
||||
[key, raw],
|
||||
) => [key, interpolateEnvironment(text(raw, `${at}.env.${key}`))]),
|
||||
),
|
||||
readyUrl: process.readyUrl === undefined ? undefined : validateBaseUrl(
|
||||
interpolateEnvironment(text(process.readyUrl, `${at}.readyUrl`)),
|
||||
),
|
||||
readyTimeoutMs: process.readyTimeoutMs === undefined
|
||||
? undefined
|
||||
: positiveInteger(process.readyTimeoutMs, `${at}.readyTimeoutMs`),
|
||||
};
|
||||
});
|
||||
uniqueIds(scenario.processes, "scenario.processes");
|
||||
}
|
||||
uniqueIds(personas, "scenario.personas");
|
||||
uniqueIds(routes, "scenario.routes");
|
||||
for (const route of routes) uniqueIds(route.capturePoints, `route ${route.id} capturePoints`);
|
||||
const captureCount = personas.length * scenario.viewports.length *
|
||||
routes.reduce((total, route) => total + route.capturePoints.length, 0);
|
||||
if (captureCount > 200) {
|
||||
throw new Error(`scenario capture matrix must not exceed 200 items (received ${captureCount})`);
|
||||
}
|
||||
return scenario;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
export type Viewport = {
|
||||
width: number;
|
||||
height: number;
|
||||
label?: string;
|
||||
deviceScaleFactor?: number;
|
||||
};
|
||||
|
||||
export type Persona = {
|
||||
id: string;
|
||||
label: string;
|
||||
auth: { kind: "anonymous" } | { kind: "storage-state"; path: string };
|
||||
login?: {
|
||||
path?: string;
|
||||
successUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ReadyCondition =
|
||||
| { kind: "selector"; selector: string; timeoutMs?: number }
|
||||
| { kind: "response"; urlPattern: string; status?: number; timeoutMs?: number }
|
||||
| { kind: "network-idle"; timeoutMs?: number };
|
||||
|
||||
export type Interaction =
|
||||
| { action: "click"; selector: string; timeoutMs?: number }
|
||||
| { action: "fill"; selector: string; value: string; timeoutMs?: number }
|
||||
| { action: "press"; selector: string; key: string; timeoutMs?: number }
|
||||
| { action: "wait"; ready: ReadyCondition };
|
||||
|
||||
export type CapturePoint = {
|
||||
id: string;
|
||||
label: string;
|
||||
interaction?: Interaction[];
|
||||
ready?: ReadyCondition;
|
||||
fullPage?: boolean;
|
||||
};
|
||||
|
||||
export type RouteScenario = {
|
||||
id: string;
|
||||
label: string;
|
||||
path: string;
|
||||
goal: string;
|
||||
dataState: string;
|
||||
ready: ReadyCondition;
|
||||
capturePoints: CapturePoint[];
|
||||
};
|
||||
|
||||
export type OwnedProcess = {
|
||||
id: string;
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
readyUrl?: string;
|
||||
readyTimeoutMs?: number;
|
||||
};
|
||||
|
||||
export type Scenario = {
|
||||
schemaVersion: 1;
|
||||
id: string;
|
||||
title: string;
|
||||
baseUrl?: string;
|
||||
locale?: string;
|
||||
timezone?: string;
|
||||
colorScheme?: "light" | "dark";
|
||||
reducedMotion?: "reduce" | "no-preference";
|
||||
redact?: {
|
||||
selectors?: string[];
|
||||
text?: string[];
|
||||
};
|
||||
personas: Persona[];
|
||||
viewports: Viewport[];
|
||||
routes: RouteScenario[];
|
||||
processes?: OwnedProcess[];
|
||||
};
|
||||
|
||||
export type CaptureError = {
|
||||
kind: "console" | "page" | "request" | "document" | "tool";
|
||||
message: string;
|
||||
url?: string;
|
||||
status?: number;
|
||||
};
|
||||
|
||||
export type ArtifactReference = {
|
||||
bundlePath: string;
|
||||
workdirPath: string | null;
|
||||
};
|
||||
|
||||
export type ScreenshotEvidence = ArtifactReference & {
|
||||
kind: "viewport" | "full-page";
|
||||
sha256: string;
|
||||
};
|
||||
|
||||
export type InteractionEvidence =
|
||||
| { action: "click"; selector: string }
|
||||
| { action: "fill"; selector: string; value: "[REDACTED]" }
|
||||
| { action: "press"; selector: string; key: string }
|
||||
| { action: "wait"; ready: ReadyCondition };
|
||||
|
||||
export type DiagnosticSummary = {
|
||||
observed: number;
|
||||
retained: number;
|
||||
truncated: boolean;
|
||||
limit: number;
|
||||
};
|
||||
|
||||
export type CaptureEvidence = {
|
||||
persona: { id: string; label: string };
|
||||
route: {
|
||||
id: string;
|
||||
path: string;
|
||||
goal: string;
|
||||
dataState: string;
|
||||
ready: ReadyCondition;
|
||||
};
|
||||
viewport: Viewport;
|
||||
theme: string;
|
||||
capturePoint: {
|
||||
id: string;
|
||||
label: string;
|
||||
ready: ReadyCondition | null;
|
||||
};
|
||||
interactions: InteractionEvidence[];
|
||||
document: { url: string; status: number | null };
|
||||
screenshots: ScreenshotEvidence[];
|
||||
snapshot: ArtifactReference | null;
|
||||
errors: CaptureError[];
|
||||
errorSummary: DiagnosticSummary;
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
};
|
||||
|
||||
export type ReviewContext = {
|
||||
schemaVersion: 1;
|
||||
runId: string;
|
||||
scenario: { id: string; title: string; sourcePath: string | null };
|
||||
source: { revision: string | null; dirty: boolean | null };
|
||||
baseUrl: string;
|
||||
browser: { name: "chromium"; version: string };
|
||||
createdAt: string;
|
||||
status: "completed" | "completed-with-errors" | "failed";
|
||||
filters: { personas: string[]; routes: string[]; viewports: string[] };
|
||||
captures: CaptureEvidence[];
|
||||
contactSheet: {
|
||||
html: ArtifactReference | null;
|
||||
png: ArtifactReference | null;
|
||||
};
|
||||
diagnostics: CaptureError[];
|
||||
diagnosticSummary: DiagnosticSummary;
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { assertRejects } from "@std/assert";
|
||||
import { join } from "@std/path";
|
||||
import {
|
||||
authMetadataPath,
|
||||
deleteAuthState,
|
||||
validateAuthState,
|
||||
writeAuthMetadata,
|
||||
} from "../src/auth_state.ts";
|
||||
|
||||
Deno.test("auth state is bound to persona, base origin, and expiry", async () => {
|
||||
const directory = await Deno.makeTempDir();
|
||||
try {
|
||||
const state = join(directory, "owner.json");
|
||||
await Deno.writeTextFile(state, '{"cookies":[],"origins":[]}');
|
||||
await writeAuthMetadata(state, "owner", "https://example.test/path", 1);
|
||||
await validateAuthState(state, "owner", "https://example.test/other");
|
||||
await assertRejects(
|
||||
() => validateAuthState(state, "non-owner", "https://example.test"),
|
||||
Error,
|
||||
"does not match persona",
|
||||
);
|
||||
await assertRejects(
|
||||
() => validateAuthState(state, "owner", "https://other.test"),
|
||||
Error,
|
||||
"belongs to https://example.test",
|
||||
);
|
||||
await assertRejects(
|
||||
() =>
|
||||
validateAuthState(
|
||||
state,
|
||||
"owner",
|
||||
"https://example.test",
|
||||
new Date(Date.now() + 2 * 60 * 60 * 1000),
|
||||
),
|
||||
Error,
|
||||
"auth state expired",
|
||||
);
|
||||
} finally {
|
||||
await Deno.remove(directory, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("auth state deletion removes state and metadata idempotently", async () => {
|
||||
const directory = await Deno.makeTempDir();
|
||||
try {
|
||||
const state = join(directory, "owner.json");
|
||||
await Deno.writeTextFile(state, "{}");
|
||||
await writeAuthMetadata(state, "owner", "http://127.0.0.1:3000", 1);
|
||||
await deleteAuthState(state);
|
||||
await deleteAuthState(state);
|
||||
await assertRejects(() => Deno.stat(state), Deno.errors.NotFound);
|
||||
await assertRejects(() => Deno.stat(authMetadataPath(state)), Deno.errors.NotFound);
|
||||
} finally {
|
||||
await Deno.remove(directory, { recursive: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { assertEquals, assertThrows } from "@std/assert";
|
||||
import { parseArguments } from "../cli.ts";
|
||||
import { isVisibleUiErrorText } from "../src/capture.ts";
|
||||
|
||||
Deno.test("CLI parses bounded capture filters", () => {
|
||||
const parsed = parseArguments([
|
||||
"capture",
|
||||
"--scenario",
|
||||
"scenario.json",
|
||||
"--personas",
|
||||
"owner,non-owner",
|
||||
"--headed",
|
||||
]);
|
||||
assertEquals(parsed.command, "capture");
|
||||
assertEquals(parsed.values.get("personas"), ["owner,non-owner"]);
|
||||
assertEquals(parsed.flags.has("headed"), true);
|
||||
});
|
||||
|
||||
Deno.test("visible UI error classification ignores ordinary status text", () => {
|
||||
assertEquals(isVisibleUiErrorText("Refresh failed (401 Unauthorized)"), true);
|
||||
assertEquals(isVisibleUiErrorText("Workspace list loaded"), false);
|
||||
});
|
||||
|
||||
Deno.test("CLI rejects positional and missing option values", () => {
|
||||
assertThrows(() => parseArguments(["capture", "scenario.json"]), Error, "unexpected argument");
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { assertEquals, assertRejects, assertStringIncludes } from "@std/assert";
|
||||
import { join } from "@std/path";
|
||||
import {
|
||||
assertBundleIsSecretFree,
|
||||
redactText,
|
||||
safeUrl,
|
||||
writePrivateJson,
|
||||
} from "../src/artifacts.ts";
|
||||
import {
|
||||
PROCESS_LOG_BYTE_LIMIT,
|
||||
startOwnedProcesses,
|
||||
stopOwnedProcesses,
|
||||
} from "../src/processes.ts";
|
||||
|
||||
Deno.test("redaction removes common credentials and query values", () => {
|
||||
const redacted = redactText(
|
||||
"Authorization: Bearer abc.def cookie=session-value token=secret-value",
|
||||
["abc.def"],
|
||||
);
|
||||
assertStringIncludes(redacted, "[REDACTED]");
|
||||
assertEquals(redacted.includes("abc.def"), false);
|
||||
assertEquals(redacted.includes("session-value"), false);
|
||||
assertEquals(
|
||||
safeUrl("https://user:pass@example.test/path?token=secret#fragment"),
|
||||
"https://example.test/path?token=%5BREDACTED%5D",
|
||||
);
|
||||
assertRejects(
|
||||
async () => assertBundleIsSecretFree('{"authorization":"Bearer abc"}'),
|
||||
Error,
|
||||
"forbidden secret marker",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("private JSON state uses owner-only permissions", async () => {
|
||||
const directory = await Deno.makeTempDir();
|
||||
try {
|
||||
const path = join(directory, "state", "owner.json");
|
||||
await writePrivateJson(path, { cookies: [], origins: [] });
|
||||
assertEquals(JSON.parse(await Deno.readTextFile(path)), { cookies: [], origins: [] });
|
||||
if (Deno.build.os !== "windows") assertEquals((await Deno.stat(path)).mode! & 0o777, 0o600);
|
||||
} finally {
|
||||
await Deno.remove(directory, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("owned process is terminated and its logs are redacted", async () => {
|
||||
const directory = await Deno.makeTempDir();
|
||||
const scenario = join(directory, "scenario.json");
|
||||
await Deno.writeTextFile(scenario, "{}");
|
||||
try {
|
||||
const processes = await startOwnedProcesses(
|
||||
[{
|
||||
id: "fixture",
|
||||
command: Deno.execPath(),
|
||||
args: ["eval", 'console.log("authorization: secret-value"); setInterval(() => {}, 1000)'],
|
||||
}],
|
||||
scenario,
|
||||
join(directory, "logs"),
|
||||
["secret-value"],
|
||||
);
|
||||
assertEquals(processes.length, 1);
|
||||
const logPath = join(directory, "logs", "fixture.stdout.log");
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
try {
|
||||
if ((await Deno.readTextFile(logPath)).length > 0) break;
|
||||
} catch {
|
||||
// The output pump creates the file asynchronously.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
const diagnostics = await stopOwnedProcesses(processes);
|
||||
assertEquals(diagnostics, []);
|
||||
const log = await Deno.readTextFile(logPath);
|
||||
assertEquals(log.includes("secret-value"), false);
|
||||
assertStringIncludes(log, "[REDACTED]");
|
||||
const metadata = JSON.parse(await Deno.readTextFile(`${logPath}.meta.json`));
|
||||
assertEquals(metadata.truncated, false);
|
||||
} finally {
|
||||
await Deno.remove(directory, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("owned process logs stop at the byte limit and record truncation", async () => {
|
||||
const directory = await Deno.makeTempDir();
|
||||
const scenario = join(directory, "scenario.json");
|
||||
await Deno.writeTextFile(scenario, "{}");
|
||||
try {
|
||||
const processes = await startOwnedProcesses(
|
||||
[{
|
||||
id: "large-output",
|
||||
command: Deno.execPath(),
|
||||
args: [
|
||||
"eval",
|
||||
`console.log("x".repeat(${
|
||||
PROCESS_LOG_BYTE_LIMIT + 32_768
|
||||
})); setInterval(() => {}, 1000)`,
|
||||
],
|
||||
}],
|
||||
scenario,
|
||||
join(directory, "logs"),
|
||||
[],
|
||||
);
|
||||
const logPath = join(directory, "logs", "large-output.stdout.log");
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
try {
|
||||
if ((await Deno.stat(logPath)).size >= PROCESS_LOG_BYTE_LIMIT) break;
|
||||
} catch {
|
||||
// The output pump creates the file asynchronously.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
assertEquals(await stopOwnedProcesses(processes), []);
|
||||
assertEquals((await Deno.stat(logPath)).size, PROCESS_LOG_BYTE_LIMIT);
|
||||
const metadata = JSON.parse(await Deno.readTextFile(`${logPath}.meta.json`));
|
||||
assertEquals(metadata.byteLimit, PROCESS_LOG_BYTE_LIMIT);
|
||||
assertEquals(metadata.truncated, true);
|
||||
assertEquals(metadata.bytesWritten, PROCESS_LOG_BYTE_LIMIT);
|
||||
} finally {
|
||||
await Deno.remove(directory, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("forced cleanup terminates a TERM-resistant descendant", async () => {
|
||||
if (Deno.build.os === "windows") return;
|
||||
const directory = await Deno.makeTempDir();
|
||||
const scenario = join(directory, "scenario.json");
|
||||
const childPidPath = join(directory, "child.pid");
|
||||
await Deno.writeTextFile(scenario, "{}");
|
||||
try {
|
||||
const childProgram = 'Deno.addSignalListener("SIGTERM", () => {}); setInterval(() => {}, 1000)';
|
||||
const parentProgram = `
|
||||
const child = new Deno.Command(Deno.execPath(), {
|
||||
args: ["eval", ${JSON.stringify(childProgram)}],
|
||||
stdout: "null",
|
||||
stderr: "null"
|
||||
}).spawn();
|
||||
Deno.writeTextFileSync(Deno.args[0], String(child.pid));
|
||||
Deno.addSignalListener("SIGTERM", () => {});
|
||||
setInterval(() => {}, 1000);
|
||||
`;
|
||||
const processes = await startOwnedProcesses(
|
||||
[{
|
||||
id: "process-tree",
|
||||
command: Deno.execPath(),
|
||||
args: ["eval", parentProgram, childPidPath],
|
||||
}],
|
||||
scenario,
|
||||
join(directory, "logs"),
|
||||
[],
|
||||
);
|
||||
let childPid = 0;
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
try {
|
||||
childPid = Number(await Deno.readTextFile(childPidPath));
|
||||
if (childPid > 0) break;
|
||||
} catch {
|
||||
// The fixture publishes its descendant PID after spawn.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
assertEquals(childPid > 0, true);
|
||||
assertEquals(await stopOwnedProcesses(processes), []);
|
||||
const status = await new Deno.Command("ps", {
|
||||
args: ["-p", String(childPid), "-o", "pid="],
|
||||
stdout: "piped",
|
||||
stderr: "null",
|
||||
}).output();
|
||||
assertEquals(new TextDecoder().decode(status.stdout).trim(), "");
|
||||
} finally {
|
||||
await Deno.remove(directory, { recursive: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { assertEquals, assertRejects, assertThrows } from "@std/assert";
|
||||
import { join } from "@std/path";
|
||||
import { cleanup } from "../src/lifecycle.ts";
|
||||
import {
|
||||
interpolateEnvironment,
|
||||
loadScenario,
|
||||
resolveScenarioPath,
|
||||
validateBaseUrl,
|
||||
} from "../src/scenario.ts";
|
||||
|
||||
function minimalScenario(extra = ""): string {
|
||||
return `{
|
||||
"schemaVersion": 1,
|
||||
"id": "test-screen",
|
||||
"title": "Test screen",
|
||||
"baseUrl": "http://127.0.0.1:5173",
|
||||
"personas": [{"id":"anonymous","label":"Anonymous","auth":{"kind":"anonymous"}}],
|
||||
"viewports": [{"label":"desktop","width":1000,"height":800}],
|
||||
"routes": [{
|
||||
"id":"home","label":"Home","path":"/","goal":"Inspect home",
|
||||
"dataState":"Fixture data","ready":{"kind":"selector","selector":"main"},
|
||||
"capturePoints":[{"id":"initial","label":"Initial"}]
|
||||
}]${extra}
|
||||
}`;
|
||||
}
|
||||
|
||||
Deno.test("scenario parser preserves explicit visual review context", async () => {
|
||||
const directory = await Deno.makeTempDir();
|
||||
try {
|
||||
const path = join(directory, "scenario.json");
|
||||
await Deno.writeTextFile(path, minimalScenario());
|
||||
const scenario = await loadScenario(path);
|
||||
assertEquals(scenario.personas[0].auth, { kind: "anonymous" });
|
||||
assertEquals(scenario.routes[0].goal, "Inspect home");
|
||||
assertEquals(scenario.routes[0].dataState, "Fixture data");
|
||||
assertEquals(scenario.routes[0].ready, {
|
||||
kind: "selector",
|
||||
selector: "main",
|
||||
timeoutMs: undefined,
|
||||
});
|
||||
assertEquals(scenario.reducedMotion, "reduce");
|
||||
} finally {
|
||||
await Deno.remove(directory, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("scenario parser rejects duplicate persona identity", async () => {
|
||||
const directory = await Deno.makeTempDir();
|
||||
try {
|
||||
const path = join(directory, "scenario.json");
|
||||
await Deno.writeTextFile(
|
||||
path,
|
||||
minimalScenario().replace(
|
||||
'[{"id":"anonymous","label":"Anonymous","auth":{"kind":"anonymous"}}]',
|
||||
'[{"id":"same","label":"First","auth":{"kind":"anonymous"}},{"id":"same","label":"Second","auth":{"kind":"anonymous"}}]',
|
||||
),
|
||||
);
|
||||
await assertRejects(() => loadScenario(path), Error, "duplicate id: same");
|
||||
} finally {
|
||||
await Deno.remove(directory, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("base URL rejects embedded credentials and non-http schemes", () => {
|
||||
assertThrows(
|
||||
() => validateBaseUrl("https://user:secret@example.test"),
|
||||
Error,
|
||||
"must not contain credentials",
|
||||
);
|
||||
assertThrows(() => validateBaseUrl("file:///tmp/index.html"), Error, "must use http or https");
|
||||
});
|
||||
|
||||
Deno.test("environment interpolation fails closed", () => {
|
||||
assertEquals(
|
||||
interpolateEnvironment("/w/${WORKSPACE_ID}", { WORKSPACE_ID: "W-test" }),
|
||||
"/w/W-test",
|
||||
);
|
||||
assertThrows(
|
||||
() => interpolateEnvironment("${MISSING}", {}),
|
||||
Error,
|
||||
"required environment variable is missing",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("committed auth profiles resolve outside the repository", async () => {
|
||||
const source = "scenarios/workspace-control-plane.json";
|
||||
const scenario = await loadScenario(source);
|
||||
for (const persona of scenario.personas) {
|
||||
if (persona.auth.kind !== "storage-state") continue;
|
||||
const statePath = resolveScenarioPath(source, persona.auth.path);
|
||||
assertEquals(statePath.startsWith(Deno.cwd()), false);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("cleanup removes only complete review bundles beyond retention", async () => {
|
||||
const directory = await Deno.makeTempDir();
|
||||
try {
|
||||
for (const name of ["one", "two", "three"]) {
|
||||
const run = join(directory, name);
|
||||
await Deno.mkdir(run);
|
||||
await Deno.writeTextFile(join(run, "review-context.json"), "{}");
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
const unrelated = join(directory, "auth");
|
||||
await Deno.mkdir(unrelated);
|
||||
await Deno.writeTextFile(join(unrelated, "state.json"), "secret");
|
||||
const removed = await cleanup({ outputDirectory: directory, keep: 1 });
|
||||
assertEquals(removed.length, 2);
|
||||
assertEquals(await Deno.readTextFile(join(unrelated, "state.json")), "secret");
|
||||
} finally {
|
||||
await Deno.remove(directory, { recursive: true });
|
||||
}
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
"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",
|
||||
"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,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts 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 tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.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/merge-request-status.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/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
|
||||
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts 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 test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.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 test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.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/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
|
||||
"build": "deno run -A npm:vite@7.2.7 build",
|
||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||
},
|
||||
|
||||
@@ -42,6 +42,10 @@ export type CompactionLifecycle = { schema_version: number, compaction_id: strin
|
||||
*/
|
||||
started_at_ms: number, ended_at_ms?: number | null, summary?: string | null, error?: string | null, new_segment_id?: string | null, };
|
||||
|
||||
export type UploadedFileAvailability = "available" | "unavailable" | "integrity_failed";
|
||||
|
||||
export type UploadedFileRef = { artifact_id: string, file_name: string, media_type: string, created_at_ms: number, availability: UploadedFileAvailability, byte_len: number, sha256: string, source_entry_id?: string | null, };
|
||||
|
||||
export type ScopeRule = {
|
||||
/**
|
||||
* Target path. Must be absolute by the time a `Scope` is built from
|
||||
@@ -144,7 +148,7 @@ export type PasteArtifactRef = { artifact_id: string, created_at_ms: number, med
|
||||
*/
|
||||
availability: PasteArtifactAvailability, byte_len: number, char_count: number, line_count: number, sha256: string, source_entry_id: string, };
|
||||
|
||||
export type Segment = { "kind": "text", content: string, } | { "kind": "paste", id: number, chars: number, lines: number, content: string, } | { "kind": "paste_artifact", artifact: PasteArtifactRef, } | { "kind": "file_ref", path: string, } | { "kind": "flow", selector: string, } | { "kind": "unknown" };
|
||||
export type Segment = { "kind": "text", content: string, } | { "kind": "paste", id: number, chars: number, lines: number, content: string, } | { "kind": "paste_artifact", artifact: PasteArtifactRef, } | { "kind": "uploaded_file", file: UploadedFileRef, } | { "kind": "file_ref", path: string, } | { "kind": "flow", selector: string, } | { "kind": "unknown" };
|
||||
|
||||
export type WorkerEvent = { "kind": "turn_ended", worker_name: string, } | { "kind": "errored", worker_name: string, message: string, } | { "kind": "shut_down", worker_name: string, } | { "kind": "scope_sub_delegated",
|
||||
/**
|
||||
@@ -192,13 +196,18 @@ resource_key?: string | null,
|
||||
/**
|
||||
* Producer-owned monotonic revision for this Worker subject.
|
||||
*/
|
||||
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, };
|
||||
subject_revision: number, state: SubscriptionWorkerState, has_running_internal_workers: boolean, workspace_id?: string | null, display_name?: string | null, profile?: string | null,
|
||||
/**
|
||||
* Workspace-facing Repository key. Runtime producers leave this unset and
|
||||
* Workspace Server projections replace `repository_id` with this field.
|
||||
*/
|
||||
repository_key?: 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 WorkspaceSubscriptionWorkdir = { working_directory_id: SubscriptionWorkdirId, repository_key: string, state: string, primary_worker_id?: SubscriptionWorkerId | null, };
|
||||
|
||||
export type SubscriptionSnapshot = { "topic": "workers", "data": { workers: Array<SubscriptionWorker>, } } | { "topic": "worker_protocol", "data": { worker_id: SubscriptionWorkerId, events: Array<Event>, } } | { "topic": "workspace_workdirs", "data": { workdirs: Array<SubscriptionWorkdir>, } };
|
||||
export type SubscriptionSnapshot = { "topic": "workers", "data": { workers: Array<SubscriptionWorker>, } } | { "topic": "worker_protocol", "data": { worker_id: SubscriptionWorkerId, events: Array<Event>, } } | { "topic": "workspace_workdirs", "data": { workdirs: Array<WorkspaceSubscriptionWorkdir>, } };
|
||||
|
||||
export type SubscriptionEventPayload = { "event": "worker_upserted", "data": { worker: SubscriptionWorker, } } | { "event": "worker_removed", "data": { worker_id: SubscriptionWorkerId, runtime_id?: string | null, } } | { "event": "worker_protocol", "data": { worker_id: SubscriptionWorkerId, event: Event, } } | { "event": "workdir_upserted", "data": { workdir: SubscriptionWorkdir, } } | { "event": "workdir_removed", "data": { working_directory_id: SubscriptionWorkdirId, } };
|
||||
export type SubscriptionEventPayload = { "event": "worker_upserted", "data": { worker: SubscriptionWorker, } } | { "event": "worker_removed", "data": { worker_id: SubscriptionWorkerId, runtime_id?: string | null, } } | { "event": "worker_protocol", "data": { worker_id: SubscriptionWorkerId, event: Event, } } | { "event": "workdir_upserted", "data": { workdir: WorkspaceSubscriptionWorkdir, } } | { "event": "workdir_removed", "data": { working_directory_id: SubscriptionWorkdirId, } };
|
||||
|
||||
export type SubscriptionRejectionCode = "invalid_request" | "unsupported_protocol_version" | "unsupported_selector" | "unauthorized" | "resource_not_found" | "capacity_exceeded" | "internal";
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ export type DeleteRepositorySshHostTrustRequest = {
|
||||
export type RepositoryAccessMode = "read_only" | "read_write";
|
||||
|
||||
export type RepositorySshAccessBinding = {
|
||||
repository_id: string;
|
||||
repository_key: string;
|
||||
credential_id: string;
|
||||
host_trust_id: string;
|
||||
access: RepositoryAccessMode;
|
||||
|
||||
@@ -23,7 +23,7 @@ export type TicketAssignmentPrincipalSummary = { "kind": "user", account_id: str
|
||||
|
||||
export type TicketActionEligibility = { can_assign_orchestrator: boolean, can_unassign_orchestrator: boolean, can_queue: boolean, can_start_manual_coder: boolean, queue_tickets: Array<string>, blockers: Array<string>, };
|
||||
|
||||
export type TicketMergeRequestSummary = { merge_request_id: string, repository_id: string, state: string, review_status: string, selector_from: string | null, selector_to: string, updated_at: string, current_subject_ref: string | null, review_subject_ref: string | null, review_requested_at: string | null, review_submitted_at: string | null, review_excerpt: string | null, };
|
||||
export type TicketMergeRequestSummary = { merge_request_id: string, repository_key: string, state: string, review_status: string, selector_from: string | null, selector_to: string, updated_at: string, current_subject_ref: string | null, review_subject_ref: string | null, review_requested_at: string | null, review_submitted_at: string | null, review_excerpt: string | null, };
|
||||
|
||||
export type MergeRequestListItem = { summary: TicketMergeRequestSummary, ticket_ids: Array<string>, thread_event_count: number, };
|
||||
|
||||
@@ -49,4 +49,4 @@ export type TicketRelationNotice = { related_ticket: string, kind: string, messa
|
||||
|
||||
export type TicketRelationView = { outgoing: Array<TicketRelation>, incoming: Array<DerivedTicketRelation>, blockers: Array<TicketRelationBlocker>, notices: Array<TicketRelationNotice>, };
|
||||
|
||||
export type TicketDetail = { id: string, resource_key: string, title: string, state: string, readiness: string | null, priority: string, created_at: string | null, updated_at: string | null, item_revision: string, queued_by: string | null, queued_at: string | null, repository_id: string | null, ref_selector: string | null, risk_flags: Array<string>, body: string, body_truncated: boolean, event_count: number, events: Array<TicketEventDetail>, event_page: QueryPage, artifact_count: number, artifacts: Array<string>, relations: TicketRelationView, linked_objectives: Array<ObjectiveLinkSummary>, implementation_reports: Array<TicketEvidenceEvent>, assignments: Array<TicketRoleAssignmentSummary>, current_coder: TicketAssignmentSummary | null, assignment_diagnostics: Array<string>, action_eligibility: TicketActionEligibility, merge_request: TicketMergeRequestSummary | null, evidence: TicketEvidenceSummary, resolution: string | null, record_source: string, };
|
||||
export type TicketDetail = { id: string, resource_key: string, title: string, state: string, readiness: string | null, priority: string, created_at: string | null, updated_at: string | null, item_revision: string, queued_by: string | null, queued_at: string | null, repository_key: string | null, ref_selector: string | null, risk_flags: Array<string>, body: string, body_truncated: boolean, event_count: number, events: Array<TicketEventDetail>, event_page: QueryPage, artifact_count: number, artifacts: Array<string>, relations: TicketRelationView, linked_objectives: Array<ObjectiveLinkSummary>, implementation_reports: Array<TicketEvidenceEvent>, assignments: Array<TicketRoleAssignmentSummary>, current_coder: TicketAssignmentSummary | null, assignment_diagnostics: Array<string>, action_eligibility: TicketActionEligibility, merge_request: TicketMergeRequestSummary | null, evidence: TicketEvidenceSummary, resolution: string | null, record_source: string, };
|
||||
|
||||
@@ -3,22 +3,77 @@
|
||||
|
||||
export type DiagnosticSeverity = "info" | "warning" | "error";
|
||||
|
||||
export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, };
|
||||
export type Diagnostic = {
|
||||
code: string;
|
||||
severity: DiagnosticSeverity;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type WorkingDirectoryMaterializerKind = "runtime_git_cache" | "local_git_worktree";
|
||||
export type WorkingDirectoryMaterializerKind =
|
||||
| "runtime_git_cache"
|
||||
| "local_git_worktree";
|
||||
|
||||
export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown";
|
||||
export type WorkingDirectoryStatusKind =
|
||||
| "active"
|
||||
| "cleanup_pending"
|
||||
| "corrupted"
|
||||
| "not_found"
|
||||
| "unknown";
|
||||
|
||||
export type WorkingDirectoryCleanupTarget = { kind: string, working_directory_id: string, repository_id: string, };
|
||||
export type WorkingDirectoryCleanupTarget = {
|
||||
kind: string;
|
||||
working_directory_id: string;
|
||||
repository_key: string;
|
||||
};
|
||||
|
||||
export type WorkingDirectoryOccupancy = { runtime_id: string, worker_id: string, display_name: string, linked_at: string, };
|
||||
export type WorkingDirectoryOccupancy = {
|
||||
runtime_id: string;
|
||||
worker_id: string;
|
||||
display_name: string;
|
||||
linked_at: string;
|
||||
};
|
||||
|
||||
export type WorkingDirectorySummary = { working_directory_id: string, repository_id: string, creation_selector?: string | null, creation_ref?: string | null, creation_tree?: string | null, current_selector?: string | null, current_ref?: string | null, current_tree?: string | null, observed_at_epoch_seconds?: number | null, materializer_kind: WorkingDirectoryMaterializerKind, cleanup_target?: WorkingDirectoryCleanupTarget | null, status: WorkingDirectoryStatusKind, cleanliness?: string | null, primary_worker_id?: string | null, occupied_by?: WorkingDirectoryOccupancy | null, };
|
||||
export type WorkingDirectorySummary = {
|
||||
working_directory_id: string;
|
||||
repository_key: string;
|
||||
creation_selector?: string | null;
|
||||
creation_ref?: string | null;
|
||||
creation_tree?: string | null;
|
||||
current_selector?: string | null;
|
||||
current_ref?: string | null;
|
||||
current_tree?: string | null;
|
||||
observed_at_epoch_seconds?: number | null;
|
||||
materializer_kind: WorkingDirectoryMaterializerKind;
|
||||
cleanup_target?: WorkingDirectoryCleanupTarget | null;
|
||||
status: WorkingDirectoryStatusKind;
|
||||
cleanliness?: string | null;
|
||||
primary_worker_id?: string | null;
|
||||
occupied_by?: WorkingDirectoryOccupancy | null;
|
||||
};
|
||||
|
||||
export type WorkingDirectoryCreateRequest = { runtime_id?: string | null, repository_id: string, selector?: string | null, operation_id?: string | null, };
|
||||
export type WorkingDirectoryCreateRequest = {
|
||||
runtime_id?: string | null;
|
||||
repository_key: string;
|
||||
selector?: string | null;
|
||||
operation_id?: string | null;
|
||||
};
|
||||
|
||||
export type WorkingDirectoryListResponse = { workspace_id: string, items: Array<WorkingDirectorySummary>, diagnostics: Array<Diagnostic>, };
|
||||
export type WorkingDirectoryListResponse = {
|
||||
workspace_id: string;
|
||||
items: Array<WorkingDirectorySummary>;
|
||||
diagnostics: Array<Diagnostic>;
|
||||
};
|
||||
|
||||
export type WorkingDirectoryDetailResponse = { workspace_id: string, runtime_id: string, item: WorkingDirectorySummary, diagnostics: Array<Diagnostic>, };
|
||||
export type WorkingDirectoryDetailResponse = {
|
||||
workspace_id: string;
|
||||
runtime_id: string;
|
||||
item: WorkingDirectorySummary;
|
||||
diagnostics: Array<Diagnostic>;
|
||||
};
|
||||
|
||||
export type WorkingDirectoryCreateResponse = { workspace_id: string, runtime_id: string, item: WorkingDirectorySummary, diagnostics: Array<Diagnostic>, };
|
||||
export type WorkingDirectoryCreateResponse = {
|
||||
workspace_id: string;
|
||||
runtime_id: string;
|
||||
item: WorkingDirectorySummary;
|
||||
diagnostics: Array<Diagnostic>;
|
||||
};
|
||||
|
||||
@@ -14,8 +14,7 @@ export type WorkspaceCatalogListResponse = Array<WorkspaceSummary>;
|
||||
|
||||
export type WorkspaceRepositoryRecord = {
|
||||
workspace_id: string;
|
||||
repository_id: string;
|
||||
name: string;
|
||||
repository_key: string;
|
||||
kind: string;
|
||||
provider: string | null;
|
||||
source: RepositorySource;
|
||||
@@ -81,6 +80,64 @@ export type WorkspaceResponse = {
|
||||
extension_points: WorkspaceExtensionPoints;
|
||||
};
|
||||
|
||||
export type WorkspaceMetadataSettingsResponse = {
|
||||
workspace_id: string;
|
||||
display_name: string;
|
||||
created_at: string;
|
||||
revision: string;
|
||||
source: string;
|
||||
diagnostics: Array<Diagnostic>;
|
||||
};
|
||||
|
||||
export type UpdateWorkspaceMetadataRequest = {
|
||||
display_name: string;
|
||||
revision: string;
|
||||
};
|
||||
|
||||
export type WorkspaceMetadataMutationResponse = {
|
||||
workspace: WorkspaceMetadataSettingsResponse;
|
||||
diagnostics: Array<Diagnostic>;
|
||||
};
|
||||
|
||||
export type ProfileSettingsResponse = {
|
||||
workspace_id: string;
|
||||
registry_revision: string;
|
||||
config_revision?: number | null;
|
||||
tree_digest?: string | null;
|
||||
projection_digest?: string | null;
|
||||
default_profile?: string | null;
|
||||
profiles: Array<WorkspaceProfileSummary>;
|
||||
sources: Array<WorkspaceProfileSourceSummary>;
|
||||
diagnostics: Array<Diagnostic>;
|
||||
};
|
||||
|
||||
export type WorkspaceProfileSummary = {
|
||||
profile_id: string;
|
||||
selector: string;
|
||||
label: string;
|
||||
source_kind: string;
|
||||
profile_source_id?: string | null;
|
||||
description?: string | null;
|
||||
editable: boolean;
|
||||
is_default: boolean;
|
||||
diagnostics: Array<Diagnostic>;
|
||||
};
|
||||
|
||||
export type WorkspaceProfileSourceSummary = {
|
||||
profile_source_id: string;
|
||||
display_path: string;
|
||||
kind: string;
|
||||
content_type: string;
|
||||
content_digest: string;
|
||||
provenance: WorkspaceProfileSourceProvenance;
|
||||
editable: boolean;
|
||||
revision: string;
|
||||
size_bytes: number;
|
||||
diagnostics: Array<Diagnostic>;
|
||||
};
|
||||
|
||||
export type WorkspaceProfileSourceProvenance = "project_profile_source_tree";
|
||||
|
||||
export type RepositorySourceKind =
|
||||
| "local_path"
|
||||
| "file"
|
||||
@@ -117,8 +174,7 @@ export type GitRepositorySummary = {
|
||||
};
|
||||
|
||||
export type RepositorySummary = {
|
||||
id: string;
|
||||
display_name: string;
|
||||
repository_key: string;
|
||||
kind: string;
|
||||
provider: string;
|
||||
source: RepositorySource;
|
||||
@@ -158,7 +214,7 @@ export type RepositoryDetailResponse = {
|
||||
|
||||
export type RepositoryLogResponse = {
|
||||
workspace_id: string;
|
||||
repository_id: string;
|
||||
repository_key: string;
|
||||
default_selector?: string | null;
|
||||
limit: number;
|
||||
items: Array<GitCommitSummary>;
|
||||
|
||||
@@ -43,17 +43,21 @@ Deno.test("root layout leaves Workspace selection explicit", async () => {
|
||||
new URL("./../../../routes/+layout.ts", import.meta.url),
|
||||
);
|
||||
assert(
|
||||
!layout.includes("/api/workspace") &&
|
||||
!layout.includes('"/api/workspace"') &&
|
||||
!layout.includes("redirect(") &&
|
||||
layout.includes("Workspace selection is explicit"),
|
||||
"root layout must not infer or redirect to a singleton Workspace",
|
||||
layout.includes("listWorkspaces(fetch)") &&
|
||||
layout.includes("accessibleWorkspaces"),
|
||||
"root layout may list accessible Workspaces but 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),
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/+layout.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
Deno.readTextFile(new URL("./../multiplexer.ts", import.meta.url)),
|
||||
]);
|
||||
|
||||
@@ -19,7 +19,7 @@ export type MergeRequestThreadEvent = {
|
||||
export type MergeRequestRecord = {
|
||||
merge_request_id: string;
|
||||
workspace_id: string;
|
||||
repository_id: string;
|
||||
repository_key: string;
|
||||
selector_from: string | null;
|
||||
selector_to: string;
|
||||
ticket_ids: string[];
|
||||
|
||||
@@ -112,12 +112,12 @@ export function parseRepositoryAccessProjection(
|
||||
bindings.forEach((binding, index) => {
|
||||
const bindingPath = `${path}.bindings[${index}]`;
|
||||
const bindingRecord = readRecord(binding, bindingPath, [
|
||||
"repository_id",
|
||||
"repository_key",
|
||||
"credential_id",
|
||||
"host_trust_id",
|
||||
"access",
|
||||
]);
|
||||
readString(bindingRecord, "repository_id", bindingPath);
|
||||
readString(bindingRecord, "repository_key", bindingPath);
|
||||
readString(bindingRecord, "credential_id", bindingPath);
|
||||
readString(bindingRecord, "host_trust_id", bindingPath);
|
||||
const access = readString(bindingRecord, "access", bindingPath);
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
|
||||
const SUMMARY_KEYS = new Set([
|
||||
"working_directory_id",
|
||||
"repository_id",
|
||||
"repository_key",
|
||||
"creation_selector",
|
||||
"creation_ref",
|
||||
"creation_tree",
|
||||
@@ -28,7 +28,7 @@ const SUMMARY_KEYS = new Set([
|
||||
]);
|
||||
const CREATE_REQUEST_KEYS = new Set([
|
||||
"runtime_id",
|
||||
"repository_id",
|
||||
"repository_key",
|
||||
"selector",
|
||||
"operation_id",
|
||||
]);
|
||||
@@ -36,7 +36,7 @@ const DIAGNOSTIC_KEYS = new Set(["code", "severity", "message"]);
|
||||
const CLEANUP_TARGET_KEYS = new Set([
|
||||
"kind",
|
||||
"working_directory_id",
|
||||
"repository_id",
|
||||
"repository_key",
|
||||
]);
|
||||
const OCCUPANCY_KEYS = new Set([
|
||||
"runtime_id",
|
||||
@@ -81,7 +81,7 @@ export function validateWorkingDirectoryCreateRequest(
|
||||
"Workdir create request",
|
||||
);
|
||||
const request: WorkingDirectoryCreateRequest = {
|
||||
repository_id: stringField(record, "repository_id"),
|
||||
repository_key: stringField(record, "repository_key"),
|
||||
};
|
||||
assignOptionalString(request, record, "runtime_id");
|
||||
assignOptionalString(request, record, "selector");
|
||||
@@ -110,7 +110,7 @@ function parseSummary(value: unknown): WorkingDirectorySummary {
|
||||
const record = exactRecord(value, SUMMARY_KEYS, "Workdir summary");
|
||||
const summary: WorkingDirectorySummary = {
|
||||
working_directory_id: stringField(record, "working_directory_id"),
|
||||
repository_id: stringField(record, "repository_id"),
|
||||
repository_key: stringField(record, "repository_key"),
|
||||
materializer_kind: enumField(record, "materializer_kind", [
|
||||
"runtime_git_cache",
|
||||
"local_git_worktree",
|
||||
@@ -166,7 +166,7 @@ function parseCleanupTarget(value: unknown): WorkingDirectoryCleanupTarget {
|
||||
return {
|
||||
kind: stringField(record, "kind"),
|
||||
working_directory_id: stringField(record, "working_directory_id"),
|
||||
repository_id: stringField(record, "repository_id"),
|
||||
repository_key: stringField(record, "repository_key"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@ export type CreateWorkspaceRequest = {
|
||||
operation_key: string;
|
||||
display_name: string;
|
||||
repository: {
|
||||
repository_key: string;
|
||||
uri: string;
|
||||
display_name: string | null;
|
||||
default_ref: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -170,8 +170,7 @@ function repositorySummary(value: unknown, path: string): RepositorySummary {
|
||||
exactKeys(
|
||||
item,
|
||||
[
|
||||
"id",
|
||||
"display_name",
|
||||
"repository_key",
|
||||
"kind",
|
||||
"provider",
|
||||
"source",
|
||||
@@ -200,8 +199,7 @@ function repositorySummary(value: unknown, path: string): RepositorySummary {
|
||||
repositoryDiagnostic(entry, `${path}.diagnostics[${index}]`)
|
||||
);
|
||||
return {
|
||||
id: string(item.id, `${path}.id`),
|
||||
display_name: string(item.display_name, `${path}.display_name`),
|
||||
repository_key: string(item.repository_key, `${path}.repository_key`),
|
||||
kind: string(item.kind, `${path}.kind`),
|
||||
provider: string(item.provider, `${path}.provider`),
|
||||
source: repositorySource(item.source, `${path}.source`),
|
||||
@@ -263,8 +261,7 @@ function workspaceRepositoryRecord(
|
||||
item,
|
||||
[
|
||||
"workspace_id",
|
||||
"repository_id",
|
||||
"name",
|
||||
"repository_key",
|
||||
"kind",
|
||||
"provider",
|
||||
"source",
|
||||
@@ -287,8 +284,7 @@ function workspaceRepositoryRecord(
|
||||
}
|
||||
return {
|
||||
workspace_id: string(item.workspace_id, `${path}.workspace_id`),
|
||||
repository_id: string(item.repository_id, `${path}.repository_id`),
|
||||
name: string(item.name, `${path}.name`),
|
||||
repository_key: string(item.repository_key, `${path}.repository_key`),
|
||||
kind: string(item.kind, `${path}.kind`),
|
||||
provider: nullableString(item.provider, `${path}.provider`),
|
||||
source: repositorySource(item.source, `${path}.source`),
|
||||
@@ -573,7 +569,7 @@ export function parseRepositoryLogResponse(
|
||||
response,
|
||||
[
|
||||
"workspace_id",
|
||||
"repository_id",
|
||||
"repository_key",
|
||||
"default_selector",
|
||||
"limit",
|
||||
"items",
|
||||
@@ -586,9 +582,9 @@ export function parseRepositoryLogResponse(
|
||||
response.workspace_id,
|
||||
"repository log response.workspace_id",
|
||||
),
|
||||
repository_id: string(
|
||||
response.repository_id,
|
||||
"repository log response.repository_id",
|
||||
repository_key: string(
|
||||
response.repository_key,
|
||||
"repository log response.repository_key",
|
||||
),
|
||||
default_selector: optionalNullableString(
|
||||
response.default_selector,
|
||||
|
||||
@@ -37,12 +37,21 @@
|
||||
type ComposerPaste,
|
||||
type ComposerTextPaste,
|
||||
} from "$lib/workspace/console/composer-draft.ts";
|
||||
import {
|
||||
ComposerHistory,
|
||||
loadComposerHistory,
|
||||
saveComposerHistory,
|
||||
shouldBrowseComposerHistory,
|
||||
type ComposerHistoryDirection,
|
||||
type ComposerHistoryEntry,
|
||||
} from "$lib/workspace/console/composer-history.ts";
|
||||
import { shouldSubmitChatKey } from "$lib/workspace/console/chat-submit.ts";
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
ariaKeyShortcuts?: string;
|
||||
historyScope: string;
|
||||
onchange?: (snapshot: ComposerDraftSnapshot) => void;
|
||||
onkeydown?: (event: KeyboardEvent) => void;
|
||||
onsubmit?: () => void;
|
||||
@@ -52,6 +61,7 @@
|
||||
disabled = false,
|
||||
ariaLabel = "Message",
|
||||
ariaKeyShortcuts = "Meta+Enter Control+Enter",
|
||||
historyScope,
|
||||
onchange,
|
||||
onkeydown,
|
||||
onsubmit,
|
||||
@@ -59,6 +69,8 @@
|
||||
|
||||
let mountElement: HTMLDivElement;
|
||||
let view: EditorView | null = null;
|
||||
let composerHistory = new ComposerHistory();
|
||||
let restoringHistory = false;
|
||||
let nextPasteId = 1;
|
||||
let nextPasteKey = 1;
|
||||
const editable = new Compartment();
|
||||
@@ -179,6 +191,40 @@
|
||||
onchange?.(currentSnapshot());
|
||||
}
|
||||
|
||||
function historyEntry(state: EditorState): ComposerHistoryEntry {
|
||||
const snapshot = currentSnapshot(state);
|
||||
return {
|
||||
segments: snapshot.segments,
|
||||
preserveExactText: snapshot.textPastes.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function browseHistory(currentView: EditorView, direction: ComposerHistoryDirection): boolean {
|
||||
const selection = currentView.state.selection.main;
|
||||
const cursorLine = currentView.state.doc.lineAt(selection.head).number;
|
||||
if (!shouldBrowseComposerHistory({
|
||||
direction,
|
||||
cursorLine,
|
||||
lineCount: currentView.state.doc.lines,
|
||||
selectionEmpty: selection.empty,
|
||||
readOnly: currentView.state.readOnly,
|
||||
composing: currentView.composing,
|
||||
})) return false;
|
||||
|
||||
const entry = direction === "older"
|
||||
? composerHistory.previous(historyEntry(currentView.state))
|
||||
: composerHistory.next();
|
||||
if (!entry) return false;
|
||||
|
||||
restoringHistory = true;
|
||||
try {
|
||||
restoreSegments(entry.segments, entry.preserveExactText);
|
||||
} finally {
|
||||
restoringHistory = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function insertPasteChip(content: string, measurement: ComposerPasteMeasurement): void {
|
||||
if (!view) return;
|
||||
const selection = view.state.selection.main;
|
||||
@@ -273,6 +319,10 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
composerHistory = loadComposerHistory(localStorage, historyScope);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
view = new EditorView({
|
||||
parent: mountElement,
|
||||
@@ -292,6 +342,14 @@
|
||||
key: "Mod-y",
|
||||
run: (currentView) => currentView.state.readOnly,
|
||||
},
|
||||
{
|
||||
key: "ArrowUp",
|
||||
run: (currentView) => browseHistory(currentView, "older"),
|
||||
},
|
||||
{
|
||||
key: "ArrowDown",
|
||||
run: (currentView) => browseHistory(currentView, "newer"),
|
||||
},
|
||||
{
|
||||
key: "Backspace",
|
||||
run: (currentView) =>
|
||||
@@ -319,6 +377,7 @@
|
||||
spellcheck: "true",
|
||||
}),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged && !restoringHistory) composerHistory.cancelNavigation();
|
||||
if (update.docChanged || update.transactions.some((tx) => tx.effects.length > 0)) {
|
||||
emitChange();
|
||||
}
|
||||
@@ -396,6 +455,16 @@
|
||||
return currentSnapshot();
|
||||
}
|
||||
|
||||
export function recordHistory(value: ComposerDraftSnapshot): void {
|
||||
const entry: ComposerHistoryEntry = {
|
||||
segments: value.segments,
|
||||
preserveExactText: value.textPastes.length > 0,
|
||||
};
|
||||
if (composerHistory.record(entry)) {
|
||||
saveComposerHistory(localStorage, historyScope, composerHistory);
|
||||
}
|
||||
}
|
||||
|
||||
export function focus(): void {
|
||||
view?.focus();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
acceptedAttachmentMediaType,
|
||||
MAX_UPLOADED_FILE_BYTES,
|
||||
validateAttachmentFile,
|
||||
} from "./composer-attachments.ts";
|
||||
|
||||
declare const Deno: { test(name: string, fn: () => void): void };
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
Deno.test("attachment validation accepts bounded text and image files", () => {
|
||||
assert(acceptedAttachmentMediaType("text/plain"), "text must be accepted");
|
||||
assert(acceptedAttachmentMediaType("image/png"), "png must be accepted");
|
||||
assert(
|
||||
!acceptedAttachmentMediaType("application/x-executable"),
|
||||
"executables must be rejected",
|
||||
);
|
||||
const valid = { name: "notes.md", type: "text/markdown", size: 32 } as File;
|
||||
assert(validateAttachmentFile(valid) === null, "bounded text should pass");
|
||||
});
|
||||
|
||||
Deno.test("attachment validation rejects over-limit files", () => {
|
||||
const tooLarge = {
|
||||
name: "large.txt",
|
||||
type: "text/plain",
|
||||
size: MAX_UPLOADED_FILE_BYTES + 1,
|
||||
} as File;
|
||||
assert(validateAttachmentFile(tooLarge)?.includes("10 MiB"), "limit should be explicit");
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { UploadedFileRef } from "$lib/generated/protocol.ts";
|
||||
|
||||
export const MAX_UPLOADED_FILE_BYTES = 10 * 1024 * 1024;
|
||||
export const MAX_FILES_PER_SUBMISSION = 8;
|
||||
|
||||
export type AttachmentUploadState = "uploading" | "uploaded" | "failed";
|
||||
|
||||
export type AttachmentUploadHandle = { abort(): void };
|
||||
|
||||
export type ComposerAttachment = {
|
||||
id: number;
|
||||
file: File;
|
||||
uploadPath: string;
|
||||
uploadId: string;
|
||||
state: AttachmentUploadState;
|
||||
progress: number;
|
||||
reference: UploadedFileRef | null;
|
||||
error: string | null;
|
||||
request: AttachmentUploadHandle | null;
|
||||
};
|
||||
|
||||
export function acceptedAttachmentMediaType(mediaType: string): boolean {
|
||||
return mediaType.startsWith("text/") ||
|
||||
mediaType === "application/json" ||
|
||||
mediaType === "application/pdf" ||
|
||||
mediaType === "image/png" ||
|
||||
mediaType === "image/jpeg" ||
|
||||
mediaType === "image/gif" ||
|
||||
mediaType === "image/webp";
|
||||
}
|
||||
|
||||
export function validateAttachmentFile(file: File): string | null {
|
||||
if (file.size > MAX_UPLOADED_FILE_BYTES) {
|
||||
return `File exceeds the ${MAX_UPLOADED_FILE_BYTES / 1024 / 1024} MiB limit.`;
|
||||
}
|
||||
if (!acceptedAttachmentMediaType(file.type)) {
|
||||
return `Unsupported file type: ${file.type || "unknown"}.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export type AttachmentUploadCallbacks = {
|
||||
progress(value: number): void;
|
||||
complete(reference: UploadedFileRef): void;
|
||||
failed(message: string): void;
|
||||
};
|
||||
|
||||
export function uploadAttachment(
|
||||
workerPath: string,
|
||||
file: File,
|
||||
uploadId: string,
|
||||
callbacks: AttachmentUploadCallbacks,
|
||||
): AttachmentUploadHandle {
|
||||
let activeRequest: XMLHttpRequest | null = null;
|
||||
let aborted = false;
|
||||
const handle: AttachmentUploadHandle = {
|
||||
abort() {
|
||||
aborted = true;
|
||||
activeRequest?.abort();
|
||||
void fetch(
|
||||
`${workerPath}/attachment-uploads/${encodeURIComponent(uploadId)}`,
|
||||
{ method: "DELETE" },
|
||||
).catch(() => undefined);
|
||||
},
|
||||
};
|
||||
const query = new URLSearchParams({
|
||||
file_name: file.name,
|
||||
media_type: file.type,
|
||||
upload_id: uploadId,
|
||||
});
|
||||
const grantRequest = new XMLHttpRequest();
|
||||
activeRequest = grantRequest;
|
||||
grantRequest.open(
|
||||
"POST",
|
||||
`${workerPath}/attachment-upload-grants?${query.toString()}`,
|
||||
);
|
||||
grantRequest.addEventListener("load", () => {
|
||||
if (aborted) return;
|
||||
if (grantRequest.status < 200 || grantRequest.status >= 300) {
|
||||
callbacks.failed(`Upload grant failed (${grantRequest.status}).`);
|
||||
return;
|
||||
}
|
||||
let uploadId: string;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(grantRequest.responseText);
|
||||
if (!isUploadGrantResponse(parsed)) {
|
||||
callbacks.failed("Upload grant returned an invalid response.");
|
||||
return;
|
||||
}
|
||||
uploadId = parsed.upload_id;
|
||||
} catch {
|
||||
callbacks.failed("Upload grant returned an invalid response.");
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadRequest = new XMLHttpRequest();
|
||||
activeRequest = uploadRequest;
|
||||
uploadRequest.open(
|
||||
"PUT",
|
||||
`${workerPath}/attachment-uploads/${encodeURIComponent(uploadId)}`,
|
||||
);
|
||||
uploadRequest.setRequestHeader("content-type", "application/octet-stream");
|
||||
uploadRequest.upload.addEventListener("progress", (event) => {
|
||||
if (event.lengthComputable && event.total > 0) {
|
||||
callbacks.progress(Math.min(1, event.loaded / event.total));
|
||||
}
|
||||
});
|
||||
uploadRequest.addEventListener("load", () => {
|
||||
if (uploadRequest.status < 200 || uploadRequest.status >= 300) {
|
||||
callbacks.failed(`Upload failed (${uploadRequest.status}).`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(uploadRequest.responseText);
|
||||
if (!isUploadedFileResponse(parsed)) {
|
||||
callbacks.failed("Upload returned an invalid attachment reference.");
|
||||
return;
|
||||
}
|
||||
callbacks.complete(parsed.file);
|
||||
} catch {
|
||||
callbacks.failed("Upload returned an invalid response.");
|
||||
}
|
||||
});
|
||||
uploadRequest.addEventListener("error", () => callbacks.failed("Upload failed."));
|
||||
uploadRequest.addEventListener("abort", () => callbacks.failed("Upload cancelled."));
|
||||
uploadRequest.send(file);
|
||||
});
|
||||
grantRequest.addEventListener("error", () => callbacks.failed("Upload grant failed."));
|
||||
grantRequest.addEventListener("abort", () => callbacks.failed("Upload cancelled."));
|
||||
grantRequest.send();
|
||||
return handle;
|
||||
}
|
||||
|
||||
function isUploadGrantResponse(
|
||||
value: unknown,
|
||||
): value is { upload_id: string; expires_at_ms: number } {
|
||||
return !!value && typeof value === "object" &&
|
||||
"upload_id" in value && typeof value.upload_id === "string" &&
|
||||
"expires_at_ms" in value && typeof value.expires_at_ms === "number";
|
||||
}
|
||||
|
||||
function isUploadedFileResponse(
|
||||
value: unknown,
|
||||
): value is { file: UploadedFileRef } {
|
||||
if (!value || typeof value !== "object" || !("file" in value)) return false;
|
||||
const file = value.file;
|
||||
return !!file && typeof file === "object" &&
|
||||
"artifact_id" in file && typeof file.artifact_id === "string" &&
|
||||
"file_name" in file && typeof file.file_name === "string" &&
|
||||
"media_type" in file && typeof file.media_type === "string" &&
|
||||
"byte_len" in file && typeof file.byte_len === "number" &&
|
||||
"sha256" in file && typeof file.sha256 === "string" &&
|
||||
"availability" in file && file.availability === "available";
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
buildComposerRequest,
|
||||
buildComposerSegmentsRequest,
|
||||
parseSigilSegments,
|
||||
} from "./composer-command.ts";
|
||||
|
||||
@@ -25,6 +26,26 @@ Deno.test("parseSigilSegments leaves hash sigils as plain text", () => {
|
||||
}]);
|
||||
});
|
||||
|
||||
Deno.test("uploaded-file-only input remains a typed run request", () => {
|
||||
const file = {
|
||||
artifact_id: "01900000-0000-7000-8000-000000000001",
|
||||
file_name: "notes.md",
|
||||
media_type: "text/markdown",
|
||||
created_at_ms: 1,
|
||||
availability: "available" as const,
|
||||
byte_len: 12,
|
||||
sha256: "a".repeat(64),
|
||||
};
|
||||
assertEquals(buildComposerSegmentsRequest([{ kind: "uploaded_file", file }]), {
|
||||
ok: true,
|
||||
request: {
|
||||
kind: "user",
|
||||
content: "[Attached file: notes.md]",
|
||||
segments: [{ kind: "uploaded_file", file }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("notify command exposes the operation instead of a System-role input", () => {
|
||||
assertEquals(buildComposerRequest(":notify reread the Ticket"), {
|
||||
ok: true,
|
||||
|
||||
@@ -88,8 +88,10 @@ export function buildComposerSegmentsRequest(
|
||||
sourceSegments: readonly Segment[],
|
||||
options: ComposerSegmentsRequestOptions = {},
|
||||
): ComposerCommandResult {
|
||||
const hasPaste = sourceSegments.some((segment) => segment.kind === "paste");
|
||||
if (!hasPaste) {
|
||||
const hasRichSegment = sourceSegments.some((segment) =>
|
||||
segment.kind === "paste" || segment.kind === "uploaded_file"
|
||||
);
|
||||
if (!hasRichSegment) {
|
||||
const content = sourceSegments.map(segmentContent).join("");
|
||||
if (!options.preserveExactText || content.trimStart().startsWith(":")) {
|
||||
return buildComposerRequest(content);
|
||||
@@ -121,7 +123,7 @@ export function buildComposerSegmentsRequest(
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
"Commands cannot include a paste chip. Remove the chip or send it as a message.",
|
||||
"Commands cannot include paste or attachment chips. Remove the chip or send it as a message.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -154,6 +156,8 @@ function segmentContent(segment: Segment): string {
|
||||
return segment.selector;
|
||||
case "paste_artifact":
|
||||
return "";
|
||||
case "uploaded_file":
|
||||
return `[Attached file: ${segment.file.file_name}]`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user