web: launch embedded workspace orchestrator
This commit is contained in:
@@ -899,7 +899,7 @@ fn builtin_profile_artifact(label: &str) -> Option<serde_json::Value> {
|
|||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
true,
|
false,
|
||||||
);
|
);
|
||||||
Some(value)
|
Some(value)
|
||||||
}
|
}
|
||||||
@@ -1504,7 +1504,7 @@ mod tests {
|
|||||||
|
|
||||||
let orchestrator = resolve("orchestrator");
|
let orchestrator = resolve("orchestrator");
|
||||||
assert!(orchestrator.feature.task.enabled);
|
assert!(orchestrator.feature.task.enabled);
|
||||||
assert!(orchestrator.feature.workers.enabled);
|
assert!(!orchestrator.feature.workers.enabled);
|
||||||
assert!(orchestrator.feature.ticket.enabled);
|
assert!(orchestrator.feature.ticket.enabled);
|
||||||
assert!(orchestrator.feature.ticket.enabled);
|
assert!(orchestrator.feature.ticket.enabled);
|
||||||
assert!(!orchestrator.feature.ticket.authoring);
|
assert!(!orchestrator.feature.ticket.authoring);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use workdir::{
|
|||||||
Workdir, WorkdirError,
|
Workdir, WorkdirError,
|
||||||
http::{OpenWorkdirSessionRequest, RemoteWorkdirSession, WorkdirHttpAuthorization},
|
http::{OpenWorkdirSessionRequest, RemoteWorkdirSession, WorkdirHttpAuthorization},
|
||||||
};
|
};
|
||||||
|
use worker_runtime::RuntimeWorkspaceScope;
|
||||||
use worker_runtime::auth::{CapabilityTokenSigner, capability_claims};
|
use worker_runtime::auth::{CapabilityTokenSigner, capability_claims};
|
||||||
use worker_runtime::catalog::{
|
use worker_runtime::catalog::{
|
||||||
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveHttpRef,
|
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveHttpRef,
|
||||||
@@ -1830,6 +1831,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let workspace_id = workspace_api.workspace_id.clone();
|
||||||
let create_request = CreateWorkerRequest {
|
let create_request = CreateWorkerRequest {
|
||||||
idempotency_key,
|
idempotency_key,
|
||||||
idempotency_fingerprint,
|
idempotency_fingerprint,
|
||||||
@@ -1842,7 +1844,11 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||||||
working_directory: request.resolved_working_directory.clone(),
|
working_directory: request.resolved_working_directory.clone(),
|
||||||
workspace_api: Some(workspace_api),
|
workspace_api: Some(workspace_api),
|
||||||
};
|
};
|
||||||
match self.runtime.create_worker(create_request) {
|
let workspace_scope = RuntimeWorkspaceScope::new(workspace_id, "embedded-backend");
|
||||||
|
match self
|
||||||
|
.runtime
|
||||||
|
.create_worker_scoped(&workspace_scope, create_request)
|
||||||
|
{
|
||||||
Ok(detail) => WorkerSpawnResult {
|
Ok(detail) => WorkerSpawnResult {
|
||||||
state: WorkerOperationState::Accepted,
|
state: WorkerOperationState::Accepted,
|
||||||
worker: Some(self.map_worker_detail(detail)),
|
worker: Some(self.map_worker_detail(detail)),
|
||||||
@@ -3433,16 +3439,15 @@ fn worker_display_metadata(
|
|||||||
tags,
|
tags,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if profile_label == Some(WORKSPACE_ORCHESTRATOR_PROFILE) {
|
if profile_label == Some(WORKSPACE_ORCHESTRATOR_PROFILE)
|
||||||
|
&& requested_display_name == Some(WORKSPACE_ORCHESTRATOR_SINGLETON_KEY)
|
||||||
|
{
|
||||||
let mut tags = vec!["orchestrator".to_string(), "singleton".to_string()];
|
let mut tags = vec!["orchestrator".to_string(), "singleton".to_string()];
|
||||||
if internal {
|
if internal {
|
||||||
tags.insert(0, "internal".to_string());
|
tags.insert(0, "internal".to_string());
|
||||||
}
|
}
|
||||||
return WorkerDisplayMetadata {
|
return WorkerDisplayMetadata {
|
||||||
display_name: requested_display_name
|
display_name: "Workspace Orchestrator".to_string(),
|
||||||
.filter(|value| !value.trim().is_empty())
|
|
||||||
.map(safe_display_hint)
|
|
||||||
.unwrap_or_else(|| "Workspace Orchestrator".to_string()),
|
|
||||||
singleton_key: Some(WORKSPACE_ORCHESTRATOR_SINGLETON_KEY.to_string()),
|
singleton_key: Some(WORKSPACE_ORCHESTRATOR_SINGLETON_KEY.to_string()),
|
||||||
tags,
|
tags,
|
||||||
};
|
};
|
||||||
@@ -3957,6 +3962,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(manifest.feature.manage_workdir.enabled);
|
assert!(manifest.feature.manage_workdir.enabled);
|
||||||
|
assert!(!manifest.feature.workers.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ pub enum Error {
|
|||||||
},
|
},
|
||||||
#[error("invalid runtime {kind} `{value}`")]
|
#[error("invalid runtime {kind} `{value}`")]
|
||||||
InvalidRuntimeIdentifier { kind: String, value: String },
|
InvalidRuntimeIdentifier { kind: String, value: String },
|
||||||
|
#[error("worker name is reserved for a dedicated Workspace service: {0}")]
|
||||||
|
ReservedWorkerName(String),
|
||||||
#[error("runtime `{runtime_id}` operation failed ({code}): {message}")]
|
#[error("runtime `{runtime_id}` operation failed ({code}): {message}")]
|
||||||
RuntimeOperationFailed {
|
RuntimeOperationFailed {
|
||||||
runtime_id: String,
|
runtime_id: String,
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ pub struct WorkspaceApi {
|
|||||||
authority: SqliteWorkspaceAuthority,
|
authority: SqliteWorkspaceAuthority,
|
||||||
runtime: Arc<RuntimeRegistry>,
|
runtime: Arc<RuntimeRegistry>,
|
||||||
companion: Arc<CompanionConsole>,
|
companion: Arc<CompanionConsole>,
|
||||||
|
orchestrator_spawn_lock: Arc<std::sync::Mutex<()>>,
|
||||||
observation_proxy: BackendObservationProxy,
|
observation_proxy: BackendObservationProxy,
|
||||||
runtime_subscription_broker: RuntimeSubscriptionBroker,
|
runtime_subscription_broker: RuntimeSubscriptionBroker,
|
||||||
resource_broker: BackendResourceBroker,
|
resource_broker: BackendResourceBroker,
|
||||||
@@ -346,6 +347,7 @@ impl WorkspaceApi {
|
|||||||
store,
|
store,
|
||||||
runtime,
|
runtime,
|
||||||
companion,
|
companion,
|
||||||
|
orchestrator_spawn_lock: Arc::new(std::sync::Mutex::new(())),
|
||||||
observation_proxy,
|
observation_proxy,
|
||||||
runtime_subscription_broker,
|
runtime_subscription_broker,
|
||||||
resource_broker,
|
resource_broker,
|
||||||
@@ -777,6 +779,11 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
|||||||
"/api/workers",
|
"/api/workers",
|
||||||
get(list_workers).post(create_workspace_worker),
|
get(list_workers).post(create_workspace_worker),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/orchestrator",
|
||||||
|
get(scoped_workspace_orchestrator_status)
|
||||||
|
.post(scoped_start_workspace_orchestrator),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/workers",
|
"/api/w/{workspace_id}/workers",
|
||||||
get(scoped_list_workers).post(scoped_create_workspace_worker),
|
get(scoped_list_workers).post(scoped_create_workspace_worker),
|
||||||
@@ -1335,6 +1342,16 @@ pub struct BrowserWorkerWorkingDirectorySelection {
|
|||||||
pub relative_cwd: Option<String>,
|
pub relative_cwd: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct BrowserWorkspaceOrchestratorResponse {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub online: bool,
|
||||||
|
pub disposition: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub worker: Option<WorkerSummary>,
|
||||||
|
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct BrowserCreateWorkerRequest {
|
pub struct BrowserCreateWorkerRequest {
|
||||||
@@ -3642,6 +3659,109 @@ async fn scoped_list_workers(
|
|||||||
list_workers(State(api)).await
|
list_workers(State(api)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn scoped_workspace_orchestrator_status(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
) -> ApiResult<Json<BrowserWorkspaceOrchestratorResponse>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
Ok(Json(workspace_orchestrator_response(&api, "observed")))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_start_workspace_orchestrator(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
) -> ApiResult<Json<BrowserWorkspaceOrchestratorResponse>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
let _guard = api.orchestrator_spawn_lock.lock().map_err(|_| {
|
||||||
|
ApiError::with_diagnostics(
|
||||||
|
Error::RuntimeOperationFailed {
|
||||||
|
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
|
||||||
|
code: "workspace_orchestrator_spawn_lock_poisoned".to_string(),
|
||||||
|
message: "Workspace Orchestrator launch lock is unavailable".to_string(),
|
||||||
|
},
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let Some(existing) = find_workspace_orchestrator(&api) {
|
||||||
|
if workspace_orchestrator_is_online(&existing) {
|
||||||
|
return Ok(Json(workspace_orchestrator_response(&api, "existing")));
|
||||||
|
}
|
||||||
|
let restored = api
|
||||||
|
.runtime
|
||||||
|
.restore_worker(&existing.runtime_id, &existing.worker_id)
|
||||||
|
.map_err(|error| error.into_error())?;
|
||||||
|
if restored.state != WorkerOperationState::Accepted {
|
||||||
|
return Err(ApiError::with_diagnostics(
|
||||||
|
Error::RuntimeOperationFailed {
|
||||||
|
runtime_id: existing.runtime_id,
|
||||||
|
code: "workspace_orchestrator_restore_rejected".to_string(),
|
||||||
|
message: "Runtime rejected Workspace Orchestrator restore".to_string(),
|
||||||
|
},
|
||||||
|
restored.diagnostics,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Ok(Json(workspace_orchestrator_response(&api, "restored")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = api.spawn_workspace_worker(
|
||||||
|
EMBEDDED_WORKER_RUNTIME_ID,
|
||||||
|
WorkerSpawnRequest {
|
||||||
|
requested_worker_name: Some(
|
||||||
|
crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY.to_string(),
|
||||||
|
),
|
||||||
|
intent: WorkerSpawnIntent::WorkspaceOrchestrator,
|
||||||
|
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
|
||||||
|
expected_segments: 0,
|
||||||
|
},
|
||||||
|
profile: ProfileSelector::Builtin("builtin:orchestrator".to_string()),
|
||||||
|
ticket_assignment: None,
|
||||||
|
initial_input: None,
|
||||||
|
working_directory_request: None,
|
||||||
|
resolved_working_directory_request: None,
|
||||||
|
resolved_working_directory: None,
|
||||||
|
resolved_config_bundle: None,
|
||||||
|
resolved_workspace_api: None,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
if result.state != WorkerOperationState::Accepted || result.worker.is_none() {
|
||||||
|
return Err(ApiError::with_diagnostics(
|
||||||
|
Error::RuntimeOperationFailed {
|
||||||
|
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
|
||||||
|
code: "workspace_orchestrator_spawn_rejected".to_string(),
|
||||||
|
message: "Embedded Runtime rejected Workspace Orchestrator launch".to_string(),
|
||||||
|
},
|
||||||
|
result.diagnostics,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Json(workspace_orchestrator_response(&api, "created")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_orchestrator_response(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
disposition: &str,
|
||||||
|
) -> BrowserWorkspaceOrchestratorResponse {
|
||||||
|
let worker = find_workspace_orchestrator(api);
|
||||||
|
let online = worker
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(workspace_orchestrator_is_online);
|
||||||
|
let diagnostics = worker
|
||||||
|
.as_ref()
|
||||||
|
.map(|worker| worker.diagnostics.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
BrowserWorkspaceOrchestratorResponse {
|
||||||
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
|
online,
|
||||||
|
disposition: disposition.to_string(),
|
||||||
|
worker,
|
||||||
|
diagnostics,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_orchestrator_is_online(worker: &WorkerSummary) -> bool {
|
||||||
|
matches!(worker.state.as_str(), "idle" | "running" | "paused")
|
||||||
|
}
|
||||||
|
|
||||||
async fn scoped_create_workspace_worker(
|
async fn scoped_create_workspace_worker(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
@@ -5965,6 +6085,9 @@ async fn create_workspace_worker(
|
|||||||
"display_name must contain at least one non-control character",
|
"display_name must contain at least one non-control character",
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
if display_name == crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY {
|
||||||
|
return Err(Error::ReservedWorkerName(display_name).into());
|
||||||
|
}
|
||||||
let initial_text = request.initial_text.trim().to_string();
|
let initial_text = request.initial_text.trim().to_string();
|
||||||
let initial_input = if initial_text.is_empty() {
|
let initial_input = if initial_text.is_empty() {
|
||||||
None
|
None
|
||||||
@@ -8589,7 +8712,9 @@ impl IntoResponse for ApiError {
|
|||||||
let status = match &self.error {
|
let status = match &self.error {
|
||||||
Error::TicketAssignmentConflict(_) => StatusCode::CONFLICT,
|
Error::TicketAssignmentConflict(_) => StatusCode::CONFLICT,
|
||||||
Error::WorkerSourceIdentity(_) => StatusCode::BAD_REQUEST,
|
Error::WorkerSourceIdentity(_) => StatusCode::BAD_REQUEST,
|
||||||
Error::InvalidRuntimeIdentifier { .. } => StatusCode::BAD_REQUEST,
|
Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => {
|
||||||
|
StatusCode::BAD_REQUEST
|
||||||
|
}
|
||||||
Error::Ticket(ticket::TicketError::NotFound(_)) => StatusCode::NOT_FOUND,
|
Error::Ticket(ticket::TicketError::NotFound(_)) => StatusCode::NOT_FOUND,
|
||||||
Error::Ticket(
|
Error::Ticket(
|
||||||
ticket::TicketError::Ambiguous { .. }
|
ticket::TicketError::Ambiguous { .. }
|
||||||
@@ -8806,6 +8931,77 @@ mod tests {
|
|||||||
assert!(!serialized.contains("materialized_path"));
|
assert!(!serialized.contains("materialized_path"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn explicit_orchestrator_launch_marks_only_the_dedicated_worker() {
|
||||||
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
|
init_clean_git_workspace(workspace.path());
|
||||||
|
let api = test_api(workspace.path()).await;
|
||||||
|
let workspace_id = api.config.workspace_id.clone();
|
||||||
|
|
||||||
|
let Json(generic) = create_workspace_worker(
|
||||||
|
State(api.clone()),
|
||||||
|
Json(BrowserCreateWorkerRequest {
|
||||||
|
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
|
||||||
|
display_name: "Generic Orchestrator Profile Worker".to_string(),
|
||||||
|
profile: Some("builtin:orchestrator".to_string()),
|
||||||
|
initial_text: String::new(),
|
||||||
|
working_directory: None,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(generic.worker.singleton_key, None);
|
||||||
|
assert!(find_workspace_orchestrator(&api).is_none());
|
||||||
|
let reserved = create_workspace_worker(
|
||||||
|
State(api.clone()),
|
||||||
|
Json(BrowserCreateWorkerRequest {
|
||||||
|
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
|
||||||
|
display_name: crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY.to_string(),
|
||||||
|
profile: Some("builtin:orchestrator".to_string()),
|
||||||
|
initial_text: String::new(),
|
||||||
|
working_directory: None,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(reserved.error, Error::ReservedWorkerName(_)));
|
||||||
|
|
||||||
|
let Json(started) = scoped_start_workspace_orchestrator(
|
||||||
|
State(api.clone()),
|
||||||
|
AxumPath(ScopedWorkspacePath {
|
||||||
|
workspace_id: workspace_id.clone(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(started.disposition, "created");
|
||||||
|
let dedicated = started.worker.expect("dedicated Orchestrator Worker");
|
||||||
|
assert_eq!(
|
||||||
|
dedicated.singleton_key.as_deref(),
|
||||||
|
Some(crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY)
|
||||||
|
);
|
||||||
|
assert_ne!(dedicated.worker_id, generic.worker_id);
|
||||||
|
|
||||||
|
let Json(existing) = scoped_start_workspace_orchestrator(
|
||||||
|
State(api.clone()),
|
||||||
|
AxumPath(ScopedWorkspacePath {
|
||||||
|
workspace_id: workspace_id.clone(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(existing.disposition, "existing");
|
||||||
|
assert_eq!(existing.worker.unwrap().worker_id, dedicated.worker_id);
|
||||||
|
|
||||||
|
let Json(status) = scoped_workspace_orchestrator_status(
|
||||||
|
State(api),
|
||||||
|
AxumPath(ScopedWorkspacePath { workspace_id }),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(status.worker.unwrap().worker_id, dedicated.worker_id);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn workspace_workdir_summaries_include_runtime_observed_rows() {
|
async fn workspace_workdir_summaries_include_runtime_observed_rows() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import "./base.dcdl" // {
|
|||||||
task = { enabled = true; };
|
task = { enabled = true; };
|
||||||
memory = { enabled = true; };
|
memory = { enabled = true; };
|
||||||
web = { enabled = true; };
|
web = { enabled = true; };
|
||||||
workers = { enabled = true; };
|
workers = { enabled = false; };
|
||||||
manage_workdir = { enabled = true; };
|
manage_workdir = { enabled = true; };
|
||||||
ticket = { enabled = true; thread = true; orchestration_control = true; };
|
ticket = { enabled = true; thread = true; orchestration_control = true; };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||||
"test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts",
|
"test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts src/lib/workspace/tickets/ticket-panel.test.ts",
|
||||||
"build": "deno run -A npm:vite@7.2.7 build",
|
"build": "deno run -A npm:vite@7.2.7 build",
|
||||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -108,9 +108,10 @@ export async function loadWorkspaceSkillDetail(
|
|||||||
export async function loadJson<T>(
|
export async function loadJson<T>(
|
||||||
fetchFn: typeof fetch,
|
fetchFn: typeof fetch,
|
||||||
path: string,
|
path: string,
|
||||||
|
init?: RequestInit,
|
||||||
): Promise<ApiResult<T>> {
|
): Promise<ApiResult<T>> {
|
||||||
try {
|
try {
|
||||||
const response = await fetchFn(path);
|
const response = await fetchFn(path, init);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -73,6 +73,42 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
.ticket-panel-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.orchestrator-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
}
|
||||||
|
.orchestrator-status > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.08rem;
|
||||||
|
min-width: 5.5rem;
|
||||||
|
}
|
||||||
|
.orchestrator-status strong {
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
.orchestrator-status span {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
.orchestrator-status-dot {
|
||||||
|
width: 0.55rem;
|
||||||
|
height: 0.55rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #a75454;
|
||||||
|
}
|
||||||
|
.orchestrator-status[data-online="true"] .orchestrator-status-dot {
|
||||||
|
background: #43a66d;
|
||||||
|
}
|
||||||
.ticket-panel-summary {
|
.ticket-panel-summary {
|
||||||
display: grid;
|
display: grid;
|
||||||
justify-items: end;
|
justify-items: end;
|
||||||
|
|||||||
@@ -10,8 +10,15 @@ import type {
|
|||||||
|
|
||||||
declare const Deno: {
|
declare const Deno: {
|
||||||
test(name: string, fn: () => Promise<void> | void): void;
|
test(name: string, fn: () => Promise<void> | void): void;
|
||||||
|
readTextFile(path: string): Promise<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function assertIncludes(actual: string, expected: string): void {
|
||||||
|
if (!actual.includes(expected)) {
|
||||||
|
throw new Error(`expected source to include ${JSON.stringify(expected)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function assertEquals<T>(actual: T, expected: T): void {
|
function assertEquals<T>(actual: T, expected: T): void {
|
||||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -97,3 +104,20 @@ Deno.test("ticket worker launch uses the common Worker route and bounded Ticket
|
|||||||
"Work on Ticket 00001KYRRDVH9 as its reviewer.",
|
"Work on Ticket 00001KYRRDVH9 as its reviewer.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("ticket panel starts the Orchestrator explicitly and gates orchestration actions", async () => {
|
||||||
|
const panelSource = await Deno.readTextFile(
|
||||||
|
"src/routes/w/[workspaceId]/tickets/+page.svelte",
|
||||||
|
);
|
||||||
|
const detailSource = await Deno.readTextFile(
|
||||||
|
"src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
|
||||||
|
);
|
||||||
|
|
||||||
|
assertIncludes(panelSource, 'workspaceApiPath(data.workspaceId, "/orchestrator")');
|
||||||
|
assertIncludes(panelSource, '{ method: "POST" }');
|
||||||
|
assertIncludes(panelSource, "Start Orchestrator");
|
||||||
|
assertIncludes(panelSource, "orchestrator.data?.online");
|
||||||
|
assertIncludes(detailSource, "{#if orchestratorOnline}");
|
||||||
|
assertIncludes(detailSource, "!orchestratorOnline");
|
||||||
|
assertIncludes(detailSource, "Orchestrator offline");
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,6 +12,23 @@ export const TICKET_STATES = [
|
|||||||
export type TicketState = (typeof TICKET_STATES)[number];
|
export type TicketState = (typeof TICKET_STATES)[number];
|
||||||
export type TicketWorkerRole = "coder" | "reviewer";
|
export type TicketWorkerRole = "coder" | "reviewer";
|
||||||
|
|
||||||
|
export type WorkspaceOrchestratorStatus = {
|
||||||
|
workspace_id: string;
|
||||||
|
online: boolean;
|
||||||
|
disposition: string;
|
||||||
|
worker?: {
|
||||||
|
runtime_id: string;
|
||||||
|
worker_id: string;
|
||||||
|
state: string;
|
||||||
|
display_name: string;
|
||||||
|
} | null;
|
||||||
|
diagnostics: Array<{
|
||||||
|
code: string;
|
||||||
|
severity: string;
|
||||||
|
message: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
const LANE_DEFINITIONS = [
|
const LANE_DEFINITIONS = [
|
||||||
{
|
{
|
||||||
id: "ready-planning",
|
id: "ready-planning",
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import type { ApiResult } from "$lib/workspace/api/http";
|
import type { ApiResult } from "$lib/workspace/api/http";
|
||||||
import { ticketLanes } from "$lib/workspace/tickets/ticket-panel";
|
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||||
|
import {
|
||||||
|
ticketLanes,
|
||||||
|
type WorkspaceOrchestratorStatus,
|
||||||
|
} from "$lib/workspace/tickets/ticket-panel";
|
||||||
import type {
|
import type {
|
||||||
TicketListResponse,
|
TicketListResponse,
|
||||||
TicketSummary,
|
TicketSummary,
|
||||||
@@ -11,13 +15,29 @@
|
|||||||
data: {
|
data: {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
tickets: ApiResult<TicketListResponse>;
|
tickets: ApiResult<TicketListResponse>;
|
||||||
|
orchestrator: ApiResult<WorkspaceOrchestratorStatus>;
|
||||||
};
|
};
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const initialTickets = untrack(() => data.tickets.data?.items ?? []);
|
const initialTickets = untrack(() => data.tickets.data?.items ?? []);
|
||||||
let tickets = $state<TicketSummary[]>(initialTickets);
|
let tickets = $state<TicketSummary[]>(initialTickets);
|
||||||
|
let orchestrator = $state<ApiResult<WorkspaceOrchestratorStatus>>(
|
||||||
|
untrack(() => data.orchestrator),
|
||||||
|
);
|
||||||
|
let orchestratorStarting = $state(false);
|
||||||
let lanes = $derived(ticketLanes(tickets));
|
let lanes = $derived(ticketLanes(tickets));
|
||||||
|
|
||||||
|
async function startOrchestrator() {
|
||||||
|
if (orchestratorStarting || orchestrator.data?.online) return;
|
||||||
|
orchestratorStarting = true;
|
||||||
|
orchestrator = await loadJson<WorkspaceOrchestratorStatus>(
|
||||||
|
fetch,
|
||||||
|
workspaceApiPath(data.workspaceId, "/orchestrator"),
|
||||||
|
{ method: "POST" },
|
||||||
|
);
|
||||||
|
orchestratorStarting = false;
|
||||||
|
}
|
||||||
|
|
||||||
function prettyDate(value?: string | null): string {
|
function prettyDate(value?: string | null): string {
|
||||||
if (!value) return "—";
|
if (!value) return "—";
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
@@ -36,12 +56,41 @@
|
|||||||
Plan, route, review, and close work without leaving the workspace.
|
Plan, route, review, and close work without leaving the workspace.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ticket-panel-controls">
|
||||||
|
<div class="orchestrator-status" data-online={orchestrator.data?.online ?? false}>
|
||||||
|
<span class="orchestrator-status-dot"></span>
|
||||||
|
<div>
|
||||||
|
<strong>Orchestrator</strong>
|
||||||
|
<span>{orchestrator.data?.online ? "Online" : "Offline"}</span>
|
||||||
|
</div>
|
||||||
|
{#if !orchestrator.data?.online}
|
||||||
|
<button
|
||||||
|
class="workspace-primary-button"
|
||||||
|
type="button"
|
||||||
|
disabled={orchestratorStarting}
|
||||||
|
onclick={startOrchestrator}
|
||||||
|
>
|
||||||
|
{orchestratorStarting ? "Starting…" : "Start Orchestrator"}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<div class="ticket-panel-summary" aria-label="Ticket summary">
|
<div class="ticket-panel-summary" aria-label="Ticket summary">
|
||||||
<strong>{tickets.length}</strong>
|
<strong>{tickets.length}</strong>
|
||||||
<span>tickets</span>
|
<span>tickets</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{#if orchestrator.error}
|
||||||
|
<p class="workspace-callout is-error">
|
||||||
|
Orchestrator status: {orchestrator.error}
|
||||||
|
</p>
|
||||||
|
{:else if !orchestrator.data?.online}
|
||||||
|
<p class="workspace-callout">
|
||||||
|
Orchestration actions are unavailable until the embedded Orchestrator is online.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<section class="ticket-kanban" aria-label="Ticket workflow board">
|
<section class="ticket-kanban" aria-label="Ticket workflow board">
|
||||||
{#each lanes as lane (lane.id)}
|
{#each lanes as lane (lane.id)}
|
||||||
<section class="ticket-lane" data-state={lane.id}>
|
<section class="ticket-lane" data-state={lane.id}>
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||||
|
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
|
||||||
import type { TicketListResponse } from "$lib/workspace/sidebar/types";
|
import type { TicketListResponse } from "$lib/workspace/sidebar/types";
|
||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const load = (async ({ fetch, params }) => {
|
export const load = (async ({ fetch, params }) => {
|
||||||
const tickets = await loadJson<TicketListResponse>(
|
const [tickets, orchestrator] = await Promise.all([
|
||||||
|
loadJson<TicketListResponse>(
|
||||||
fetch,
|
fetch,
|
||||||
`${workspaceApiPath(params.workspaceId, "/tickets")}?limit=1000`,
|
`${workspaceApiPath(params.workspaceId, "/tickets")}?limit=1000`,
|
||||||
);
|
),
|
||||||
|
loadJson<WorkspaceOrchestratorStatus>(
|
||||||
|
fetch,
|
||||||
|
workspaceApiPath(params.workspaceId, "/orchestrator"),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
workspaceId: params.workspaceId,
|
workspaceId: params.workspaceId,
|
||||||
tickets,
|
tickets,
|
||||||
|
orchestrator,
|
||||||
};
|
};
|
||||||
}) satisfies PageLoad;
|
}) satisfies PageLoad;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
relationLabel,
|
relationLabel,
|
||||||
TICKET_STATES,
|
TICKET_STATES,
|
||||||
ticketWorkerLaunchHref,
|
ticketWorkerLaunchHref,
|
||||||
|
type WorkspaceOrchestratorStatus,
|
||||||
} from "$lib/workspace/tickets/ticket-panel";
|
} from "$lib/workspace/tickets/ticket-panel";
|
||||||
import type { ApiResult } from "$lib/workspace/api/http";
|
import type { ApiResult } from "$lib/workspace/api/http";
|
||||||
import type {
|
import type {
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
ticketId: string;
|
ticketId: string;
|
||||||
ticket: ApiResult<TicketDetail>;
|
ticket: ApiResult<TicketDetail>;
|
||||||
repositories: ApiResult<RepositoryListResponse>;
|
repositories: ApiResult<RepositoryListResponse>;
|
||||||
|
orchestrator: ApiResult<WorkspaceOrchestratorStatus>;
|
||||||
};
|
};
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -29,6 +31,7 @@
|
|||||||
const loadedTicket = initialData.ticket.data;
|
const loadedTicket = initialData.ticket.data;
|
||||||
if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed");
|
if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed");
|
||||||
const loadedRepositories = initialData.repositories.data;
|
const loadedRepositories = initialData.repositories.data;
|
||||||
|
const orchestratorOnline = initialData.orchestrator.data?.online ?? false;
|
||||||
|
|
||||||
let ticket = $state<TicketDetail>(loadedTicket);
|
let ticket = $state<TicketDetail>(loadedTicket);
|
||||||
let editing = $state(false);
|
let editing = $state(false);
|
||||||
@@ -270,11 +273,19 @@
|
|||||||
<p class="ticket-assignment-line">
|
<p class="ticket-assignment-line">
|
||||||
Assigned to <strong>{ticket.assignee ?? "Unassigned"}</strong>
|
Assigned to <strong>{ticket.assignee ?? "Unassigned"}</strong>
|
||||||
</p>
|
</p>
|
||||||
<p>The common launch flow carries a short canonical Ticket message and the target below.</p>
|
{#if orchestratorOnline}
|
||||||
|
<p>The Orchestrator is online. Start a role-specific Worker with the Ticket target below.</p>
|
||||||
<div class="ticket-role-actions">
|
<div class="ticket-role-actions">
|
||||||
<a class="workspace-primary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "coder")}>Coder</a>
|
<a class="workspace-primary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "coder")}>Coder</a>
|
||||||
<a class="workspace-secondary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "reviewer")}>Reviewer</a>
|
<a class="workspace-secondary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "reviewer")}>Reviewer</a>
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="workspace-callout">Start the Workspace Orchestrator from the Ticket panel before launching Ticket Workers.</p>
|
||||||
|
<div class="ticket-role-actions">
|
||||||
|
<button class="workspace-primary-button" type="button" disabled>Coder</button>
|
||||||
|
<button class="workspace-secondary-button" type="button" disabled>Reviewer</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="ticket-control-card">
|
<section class="ticket-control-card">
|
||||||
@@ -309,8 +320,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{#if ticket.state === "ready"}
|
{#if ticket.state === "ready"}
|
||||||
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue"} onclick={() => mutate("queue", "/queue", {})}>
|
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !orchestratorOnline} onclick={() => mutate("queue", "/queue", {})}>
|
||||||
{busy === "queue" ? "Queueing…" : "Queue ticket"}
|
{busy === "queue" ? "Queueing…" : orchestratorOnline ? "Queue ticket" : "Orchestrator offline"}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||||
|
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
|
||||||
import type {
|
import type {
|
||||||
RepositoryListResponse,
|
RepositoryListResponse,
|
||||||
TicketDetail,
|
TicketDetail,
|
||||||
@@ -6,7 +7,7 @@ import type {
|
|||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const load = (async ({ fetch, params }) => {
|
export const load = (async ({ fetch, params }) => {
|
||||||
const [ticket, repositories] = await Promise.all([
|
const [ticket, repositories, orchestrator] = await Promise.all([
|
||||||
loadJson<TicketDetail>(
|
loadJson<TicketDetail>(
|
||||||
fetch,
|
fetch,
|
||||||
workspaceApiPath(
|
workspaceApiPath(
|
||||||
@@ -18,6 +19,10 @@ export const load = (async ({ fetch, params }) => {
|
|||||||
fetch,
|
fetch,
|
||||||
workspaceApiPath(params.workspaceId, "/repositories"),
|
workspaceApiPath(params.workspaceId, "/repositories"),
|
||||||
),
|
),
|
||||||
|
loadJson<WorkspaceOrchestratorStatus>(
|
||||||
|
fetch,
|
||||||
|
workspaceApiPath(params.workspaceId, "/orchestrator"),
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -25,5 +30,6 @@ export const load = (async ({ fetch, params }) => {
|
|||||||
ticketId: params.ticketId,
|
ticketId: params.ticketId,
|
||||||
ticket,
|
ticket,
|
||||||
repositories,
|
repositories,
|
||||||
|
orchestrator,
|
||||||
};
|
};
|
||||||
}) satisfies PageLoad;
|
}) satisfies PageLoad;
|
||||||
|
|||||||
Reference in New Issue
Block a user