feat(workspace): allow creation without an initial repository
Keep bootstrap, signing identity, config and resource-key allocation atomic with optional Repository intent. Preserve repository-present fingerprints for saved receipts; replay reads current authority at the initial key and never recreates missing assets. Provide explicit repository-free CLI creation, optional TUI/Web creation inputs, nullable generated contracts, and valid empty catalog/Worker paths. Retain existing authenticated non-owner viewing policy pending the T-717 authorization decision. Validation: root cargo check; server tests 639 lib + 11 bin; server-api 70 default / 77 typescript; client 52 lib + 2 integration; tui 337; yoi 11 lib + 120 bin. OpenAPI/TS freshness, cargo fmt and diff checks pass. Web check/build, 18 model tests, 268 component tests and Chromium 8 width/theme combinations pass; before/after visual evidence is documented in web/workspace/test/visual-review. Full Web suite has one independently reproduced baseline Ticket source-policy failure.
This commit is contained in:
@@ -434,11 +434,11 @@ mod tests {
|
||||
let request = CreateBackendWorkspaceRequest {
|
||||
operation_key: "workspace-create-1".to_string(),
|
||||
display_name: "Alpha".to_string(),
|
||||
repository: CreateBackendWorkspaceRepository {
|
||||
repository: Some(CreateBackendWorkspaceRepository {
|
||||
repository_key: "main".to_string(),
|
||||
uri: "/srv/repos/alpha".to_string(),
|
||||
default_ref: Some("develop".to_string()),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
let retry = request.clone();
|
||||
|
||||
@@ -8,11 +8,11 @@ fn workspace_creation_request_preserves_operation_key_for_retry() {
|
||||
let request = CreateBackendWorkspaceRequest {
|
||||
operation_key: "workspace-create-1".to_string(),
|
||||
display_name: "Alpha".to_string(),
|
||||
repository: CreateBackendWorkspaceRepository {
|
||||
repository: Some(CreateBackendWorkspaceRepository {
|
||||
repository_key: "main".to_string(),
|
||||
uri: "/srv/repos/alpha".to_string(),
|
||||
default_ref: Some("develop".to_string()),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
assert_eq!(request.clone(), request);
|
||||
|
||||
@@ -4239,22 +4239,27 @@ pub struct InitialRepositoryIntent {
|
||||
pub default_ref: Option<String>,
|
||||
}
|
||||
|
||||
/// Request for atomically creating a Workspace and its initial Repository.
|
||||
/// Request for atomically creating a Workspace with an optional initial Repository.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceCreateRequest {
|
||||
pub operation_key: String,
|
||||
pub display_name: String,
|
||||
pub repository: InitialRepositoryIntent,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub repository: Option<InitialRepositoryIntent>,
|
||||
}
|
||||
|
||||
/// Response returned after atomically creating a Workspace and its first Repository.
|
||||
/// Response returned after atomically creating a Workspace.
|
||||
///
|
||||
/// On replay, Workspace/config and the Repository at the originally requested key are
|
||||
/// current authority, not historical snapshots. Repository is null when creation omitted
|
||||
/// it or that key has since been removed/renamed; replay never recreates an asset.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceCreateResponse {
|
||||
pub workspace: WorkspaceSummary,
|
||||
pub repository: WorkspaceRepositoryRecord,
|
||||
pub repository: Option<WorkspaceRepositoryRecord>,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub config_revision: u64,
|
||||
@@ -12204,12 +12209,27 @@ mod tests {
|
||||
let request = WorkspaceCreateRequest {
|
||||
operation_key: "workspace-create-1".to_string(),
|
||||
display_name: "Workspace".to_string(),
|
||||
repository: InitialRepositoryIntent {
|
||||
repository: Some(InitialRepositoryIntent {
|
||||
repository_key: "main".to_string(),
|
||||
uri: "/srv/repositories/main".to_string(),
|
||||
default_ref: Some("develop".to_string()),
|
||||
},
|
||||
}),
|
||||
};
|
||||
for repository in [None, Some(serde_json::Value::Null)] {
|
||||
let mut wire =
|
||||
serde_json::json!({"operation_key": "empty", "display_name": "Workspace"});
|
||||
if let Some(repository) = repository {
|
||||
wire["repository"] = repository;
|
||||
}
|
||||
let decoded: WorkspaceCreateRequest = serde_json::from_value(wire).unwrap();
|
||||
assert!(decoded.repository.is_none());
|
||||
assert!(
|
||||
serde_json::to_value(decoded)
|
||||
.unwrap()
|
||||
.get("repository")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
let json = serde_json::to_value(&request).unwrap();
|
||||
assert_eq!(json["operation_key"], "workspace-create-1");
|
||||
assert_eq!(json["repository"]["uri"], "/srv/repositories/main");
|
||||
|
||||
@@ -199,30 +199,40 @@ fn prompt_create_request_with_io(
|
||||
if display_name.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(uri) = prompt_line(input, output, "Initial repository absolute path/URI: ")? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let uri = uri.trim().to_string();
|
||||
if uri.is_empty() {
|
||||
writeln!(output, "Repository path/URI is required.")?;
|
||||
return Ok(None);
|
||||
}
|
||||
let repository_key = loop {
|
||||
let Some(repository_key) = prompt_line(input, output, "Repository key (required): ")?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if repository_key.trim().is_empty() {
|
||||
writeln!(output, "Repository key is required.")?;
|
||||
continue;
|
||||
}
|
||||
break repository_key;
|
||||
};
|
||||
let Some(default_ref) = prompt_line(input, output, "Default ref [repository default]: ")?
|
||||
let Some(uri) = prompt_line(
|
||||
input,
|
||||
output,
|
||||
"Initial repository absolute path/URI (optional, Enter to skip): ",
|
||||
)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let default_ref = default_ref.trim().to_string();
|
||||
let uri = uri.trim().to_string();
|
||||
let repository = if uri.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let repository_key = loop {
|
||||
let Some(repository_key) = prompt_line(input, output, "Repository key (required): ")?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if repository_key.trim().is_empty() {
|
||||
writeln!(output, "Repository key is required.")?;
|
||||
continue;
|
||||
}
|
||||
break repository_key;
|
||||
};
|
||||
let Some(default_ref) = prompt_line(input, output, "Default ref [repository default]: ")?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let default_ref = default_ref.trim().to_string();
|
||||
Some(CreateBackendWorkspaceRepository {
|
||||
uri,
|
||||
repository_key,
|
||||
default_ref: (!default_ref.is_empty()).then_some(default_ref),
|
||||
})
|
||||
};
|
||||
let operation_key = format!(
|
||||
"tui-workspace-create-{}-{}",
|
||||
std::process::id(),
|
||||
@@ -234,11 +244,7 @@ fn prompt_create_request_with_io(
|
||||
Ok(Some(CreateBackendWorkspaceRequest {
|
||||
operation_key,
|
||||
display_name,
|
||||
repository: CreateBackendWorkspaceRepository {
|
||||
uri,
|
||||
repository_key,
|
||||
default_ref: (!default_ref.is_empty()).then_some(default_ref),
|
||||
},
|
||||
repository,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -286,15 +292,29 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_creation_without_initial_repository_skips_repository_prompts() {
|
||||
let (request, output) = prompt_create("Workspace\n\n");
|
||||
assert!(request.unwrap().repository.is_none());
|
||||
assert!(!output.contains("Repository key (required)"));
|
||||
assert!(!output.contains("Default ref"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_creation_preserves_an_explicit_non_main_repository_key() {
|
||||
let (request, output) = prompt_create("Platform\n/srv/platform\nplatform\ndevelop\n");
|
||||
let request = request.expect("complete input should create a request");
|
||||
|
||||
assert_eq!(request.display_name, "Platform");
|
||||
assert_eq!(request.repository.uri, "/srv/platform");
|
||||
assert_eq!(request.repository.repository_key, "platform");
|
||||
assert_eq!(request.repository.default_ref.as_deref(), Some("develop"));
|
||||
assert_eq!(request.repository.as_ref().unwrap().uri, "/srv/platform");
|
||||
assert_eq!(
|
||||
request.repository.as_ref().unwrap().repository_key,
|
||||
"platform"
|
||||
);
|
||||
assert_eq!(
|
||||
request.repository.as_ref().unwrap().default_ref.as_deref(),
|
||||
Some("develop")
|
||||
);
|
||||
assert!(output.contains("Repository key (required): "));
|
||||
assert!(!output.contains("Repository key [main]"));
|
||||
}
|
||||
@@ -304,7 +324,10 @@ mod tests {
|
||||
let (request, output) = prompt_create("Platform\n/srv/platform\n\n \nplatform\n\n");
|
||||
let request = request.expect("a later explicit key should create a request");
|
||||
|
||||
assert_eq!(request.repository.repository_key, "platform");
|
||||
assert_eq!(
|
||||
request.repository.as_ref().unwrap().repository_key,
|
||||
"platform"
|
||||
);
|
||||
assert_eq!(output.matches("Repository key is required.").count(), 2);
|
||||
assert_eq!(output.matches("Repository key (required): ").count(), 3);
|
||||
}
|
||||
@@ -314,7 +337,7 @@ mod tests {
|
||||
let (request, _) = prompt_create("Workspace\n/srv/repository\n platform \n\n");
|
||||
|
||||
assert_eq!(
|
||||
request.unwrap().repository.repository_key,
|
||||
request.unwrap().repository.unwrap().repository_key,
|
||||
" platform ",
|
||||
"the Backend must validate the creator's exact Repository key"
|
||||
);
|
||||
@@ -325,7 +348,7 @@ mod tests {
|
||||
let (request, _) = prompt_create("Workspace\n/srv/repository\nmain\n\n");
|
||||
|
||||
assert_eq!(
|
||||
request.unwrap().repository.repository_key,
|
||||
request.unwrap().repository.unwrap().repository_key,
|
||||
"main",
|
||||
"main remains valid when the creator enters it explicitly"
|
||||
);
|
||||
|
||||
@@ -75,18 +75,6 @@ impl RepositoryRegistryReader {
|
||||
}
|
||||
|
||||
pub fn list(&self) -> RepositoryListProjection {
|
||||
if self.repositories.is_empty() {
|
||||
return RepositoryListProjection {
|
||||
items: Vec::new(),
|
||||
diagnostics: vec![Diagnostic {
|
||||
severity: DiagnosticSeverity::Warning,
|
||||
code: "repository_config_empty".to_string(),
|
||||
message: "No repositories are configured for this workspace backend."
|
||||
.to_string(),
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
RepositoryListProjection {
|
||||
items: self
|
||||
.repositories
|
||||
@@ -598,12 +586,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_registry_reports_diagnostic_without_implicit_repository() {
|
||||
fn empty_registry_is_valid_without_warning_or_implicit_repository() {
|
||||
let projection = RepositoryRegistryReader::new(Vec::new()).list();
|
||||
|
||||
assert!(projection.items.is_empty());
|
||||
assert_eq!(projection.diagnostics.len(), 1);
|
||||
assert_eq!(projection.diagnostics[0].code, "repository_config_empty");
|
||||
assert!(projection.diagnostics.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -355,14 +355,16 @@ impl ServerConfig {
|
||||
workspace: &WorkspaceRecord,
|
||||
repositories: Vec<RepositoryRecord>,
|
||||
) -> Result<Self> {
|
||||
if repositories.is_empty() {
|
||||
return Err(Error::Config(format!(
|
||||
"Workspace {} has no registered repository",
|
||||
workspace.workspace_id
|
||||
)));
|
||||
}
|
||||
let workspace_execution_root =
|
||||
Self::default_workspace_backend_data_root(&workspace.workspace_id);
|
||||
// Derive scoped storage from the configured authority database, not process-global
|
||||
// defaults. This also keeps custom server roots and temporary API fixtures isolated.
|
||||
let workspace_execution_root = self
|
||||
.database_path
|
||||
.parent()
|
||||
.ok_or_else(|| {
|
||||
Error::Config("Server database path must have a parent directory".to_string())
|
||||
})?
|
||||
.join("workspaces")
|
||||
.join(&workspace.workspace_id);
|
||||
let repositories = repositories
|
||||
.into_iter()
|
||||
.map(|repository| ConfiguredRepository {
|
||||
@@ -386,9 +388,8 @@ impl ServerConfig {
|
||||
scoped
|
||||
.workspace_created_at
|
||||
.clone_from(&workspace.created_at);
|
||||
scoped.embedded_runtime_store_root = workspace_execution_root.join("embedded-runtime");
|
||||
scoped.workspace_execution_root = workspace_execution_root;
|
||||
scoped.embedded_runtime_store_root =
|
||||
Self::default_embedded_runtime_store_root(&workspace.workspace_id);
|
||||
scoped.repositories = repositories;
|
||||
// Runtime trust is server-global. Only explicitly assigned sources enter
|
||||
// this Workspace's registry and receive Workspace-scoped capabilities.
|
||||
@@ -2549,7 +2550,7 @@ fn workspace_create_response(
|
||||
) -> WorkspaceCreateResponse {
|
||||
WorkspaceCreateResponse {
|
||||
workspace: workspace_summary(created.workspace),
|
||||
repository: workspace_repository_record(created.repository),
|
||||
repository: created.repository.map(workspace_repository_record),
|
||||
config_revision: created.config_revision,
|
||||
request_fingerprint: created.request_fingerprint,
|
||||
replayed: created.replayed,
|
||||
@@ -42748,7 +42749,10 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
scoped.workspace_execution_root,
|
||||
ServerConfig::default_workspace_backend_data_root("remote-workspace")
|
||||
base.database_path
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("workspaces/remote-workspace")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45045,6 +45049,235 @@ mod tests {
|
||||
assert!(auth["cookie_name"].is_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repository_free_workspace_can_be_created_selected_and_used_then_register_repository() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let config = test_server_config(temp.path());
|
||||
let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap());
|
||||
let owner = seed_test_api_token(store.as_ref(), "empty-owner");
|
||||
let foreign = seed_test_api_token(store.as_ref(), "empty-foreign");
|
||||
let app = build_workspace_server_router(config.clone(), store.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let wire =
|
||||
json!({"operation_key": "create-empty-workspace", "display_name": "Empty Workspace"});
|
||||
request_json(
|
||||
app.clone(),
|
||||
"POST",
|
||||
"/api/workspaces",
|
||||
Some(wire.clone()),
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await;
|
||||
let created = request_json_authenticated(
|
||||
app.clone(),
|
||||
"POST",
|
||||
"/api/workspaces",
|
||||
Some(wire.clone()),
|
||||
&owner,
|
||||
StatusCode::CREATED,
|
||||
)
|
||||
.await;
|
||||
assert!(created["repository"].is_null());
|
||||
assert!(!created["replayed"].as_bool().unwrap());
|
||||
let id = created["workspace"]["workspace_id"].as_str().unwrap();
|
||||
assert!(store.list_repositories(id).unwrap().is_empty());
|
||||
let scoped = format!("/api/w/{id}");
|
||||
|
||||
let replay = request_json_authenticated(
|
||||
app.clone(),
|
||||
"POST",
|
||||
"/api/workspaces",
|
||||
Some(wire.clone()),
|
||||
&owner,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(replay["workspace"]["workspace_id"], id);
|
||||
assert!(replay["replayed"].as_bool().unwrap());
|
||||
let repositories = request_json_authenticated(
|
||||
app.clone(),
|
||||
"GET",
|
||||
&format!("{scoped}/repositories"),
|
||||
None,
|
||||
&owner,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(repositories["items"], json!([]));
|
||||
assert_eq!(repositories["diagnostics"], json!([]));
|
||||
request_json_authenticated(
|
||||
app.clone(),
|
||||
"GET",
|
||||
&format!("{scoped}/workspace"),
|
||||
None,
|
||||
&owner,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
request_json(
|
||||
app.clone(),
|
||||
"GET",
|
||||
&format!("{scoped}/workspace"),
|
||||
None,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
)
|
||||
.await;
|
||||
let foreign_catalog = request_json_authenticated(
|
||||
app.clone(),
|
||||
"GET",
|
||||
"/api/workspaces",
|
||||
None,
|
||||
&foreign,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(foreign_catalog, json!([]));
|
||||
let deletion = request_json_authenticated(
|
||||
app.clone(),
|
||||
"GET",
|
||||
&format!("/api/workspaces/{id}/deletion"),
|
||||
None,
|
||||
&owner,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(deletion["resources"]["repositories"], 0);
|
||||
assert_eq!(
|
||||
deletion["blockers"][0]["kind"], "last_accessible_workspace",
|
||||
"{deletion}"
|
||||
);
|
||||
let worker = request_json_authenticated(app.clone(), "POST", &format!("{scoped}/workers"), Some(json!({"runtime_id": "embedded-worker-runtime", "display_name": "Worker", "initial_submit": [], "feature_connections": {}})), &owner, StatusCode::OK).await;
|
||||
assert!(worker["worker_id"].is_string(), "{worker}");
|
||||
let detail = request_json_authenticated(
|
||||
app.clone(),
|
||||
"GET",
|
||||
&format!(
|
||||
"{scoped}/runtimes/embedded-worker-runtime/workers/{}",
|
||||
worker["worker_id"].as_str().unwrap()
|
||||
),
|
||||
None,
|
||||
&owner,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(detail["profile"], "builtin:companion");
|
||||
let worker_ref = RuntimeWorkerRef::new(
|
||||
"embedded-worker-runtime",
|
||||
worker["worker_id"].as_str().unwrap(),
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.list_worker_workdir_links(id, &worker_ref)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(detail["resource_key"], "W-1");
|
||||
let repository = json!({"repository_key": "later", "source": "https://example.test/later.git", "default_ref": "main"});
|
||||
request_json_authenticated(
|
||||
app.clone(),
|
||||
"POST",
|
||||
&format!("{scoped}/repositories"),
|
||||
Some(repository.clone()),
|
||||
&foreign,
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await;
|
||||
request_json_authenticated(
|
||||
app.clone(),
|
||||
"POST",
|
||||
&format!("{scoped}/repositories"),
|
||||
Some(repository),
|
||||
&owner,
|
||||
StatusCode::CREATED,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(store.list_repositories(id).unwrap().len(), 1);
|
||||
let replay = request_json_authenticated(
|
||||
app.clone(),
|
||||
"POST",
|
||||
"/api/workspaces",
|
||||
Some(wire.clone()),
|
||||
&owner,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
replay["repository"].is_null(),
|
||||
"omitted initial repository never means a later asset"
|
||||
);
|
||||
let mut conflict = wire.clone();
|
||||
conflict["repository"] =
|
||||
json!({"repository_key": "later", "uri": "https://example.test/later.git"});
|
||||
request_json_authenticated(
|
||||
app.clone(),
|
||||
"POST",
|
||||
"/api/workspaces",
|
||||
Some(conflict),
|
||||
&owner,
|
||||
StatusCode::CONFLICT,
|
||||
)
|
||||
.await;
|
||||
for repository in [
|
||||
json!({"repository_key": "main", "uri": "relative/repo"}),
|
||||
json!({"repository_key": " main ", "uri": "/runtime/repo"}),
|
||||
] {
|
||||
let invalid = json!({"operation_key": "invalid-empty-repo", "display_name": "Workspace", "repository": repository});
|
||||
request_json_authenticated(
|
||||
app.clone(),
|
||||
"POST",
|
||||
"/api/workspaces",
|
||||
Some(invalid),
|
||||
&owner,
|
||||
StatusCode::BAD_REQUEST,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
assert_eq!(store.list_workspaces().unwrap().len(), 1);
|
||||
// With another accessible Workspace, zero repositories are not a deletion blocker.
|
||||
let second = request_json_authenticated(
|
||||
app.clone(),
|
||||
"POST",
|
||||
"/api/workspaces",
|
||||
Some(json!({"operation_key": "second-empty", "display_name": "Second Empty"})),
|
||||
&owner,
|
||||
StatusCode::CREATED,
|
||||
)
|
||||
.await;
|
||||
let second_id = second["workspace"]["workspace_id"].as_str().unwrap();
|
||||
let deletion_uri = format!("/api/workspaces/{second_id}/deletion");
|
||||
let preflight = request_json_authenticated(
|
||||
app.clone(),
|
||||
"GET",
|
||||
&deletion_uri,
|
||||
None,
|
||||
&owner,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(preflight["can_delete"], true, "{preflight}");
|
||||
assert_eq!(preflight["resources"]["repositories"], 0);
|
||||
let deletion_request = json!({"operation_id": "delete-second-empty", "expected_revision": preflight["expected_revision"], "confirmation": "Second Empty"});
|
||||
request_json_authenticated(
|
||||
app.clone(),
|
||||
"POST",
|
||||
&deletion_uri,
|
||||
Some(deletion_request.clone()),
|
||||
&foreign,
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await;
|
||||
request_json_authenticated(
|
||||
app,
|
||||
"POST",
|
||||
&deletion_uri,
|
||||
Some(deletion_request),
|
||||
&owner,
|
||||
StatusCode::ACCEPTED,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_server_router_requires_identity_for_scoped_rest() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -45087,11 +45320,11 @@ mod tests {
|
||||
WorkspaceCreateRequest {
|
||||
operation_key: "create-auth".to_owned(),
|
||||
display_name: "Auth Workspace".to_owned(),
|
||||
repository: crate::workspace_catalog::InitialRepositoryIntent {
|
||||
repository: Some(crate::workspace_catalog::InitialRepositoryIntent {
|
||||
repository_key: "main".to_string(),
|
||||
uri: repository.display().to_string(),
|
||||
default_ref: None,
|
||||
},
|
||||
}),
|
||||
},
|
||||
"account-auth".to_owned(),
|
||||
)
|
||||
@@ -45217,11 +45450,11 @@ mod tests {
|
||||
let create_request = WorkspaceCreateRequest {
|
||||
operation_key: "create-authenticated-workspace".to_owned(),
|
||||
display_name: "Authenticated Workspace".to_owned(),
|
||||
repository: crate::workspace_catalog::InitialRepositoryIntent {
|
||||
repository: Some(crate::workspace_catalog::InitialRepositoryIntent {
|
||||
repository_key: "main".to_string(),
|
||||
uri: created_repository.display().to_string(),
|
||||
default_ref: Some("develop".to_owned()),
|
||||
},
|
||||
}),
|
||||
};
|
||||
let created_response = app
|
||||
.clone()
|
||||
@@ -46345,11 +46578,11 @@ mod tests {
|
||||
WorkspaceCreateRequest {
|
||||
operation_key: "create-a".to_string(),
|
||||
display_name: "Workspace A".to_string(),
|
||||
repository: crate::workspace_catalog::InitialRepositoryIntent {
|
||||
repository: Some(crate::workspace_catalog::InitialRepositoryIntent {
|
||||
repository_key: "main".to_string(),
|
||||
uri: repository_a.display().to_string(),
|
||||
default_ref: None,
|
||||
},
|
||||
}),
|
||||
},
|
||||
"account-two-workspaces".to_owned(),
|
||||
)
|
||||
@@ -46359,11 +46592,11 @@ mod tests {
|
||||
WorkspaceCreateRequest {
|
||||
operation_key: "create-b".to_string(),
|
||||
display_name: "Workspace B".to_string(),
|
||||
repository: crate::workspace_catalog::InitialRepositoryIntent {
|
||||
repository: Some(crate::workspace_catalog::InitialRepositoryIntent {
|
||||
repository_key: "main".to_string(),
|
||||
uri: repository_b.display().to_string(),
|
||||
default_ref: None,
|
||||
},
|
||||
}),
|
||||
},
|
||||
"account-two-workspaces".to_owned(),
|
||||
)
|
||||
@@ -57520,7 +57753,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_repository_config_returns_empty_list_with_warning() {
|
||||
async fn empty_repository_config_returns_valid_empty_list_without_warning() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut config = test_server_config(root.path());
|
||||
config.repositories.clear();
|
||||
@@ -57537,10 +57770,7 @@ mod tests {
|
||||
let repositories = get_json(app, "/api/repositories").await;
|
||||
|
||||
assert!(repositories["items"].as_array().unwrap().is_empty());
|
||||
assert_eq!(
|
||||
repositories["diagnostics"][0]["code"],
|
||||
"repository_config_empty"
|
||||
);
|
||||
assert_eq!(repositories["diagnostics"], json!([]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -384,13 +384,13 @@ pub struct WorkspaceBootstrapRecord {
|
||||
pub operation_key: String,
|
||||
pub request_fingerprint: String,
|
||||
pub workspace: WorkspaceRecord,
|
||||
pub repository: RepositoryRecord,
|
||||
pub repository: Option<RepositoryRecord>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkspaceBootstrapResult {
|
||||
pub workspace: WorkspaceRecord,
|
||||
pub repository: RepositoryRecord,
|
||||
pub repository: Option<RepositoryRecord>,
|
||||
pub config_revision: u64,
|
||||
pub replayed: bool,
|
||||
}
|
||||
@@ -4431,7 +4431,14 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
signing_identity: &WorkspaceSigningIdentityActivation,
|
||||
identity_provisioning_operation_key: &str,
|
||||
) -> Result<WorkspaceBootstrapResult> {
|
||||
validate_repository_record_identity(&record.repository)?;
|
||||
if let Some(repository) = &record.repository {
|
||||
validate_repository_record_identity(repository)?;
|
||||
if repository.workspace_id != record.workspace.workspace_id {
|
||||
return Err(Error::Store(
|
||||
"Initial Repository does not belong to the Workspace bootstrap".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if signing_identity.workspace_id != record.workspace.workspace_id {
|
||||
return Err(Error::Store(
|
||||
"Workspace signing identity does not belong to the Workspace bootstrap".to_string(),
|
||||
@@ -4472,14 +4479,14 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
params![workspace_id],
|
||||
read_workspace_record,
|
||||
)?;
|
||||
let repository = tx.query_row(
|
||||
let repository = if let Some(initial) = &record.repository { tx.query_row(
|
||||
r#"SELECT workspace_id, repository_id, repository_key, kind, provider,
|
||||
source_kind, source_uri, default_ref, source_revision,
|
||||
source_fingerprint, observed_status, observed_at, created_at, updated_at
|
||||
FROM repositories WHERE workspace_id = ?1 AND repository_key = ?2"#,
|
||||
params![workspace.workspace_id, record.repository.repository_key],
|
||||
params![workspace.workspace_id, initial.repository_key],
|
||||
read_repository_record,
|
||||
)?;
|
||||
).optional()? } else { None };
|
||||
let persisted_identity = tx.query_row(
|
||||
r#"SELECT workspace_id, key_id, algorithm, public_key,
|
||||
public_key_fingerprint, private_material_ref, revision, state,
|
||||
@@ -4566,6 +4573,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
record.workspace.updated_at,
|
||||
],
|
||||
)?;
|
||||
if let Some(repository) = &record.repository {
|
||||
tx.execute(
|
||||
r#"INSERT INTO repositories (
|
||||
workspace_id, repository_id, repository_key, kind, provider, uri,
|
||||
@@ -4573,23 +4581,24 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
source_fingerprint, observed_status, observed_at, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)"#,
|
||||
params![
|
||||
record.repository.workspace_id,
|
||||
record.repository.repository_id,
|
||||
record.repository.repository_key,
|
||||
record.repository.kind,
|
||||
record.repository.provider,
|
||||
record.repository.source.uri,
|
||||
record.repository.source.kind.as_str(),
|
||||
record.repository.source.uri,
|
||||
record.repository.default_ref,
|
||||
record.repository.source_revision,
|
||||
record.repository.source_fingerprint,
|
||||
record.repository.observed_status.as_str(),
|
||||
record.repository.observed_at,
|
||||
record.repository.created_at,
|
||||
record.repository.updated_at,
|
||||
repository.workspace_id,
|
||||
repository.repository_id,
|
||||
repository.repository_key,
|
||||
repository.kind,
|
||||
repository.provider,
|
||||
repository.source.uri,
|
||||
repository.source.kind.as_str(),
|
||||
repository.source.uri,
|
||||
repository.default_ref,
|
||||
repository.source_revision,
|
||||
repository.source_fingerprint,
|
||||
repository.observed_status.as_str(),
|
||||
repository.observed_at,
|
||||
repository.created_at,
|
||||
repository.updated_at,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
if crate::config_source::load_state(&tx, &record.workspace.workspace_id)?.is_none() {
|
||||
let state = crate::config_source::initial_state()?;
|
||||
@@ -21732,7 +21741,7 @@ INSERT INTO worker_registry (
|
||||
operation_key: "invalid-key".to_string(),
|
||||
request_fingerprint: "sha256:invalid-key".to_string(),
|
||||
workspace: workspace.clone(),
|
||||
repository,
|
||||
repository: Some(repository),
|
||||
},
|
||||
&signing_identity,
|
||||
"identity-store-test",
|
||||
@@ -21771,7 +21780,7 @@ INSERT INTO worker_registry (
|
||||
operation_key: "create-workspace".to_string(),
|
||||
request_fingerprint: "sha256:create-workspace".to_string(),
|
||||
workspace,
|
||||
repository: valid_repository,
|
||||
repository: Some(valid_repository),
|
||||
};
|
||||
store
|
||||
.reserve_workspace_signing_identity_provisioning(
|
||||
@@ -21798,7 +21807,7 @@ INSERT INTO worker_registry (
|
||||
);
|
||||
let mut duplicate = first;
|
||||
duplicate.operation_key = "duplicate-workspace".to_string();
|
||||
duplicate.repository.repository_id = Uuid::now_v7().to_string();
|
||||
duplicate.repository.as_mut().unwrap().repository_id = Uuid::now_v7().to_string();
|
||||
let error = store
|
||||
.create_workspace_bootstrap(&duplicate, &signing_identity, "identity-store-test")
|
||||
.unwrap_err()
|
||||
|
||||
@@ -22,7 +22,7 @@ const MAX_OPERATION_KEY_BYTES: usize = 200;
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkspaceCreateResult {
|
||||
pub workspace: WorkspaceRecord,
|
||||
pub repository: RepositoryRecord,
|
||||
pub repository: Option<RepositoryRecord>,
|
||||
pub config_revision: u64,
|
||||
pub request_fingerprint: String,
|
||||
pub replayed: bool,
|
||||
@@ -94,19 +94,23 @@ impl WorkspaceCatalogService {
|
||||
)?;
|
||||
let display_name =
|
||||
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();
|
||||
server_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
|
||||
let repository_intent = request
|
||||
.repository
|
||||
.default_ref
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("HEAD")
|
||||
.to_string();
|
||||
.map(|repository| {
|
||||
let source = validate_repository_source(&repository.uri)?;
|
||||
server_api::validate_repository_key(&repository.repository_key).map_err(
|
||||
|error| Error::InvalidInput(format!("invalid Repository key: {error}")),
|
||||
)?;
|
||||
let default_ref = repository
|
||||
.default_ref
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("HEAD")
|
||||
.to_string();
|
||||
Ok::<_, Error>((repository.repository_key, source, default_ref))
|
||||
})
|
||||
.transpose()?;
|
||||
let requested_workspace_id = requested_workspace_id
|
||||
.map(|value| {
|
||||
Uuid::parse_str(value.trim())
|
||||
@@ -121,9 +125,9 @@ impl WorkspaceCatalogService {
|
||||
requested_workspace_id.as_deref(),
|
||||
&display_name,
|
||||
Some(&owner_account_id),
|
||||
&repository_key,
|
||||
&repository_uri,
|
||||
&default_ref,
|
||||
repository_intent.as_ref().map(|(key, source, selector)| {
|
||||
(key.as_str(), source.uri.as_str(), selector.as_str())
|
||||
}),
|
||||
);
|
||||
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
|
||||
let (signing_identity, identity_provisioning_operation_key) =
|
||||
@@ -146,21 +150,23 @@ impl WorkspaceCatalogService {
|
||||
created_at: now.clone(),
|
||||
updated_at: now.clone(),
|
||||
},
|
||||
repository: RepositoryRecord {
|
||||
workspace_id,
|
||||
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(),
|
||||
default_ref: Some(default_ref),
|
||||
source_revision: 1,
|
||||
source_fingerprint: repository_source_fingerprint(&repository_source),
|
||||
observed_status: RepositoryObservedStatus::Unverified,
|
||||
observed_at: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
},
|
||||
repository: repository_intent.map(
|
||||
|(repository_key, repository_source, default_ref)| RepositoryRecord {
|
||||
workspace_id,
|
||||
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(),
|
||||
default_ref: Some(default_ref),
|
||||
source_revision: 1,
|
||||
source_fingerprint: repository_source_fingerprint(&repository_source),
|
||||
observed_status: RepositoryObservedStatus::Unverified,
|
||||
observed_at: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
},
|
||||
),
|
||||
},
|
||||
&signing_identity,
|
||||
&identity_provisioning_operation_key,
|
||||
@@ -193,20 +199,19 @@ fn workspace_create_fingerprint(
|
||||
requested_workspace_id: Option<&str>,
|
||||
display_name: &str,
|
||||
owner_account_id: Option<&str>,
|
||||
repository_key: &str,
|
||||
repository_uri: &str,
|
||||
default_ref: &str,
|
||||
repository: Option<(&str, &str, &str)>,
|
||||
) -> String {
|
||||
let payload = serde_json::json!({
|
||||
"requested_workspace_id": requested_workspace_id,
|
||||
"display_name": display_name,
|
||||
"owner_account_id": owner_account_id,
|
||||
"repository": {
|
||||
"repository_key": repository_key,
|
||||
"uri": repository_uri,
|
||||
"default_ref": default_ref,
|
||||
// Preserve repository-present fingerprint bytes for persisted create receipts.
|
||||
"repository": repository.map(|(key, uri, selector)| serde_json::json!({
|
||||
"repository_key": key,
|
||||
"uri": uri,
|
||||
"default_ref": selector,
|
||||
"kind": "git",
|
||||
}
|
||||
}))
|
||||
});
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(serde_json::to_vec(&payload).expect("workspace fingerprint serializes"));
|
||||
@@ -294,11 +299,11 @@ mod tests {
|
||||
let request = WorkspaceCreateRequest {
|
||||
operation_key: "request-1".to_string(),
|
||||
display_name: "Workspace A".to_string(),
|
||||
repository: InitialRepositoryIntent {
|
||||
repository: Some(InitialRepositoryIntent {
|
||||
uri: repository.path().display().to_string(),
|
||||
repository_key: "platform".to_string(),
|
||||
default_ref: None,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
let owner_account_id = owner_account(store.as_ref());
|
||||
@@ -354,11 +359,11 @@ mod tests {
|
||||
let request = WorkspaceCreateRequest {
|
||||
operation_key: operation_key.to_string(),
|
||||
display_name: "Workspace A".to_string(),
|
||||
repository: InitialRepositoryIntent {
|
||||
repository: Some(InitialRepositoryIntent {
|
||||
uri: repository.path().display().to_string(),
|
||||
repository_key: repository_key.to_string(),
|
||||
default_ref: None,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
let error = service
|
||||
@@ -373,36 +378,37 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_recovers_same_reserved_identity_after_material_write_failure() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let database_path = temp.path().join("server.db");
|
||||
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
|
||||
let owner_account_id = owner_account(store.as_ref());
|
||||
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
|
||||
let service = WorkspaceCatalogService::new(
|
||||
store.clone(),
|
||||
Arc::new(FailFirstMaterialWrite {
|
||||
inner: materials.clone(),
|
||||
fail: AtomicBool::new(true),
|
||||
}),
|
||||
);
|
||||
let repository = git_repository();
|
||||
let request = WorkspaceCreateRequest {
|
||||
operation_key: "material-failure".to_string(),
|
||||
display_name: "Workspace A".to_string(),
|
||||
repository: InitialRepositoryIntent {
|
||||
uri: repository.path().display().to_string(),
|
||||
repository_key: "main".to_string(),
|
||||
default_ref: None,
|
||||
},
|
||||
};
|
||||
for with_repository in [false, true] {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let database_path = temp.path().join("server.db");
|
||||
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
|
||||
let owner_account_id = owner_account(store.as_ref());
|
||||
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
|
||||
let service = WorkspaceCatalogService::new(
|
||||
store.clone(),
|
||||
Arc::new(FailFirstMaterialWrite {
|
||||
inner: materials.clone(),
|
||||
fail: AtomicBool::new(true),
|
||||
}),
|
||||
);
|
||||
let repository = git_repository();
|
||||
let request = WorkspaceCreateRequest {
|
||||
operation_key: "material-failure".to_string(),
|
||||
display_name: "Workspace A".to_string(),
|
||||
repository: with_repository.then(|| InitialRepositoryIntent {
|
||||
uri: repository.path().display().to_string(),
|
||||
repository_key: "main".to_string(),
|
||||
default_ref: None,
|
||||
}),
|
||||
};
|
||||
|
||||
assert!(
|
||||
service
|
||||
.create(request.clone(), owner_account_id.clone())
|
||||
.is_err()
|
||||
);
|
||||
assert!(store.list_workspaces().unwrap().is_empty());
|
||||
let reserved_key = store
|
||||
assert!(
|
||||
service
|
||||
.create(request.clone(), owner_account_id.clone())
|
||||
.is_err()
|
||||
);
|
||||
assert!(store.list_workspaces().unwrap().is_empty());
|
||||
let reserved_key = store
|
||||
.with_conn(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT key_id FROM workspace_signing_identity_provisioning_operations WHERE operation_key = 'workspace-create:material-failure' AND state = 'pending'",
|
||||
@@ -413,55 +419,57 @@ mod tests {
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
drop(service);
|
||||
drop(store);
|
||||
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
|
||||
let restarted = WorkspaceCatalogService::new(store.clone(), materials);
|
||||
let created = restarted.create(request, owner_account_id).unwrap();
|
||||
let identity = store
|
||||
.get_workspace_signing_identity(&created.workspace.workspace_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(identity.key_id, reserved_key);
|
||||
assert_eq!(store.list_workspaces().unwrap().len(), 1);
|
||||
drop(service);
|
||||
drop(store);
|
||||
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
|
||||
let restarted = WorkspaceCatalogService::new(store.clone(), materials);
|
||||
let created = restarted.create(request, owner_account_id).unwrap();
|
||||
let identity = store
|
||||
.get_workspace_signing_identity(&created.workspace.workspace_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(identity.key_id, reserved_key);
|
||||
assert_eq!(store.list_workspaces().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_rolls_back_db_state_and_recovers_published_identity_after_restart() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let database_path = temp.path().join("server.db");
|
||||
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
|
||||
let owner_account_id = owner_account(store.as_ref());
|
||||
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
|
||||
let service = WorkspaceCatalogService::new(store.clone(), materials.clone());
|
||||
let repository = git_repository();
|
||||
let request = WorkspaceCreateRequest {
|
||||
operation_key: "db-failure".to_string(),
|
||||
display_name: "Workspace A".to_string(),
|
||||
repository: InitialRepositoryIntent {
|
||||
uri: repository.path().display().to_string(),
|
||||
repository_key: "main".to_string(),
|
||||
default_ref: None,
|
||||
},
|
||||
};
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute_batch(
|
||||
r#"CREATE TRIGGER fail_workspace_create_identity_audit
|
||||
for with_repository in [false, true] {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let database_path = temp.path().join("server.db");
|
||||
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
|
||||
let owner_account_id = owner_account(store.as_ref());
|
||||
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
|
||||
let service = WorkspaceCatalogService::new(store.clone(), materials.clone());
|
||||
let repository = git_repository();
|
||||
let request = WorkspaceCreateRequest {
|
||||
operation_key: "db-failure".to_string(),
|
||||
display_name: "Workspace A".to_string(),
|
||||
repository: with_repository.then(|| InitialRepositoryIntent {
|
||||
uri: repository.path().display().to_string(),
|
||||
repository_key: "main".to_string(),
|
||||
default_ref: None,
|
||||
}),
|
||||
};
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute_batch(
|
||||
r#"CREATE TRIGGER fail_workspace_create_identity_audit
|
||||
BEFORE INSERT ON workspace_signing_identity_audit
|
||||
BEGIN SELECT RAISE(ABORT, 'injected audit failure'); END;"#,
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
service
|
||||
.create(request.clone(), owner_account_id.clone())
|
||||
.is_err()
|
||||
);
|
||||
assert!(store.list_workspaces().unwrap().is_empty());
|
||||
let (reserved_key, material_ref) = store
|
||||
assert!(
|
||||
service
|
||||
.create(request.clone(), owner_account_id.clone())
|
||||
.is_err()
|
||||
);
|
||||
assert!(store.list_workspaces().unwrap().is_empty());
|
||||
let (reserved_key, material_ref) = store
|
||||
.with_conn(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT key_id, private_material_ref FROM workspace_signing_identity_provisioning_operations WHERE operation_key = 'workspace-create:db-failure' AND state = 'pending'",
|
||||
@@ -471,25 +479,26 @@ mod tests {
|
||||
.map_err(Error::from)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(materials.load(&material_ref).unwrap().is_some());
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute_batch("DROP TRIGGER fail_workspace_create_identity_audit;")?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(materials.load(&material_ref).unwrap().is_some());
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute_batch("DROP TRIGGER fail_workspace_create_identity_audit;")?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
drop(service);
|
||||
drop(store);
|
||||
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
|
||||
let restarted = WorkspaceCatalogService::new(store.clone(), materials);
|
||||
let created = restarted.create(request, owner_account_id).unwrap();
|
||||
let identity = store
|
||||
.get_workspace_signing_identity(&created.workspace.workspace_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(identity.key_id, reserved_key);
|
||||
assert_eq!(identity.state, "active");
|
||||
drop(service);
|
||||
drop(store);
|
||||
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
|
||||
let restarted = WorkspaceCatalogService::new(store.clone(), materials);
|
||||
let created = restarted.create(request, owner_account_id).unwrap();
|
||||
let identity = store
|
||||
.get_workspace_signing_identity(&created.workspace.workspace_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(identity.key_id, reserved_key);
|
||||
assert_eq!(identity.state, "active");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -506,11 +515,11 @@ mod tests {
|
||||
let mut request = WorkspaceCreateRequest {
|
||||
operation_key: "request-1".to_string(),
|
||||
display_name: "Workspace A".to_string(),
|
||||
repository: InitialRepositoryIntent {
|
||||
repository: Some(InitialRepositoryIntent {
|
||||
uri: repository.path().display().to_string(),
|
||||
repository_key: "main".to_string(),
|
||||
default_ref: None,
|
||||
},
|
||||
}),
|
||||
};
|
||||
service
|
||||
.create(request.clone(), owner_account_id.clone())
|
||||
@@ -524,6 +533,199 @@ mod tests {
|
||||
assert!(error.contains("different input"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_free_create_initializes_authority_and_concurrent_retries_converge() {
|
||||
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
||||
let owner = owner_account(store.as_ref());
|
||||
let service = WorkspaceCatalogService::new(
|
||||
store.clone(),
|
||||
Arc::new(InMemoryWorkspaceSigningMaterialStore::default()),
|
||||
);
|
||||
let request = WorkspaceCreateRequest {
|
||||
operation_key: "empty-concurrent".to_string(),
|
||||
display_name: "No repositories".to_string(),
|
||||
repository: None,
|
||||
};
|
||||
let barrier = Arc::new(std::sync::Barrier::new(4));
|
||||
let handles = (0..4)
|
||||
.map(|_| {
|
||||
let (service, request, owner, barrier) = (
|
||||
service.clone(),
|
||||
request.clone(),
|
||||
owner.clone(),
|
||||
barrier.clone(),
|
||||
);
|
||||
std::thread::spawn(move || {
|
||||
barrier.wait();
|
||||
service.create(request, owner).unwrap()
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let results = handles
|
||||
.into_iter()
|
||||
.map(|handle| handle.join().unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
let id = &results[0].workspace.workspace_id;
|
||||
assert_eq!(results.iter().filter(|result| !result.replayed).count(), 1);
|
||||
assert!(
|
||||
results
|
||||
.iter()
|
||||
.all(|result| &result.workspace.workspace_id == id && result.repository.is_none())
|
||||
);
|
||||
assert_eq!(store.list_workspaces().unwrap().len(), 1);
|
||||
assert!(store.list_repositories(id).unwrap().is_empty());
|
||||
assert!(store.load_workspace_config(id).unwrap().is_some());
|
||||
assert_eq!(
|
||||
store
|
||||
.get_workspace_signing_identity(id)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.state,
|
||||
"active"
|
||||
);
|
||||
let counters = store.with_conn(|conn| {
|
||||
let mut query = conn.prepare("SELECT resource_kind, next_sequence FROM workspace_resource_key_counters WHERE workspace_id = ?1 ORDER BY resource_kind")?;
|
||||
Ok(query.query_map([id], |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)))?.collect::<std::result::Result<Vec<_>, _>>()?)
|
||||
}).unwrap();
|
||||
assert_eq!(
|
||||
counters,
|
||||
vec![
|
||||
("objective".into(), 1),
|
||||
("ticket".into(), 1),
|
||||
("worker".into(), 1)
|
||||
]
|
||||
);
|
||||
let mut different = request.clone();
|
||||
different.repository = Some(InitialRepositoryIntent {
|
||||
repository_key: "main".into(),
|
||||
uri: "/runtime/repo".into(),
|
||||
default_ref: None,
|
||||
});
|
||||
assert!(
|
||||
service
|
||||
.create(different, owner.clone())
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("different input")
|
||||
);
|
||||
assert!(service.create(request, "missing-owner".into()).is_err());
|
||||
assert!(store.list_repositories(id).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_reads_current_repository_at_initial_key_and_never_recreates_it() {
|
||||
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
||||
let owner = owner_account(store.as_ref());
|
||||
let service = WorkspaceCatalogService::new(
|
||||
store.clone(),
|
||||
Arc::new(InMemoryWorkspaceSigningMaterialStore::default()),
|
||||
);
|
||||
let request = WorkspaceCreateRequest {
|
||||
operation_key: "current-repository".into(),
|
||||
display_name: "Workspace".into(),
|
||||
repository: Some(InitialRepositoryIntent {
|
||||
repository_key: "main".into(),
|
||||
uri: "/runtime/original".into(),
|
||||
default_ref: None,
|
||||
}),
|
||||
};
|
||||
let created = service.create(request.clone(), owner.clone()).unwrap();
|
||||
let mut repository = created.repository.unwrap();
|
||||
repository.source = validate_repository_source("https://example.test/changed.git").unwrap();
|
||||
repository.source_revision += 1;
|
||||
repository.source_fingerprint = repository_source_fingerprint(&repository.source);
|
||||
store.with_conn(|conn| {
|
||||
conn.execute("UPDATE repositories SET source_kind = ?1, source_uri = ?2, source_revision = ?3, source_fingerprint = ?4 WHERE workspace_id = ?5 AND repository_key = 'main'",
|
||||
rusqlite::params![repository.source.kind.as_str(), repository.source.uri, repository.source_revision, repository.source_fingerprint, repository.workspace_id])?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
assert_eq!(
|
||||
service
|
||||
.create(request.clone(), owner.clone())
|
||||
.unwrap()
|
||||
.repository,
|
||||
Some(repository)
|
||||
);
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"DELETE FROM repositories WHERE workspace_id = ?1",
|
||||
[&created.workspace.workspace_id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let replay = service.create(request, owner).unwrap();
|
||||
assert!(replay.replayed);
|
||||
assert!(replay.repository.is_none());
|
||||
assert!(
|
||||
store
|
||||
.list_repositories(&replay.workspace.workspace_id)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_repository_free_receipt_replays_after_catalog_reopen() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let database = temp.path().join("server.db");
|
||||
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
|
||||
let request = WorkspaceCreateRequest {
|
||||
operation_key: "persist-empty".into(),
|
||||
display_name: "Workspace".into(),
|
||||
repository: None,
|
||||
};
|
||||
let (owner, created) = {
|
||||
let store = Arc::new(SqliteWorkspaceStore::open(&database).unwrap());
|
||||
let owner = owner_account(store.as_ref());
|
||||
let service = WorkspaceCatalogService::new(store, materials.clone());
|
||||
let created = service.create(request.clone(), owner.clone()).unwrap();
|
||||
(owner, created)
|
||||
};
|
||||
let store = Arc::new(SqliteWorkspaceStore::open(&database).unwrap());
|
||||
let reopened = WorkspaceCatalogService::new(store.clone(), materials);
|
||||
let replay = reopened.create(request, owner).unwrap();
|
||||
assert_eq!(replay.workspace, created.workspace);
|
||||
assert!(replay.replayed);
|
||||
assert!(replay.repository.is_none());
|
||||
assert!(
|
||||
store
|
||||
.list_repositories(&replay.workspace.workspace_id)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_present_fingerprint_preserves_saved_receipt_contract() {
|
||||
let legacy_payload = serde_json::json!({
|
||||
"requested_workspace_id": null, "display_name": "Workspace", "owner_account_id": "owner",
|
||||
"repository": {"repository_key": "main", "uri": "/runtime/repo", "default_ref": "HEAD", "kind": "git"}
|
||||
});
|
||||
let legacy_digest = Sha256::digest(serde_json::to_vec(&legacy_payload).unwrap());
|
||||
let legacy = format!(
|
||||
"sha256:{}",
|
||||
legacy_digest
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>()
|
||||
);
|
||||
assert_eq!(
|
||||
workspace_create_fingerprint(
|
||||
None,
|
||||
"Workspace",
|
||||
Some("owner"),
|
||||
Some(("main", "/runtime/repo", "HEAD"))
|
||||
),
|
||||
legacy
|
||||
);
|
||||
assert_ne!(
|
||||
workspace_create_fingerprint(None, "Workspace", Some("owner"), None),
|
||||
legacy
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_intent_accepts_unavailable_local_sources_without_server_io() {
|
||||
let remote = validate_repository_source("https://example.test/repo.git").unwrap();
|
||||
@@ -564,11 +766,11 @@ mod tests {
|
||||
WorkspaceCreateRequest {
|
||||
operation_key: "organization-owner-create".to_string(),
|
||||
display_name: "Organization Workspace".to_string(),
|
||||
repository: InitialRepositoryIntent {
|
||||
repository: Some(InitialRepositoryIntent {
|
||||
uri: repository.path().display().to_string(),
|
||||
repository_key: "main".to_string(),
|
||||
default_ref: None,
|
||||
},
|
||||
}),
|
||||
},
|
||||
"organization-owner".to_string(),
|
||||
)
|
||||
@@ -605,11 +807,11 @@ mod tests {
|
||||
WorkspaceCreateRequest {
|
||||
operation_key: "owner-a-create".to_string(),
|
||||
display_name: "Owner A Workspace".to_string(),
|
||||
repository: InitialRepositoryIntent {
|
||||
repository: Some(InitialRepositoryIntent {
|
||||
uri: repository_a.path().display().to_string(),
|
||||
repository_key: "main".to_string(),
|
||||
default_ref: None,
|
||||
},
|
||||
}),
|
||||
},
|
||||
owner_a.clone(),
|
||||
)
|
||||
@@ -619,11 +821,11 @@ mod tests {
|
||||
WorkspaceCreateRequest {
|
||||
operation_key: "owner-b-create".to_string(),
|
||||
display_name: "Owner B Workspace".to_string(),
|
||||
repository: InitialRepositoryIntent {
|
||||
repository: Some(InitialRepositoryIntent {
|
||||
uri: repository_b.path().display().to_string(),
|
||||
repository_key: "main".to_string(),
|
||||
default_ref: None,
|
||||
},
|
||||
}),
|
||||
},
|
||||
owner_b.clone(),
|
||||
)
|
||||
@@ -660,11 +862,11 @@ mod tests {
|
||||
WorkspaceCreateRequest {
|
||||
operation_key: "remote-create".to_string(),
|
||||
display_name: "Remote Workspace".to_string(),
|
||||
repository: InitialRepositoryIntent {
|
||||
repository: Some(InitialRepositoryIntent {
|
||||
uri: "ssh://git@example.test/org/repository.git".to_string(),
|
||||
repository_key: "remote".to_string(),
|
||||
default_ref: Some("main".to_string()),
|
||||
},
|
||||
}),
|
||||
},
|
||||
owner_account_id,
|
||||
)
|
||||
@@ -673,7 +875,7 @@ mod tests {
|
||||
let persisted = store
|
||||
.get_repository(
|
||||
&result.workspace.workspace_id,
|
||||
&result.repository.repository_id,
|
||||
&result.repository.as_ref().unwrap().repository_id,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
@@ -24,6 +24,39 @@ Does not own:
|
||||
|
||||
Keeping product CLI ownership here prevents lower crates from depending on the binary name or parsing top-level arguments. Runtime launch should use typed command boundaries, not shell-command strings.
|
||||
|
||||
## Workspace creation and selection
|
||||
|
||||
`yoi init` remains Git-oriented and requires an explicit initial repository key:
|
||||
|
||||
```sh
|
||||
yoi --backend https://backend.example init --display-name Development --repository-key platform
|
||||
```
|
||||
|
||||
For a Workspace with no initial repository, use the same Backend creation API explicitly:
|
||||
|
||||
```sh
|
||||
yoi --backend https://backend.example workspace create --display-name Research
|
||||
```
|
||||
|
||||
Both commands record Workspace-to-Backend routing in the global client configuration,
|
||||
not repository-local identity files. Repository-free creation prints the Workspace id;
|
||||
select it explicitly from any directory, without Git discovery:
|
||||
|
||||
```sh
|
||||
yoi --workspace-id <ID>
|
||||
yoi --workspace-id <ID> workers
|
||||
yoi --workspace-id <ID> resume
|
||||
yoi --workspace-id <ID> panel
|
||||
```
|
||||
|
||||
`--workspace-id` selects Backend even when `default_connection` is `local`, and conflicts
|
||||
with `--local`. An explicit `--backend <URL>` overrides configured routing. Without an
|
||||
explicit Workspace id, Backend commands retain Git-based Workspace discovery.
|
||||
|
||||
Normal Backend startup launches the existing workdir-free Worker form. Repository and
|
||||
workdir attachment selection are not prerequisites imposed by this CLI; Runtime
|
||||
availability and workdir requirements remain Backend/TUI responsibilities.
|
||||
|
||||
## See also
|
||||
|
||||
- [`../../docs/design/overview.md`](../../docs/design/overview.md)
|
||||
|
||||
@@ -250,10 +250,15 @@ pub(crate) fn resolve_connection_aware_cli_connection<R: CliConnectionResolver +
|
||||
"--local and --backend are mutually exclusive".to_string(),
|
||||
));
|
||||
}
|
||||
if explicit_local && workspace_id.is_some() {
|
||||
return Err(ParseError(
|
||||
"--local and --workspace-id are mutually exclusive".to_string(),
|
||||
));
|
||||
}
|
||||
if explicit_local {
|
||||
return resolver.resolve_connection(command, CliConnectionInput::StandaloneTarget);
|
||||
}
|
||||
if explicit_backend_url.is_some() {
|
||||
if explicit_backend_url.is_some() || workspace_id.is_some() {
|
||||
return resolver.resolve_connection(
|
||||
command,
|
||||
CliConnectionInput::BackendTarget {
|
||||
|
||||
+322
-12
@@ -27,7 +27,8 @@ use client::{BackendAuthTarget, Target, TargetKind, start_device_login, wait_for
|
||||
use serde::Deserialize;
|
||||
use tui::{BackendWorkerPickerIntent, LaunchMode, LaunchOptions};
|
||||
use workspace_bootstrap::{
|
||||
InitOptions, discover_repository_root, run_init, select_backend_workspace_for_repository,
|
||||
InitOptions, WorkspaceCreateOptions, discover_repository_root, run_init, run_workspace_create,
|
||||
select_backend_workspace_for_repository, workspace_creation_message,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -56,6 +57,8 @@ enum Mode {
|
||||
no_wait: bool,
|
||||
},
|
||||
Init(InitOptions),
|
||||
WorkspaceHelp,
|
||||
WorkspaceCreate(WorkspaceCreateOptions),
|
||||
WorkdirHelp,
|
||||
WorkdirShare(workdir_share::WorkdirShareOptions),
|
||||
WorkerRuntime(Vec<String>),
|
||||
@@ -123,10 +126,7 @@ async fn run(mode: Mode) -> ExitCode {
|
||||
},
|
||||
Mode::Init(options) => match run_init(options).await {
|
||||
Ok(workspace) => {
|
||||
println!(
|
||||
"Initialized Workspace '{}' for repository '{}'",
|
||||
workspace.workspace.display_name, workspace.repository.repository_key
|
||||
);
|
||||
println!("{}", workspace_creation_message(&workspace));
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -134,6 +134,20 @@ async fn run(mode: Mode) -> ExitCode {
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
},
|
||||
Mode::WorkspaceHelp => {
|
||||
print!("{WORKSPACE_HELP}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Mode::WorkspaceCreate(options) => match run_workspace_create(options).await {
|
||||
Ok(workspace) => {
|
||||
println!("{}", workspace_creation_message(&workspace));
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("yoi workspace create: {error}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
},
|
||||
Mode::WorkdirHelp => {
|
||||
print_workdir_help();
|
||||
ExitCode::SUCCESS
|
||||
@@ -278,7 +292,7 @@ fn resolve_tui_target<R: CliConnectionResolver + ?Sized>(
|
||||
command,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
selection.workspace_id.as_deref(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -521,6 +535,9 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
|
||||
.map_err(|error| ParseError(error.to_string()))?;
|
||||
return Ok(Mode::Ticket { cli, target });
|
||||
}
|
||||
"workspace" => {
|
||||
return parse_workspace_args(&args[1..], &target_selection);
|
||||
}
|
||||
"init" => {
|
||||
if target_selection.explicit_local {
|
||||
return Err(ParseError(
|
||||
@@ -807,6 +824,68 @@ fn parse_workdir_args(
|
||||
}))
|
||||
}
|
||||
|
||||
const WORKSPACE_HELP: &str = "yoi workspace\n\nUsage:\n yoi [--backend <URL>] workspace create --display-name <NAME>\n\nCreate a Workspace without an initial repository; no Git checkout is needed.\nUse `yoi init` to create a Workspace with the current Git repository.\nSelect the created Workspace with `yoi --workspace-id <ID>`; this does not require Git.\n\nOptions:\n --display-name <NAME> Workspace display name (required)\n -h, --help Print help\n";
|
||||
|
||||
fn parse_workspace_args(args: &[String], selection: &TargetSelection) -> Result<Mode, ParseError> {
|
||||
let Some((subcommand, args)) = args.split_first() else {
|
||||
return Ok(Mode::WorkspaceHelp);
|
||||
};
|
||||
if matches!(subcommand.as_str(), "--help" | "-h") {
|
||||
return Ok(Mode::WorkspaceHelp);
|
||||
}
|
||||
if subcommand != "create" {
|
||||
return Err(ParseError(format!(
|
||||
"unknown yoi workspace subcommand `{subcommand}`"
|
||||
)));
|
||||
}
|
||||
if args
|
||||
.iter()
|
||||
.any(|arg| matches!(arg.as_str(), "--help" | "-h"))
|
||||
{
|
||||
return Ok(Mode::WorkspaceHelp);
|
||||
}
|
||||
if selection.explicit_local {
|
||||
return Err(ParseError(
|
||||
"yoi workspace create requires a Backend target and cannot use --local".to_string(),
|
||||
));
|
||||
}
|
||||
if selection.workspace_id.is_some() {
|
||||
return Err(ParseError(
|
||||
"yoi workspace create creates a Workspace and does not accept --workspace-id"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let mut display_name = None;
|
||||
let mut index = 0;
|
||||
while index < args.len() {
|
||||
match args[index].as_str() {
|
||||
"--display-name" => {
|
||||
if display_name.is_some() {
|
||||
return Err(ParseError(
|
||||
"--display-name may only be provided once".to_string(),
|
||||
));
|
||||
}
|
||||
display_name =
|
||||
Some(required_option_value(args, index, "--display-name")?.to_string());
|
||||
index += 2;
|
||||
}
|
||||
unknown => {
|
||||
return Err(ParseError(format!(
|
||||
"unknown yoi workspace create option `{unknown}`"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let display_name = display_name.ok_or_else(|| {
|
||||
ParseError("yoi workspace create requires --display-name NAME".to_string())
|
||||
})?;
|
||||
Ok(Mode::WorkspaceCreate(WorkspaceCreateOptions {
|
||||
backend_url: resolve_backend_url(selection.backend_url.clone(), None)?,
|
||||
display_name,
|
||||
repository: None,
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_init_args(
|
||||
args: &[String],
|
||||
explicit_backend_url: Option<String>,
|
||||
@@ -1989,6 +2068,7 @@ Usage:
|
||||
yoi [TARGET] workers [-r|--stopped] [--runtime-id <ID>]
|
||||
yoi [TARGET] resume [--all] [--runtime-id <ID>]
|
||||
yoi --backend <URL> [--workspace-id <ID>] panel
|
||||
yoi [--backend <URL>] workspace create --display-name <NAME>
|
||||
yoi [--backend <URL>] init --display-name <NAME> --repository-key <KEY> [--repository <PATH>] [--default-ref <REF>]
|
||||
yoi [--backend <URL>] login [--no-wait]
|
||||
yoi [--backend <URL>] workdir share <PATH> [--workspace-id <ID>] [--permission <read|write|command>] [--read-only|--read-write] [--ttl <TTL>] [--display-name <NAME>] [--non-interactive]
|
||||
@@ -1997,9 +2077,11 @@ Usage:
|
||||
Target selection:
|
||||
--local Use the client-owned one-process Standalone host
|
||||
--backend <URL> Use a Workspace Backend explicitly
|
||||
--workspace-id <ID> Scope Backend routes to a Workspace id
|
||||
--workspace-id <ID> Select a Backend Workspace explicitly; no Git checkout needed
|
||||
|
||||
If no target is explicit, connection-aware commands use the merged client config.
|
||||
Explicit --workspace-id selects Backend even when the default connection is local.
|
||||
Without --workspace-id, Backend selection matches the current Git repository.
|
||||
`default_connection = "local"` selects Standalone; it does not enable a filesystem
|
||||
Ticket, Objective, Worker catalog, PID, socket, or subprocess authority.
|
||||
|
||||
@@ -2017,6 +2099,7 @@ Console options:
|
||||
--worker-id <ID> Backend Worker id; requires --runtime-id
|
||||
|
||||
Host commands:
|
||||
yoi workspace create Create a Backend Workspace without an initial repository.
|
||||
yoi init Register the current Git repository as a new Backend Workspace.
|
||||
yoi workdir share Interactively share a client-hosted External Workdir.
|
||||
keys Manage local model/API keys
|
||||
@@ -2058,7 +2141,7 @@ Authority:
|
||||
|
||||
Options:
|
||||
--backend <URL> Use this Workspace Backend
|
||||
--workspace-id <ID> Scope Backend routes to a Workspace id
|
||||
--workspace-id <ID> Select a Backend Workspace explicitly; no Git checkout needed
|
||||
--workspace <PATH> Match this Git repository against Server DB Repository records
|
||||
-r, --stopped List stopped Backend Workers
|
||||
--runtime-id <ID> Restrict the Backend Worker picker to a Runtime id
|
||||
@@ -2077,7 +2160,7 @@ Usage:
|
||||
Target options:
|
||||
--local Restore from the client-owned Standalone Worker store
|
||||
--backend <URL> Restore a stopped Backend Workspace Worker
|
||||
--workspace-id <ID> Scope Backend routes to a Workspace id
|
||||
--workspace-id <ID> Select a Backend Workspace explicitly; no Git checkout needed
|
||||
|
||||
Options:
|
||||
--workspace <PATH> Scope Standalone Workers to this cwd identity (defaults to cwd)
|
||||
@@ -2461,7 +2544,8 @@ backend = "shared"
|
||||
)
|
||||
);
|
||||
assert!(!TOP_LEVEL_HELP.contains("Build/check/list/show plugins"));
|
||||
assert!(!TOP_LEVEL_HELP.contains("yoi workspace"));
|
||||
assert!(TOP_LEVEL_HELP.contains("yoi workspace create"));
|
||||
assert!(!TOP_LEVEL_HELP.contains("yoi workspace serve"));
|
||||
assert!(!TOP_LEVEL_HELP.contains("yoi server"));
|
||||
assert!(!TOP_LEVEL_HELP.contains("TARGET_OPTIONS"));
|
||||
}
|
||||
@@ -3174,6 +3258,232 @@ backend = "shared"
|
||||
assert!(!error.contains("stale-local-workspace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_create_parsing_requires_no_repository_inputs() {
|
||||
let resolver = FixedCliConnectionResolver {
|
||||
backend_url: "http://unused.example",
|
||||
};
|
||||
let args = [
|
||||
"--backend",
|
||||
"http://backend.example",
|
||||
"workspace",
|
||||
"create",
|
||||
"--display-name",
|
||||
"Research",
|
||||
]
|
||||
.map(String::from);
|
||||
let Mode::WorkspaceCreate(options) =
|
||||
parse_args_slice_with_connection_resolver(&args, &resolver).unwrap()
|
||||
else {
|
||||
panic!("expected workspace creation mode");
|
||||
};
|
||||
assert_eq!(options.backend_url, "http://backend.example");
|
||||
assert_eq!(options.display_name, "Research");
|
||||
assert!(options.repository.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_create_rejects_repository_options_and_invalid_creation_selectors() {
|
||||
let resolver = FixedCliConnectionResolver {
|
||||
backend_url: "http://unused.example",
|
||||
};
|
||||
for (prefix, suffix, expected) in [
|
||||
(
|
||||
vec!["--local"],
|
||||
vec!["--display-name", "Research"],
|
||||
"cannot use --local",
|
||||
),
|
||||
(
|
||||
vec!["--workspace-id", "existing"],
|
||||
vec!["--display-name", "Research"],
|
||||
"does not accept --workspace-id",
|
||||
),
|
||||
(vec![], vec![], "requires --display-name NAME"),
|
||||
(
|
||||
vec![],
|
||||
vec!["--display-name"],
|
||||
"--display-name requires a value",
|
||||
),
|
||||
(
|
||||
vec![],
|
||||
vec!["--display-name", "A", "--display-name", "B"],
|
||||
"may only be provided once",
|
||||
),
|
||||
(
|
||||
vec![],
|
||||
vec!["--display-name", "A", "--repository", "/tmp"],
|
||||
"unknown yoi workspace create option `--repository`",
|
||||
),
|
||||
(
|
||||
vec![],
|
||||
vec!["--display-name", "A", "--repository-key", "main"],
|
||||
"unknown yoi workspace create option `--repository-key`",
|
||||
),
|
||||
(
|
||||
vec![],
|
||||
vec!["--display-name", "A", "--default-ref", "main"],
|
||||
"unknown yoi workspace create option `--default-ref`",
|
||||
),
|
||||
] {
|
||||
let args = [prefix, vec!["workspace", "create"], suffix]
|
||||
.concat()
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect::<Vec<_>>();
|
||||
let error = parse_args_slice_with_connection_resolver(&args, &resolver).unwrap_err();
|
||||
assert!(error.to_string().contains(expected), "{args:?}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_help_documents_repo_free_creation_without_resolving_backend() {
|
||||
for args in [
|
||||
vec!["workspace"],
|
||||
vec!["workspace", "--help"],
|
||||
vec!["workspace", "create", "--help"],
|
||||
] {
|
||||
assert!(matches!(
|
||||
parse_args_from(args).unwrap(),
|
||||
Mode::WorkspaceHelp
|
||||
));
|
||||
}
|
||||
assert!(TOP_LEVEL_HELP.contains("workspace create --display-name <NAME>"));
|
||||
assert!(WORKSPACE_HELP.contains("without an initial repository"));
|
||||
assert!(WORKSPACE_HELP.contains("--workspace-id <ID>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_workspace_selects_backend_from_non_git_directory_even_with_local_default() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let resolver = FixedCliConnectionResolver {
|
||||
backend_url: "http://configured-backend.example",
|
||||
};
|
||||
// This resolver defaults to Standalone and rejects repository-based selection.
|
||||
for command in [None, Some("workers"), Some("resume"), Some("panel")] {
|
||||
let mut args = vec!["--workspace-id".to_string(), "repo-free".to_string()];
|
||||
if let Some(command) = command {
|
||||
args.push(command.to_string());
|
||||
}
|
||||
args.extend([
|
||||
"--workspace".to_string(),
|
||||
directory.path().display().to_string(),
|
||||
]);
|
||||
let Mode::Tui {
|
||||
target,
|
||||
mode,
|
||||
workspace_root,
|
||||
} = parse_args_slice_with_connection_resolver(&args, &resolver).unwrap()
|
||||
else {
|
||||
panic!("expected TUI mode for {args:?}");
|
||||
};
|
||||
assert_eq!(workspace_root, directory.path());
|
||||
assert_eq!(
|
||||
target.resolve().unwrap(),
|
||||
client::ResolvedTarget::Backend {
|
||||
base_url: "http://configured-backend.example".to_string(),
|
||||
workspace_id: "repo-free".to_string(),
|
||||
}
|
||||
);
|
||||
match command {
|
||||
None => assert!(matches!(mode, LaunchMode::BackendSpawn)),
|
||||
Some("workers") => assert!(matches!(
|
||||
mode,
|
||||
LaunchMode::Workers {
|
||||
intent: BackendWorkerPickerIntent::Attach { .. },
|
||||
..
|
||||
}
|
||||
)),
|
||||
Some("resume") => assert!(matches!(
|
||||
mode,
|
||||
LaunchMode::Workers {
|
||||
intent: BackendWorkerPickerIntent::Resume,
|
||||
..
|
||||
}
|
||||
)),
|
||||
Some("panel") => assert!(matches!(mode, LaunchMode::Panel)),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
assert!(!directory.path().join(".git").exists());
|
||||
assert!(!directory.path().join(".yoi").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_workspace_attaches_to_backend_worker_without_git_discovery() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let resolver = FixedCliConnectionResolver {
|
||||
backend_url: "http://configured-backend.example",
|
||||
};
|
||||
let args = [
|
||||
vec![
|
||||
"--workspace-id=repo-free",
|
||||
"--runtime-id",
|
||||
"runtime-a",
|
||||
"--worker-id",
|
||||
"worker-a",
|
||||
"--workspace",
|
||||
]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![directory.path().display().to_string()],
|
||||
]
|
||||
.concat();
|
||||
let Mode::Tui {
|
||||
target,
|
||||
mode:
|
||||
LaunchMode::OpenWorker {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
},
|
||||
..
|
||||
} = parse_args_slice_with_connection_resolver(&args, &resolver).unwrap()
|
||||
else {
|
||||
panic!("expected Backend Worker attach mode");
|
||||
};
|
||||
assert_eq!(
|
||||
target.resolve().unwrap(),
|
||||
client::ResolvedTarget::Backend {
|
||||
base_url: "http://configured-backend.example".to_string(),
|
||||
workspace_id: "repo-free".to_string(),
|
||||
}
|
||||
);
|
||||
assert_eq!(runtime_id, "runtime-a");
|
||||
assert_eq!(worker_id, "worker-a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_workspace_conflicts_with_local_selection() {
|
||||
let resolver = FixedCliConnectionResolver {
|
||||
backend_url: "http://unused.example",
|
||||
};
|
||||
let args = ["--local", "--workspace-id", "repo-free"].map(String::from);
|
||||
let error = parse_args_slice_with_connection_resolver(&args, &resolver).unwrap_err();
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"--local and --workspace-id are mutually exclusive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_rejects_non_git_repository_instead_of_creating_repo_free_workspace() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let args = vec![
|
||||
"--display-name".to_string(),
|
||||
"Research".to_string(),
|
||||
"--repository-key".to_string(),
|
||||
"main".to_string(),
|
||||
"--repository".to_string(),
|
||||
directory.path().display().to_string(),
|
||||
];
|
||||
let error = parse_init_args(&args, Some("http://backend.example".to_string())).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("not inside a readable Git repository")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_parsing_uses_git_root_without_writing_repository_local_identity() {
|
||||
let repository = tempfile::tempdir().unwrap();
|
||||
@@ -3398,9 +3708,9 @@ backend = "shared"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_workspace_command_is_removed_from_yoi_surface() {
|
||||
fn workspace_creation_does_not_restore_removed_workspace_server_command() {
|
||||
let err = parse_args_from(["workspace", "serve", "--listen", "127.0.0.1:0"]).unwrap_err();
|
||||
assert_eq!(err.to_string(), "unknown command `workspace`");
|
||||
assert_eq!(err.to_string(), "unknown yoi workspace subcommand `serve`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -19,6 +19,13 @@ pub(crate) struct InitOptions {
|
||||
pub(crate) default_ref: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct WorkspaceCreateOptions {
|
||||
pub(crate) backend_url: String,
|
||||
pub(crate) display_name: String,
|
||||
pub(crate) repository: Option<CreateBackendWorkspaceRepository>,
|
||||
}
|
||||
|
||||
pub(crate) async fn run_init(
|
||||
options: InitOptions,
|
||||
) -> Result<server_api::WorkspaceCreateResponse, ParseError> {
|
||||
@@ -26,27 +33,56 @@ pub(crate) async fn run_init(
|
||||
.repository_root
|
||||
.to_str()
|
||||
.ok_or_else(|| ParseError("the repository root is not valid UTF-8".to_string()))?;
|
||||
run_workspace_create(WorkspaceCreateOptions {
|
||||
backend_url: options.backend_url,
|
||||
display_name: options.display_name,
|
||||
repository: Some(CreateBackendWorkspaceRepository {
|
||||
repository_key: options.repository_key,
|
||||
uri: repository_uri.to_string(),
|
||||
default_ref: options.default_ref,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn run_workspace_create(
|
||||
options: WorkspaceCreateOptions,
|
||||
) -> Result<server_api::WorkspaceCreateResponse, ParseError> {
|
||||
let target = BackendWorkspaceCatalogTarget {
|
||||
base_url: options.backend_url.clone(),
|
||||
};
|
||||
let response = create_backend_workspace(
|
||||
&target,
|
||||
&CreateBackendWorkspaceRequest {
|
||||
operation_key: uuid::Uuid::now_v7().to_string(),
|
||||
display_name: options.display_name,
|
||||
repository: CreateBackendWorkspaceRepository {
|
||||
repository_key: options.repository_key,
|
||||
uri: repository_uri.to_string(),
|
||||
default_ref: options.default_ref,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| ParseError(format!("Backend rejected Workspace creation: {error}")))?;
|
||||
let request = workspace_create_request(options.display_name, options.repository);
|
||||
let response = create_backend_workspace(&target, &request)
|
||||
.await
|
||||
.map_err(|error| ParseError(format!("Backend rejected Workspace creation: {error}")))?;
|
||||
record_workspace_backend_routing(&response.workspace.workspace_id, &options.backend_url)?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn workspace_create_request(
|
||||
display_name: String,
|
||||
repository: Option<CreateBackendWorkspaceRepository>,
|
||||
) -> CreateBackendWorkspaceRequest {
|
||||
CreateBackendWorkspaceRequest {
|
||||
operation_key: uuid::Uuid::now_v7().to_string(),
|
||||
display_name,
|
||||
repository,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn workspace_creation_message(response: &server_api::WorkspaceCreateResponse) -> String {
|
||||
match &response.repository {
|
||||
Some(repository) => format!(
|
||||
"Initialized Workspace '{}' for repository '{}'",
|
||||
response.workspace.display_name, repository.repository_key
|
||||
),
|
||||
None => format!(
|
||||
"Created Workspace '{}' (workspace-id: {}) without an initial repository",
|
||||
response.workspace.display_name, response.workspace.workspace_id
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn select_backend_workspace_for_repository(
|
||||
base_url: &str,
|
||||
repository_path: &Path,
|
||||
@@ -333,6 +369,85 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_creation_wire_request_omits_repository_for_repo_free_creation() {
|
||||
let request = workspace_create_request("Research".to_string(), None);
|
||||
let body = serde_json::to_value(request).unwrap();
|
||||
assert_eq!(body["display_name"], "Research");
|
||||
assert!(
|
||||
body.get("repository")
|
||||
.is_none_or(serde_json::Value::is_null)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_workspace_creation_wire_request_preserves_initial_repository() {
|
||||
let request = workspace_create_request(
|
||||
"Development".to_string(),
|
||||
Some(CreateBackendWorkspaceRepository {
|
||||
repository_key: "platform".to_string(),
|
||||
uri: "/checkout/platform".to_string(),
|
||||
default_ref: Some("develop".to_string()),
|
||||
}),
|
||||
);
|
||||
let body = serde_json::to_value(request).unwrap();
|
||||
assert_eq!(
|
||||
body["repository"],
|
||||
serde_json::json!({
|
||||
"repository_key": "platform",
|
||||
"uri": "/checkout/platform",
|
||||
"default_ref": "develop",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_response_with_null_repository_reports_workspace_including_on_replay() {
|
||||
for replayed in [false, true] {
|
||||
let body = serde_json::json!({
|
||||
"workspace": workspace_summary("workspace-a", "Research"),
|
||||
"repository": null,
|
||||
"config_revision": 0,
|
||||
"request_fingerprint": "fingerprint",
|
||||
"replayed": replayed,
|
||||
});
|
||||
let response = serde_json::from_value(body).unwrap();
|
||||
assert_eq!(
|
||||
workspace_creation_message(&response),
|
||||
"Created Workspace 'Research' (workspace-id: workspace-a) without an initial repository"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_response_with_repository_preserves_git_init_message() {
|
||||
let body = serde_json::json!({
|
||||
"workspace": workspace_summary("workspace-a", "Development"),
|
||||
"repository": {
|
||||
"workspace_id": "workspace-a",
|
||||
"repository_key": "platform",
|
||||
"kind": "git",
|
||||
"provider": "builtin:git",
|
||||
"source": {"kind": "local_path", "uri": "/checkout/platform"},
|
||||
"default_ref": "develop",
|
||||
"source_revision": 1,
|
||||
"source_fingerprint": "fingerprint",
|
||||
"observed_status": "unverified",
|
||||
"observed_at": null,
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-01T00:00:00Z",
|
||||
},
|
||||
"config_revision": 1,
|
||||
"request_fingerprint": "fingerprint",
|
||||
"replayed": false,
|
||||
});
|
||||
let response = serde_json::from_value(body).unwrap();
|
||||
assert_eq!(
|
||||
workspace_creation_message(&response),
|
||||
"Initialized Workspace 'Development' for repository 'platform'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_catalog_selection_handles_single_zero_and_multiple_matches() {
|
||||
let repository = tempfile::tempdir().unwrap();
|
||||
|
||||
+20
-8
@@ -13963,7 +13963,7 @@
|
||||
},
|
||||
"WorkspaceCreateRequest": {
|
||||
"additionalProperties": false,
|
||||
"description": "Request for atomically creating a Workspace and its initial Repository.",
|
||||
"description": "Request for atomically creating a Workspace with an optional initial Repository.",
|
||||
"properties": {
|
||||
"display_name": {
|
||||
"type": "string"
|
||||
@@ -13972,19 +13972,25 @@
|
||||
"type": "string"
|
||||
},
|
||||
"repository": {
|
||||
"$ref": "#/components/schemas/InitialRepositoryIntent"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/InitialRepositoryIntent"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"operation_key",
|
||||
"display_name",
|
||||
"repository"
|
||||
"display_name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"WorkspaceCreateResponse": {
|
||||
"additionalProperties": false,
|
||||
"description": "Response returned after atomically creating a Workspace and its first Repository.",
|
||||
"description": "Response returned after atomically creating a Workspace.\n\nOn replay, Workspace/config and the Repository at the originally requested key are\ncurrent authority, not historical snapshots. Repository is null when creation omitted\nit or that key has since been removed/renamed; replay never recreates an asset.",
|
||||
"properties": {
|
||||
"config_revision": {
|
||||
"format": "uint64",
|
||||
@@ -13996,7 +14002,14 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"repository": {
|
||||
"$ref": "#/components/schemas/WorkspaceRepositoryRecord"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/WorkspaceRepositoryRecord"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"request_fingerprint": {
|
||||
"type": "string"
|
||||
@@ -14007,7 +14020,6 @@
|
||||
},
|
||||
"required": [
|
||||
"workspace",
|
||||
"repository",
|
||||
"config_revision",
|
||||
"request_fingerprint",
|
||||
"replayed"
|
||||
@@ -15538,7 +15550,7 @@
|
||||
"info": {
|
||||
"title": "Yoi Server API",
|
||||
"version": "0.1.0",
|
||||
"x-yoi-source-digest": "sha256:4f468e00efa86e6e33e8fa4f14deca93ee15b955071920facd56c59c3d2fefb8"
|
||||
"x-yoi-source-digest": "sha256:fcd997d5ab44e2197ce674fd4ffd59eb84355e161c0a7f500190bdc991c9f053"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.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/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.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-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts tests/runtime-workers.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/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
|
||||
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
|
||||
"test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts",
|
||||
"build": "deno run -A npm:vite@8.3.2 build",
|
||||
"preview": "deno run -A npm:vite@8.3.2 preview",
|
||||
"generate:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:4f468e00efa86e6e33e8fa4f14deca93ee15b955071920facd56c59c3d2fefb8)
|
||||
// Input contract: 0.1.0 (sha256:fcd997d5ab44e2197ce674fd4ffd59eb84355e161c0a7f500190bdc991c9f053)
|
||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||
|
||||
@@ -34,7 +34,7 @@ export type WorkspaceRepositoryRecord = {
|
||||
|
||||
export type WorkspaceCreateResponse = {
|
||||
workspace: WorkspaceSummary;
|
||||
repository: WorkspaceRepositoryRecord;
|
||||
repository: WorkspaceRepositoryRecord | null;
|
||||
config_revision: number;
|
||||
request_fingerprint: string;
|
||||
replayed: boolean;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:4f468e00efa86e6e33e8fa4f14deca93ee15b955071920facd56c59c3d2fefb8)
|
||||
// Input contract: 0.1.0 (sha256:fcd997d5ab44e2197ce674fd4ffd59eb84355e161c0a7f500190bdc991c9f053)
|
||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:4f468e00efa86e6e33e8fa4f14deca93ee15b955071920facd56c59c3d2fefb8)
|
||||
// Input contract: 0.1.0 (sha256:fcd997d5ab44e2197ce674fd4ffd59eb84355e161c0a7f500190bdc991c9f053)
|
||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:4f468e00efa86e6e33e8fa4f14deca93ee15b955071920facd56c59c3d2fefb8)
|
||||
// Input contract: 0.1.0 (sha256:fcd997d5ab44e2197ce674fd4ffd59eb84355e161c0a7f500190bdc991c9f053)
|
||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:4f468e00efa86e6e33e8fa4f14deca93ee15b955071920facd56c59c3d2fefb8)
|
||||
// Input contract: 0.1.0 (sha256:fcd997d5ab44e2197ce674fd4ffd59eb84355e161c0a7f500190bdc991c9f053)
|
||||
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:4f468e00efa86e6e33e8fa4f14deca93ee15b955071920facd56c59c3d2fefb8)
|
||||
// Input contract: 0.1.0 (sha256:fcd997d5ab44e2197ce674fd4ffd59eb84355e161c0a7f500190bdc991c9f053)
|
||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export const createdWorkspace = {
|
||||
workspace_id: "home-empty",
|
||||
owner_account_id: "fixture-owner",
|
||||
display_name: "New Workspace",
|
||||
state: "active",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T00:00:00Z",
|
||||
};
|
||||
|
||||
export const currentInitialRepository = {
|
||||
workspace_id: createdWorkspace.workspace_id,
|
||||
repository_key: "platform",
|
||||
kind: "git",
|
||||
provider: "git",
|
||||
source: { kind: "https", uri: "https://example.test/current.git" },
|
||||
default_ref: "main",
|
||||
source_revision: 2,
|
||||
source_fingerprint: "sha256:current",
|
||||
observed_status: "unverified",
|
||||
observed_at: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T00:00:00Z",
|
||||
};
|
||||
|
||||
export function creationResponse(repository: unknown = null, replayed = false) {
|
||||
return {
|
||||
workspace: createdWorkspace,
|
||||
repository,
|
||||
config_revision: 1,
|
||||
request_fingerprint: "sha256:creation",
|
||||
replayed,
|
||||
};
|
||||
}
|
||||
@@ -17,11 +17,11 @@ export type CreateWorkspaceResponse = WorkspaceCreateResponse;
|
||||
export type CreateWorkspaceRequest = {
|
||||
operation_key: string;
|
||||
display_name: string;
|
||||
repository: {
|
||||
repository?: {
|
||||
repository_key: string;
|
||||
uri: string;
|
||||
default_ref: string | null;
|
||||
};
|
||||
} | null;
|
||||
};
|
||||
|
||||
export class WorkspaceCatalogError extends Error {
|
||||
|
||||
@@ -569,7 +569,7 @@ export function parseWorkspaceCreateResponse(
|
||||
response.workspace,
|
||||
"workspace create response.workspace",
|
||||
),
|
||||
repository: workspaceRepositoryRecord(
|
||||
repository: response.repository === null ? null : workspaceRepositoryRecord(
|
||||
response.repository,
|
||||
"workspace create response.repository",
|
||||
),
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { WorkerLaunchOptionsResponse } from "./types.ts";
|
||||
|
||||
export function emptyLaunchOptions(
|
||||
workspaceId: string,
|
||||
): WorkerLaunchOptionsResponse {
|
||||
return {
|
||||
workspace_id: workspaceId,
|
||||
runtimes: [
|
||||
{
|
||||
runtime_id: "embedded",
|
||||
display_name: "Embedded",
|
||||
status: "active",
|
||||
worker_creation_available: true,
|
||||
built_in: true,
|
||||
working_directory_required: false,
|
||||
diagnostics: [],
|
||||
},
|
||||
{
|
||||
runtime_id: "remote",
|
||||
display_name: "Remote",
|
||||
status: "active",
|
||||
worker_creation_available: true,
|
||||
built_in: false,
|
||||
working_directory_required: true,
|
||||
diagnostics: [],
|
||||
},
|
||||
],
|
||||
default_profile: "builtin:companion",
|
||||
profiles: [{
|
||||
id: "builtin:companion",
|
||||
label: "Companion",
|
||||
description: "Conversation",
|
||||
feature_connections: { subjektiv: false },
|
||||
}],
|
||||
repositories: [],
|
||||
working_directories: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
.workspace-catalog-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(250px, 100%), 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -109,7 +109,7 @@
|
||||
.workspace-create-panel {
|
||||
padding: 1.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 0.7fr) minmax(320px, 1.3fr);
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
@@ -136,6 +136,32 @@
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.workspace-create-panel .workspace-repository-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.workspace-repository-fields {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workspace-create-panel input:not([type="checkbox"]) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.workspace-create-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr));
|
||||
}
|
||||
|
||||
.workspace-create-panel label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workspace-create-row > label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
let creating = $state(false);
|
||||
let creationError = $state<string | null>(null);
|
||||
let displayName = $state("");
|
||||
let includeRepository = $state(false);
|
||||
let repositoryKey = $state("");
|
||||
let repositoryUri = $state("");
|
||||
let defaultRef = $state("");
|
||||
@@ -46,23 +47,17 @@
|
||||
event.preventDefault();
|
||||
if (creating) return;
|
||||
const normalized = {
|
||||
displayName: displayName.trim(),
|
||||
repositoryUri: repositoryUri.trim(),
|
||||
repositoryKey: repositoryKey.trim(),
|
||||
defaultRef: defaultRef.trim(),
|
||||
display_name: displayName.trim(),
|
||||
repository: includeRepository ? {
|
||||
repository_key: repositoryKey.trim(),
|
||||
uri: repositoryUri.trim(),
|
||||
default_ref: defaultRef.trim() || null,
|
||||
} : null,
|
||||
};
|
||||
const signature = JSON.stringify(normalized);
|
||||
const request = lastSubmission?.signature === signature
|
||||
? lastSubmission.request
|
||||
: {
|
||||
operation_key: createOperationKey(),
|
||||
display_name: normalized.displayName,
|
||||
repository: {
|
||||
repository_key: normalized.repositoryKey,
|
||||
uri: normalized.repositoryUri,
|
||||
default_ref: normalized.defaultRef || null,
|
||||
},
|
||||
};
|
||||
: { operation_key: createOperationKey(), ...normalized };
|
||||
lastSubmission = { signature, request };
|
||||
creating = true;
|
||||
creationError = null;
|
||||
@@ -154,29 +149,38 @@
|
||||
<div>
|
||||
<p class="workspace-catalog-eyebrow">New team space</p>
|
||||
<h2 id="workspace-create-title">Create Workspace</h2>
|
||||
<p>
|
||||
Repository sources are interpreted by Backend authority. Supported Git sources are absolute local paths, file://, ssh://, http(s)://, and user@host:path; Browser-local paths and embedded credentials are not authority. Plain HTTP is unencrypted, so prefer HTTPS or SSH.
|
||||
</p>
|
||||
<p>You can add repositories later in Workspace Settings.</p>
|
||||
</div>
|
||||
<form onsubmit={submitCreation}>
|
||||
<label>
|
||||
Workspace display name
|
||||
<input bind:value={displayName} required autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
Initial repository absolute path or URI
|
||||
<input bind:value={repositoryUri} required autocomplete="off" />
|
||||
<label class="workspace-repository-toggle">
|
||||
<input type="checkbox" bind:checked={includeRepository} aria-controls={includeRepository ? "initial-repository-fields" : undefined} />
|
||||
Add an initial repository (optional)
|
||||
</label>
|
||||
<div class="workspace-create-row">
|
||||
<label>
|
||||
Repository key
|
||||
<input bind:value={repositoryKey} required pattern="[a-z0-9]|[a-z0-9][a-z0-9-]*[a-z0-9]" maxlength="64" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
Default ref
|
||||
<input bind:value={defaultRef} placeholder="repository default" autocomplete="off" />
|
||||
</label>
|
||||
</div>
|
||||
{#if includeRepository}
|
||||
<div id="initial-repository-fields" class="workspace-repository-fields">
|
||||
<p>
|
||||
Repository sources are interpreted by Backend authority. Supported Git sources are absolute local paths, file://, ssh://, http(s)://, and user@host:path; Browser-local paths and embedded credentials are not authority. Plain HTTP is unencrypted, so prefer HTTPS or SSH.
|
||||
</p>
|
||||
<label>
|
||||
Initial repository absolute path or URI
|
||||
<input bind:value={repositoryUri} required autocomplete="off" />
|
||||
</label>
|
||||
<div class="workspace-create-row">
|
||||
<label>
|
||||
Repository key
|
||||
<input bind:value={repositoryKey} required pattern="[a-z0-9]|[a-z0-9](?:[a-z0-9]|-)*[a-z0-9]" maxlength="64" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
Default ref
|
||||
<input bind:value={defaultRef} placeholder="repository default" autocomplete="off" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if creationError}
|
||||
<div class="workspace-catalog-alert" role="alert">{creationError}</div>
|
||||
{/if}
|
||||
|
||||
@@ -383,6 +383,53 @@ test("deletion retry reuses the persisted operation id and confirmation, then re
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
test("zero-repository Workspace deletion follows authoritative preflight and exact-name confirmation", async () => {
|
||||
const resources = {
|
||||
workers: 0,
|
||||
workdirs: 0,
|
||||
repositories: 0,
|
||||
runtime_bindings: 0,
|
||||
secrets: 0,
|
||||
artifacts: 0,
|
||||
};
|
||||
const api = mockApi(async (request) => {
|
||||
if (request.url.includes("/workspace-deletions/")) {
|
||||
return Response.json({
|
||||
...operation("succeeded", request.url.split("/").pop()),
|
||||
resources,
|
||||
});
|
||||
}
|
||||
if (request.url.endsWith("/deletion")) {
|
||||
return request.method === "POST"
|
||||
? Response.json({
|
||||
...operation("succeeded", (await request.json()).operation_id),
|
||||
resources,
|
||||
})
|
||||
: Response.json({
|
||||
...deletionFixture(),
|
||||
resources,
|
||||
can_delete: true,
|
||||
blockers: [],
|
||||
});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
mount();
|
||||
const confirmation = await openDeletion();
|
||||
const remove = screen.getByRole("button", { name: "Delete Workspace" });
|
||||
expect((remove as HTMLButtonElement).disabled).toBe(true);
|
||||
await fireEvent.input(confirmation, {
|
||||
target: { value: metadataFixture().display_name },
|
||||
});
|
||||
await fireEvent.click(remove);
|
||||
await waitFor(() => expect(goto).toHaveBeenCalledWith("/"));
|
||||
expect(api.mock.calls.filter(([, init]) => init?.method === "POST"))
|
||||
.toHaveLength(1);
|
||||
expect(
|
||||
api.mock.calls.some(([path]) => String(path).includes("/repositories")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("reload resumes persisted deletion without sending another POST", async () => {
|
||||
sessionStorage.setItem(
|
||||
"yoi:workspace-deletion:home-owner",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// @vitest-environment happy-dom
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import type { ComponentProps } from "svelte";
|
||||
import NewWorker from "./+page.svelte";
|
||||
function mount() {
|
||||
const data = {
|
||||
workspaceId: "empty",
|
||||
repositories: {
|
||||
workspace_id: "empty",
|
||||
items: [],
|
||||
source: "fixture",
|
||||
diagnostics: [],
|
||||
},
|
||||
ticketContext: null,
|
||||
} as unknown as ComponentProps<typeof NewWorker>["data"];
|
||||
return render(NewWorker, {
|
||||
props: { data, params: { workspaceId: "empty" } },
|
||||
});
|
||||
}
|
||||
import { emptyLaunchOptions } from "#lib/workspace/sidebar/worker-launch.test-fixtures.ts";
|
||||
|
||||
vi.mock("$app/navigation", () => ({ goto: vi.fn(async () => {}) }));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("zero-repository Workspace launches a normal embedded Worker request without Workdir or Ticket assignment", async () => {
|
||||
const api = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) =>
|
||||
String(input).endsWith("/launch-options")
|
||||
? Response.json(emptyLaunchOptions("empty"))
|
||||
: Response.json({
|
||||
error: "unavailable",
|
||||
message: "Retry later",
|
||||
diagnostics: [],
|
||||
}, { status: 503 })
|
||||
);
|
||||
vi.stubGlobal("fetch", api);
|
||||
mount();
|
||||
const start = await screen.findByRole("button", { name: "Start Worker" });
|
||||
expect((start as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(
|
||||
screen.getByText(
|
||||
"No filesystem tools or Bash will be available without a Workdir.",
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByLabelText("Attachment 1 Workdir")).toBeNull();
|
||||
await fireEvent.click(start);
|
||||
await waitFor(() => expect(api).toHaveBeenCalledTimes(2));
|
||||
const body = JSON.parse(String(api.mock.calls[1][1]?.body));
|
||||
expect(body).toMatchObject({
|
||||
runtime_id: "embedded",
|
||||
profile: "builtin:companion",
|
||||
workdir_attachments: [],
|
||||
ticket_assignment: null,
|
||||
});
|
||||
expect(body).not.toHaveProperty("repository_key");
|
||||
});
|
||||
|
||||
test("zero-repository Workspace does not bypass a remote Runtime Workdir requirement", async () => {
|
||||
const options = emptyLaunchOptions("empty");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
Response.json({ ...options, runtimes: [...options.runtimes].reverse() })
|
||||
),
|
||||
);
|
||||
mount();
|
||||
await screen.findByRole("button", { name: "Start Worker" });
|
||||
await screen.findByLabelText("Attachment 1 Workdir");
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "Start Worker" }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
// @vitest-environment happy-dom
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { goto } from "$app/navigation";
|
||||
import Catalog from "./+page.svelte";
|
||||
import {
|
||||
createdWorkspace,
|
||||
creationResponse,
|
||||
} from "#lib/workspace/api/workspace-catalog.test-fixtures.ts";
|
||||
|
||||
vi.mock("$app/navigation", () => ({ goto: vi.fn(async () => {}) }));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
function mount() {
|
||||
return render(Catalog, {
|
||||
props: {
|
||||
data: {
|
||||
accessibleWorkspaces: [createdWorkspace],
|
||||
workspaceCatalogError: null,
|
||||
workspaces: [{ ...createdWorkspace, repositories: [] }],
|
||||
catalogError: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
function submit() {
|
||||
return fireEvent.submit(
|
||||
screen.getByRole("button", { name: /Create Workspace|Retry creation/ })
|
||||
.closest("form")!,
|
||||
);
|
||||
}
|
||||
|
||||
test("blank repository form creates and opens a Workspace using a null repository response", async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
Response.json(creationResponse()),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
mount();
|
||||
expect(
|
||||
screen.getByRole("link", { name: /New Workspace.*No repositories/ })
|
||||
.getAttribute("href"),
|
||||
).toBe("/w/home-empty");
|
||||
expect(screen.queryByLabelText("Repository key")).toBeNull();
|
||||
await fireEvent.input(screen.getByLabelText("Workspace display name"), {
|
||||
target: { value: " New Workspace " },
|
||||
});
|
||||
await submit();
|
||||
await waitFor(() => expect(goto).toHaveBeenCalledWith("/w/home-empty"));
|
||||
expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body))).toMatchObject({
|
||||
display_name: "New Workspace",
|
||||
repository: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("opting into a repository requires an explicit key and source and submits normalized values", async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
Response.json(creationResponse()),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
mount();
|
||||
await fireEvent.click(
|
||||
screen.getByRole("checkbox", {
|
||||
name: "Add an initial repository (optional)",
|
||||
}),
|
||||
);
|
||||
const key = screen.getByLabelText("Repository key") as HTMLInputElement;
|
||||
const uri = screen.getByLabelText(
|
||||
"Initial repository absolute path or URI",
|
||||
) as HTMLInputElement;
|
||||
expect(key.required && uri.required).toBe(true);
|
||||
expect(key.value).toBe("");
|
||||
await fireEvent.input(screen.getByLabelText("Workspace display name"), {
|
||||
target: { value: "New Workspace" },
|
||||
});
|
||||
await fireEvent.input(key, { target: { value: "platform" } });
|
||||
await fireEvent.input(uri, { target: { value: " /srv/platform " } });
|
||||
await fireEvent.input(screen.getByLabelText("Default ref"), {
|
||||
target: { value: " main " },
|
||||
});
|
||||
await submit();
|
||||
await waitFor(() => expect(fetcher).toHaveBeenCalledOnce());
|
||||
expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body)).repository).toEqual(
|
||||
{ repository_key: "platform", uri: "/srv/platform", default_ref: "main" },
|
||||
);
|
||||
});
|
||||
|
||||
test("unchanged empty creation retries reuse the operation key and accept deleted-repository replay", async () => {
|
||||
const fetcher = vi.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(
|
||||
Response.json({ message: "Temporary failure" }, { status: 503 }),
|
||||
)
|
||||
.mockResolvedValueOnce(Response.json(creationResponse(null, true)));
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
mount();
|
||||
await fireEvent.input(screen.getByLabelText("Workspace display name"), {
|
||||
target: { value: "New Workspace" },
|
||||
});
|
||||
await submit();
|
||||
expect(await screen.findByRole("alert")).toBeTruthy();
|
||||
await submit();
|
||||
await waitFor(() => expect(goto).toHaveBeenCalledWith("/w/home-empty"));
|
||||
expect(
|
||||
fetcher.mock.calls.map(([, init]) => JSON.parse(String(init?.body)))[0],
|
||||
).toEqual(JSON.parse(String(fetcher.mock.calls[1][1]?.body)));
|
||||
});
|
||||
|
||||
test("opting out ignores retained repository inputs and changes the creation operation", async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () =>
|
||||
Response.json({ message: "Temporary failure" }, { status: 503 })
|
||||
);
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
mount();
|
||||
const toggle = screen.getByRole("checkbox", {
|
||||
name: "Add an initial repository (optional)",
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText("Workspace display name"), {
|
||||
target: { value: "New Workspace" },
|
||||
});
|
||||
await fireEvent.click(toggle);
|
||||
await fireEvent.input(screen.getByLabelText("Repository key"), {
|
||||
target: { value: "platform" },
|
||||
});
|
||||
await fireEvent.input(
|
||||
screen.getByLabelText("Initial repository absolute path or URI"),
|
||||
{ target: { value: "/srv/platform" } },
|
||||
);
|
||||
await submit();
|
||||
await screen.findByRole("alert");
|
||||
await fireEvent.click(toggle);
|
||||
await submit();
|
||||
await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2));
|
||||
const bodies = fetcher.mock.calls.map(([, init]) =>
|
||||
JSON.parse(String(init?.body))
|
||||
);
|
||||
expect(bodies[1].repository).toBeNull();
|
||||
expect(bodies[1].operation_key).not.toBe(bodies[0].operation_key);
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
# Optional initial repository validation (T-717 Web portion)
|
||||
|
||||
These scenarios use the production static Workspace build and the existing
|
||||
`tools/web-ux` capture/process harness. The loopback proxy only extends the shared
|
||||
Home fixture's API responses for empty Worker lists and launch options; it does
|
||||
not replace the application shell, styles, or components. No real Workspace,
|
||||
account, repository, Runtime, or Worker is created.
|
||||
|
||||
## Run
|
||||
|
||||
From `web/workspace`, with the repository's pinned Chromium available:
|
||||
|
||||
```sh
|
||||
deno task build
|
||||
deno task test:browser:empty-workspace
|
||||
```
|
||||
|
||||
The browser test covers light/dark at 1440, 768, 390, and 320px: optional Git
|
||||
native validation, opting out after entering repository values, null creation
|
||||
response/navigation, zero-repository catalog reopening, normal embedded Worker
|
||||
submission without Workdir or Ticket assignment, and remote Runtime Workdir
|
||||
requirements. It uses dynamically allocated loopback ports and the existing
|
||||
owned-process cleanup harness. Screenshots and logs are ignored artifacts under
|
||||
`.svelte-kit/t717/browser`.
|
||||
|
||||
From `tools/web-ux`:
|
||||
|
||||
```sh
|
||||
deno task web-ux capture \
|
||||
--scenario ../../web/workspace/test/visual-review/optional-repository.json \
|
||||
--output ../../web/workspace/.svelte-kit/t717/visual --run-id empty-workspace
|
||||
|
||||
deno task web-ux capture \
|
||||
--scenario ../../web/workspace/test/visual-review/optional-repository-interactions.json \
|
||||
--output ../../web/workspace/.svelte-kit/t717/visual --run-id creation-interactions
|
||||
```
|
||||
|
||||
The interaction scenario intentionally rejects creation with HTTP400. Capture
|
||||
exit status 2 and the recorded request/console/visible validation errors are
|
||||
expected for `validation-error`, not evidence of a successful creation. No other
|
||||
capture error is expected. To repeat dark captures, use a scenario copy with
|
||||
`colorScheme: "dark"`, preserving the process cwd paths.
|
||||
|
||||
## Implementer-owned visual review
|
||||
|
||||
- Baseline revision: `e939cd4e4b1f6b63b81411fd7766062f2d69ad0d`.
|
||||
- Before: `.svelte-kit/t717/visual/before-final-light` and `before-dark`.
|
||||
These use an isolated build under `.svelte-kit/t717/baseline-source`, with the
|
||||
changed production form, catalog API, model, and CSS restored from baseline
|
||||
Git source. The original pre-edit light capture is also retained as
|
||||
`.svelte-kit/t717/visual/before-valid`.
|
||||
- After: `.svelte-kit/t717/visual/final-light`, `final-dark`,
|
||||
`final-interactions-light`, and `final-interactions-dark`.
|
||||
- Comparisons: `.svelte-kit/t717/visual/final-comparison-light` and
|
||||
`final-comparison-dark`.
|
||||
- Personas: synthetic owner and non-owner permission projections; catalog
|
||||
creation/reopening uses the same synthetic account.
|
||||
- Viewports: 1440, 768, 390px; additional browser screenshots/checks at 320px.
|
||||
- Themes: light and dark.
|
||||
- States: zero repositories, empty Home/Sidebar, representative member work
|
||||
records, long catalog names, unchecked/checked optional repository form,
|
||||
populated inputs, and intentional Backend validation failure.
|
||||
- Reviewed by the implementer using `ViewImage`: before/after contact sheets,
|
||||
individual narrow/mobile populated-form screenshots, and the final mobile
|
||||
dark validation-error screenshot. Reviewed context bundles and accessibility
|
||||
snapshots for document status, control names/state, screenshot hashes,
|
||||
console/page/request errors, and capture points.
|
||||
- Fixed: catalog/form clipping at narrow content widths; repository field
|
||||
disclosure/required-state handling. Chromium tests also found and fixed the
|
||||
existing repository-key HTML pattern's invalid modern-regexp character class.
|
||||
- Result: pass. All baseline and final main captures have document status 200
|
||||
and no retained browser/UI errors. Only the intentional HTTP400 interaction
|
||||
errors remain. Home, member Sidebar, and Worker launch comparisons show zero
|
||||
changed pixels; catalog changes are confined to the intended surface.
|
||||
|
||||
## Validation and limitations
|
||||
|
||||
- `deno task check`: 0 errors, 0 warnings.
|
||||
- Targeted model/catalog Deno tests: 18 passed.
|
||||
- `deno task test:component`: 29 files, 268 tests passed.
|
||||
- `deno task test:browser:empty-workspace`: passed (8 theme/width combinations).
|
||||
- `deno task build`: passed.
|
||||
- Web-scoped `git diff --check HEAD -- .`: passed.
|
||||
- `deno task test`: 522 passed, 1 pre-existing failure in
|
||||
`src/lib/workspace/console/worker-console.ui.test.ts`,
|
||||
“workspace Tickets surface provides Kanban and lifecycle controls”. It fails
|
||||
the unchanged Ticket detail source assertion at line 429. The same failure
|
||||
was reproduced against the isolated baseline source. Evidence:
|
||||
`.svelte-kit/t717/unit.log` and `baseline-ticket-failure.log`.
|
||||
- Canonical OpenAPI and generated TypeScript were regenerated together.
|
||||
The Web parser uses the generated nullable creation-response type directly.
|
||||
Backend persistence/idempotency is covered separately by Store/catalog and
|
||||
real HTTP boundary tests in `yoi-workspace-server`.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// Extends the existing Web UX fixture at the API boundary, never the production shell/CSS.
|
||||
// Both loopback servers are owned and cleaned up by the Web UX capture harness.
|
||||
const port = Number(Deno.args[0]);
|
||||
const upstream = Deno.args[1];
|
||||
if (!Number.isInteger(port) || !upstream) {
|
||||
throw new Error("usage: server <port> <upstream-url>");
|
||||
}
|
||||
|
||||
import { emptyLaunchOptions } from "../../src/lib/workspace/sidebar/worker-launch.test-fixtures.ts";
|
||||
import { dashboardFixture } from "../../src/lib/workspace/home/dashboard.test-fixtures.ts";
|
||||
|
||||
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/api/workspaces" && request.method === "POST") {
|
||||
return Response.json({
|
||||
message:
|
||||
"Workspace display name was rejected by Backend validation. Choose another display name and retry; no Workspace was created.",
|
||||
}, { status: 400 });
|
||||
}
|
||||
const match = url.pathname.match(/^\/api\/w\/([^/]+)(\/.*)$/);
|
||||
if (match) {
|
||||
const [, id, path] = match;
|
||||
// The shared catalog includes a deliberate failure persona, which can be hover-preloaded.
|
||||
// This scenario tests zero repositories, not that separate failure contract.
|
||||
if (id === "home-error" && path === "/merge-requests") {
|
||||
return Response.json(
|
||||
dashboardFixture(path, url.searchParams, "home-empty"),
|
||||
);
|
||||
}
|
||||
if (path === "/workers") {
|
||||
return Response.json({
|
||||
workspace_id: id,
|
||||
limit: 100,
|
||||
items: [],
|
||||
source: "fixture",
|
||||
diagnostics: [],
|
||||
});
|
||||
}
|
||||
if (path === "/workers/launch-options") {
|
||||
return Response.json(emptyLaunchOptions(id));
|
||||
}
|
||||
}
|
||||
// Forward HTTP and bridge the existing workspace subscription fixture.
|
||||
const target = new URL(url.pathname + url.search, upstream);
|
||||
if (request.headers.get("upgrade") === "websocket") {
|
||||
const { socket, response } = Deno.upgradeWebSocket(request);
|
||||
target.protocol = "ws:";
|
||||
const peer = new WebSocket(target);
|
||||
const pending: string[] = [];
|
||||
socket.onmessage = (event) =>
|
||||
peer.readyState === WebSocket.OPEN
|
||||
? peer.send(event.data)
|
||||
: pending.push(String(event.data));
|
||||
peer.onopen = () => {
|
||||
for (const frame of pending) peer.send(frame);
|
||||
};
|
||||
peer.onmessage = (event) => {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send(event.data);
|
||||
};
|
||||
socket.onclose = () => peer.close();
|
||||
peer.onclose = () => socket.close();
|
||||
return response;
|
||||
}
|
||||
return await fetch(new Request(target, request));
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { assert, assertEquals } from "jsr:@std/assert@1.0.19";
|
||||
import { chromium } from "npm:playwright@1.59.1";
|
||||
import { loadScenario } from "../../../../tools/web-ux/src/scenario.ts";
|
||||
import {
|
||||
startOwnedProcesses,
|
||||
stopOwnedProcesses,
|
||||
} from "../../../../tools/web-ux/src/processes.ts";
|
||||
import { creationResponse } from "../../src/lib/workspace/api/workspace-catalog.test-fixtures.ts";
|
||||
|
||||
// Browser-only contract over the production static build and loopback API fixtures;
|
||||
// no actual Backend, Runtime, Worker, account or repository is created.
|
||||
Deno.test("production empty Workspace supports optional Git validation, reopening, and Workdir-less Worker submission at all review widths", async () => {
|
||||
const scenarioPath =
|
||||
new URL("./optional-repository.json", import.meta.url).pathname;
|
||||
const scenario = await loadScenario(scenarioPath);
|
||||
async function freePort() {
|
||||
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
||||
const port = (listener.addr as Deno.NetAddr).port;
|
||||
listener.close();
|
||||
return port;
|
||||
}
|
||||
const upstreamPort = await freePort();
|
||||
let proxyPort = await freePort();
|
||||
while (proxyPort === upstreamPort) proxyPort = await freePort();
|
||||
scenario.baseUrl = `http://127.0.0.1:${proxyPort}`;
|
||||
scenario.processes![0].args![4] = String(upstreamPort);
|
||||
scenario.processes![0].readyUrl = `http://127.0.0.1:${upstreamPort}/health`;
|
||||
scenario.processes![1].args![3] = String(proxyPort);
|
||||
scenario.processes![1].args![4] = `http://127.0.0.1:${upstreamPort}`;
|
||||
scenario.processes![1].readyUrl = `${scenario.baseUrl}/health`;
|
||||
const output =
|
||||
new URL("../../.svelte-kit/t717/browser/", import.meta.url).pathname;
|
||||
const processes = await startOwnedProcesses(
|
||||
scenario.processes!,
|
||||
scenarioPath,
|
||||
`${output}/process-logs`,
|
||||
[],
|
||||
);
|
||||
try {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390, 320]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: 900 },
|
||||
colorScheme,
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(String(error)));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") errors.push(message.text());
|
||||
});
|
||||
page.on("response", (response) => {
|
||||
if (response.status() >= 400) {
|
||||
errors.push(`${response.status()} ${response.url()}`);
|
||||
}
|
||||
});
|
||||
const creationBodies: Record<string, unknown>[] = [];
|
||||
await page.route("**/api/workspaces", async (route) => {
|
||||
if (route.request().method() !== "POST") {
|
||||
return await route.continue();
|
||||
}
|
||||
creationBodies.push(route.request().postDataJSON());
|
||||
await route.fulfill({ json: creationResponse() });
|
||||
});
|
||||
await page.goto(scenario.baseUrl!);
|
||||
const name = page.getByLabel("Workspace display name");
|
||||
await name.fill("New Workspace");
|
||||
const toggle = page.getByRole("checkbox", {
|
||||
name: "Add an initial repository (optional)",
|
||||
});
|
||||
await toggle.check();
|
||||
await page.getByRole("button", {
|
||||
name: "Create Workspace",
|
||||
exact: true,
|
||||
}).click();
|
||||
assertEquals(creationBodies.length, 0); // Native required fields stop partial repository submission.
|
||||
await page.getByLabel("Repository key", { exact: true }).fill(
|
||||
"Invalid key",
|
||||
);
|
||||
await page.getByLabel("Initial repository absolute path or URI")
|
||||
.fill("/srv/platform");
|
||||
assertEquals(
|
||||
await page.getByLabel("Repository key", { exact: true }).evaluate(
|
||||
(input) =>
|
||||
(input as unknown as { checkValidity(): boolean })
|
||||
.checkValidity(),
|
||||
),
|
||||
false,
|
||||
);
|
||||
await page.getByLabel("Repository key", { exact: true }).fill(
|
||||
"platform",
|
||||
);
|
||||
assertEquals(
|
||||
await page.locator("form").evaluate((form) =>
|
||||
(form as unknown as { checkValidity(): boolean })
|
||||
.checkValidity()
|
||||
),
|
||||
true,
|
||||
);
|
||||
await Deno.mkdir(output, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: `${output}/${colorScheme}-${width}-repository.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
assertEquals(
|
||||
await page.evaluate(
|
||||
"document.documentElement.scrollWidth > innerWidth",
|
||||
),
|
||||
false,
|
||||
`${colorScheme} ${width}px`,
|
||||
);
|
||||
await toggle.uncheck();
|
||||
await page.getByRole("button", {
|
||||
name: "Create Workspace",
|
||||
exact: true,
|
||||
}).click();
|
||||
await page.waitForURL("**/w/home-empty");
|
||||
assertEquals(creationBodies.length, 1);
|
||||
assertEquals(creationBodies[0].repository, null);
|
||||
await page.locator("[data-feed-ready=home-active]").waitFor();
|
||||
assertEquals(await page.locator("main [role=alert]").count(), 0);
|
||||
// Reopening is based on Workspace identity, not a repository record.
|
||||
await page.goto(scenario.baseUrl!);
|
||||
await page.locator('a.workspace-catalog-card[href="/w/home-empty"]')
|
||||
.click();
|
||||
await page.waitForURL("**/w/home-empty");
|
||||
await page.goto(`${scenario.baseUrl}/w/home-empty/workers/new`);
|
||||
await page.locator(".worker-launch-form").waitFor();
|
||||
assert(
|
||||
await page.getByRole("button", { name: "Start Worker" })
|
||||
.isEnabled(),
|
||||
);
|
||||
await page.getByRole("combobox", { name: "Runtime", exact: true })
|
||||
.selectOption("remote");
|
||||
assert(
|
||||
await page.getByRole("button", { name: "Start Worker" })
|
||||
.isDisabled(),
|
||||
);
|
||||
await page.getByLabel("Attachment 1 Workdir").selectOption(
|
||||
"__new_working_directory__",
|
||||
);
|
||||
assert(
|
||||
await page.getByRole("button", {
|
||||
name: "Create Workdir",
|
||||
exact: true,
|
||||
}).isDisabled(),
|
||||
);
|
||||
await page.getByRole("combobox", { name: "Runtime", exact: true })
|
||||
.selectOption("embedded");
|
||||
const submitted = page.waitForRequest((request) =>
|
||||
request.url().endsWith("/api/w/home-empty/workers") &&
|
||||
request.method() === "POST"
|
||||
);
|
||||
await page.route("**/api/w/home-empty/workers", async (route) => {
|
||||
if (route.request().method() !== "POST") {
|
||||
return await route.continue();
|
||||
}
|
||||
// Successful, synthetic launch projection; the test only exercises browser/API wiring.
|
||||
await route.fulfill({
|
||||
json: {
|
||||
workspace_id: "home-empty",
|
||||
runtime_id: "embedded",
|
||||
worker_id: "fixture-worker",
|
||||
console_href: "/w/home-empty",
|
||||
diagnostics: [],
|
||||
worker: {
|
||||
runtime_id: "embedded",
|
||||
worker_id: "fixture-worker",
|
||||
host_id: "fixture-host",
|
||||
display_name: "Worker",
|
||||
label: "Worker",
|
||||
profile: "builtin:companion",
|
||||
singleton_key: null,
|
||||
tags: [],
|
||||
workspace: {
|
||||
visibility: "workspace",
|
||||
identity: "home-empty",
|
||||
workspace_id: "home-empty",
|
||||
},
|
||||
state: "idle",
|
||||
last_seen_at: null,
|
||||
pinned: false,
|
||||
retention_state: "active",
|
||||
implementation: { kind: "yoi", display_hint: "Yoi" },
|
||||
workdir_attachments: [],
|
||||
diagnostics: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.getByRole("button", { name: "Start Worker" }).click();
|
||||
const body = (await submitted).postDataJSON();
|
||||
assertEquals(body.workdir_attachments, []);
|
||||
assertEquals(body.ticket_assignment, null);
|
||||
assertEquals(body.runtime_id, "embedded");
|
||||
await page.waitForURL("**/w/home-empty");
|
||||
assertEquals(errors, []);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
} finally {
|
||||
assertEquals(await stopOwnedProcesses(processes), []);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "optional-repository-interactions",
|
||||
"title": "Optional repository disclosure and validation failure",
|
||||
"baseUrl": "http://127.0.0.1:15281",
|
||||
"colorScheme": "light",
|
||||
"reducedMotion": "reduce",
|
||||
"personas": [
|
||||
{
|
||||
"id": "fixture",
|
||||
"label": "Synthetic account; permissions vary by Workspace",
|
||||
"auth": {
|
||||
"kind": "anonymous"
|
||||
}
|
||||
}
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"label": "desktop",
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "narrow",
|
||||
"width": 768,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "mobile",
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"id": "catalog",
|
||||
"label": "Create Workspace without a repository",
|
||||
"path": "/",
|
||||
"goal": "Reopen a zero-repository Workspace or create a new Workspace without configuring Git.",
|
||||
"dataState": "Opt-in Git fields followed by intentional HTTP400 creation validation failure; no actual resources are created.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".workspace-create-panel input"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "repository-fields",
|
||||
"label": "Opt-in Git fields",
|
||||
"fullPage": true,
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".workspace-repository-toggle input"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "validation-error",
|
||||
"label": "Intentional Backend validation failure",
|
||||
"fullPage": true,
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".workspace-repository-toggle input"
|
||||
},
|
||||
{
|
||||
"action": "fill",
|
||||
"selector": ".workspace-create-panel input[required]",
|
||||
"value": "Rejected Workspace"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".workspace-create-panel button[type=submit]"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".workspace-create-panel [role=alert]"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"processes": [
|
||||
{
|
||||
"id": "workspace-fixture",
|
||||
"command": "deno",
|
||||
"cwd": "../../../../tools/web-ux",
|
||||
"args": [
|
||||
"run",
|
||||
"--allow-net",
|
||||
"--allow-read",
|
||||
"browser-tests/workspace_home_fixture_server.ts",
|
||||
"15282",
|
||||
"../../web/workspace/build"
|
||||
],
|
||||
"readyUrl": "http://127.0.0.1:15282/health"
|
||||
},
|
||||
{
|
||||
"id": "empty-workspace-api",
|
||||
"command": "deno",
|
||||
"cwd": "../..",
|
||||
"args": [
|
||||
"run",
|
||||
"--allow-net",
|
||||
"test/visual-review/empty-workspace-fixture-server.ts",
|
||||
"15281",
|
||||
"http://127.0.0.1:15282"
|
||||
],
|
||||
"readyUrl": "http://127.0.0.1:15281/health"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "optional-initial-repository",
|
||||
"title": "Optional initial repository and empty Workspace",
|
||||
"baseUrl": "http://127.0.0.1:15281",
|
||||
"colorScheme": "light",
|
||||
"reducedMotion": "reduce",
|
||||
"personas": [
|
||||
{
|
||||
"id": "fixture",
|
||||
"label": "Synthetic account; permissions vary by Workspace",
|
||||
"auth": {
|
||||
"kind": "anonymous"
|
||||
}
|
||||
}
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"label": "desktop",
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "narrow",
|
||||
"width": 768,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "mobile",
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"id": "catalog",
|
||||
"label": "Create Workspace without a repository",
|
||||
"path": "/",
|
||||
"goal": "Reopen a zero-repository Workspace or create a new Workspace without configuring Git.",
|
||||
"dataState": "Accessible Workspaces with no repositories and blank creation form.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".workspace-create-panel input"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Blank creation form",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "empty-owner",
|
||||
"label": "Empty Workspace owner",
|
||||
"path": "/w/home-empty",
|
||||
"goal": "Use Home and Sidebar without any repositories or work records.",
|
||||
"dataState": "Owner permissions; zero repositories, Workers, tickets, Objectives, and Merge Requests.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Empty Home and Sidebar"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "member",
|
||||
"label": "Zero-repository member",
|
||||
"path": "/w/home-member",
|
||||
"goal": "Navigate authorized work without owner-only repository management actions.",
|
||||
"dataState": "Non-owner permissions; zero repositories, representative work records.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Member Home and Sidebar"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "worker",
|
||||
"label": "New Worker without repositories",
|
||||
"path": "/w/home-empty/workers/new",
|
||||
"goal": "Start a normal conversation Worker without a Workdir on embedded Runtime.",
|
||||
"dataState": "No repositories or Workdirs, embedded and remote Runtime candidates.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".worker-launch-form"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Embedded Worker launch",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"processes": [
|
||||
{
|
||||
"id": "workspace-fixture",
|
||||
"command": "deno",
|
||||
"cwd": "../../../../tools/web-ux",
|
||||
"args": [
|
||||
"run",
|
||||
"--allow-net",
|
||||
"--allow-read",
|
||||
"browser-tests/workspace_home_fixture_server.ts",
|
||||
"15282",
|
||||
"../../web/workspace/build"
|
||||
],
|
||||
"readyUrl": "http://127.0.0.1:15282/health"
|
||||
},
|
||||
{
|
||||
"id": "empty-workspace-api",
|
||||
"command": "deno",
|
||||
"cwd": "../..",
|
||||
"args": [
|
||||
"run",
|
||||
"--allow-net",
|
||||
"test/visual-review/empty-workspace-fixture-server.ts",
|
||||
"15281",
|
||||
"http://127.0.0.1:15282"
|
||||
],
|
||||
"readyUrl": "http://127.0.0.1:15281/health"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,12 +1,7 @@
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
readTextFile(path: URL): Promise<string>;
|
||||
};
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function assertEquals(actual: unknown, expected: unknown): void {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(
|
||||
@@ -35,6 +30,26 @@ import {
|
||||
loadWorkspaceCatalog,
|
||||
WorkspaceCatalogError,
|
||||
} from "../src/lib/workspace/api/workspace-catalog.ts";
|
||||
import { creationResponse } from "../src/lib/workspace/api/workspace-catalog.test-fixtures.ts";
|
||||
|
||||
Deno.test("workspace creation accepts omitted and null repository requests without fabricating a repository", async () => {
|
||||
for (const repository of [undefined, null]) {
|
||||
const request = {
|
||||
operation_key: "empty-create",
|
||||
display_name: "New Workspace",
|
||||
...(repository === null ? { repository } : {}),
|
||||
};
|
||||
const fetcher = async (url: string | URL | Request, init?: RequestInit) => {
|
||||
assertEquals(url, "/api/workspaces");
|
||||
assertEquals(init?.method, "POST");
|
||||
assertEquals(JSON.parse(String(init?.body)), request);
|
||||
return Response.json(creationResponse());
|
||||
};
|
||||
const response = await createWorkspace(fetcher as typeof fetch, request);
|
||||
assertEquals(response.repository, null);
|
||||
assertEquals(response.workspace.workspace_id, "home-empty");
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("workspace catalog enriches each visible workspace without dropping siblings", async () => {
|
||||
const fetcher = (input: string | URL | Request) => {
|
||||
@@ -118,27 +133,3 @@ Deno.test("workspace creation preserves caller-owned operation key across retry"
|
||||
);
|
||||
assertEquals(bodies, [request, request]);
|
||||
});
|
||||
|
||||
Deno.test("workspace creation form starts with an explicit required Repository key", async () => {
|
||||
const source = await Deno.readTextFile(
|
||||
new URL("../src/routes/+page.svelte", import.meta.url),
|
||||
);
|
||||
|
||||
assert(
|
||||
source.includes('let repositoryKey = $state("");'),
|
||||
"the form must start without an implicit Repository key",
|
||||
);
|
||||
assert(
|
||||
source.includes("<input bind:value={repositoryKey} required"),
|
||||
"the Browser must require creator input for the Repository key",
|
||||
);
|
||||
assert(
|
||||
source.includes("repository_key: normalized.repositoryKey"),
|
||||
"the Browser must submit the creator-provided Repository key",
|
||||
);
|
||||
assert(
|
||||
!source.includes('repositoryKey || "main"') &&
|
||||
!source.includes("repositoryKey || 'main'"),
|
||||
"the Browser must not fall back to main",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,12 +8,57 @@ import {
|
||||
parseRepositoryApiError,
|
||||
parseRepositoryListApiResult,
|
||||
parseRepositoryListResponse,
|
||||
parseWorkspaceCreateResponse,
|
||||
parseWorkspaceDeletionOperationResponse,
|
||||
parseWorkspaceDeletionPreflightResponse,
|
||||
parseWorkspaceResponse,
|
||||
REPOSITORY_API_LIMITS,
|
||||
} from "../src/lib/workspace/api/workspace-model.ts";
|
||||
|
||||
import {
|
||||
creationResponse,
|
||||
currentInitialRepository,
|
||||
} from "../src/lib/workspace/api/workspace-catalog.test-fixtures.ts";
|
||||
|
||||
Deno.test("workspace creation decodes absent and deleted initial repositories as explicit null", () => {
|
||||
for (const replayed of [false, true]) {
|
||||
const parsed = parseWorkspaceCreateResponse(
|
||||
JSON.parse(JSON.stringify(creationResponse(null, replayed))),
|
||||
);
|
||||
if (parsed.repository !== null || parsed.replayed !== replayed) {
|
||||
throw new Error("nullable creation response was not preserved");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("workspace creation replay preserves the returned current repository record", () => {
|
||||
const parsed = parseWorkspaceCreateResponse(
|
||||
creationResponse(currentInitialRepository, true),
|
||||
);
|
||||
if (
|
||||
JSON.stringify(parsed.repository) !==
|
||||
JSON.stringify(currentInitialRepository)
|
||||
) throw new Error("current repository record was replaced or lost");
|
||||
});
|
||||
|
||||
Deno.test("workspace creation rejects missing and malformed repository response fields", () => {
|
||||
for (
|
||||
const value of [undefined, false, {}, {
|
||||
...currentInitialRepository,
|
||||
source_revision: -1,
|
||||
}]
|
||||
) {
|
||||
assertThrows(
|
||||
() =>
|
||||
parseWorkspaceCreateResponse({
|
||||
...creationResponse(),
|
||||
repository: value,
|
||||
}),
|
||||
"workspace create response.repository",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function assertThrows(operation: () => unknown, expected: string): void {
|
||||
try {
|
||||
operation();
|
||||
|
||||
Reference in New Issue
Block a user