Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
860252c22d | ||
|
|
2d28c05447 | ||
|
|
a0460f107d | ||
|
|
d10783c0d6 | ||
|
|
88338229a1 | ||
|
|
d0ea9f598d | ||
|
|
d5f49cbf00 | ||
|
|
4ac448d66c |
@@ -1233,6 +1233,22 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resolved.manifest.worker.name, "role-worker");
|
assert_eq!(resolved.manifest.worker.name, "role-worker");
|
||||||
|
let compaction = resolved.manifest.compaction.as_ref().unwrap();
|
||||||
|
assert_eq!(compaction.worker_max_turns, Some(100), "{}", entry.name);
|
||||||
|
assert_eq!(
|
||||||
|
compaction.worker_context_max_tokens, 100_000,
|
||||||
|
"{}",
|
||||||
|
entry.name
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
compaction
|
||||||
|
.model
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|model| model.ref_.as_deref()),
|
||||||
|
Some("codex-oauth/gpt-6-luna"),
|
||||||
|
"{}",
|
||||||
|
entry.name
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -240,6 +240,16 @@ pub enum Method {
|
|||||||
#[serde(skip_deserializing, default)]
|
#[serde(skip_deserializing, default)]
|
||||||
source: AuthenticatedInputSource,
|
source: AuthenticatedInputSource,
|
||||||
},
|
},
|
||||||
|
/// Agent-controlled input: accept only at an Idle controller boundary, never
|
||||||
|
/// enqueue behind a run. Unlike human Submit, non-Idle admission is rejected.
|
||||||
|
#[cfg_attr(feature = "typescript", ts(skip))]
|
||||||
|
#[cfg_attr(feature = "json-schema", schemars(skip))]
|
||||||
|
SubmitIfIdle {
|
||||||
|
submission_request_id: String,
|
||||||
|
input: Vec<Segment>,
|
||||||
|
#[serde(skip_deserializing, default)]
|
||||||
|
source: AuthenticatedInputSource,
|
||||||
|
},
|
||||||
/// Human-readable text injected into the target Worker's LLM context as a
|
/// Human-readable text injected into the target Worker's LLM context as a
|
||||||
/// non-blocking system message. Accepted notifications are delivered at the
|
/// non-blocking system message. Accepted notifications are delivered at the
|
||||||
/// next between-turn boundary, or start a coalesced notification run while
|
/// next between-turn boundary, or start a coalesced notification run while
|
||||||
|
|||||||
@@ -402,6 +402,8 @@ pub struct WorkingDirectoryStatus {
|
|||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum WorkerInputKind {
|
pub enum WorkerInputKind {
|
||||||
User,
|
User,
|
||||||
|
/// Agent input accepted only while Idle; never enters the human Submit queue.
|
||||||
|
UserIfIdle,
|
||||||
Notify,
|
Notify,
|
||||||
Compact,
|
Compact,
|
||||||
ListRewindTargets,
|
ListRewindTargets,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ pub enum RuntimeError {
|
|||||||
#[error("worker {worker_id} was not found")]
|
#[error("worker {worker_id} was not found")]
|
||||||
WorkerNotFound { worker_id: WorkerId },
|
WorkerNotFound { worker_id: WorkerId },
|
||||||
|
|
||||||
#[error("worker {worker_id} has no execution backend: {message}")]
|
#[error("worker {worker_id} execution is unavailable: {message}")]
|
||||||
WorkerExecutionUnavailable {
|
WorkerExecutionUnavailable {
|
||||||
worker_id: WorkerId,
|
worker_id: WorkerId,
|
||||||
message: String,
|
message: String,
|
||||||
|
|||||||
@@ -1556,6 +1556,19 @@ fn authorize_runtime_protocol_method(
|
|||||||
submission_request_id,
|
submission_request_id,
|
||||||
input,
|
input,
|
||||||
},
|
},
|
||||||
|
protocol::Method::SubmitIfIdle {
|
||||||
|
submission_request_id,
|
||||||
|
input,
|
||||||
|
..
|
||||||
|
} => protocol::Method::SubmitIfIdle {
|
||||||
|
source: transport_source.cloned().unwrap_or_else(|| {
|
||||||
|
protocol::AuthenticatedInputSource::Backend {
|
||||||
|
operation_id: submission_request_id.clone(),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
submission_request_id,
|
||||||
|
input,
|
||||||
|
},
|
||||||
protocol::Method::NotifyTracked {
|
protocol::Method::NotifyTracked {
|
||||||
notification_request_id,
|
notification_request_id,
|
||||||
message,
|
message,
|
||||||
@@ -3477,14 +3490,31 @@ mod tests {
|
|||||||
conflicting_request.create_fingerprint = "different-fingerprint".to_string();
|
conflicting_request.create_fingerprint = "different-fingerprint".to_string();
|
||||||
let generated = client.create_worker(generated_request).await.unwrap();
|
let generated = client.create_worker(generated_request).await.unwrap();
|
||||||
assert_eq!(generated.worker.worker_id.to_string(), generated_worker_id);
|
assert_eq!(generated.worker.worker_id.to_string(), generated_worker_id);
|
||||||
let generated_input: runtime_api::WorkerInput = serde_json::from_value(
|
for kind in [
|
||||||
serde_json::to_value(WorkerInput::user("generated client input")).unwrap(),
|
crate::interaction::WorkerInputKind::User,
|
||||||
)
|
crate::interaction::WorkerInputKind::UserIfIdle,
|
||||||
.unwrap();
|
] {
|
||||||
client
|
let mut input = WorkerInput::user("generated client input");
|
||||||
.send_worker_input(generated_worker_id.clone(), generated_input)
|
input.kind = kind.clone();
|
||||||
.await
|
input.submission_request_id = Some(format!("generated-{kind:?}"));
|
||||||
.unwrap();
|
// The remote adapter performs this same conversion before sending.
|
||||||
|
// Both the generated API contract and the HTTP receiver must retain
|
||||||
|
// Idle-only admission rather than silently downgrading it to User.
|
||||||
|
let generated_input: runtime_api::WorkerInput =
|
||||||
|
serde_json::from_value(serde_json::to_value(&input).unwrap()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&generated_input).unwrap(),
|
||||||
|
serde_json::to_value(&input).unwrap()
|
||||||
|
);
|
||||||
|
let response = client
|
||||||
|
.send_worker_input(generated_worker_id.clone(), generated_input)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
Some(response.ack.submission.unwrap().submission_request_id),
|
||||||
|
input.submission_request_id
|
||||||
|
);
|
||||||
|
}
|
||||||
client
|
client
|
||||||
.stop_worker(
|
.stop_worker(
|
||||||
generated_worker_id.clone(),
|
generated_worker_id.clone(),
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum WorkerInputKind {
|
pub enum WorkerInputKind {
|
||||||
User,
|
User,
|
||||||
|
/// Agent input that must never enter the human Submit queue.
|
||||||
|
UserIfIdle,
|
||||||
Notify,
|
Notify,
|
||||||
Compact,
|
Compact,
|
||||||
ListRewindTargets,
|
ListRewindTargets,
|
||||||
|
|||||||
@@ -1425,9 +1425,9 @@ impl Runtime {
|
|||||||
let previous_workspace_api = {
|
let previous_workspace_api = {
|
||||||
let state = self.lock()?;
|
let state = self.lock()?;
|
||||||
let worker = state.worker(worker_ref)?;
|
let worker = state.worker(worker_ref)?;
|
||||||
if worker.has_pending_lifecycle_operation() {
|
if let Some(operation) = worker.pending_lifecycle_operation_name() {
|
||||||
return Err(RuntimeError::InvalidRequest(format!(
|
return Err(RuntimeError::InvalidRequest(format!(
|
||||||
"worker {} has pending restore reconciliation",
|
"worker {} has pending {operation} reconciliation",
|
||||||
worker_ref.worker_id
|
worker_ref.worker_id
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
@@ -1453,9 +1453,9 @@ impl Runtime {
|
|||||||
{
|
{
|
||||||
let mut state = self.lock()?;
|
let mut state = self.lock()?;
|
||||||
let worker = state.worker_mut(worker_ref)?;
|
let worker = state.worker_mut(worker_ref)?;
|
||||||
if worker.has_pending_lifecycle_operation() {
|
if let Some(operation) = worker.pending_lifecycle_operation_name() {
|
||||||
return Err(RuntimeError::InvalidRequest(format!(
|
return Err(RuntimeError::InvalidRequest(format!(
|
||||||
"worker {} has pending restore reconciliation",
|
"worker {} has pending {operation} reconciliation",
|
||||||
worker_ref.worker_id
|
worker_ref.worker_id
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
@@ -2052,10 +2052,10 @@ impl Runtime {
|
|||||||
fn ensure_worker_execution(&self, worker_ref: &WorkerRef) -> Result<(), RuntimeError> {
|
fn ensure_worker_execution(&self, worker_ref: &WorkerRef) -> Result<(), RuntimeError> {
|
||||||
let state = self.lock()?;
|
let state = self.lock()?;
|
||||||
let worker = state.worker(worker_ref)?;
|
let worker = state.worker(worker_ref)?;
|
||||||
if worker.has_pending_lifecycle_operation() {
|
if let Some(operation) = worker.pending_lifecycle_operation_name() {
|
||||||
return Err(RuntimeError::WorkerExecutionUnavailable {
|
return Err(RuntimeError::WorkerExecutionUnavailable {
|
||||||
worker_id: worker_ref.worker_id,
|
worker_id: worker_ref.worker_id,
|
||||||
message: "worker restore reconciliation is pending".to_string(),
|
message: format!("worker {operation} reconciliation is pending"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if worker.execution_handle.is_some() {
|
if worker.execution_handle.is_some() {
|
||||||
@@ -2090,7 +2090,10 @@ impl Runtime {
|
|||||||
mut input: WorkerInput,
|
mut input: WorkerInput,
|
||||||
) -> Result<WorkerInteractionAck, RuntimeError> {
|
) -> Result<WorkerInteractionAck, RuntimeError> {
|
||||||
validate_worker_input(&input)?;
|
validate_worker_input(&input)?;
|
||||||
if matches!(input.kind, WorkerInputKind::User | WorkerInputKind::Notify) {
|
if matches!(
|
||||||
|
input.kind,
|
||||||
|
WorkerInputKind::User | WorkerInputKind::UserIfIdle | WorkerInputKind::Notify
|
||||||
|
) {
|
||||||
let request_id = input
|
let request_id = input
|
||||||
.submission_request_id
|
.submission_request_id
|
||||||
.clone()
|
.clone()
|
||||||
@@ -2098,7 +2101,11 @@ impl Runtime {
|
|||||||
.unwrap_or_else(|| Uuid::now_v7().to_string());
|
.unwrap_or_else(|| Uuid::now_v7().to_string());
|
||||||
input.submission_request_id = Some(request_id);
|
input.submission_request_id = Some(request_id);
|
||||||
}
|
}
|
||||||
let expected_submission_id = (input.kind == WorkerInputKind::User).then(|| {
|
let expected_submission_id = matches!(
|
||||||
|
input.kind,
|
||||||
|
WorkerInputKind::User | WorkerInputKind::UserIfIdle
|
||||||
|
)
|
||||||
|
.then(|| {
|
||||||
input
|
input
|
||||||
.submission_request_id
|
.submission_request_id
|
||||||
.clone()
|
.clone()
|
||||||
@@ -2110,6 +2117,17 @@ impl Runtime {
|
|||||||
.clone()
|
.clone()
|
||||||
.expect("Notify request id must be assigned before dispatch")
|
.expect("Notify request id must be assigned before dispatch")
|
||||||
});
|
});
|
||||||
|
if input.kind == WorkerInputKind::UserIfIdle {
|
||||||
|
let state = self.lock()?;
|
||||||
|
let worker = state.worker(worker_ref)?;
|
||||||
|
// Stopped/restoring Workers have no controller to enforce admission.
|
||||||
|
// Live busy state is checked atomically by the controller, not here.
|
||||||
|
if worker.status == WorkerStatus::Stopped || worker.has_pending_lifecycle_operation() {
|
||||||
|
return Err(RuntimeError::InvalidRequest(
|
||||||
|
"WorkerSendInput requires an Idle Worker and never queues input. Use WorkerNotify for information about work already in progress.".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
self.ensure_worker_execution(worker_ref)?;
|
self.ensure_worker_execution(worker_ref)?;
|
||||||
let (backend, handle) = {
|
let (backend, handle) = {
|
||||||
let state = self.lock()?;
|
let state = self.lock()?;
|
||||||
@@ -2716,9 +2734,9 @@ impl Runtime {
|
|||||||
let state = self.lock()?;
|
let state = self.lock()?;
|
||||||
state.ensure_running()?;
|
state.ensure_running()?;
|
||||||
let worker = state.worker(worker_ref)?;
|
let worker = state.worker(worker_ref)?;
|
||||||
if worker.has_pending_lifecycle_operation() {
|
if let Some(operation) = worker.pending_lifecycle_operation_name() {
|
||||||
return Err(RuntimeError::InvalidRequest(format!(
|
return Err(RuntimeError::InvalidRequest(format!(
|
||||||
"worker {} has pending restore reconciliation",
|
"worker {} has pending {operation} reconciliation",
|
||||||
worker_ref.worker_id
|
worker_ref.worker_id
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
@@ -2775,9 +2793,9 @@ impl Runtime {
|
|||||||
state.ensure_running()?;
|
state.ensure_running()?;
|
||||||
state.ensure_worker_ref(worker_ref)?;
|
state.ensure_worker_ref(worker_ref)?;
|
||||||
let worker = state.worker(worker_ref)?;
|
let worker = state.worker(worker_ref)?;
|
||||||
if worker.has_pending_lifecycle_operation() {
|
if let Some(operation) = worker.pending_lifecycle_operation_name() {
|
||||||
return Err(RuntimeError::InvalidRequest(format!(
|
return Err(RuntimeError::InvalidRequest(format!(
|
||||||
"worker {} has pending restore reconciliation and cannot be deleted",
|
"worker {} has pending {operation} reconciliation and cannot be deleted",
|
||||||
worker_ref.worker_id
|
worker_ref.worker_id
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
@@ -2816,9 +2834,9 @@ impl Runtime {
|
|||||||
state.ensure_running()?;
|
state.ensure_running()?;
|
||||||
state.ensure_worker_ref(worker_ref)?;
|
state.ensure_worker_ref(worker_ref)?;
|
||||||
let worker = state.worker(worker_ref)?;
|
let worker = state.worker(worker_ref)?;
|
||||||
if worker.has_pending_lifecycle_operation() {
|
if let Some(operation) = worker.pending_lifecycle_operation_name() {
|
||||||
return Err(RuntimeError::InvalidRequest(format!(
|
return Err(RuntimeError::InvalidRequest(format!(
|
||||||
"worker {} still has pending restore reconciliation",
|
"worker {} still has pending {operation} reconciliation",
|
||||||
worker_ref.worker_id
|
worker_ref.worker_id
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
@@ -3601,10 +3619,10 @@ impl Runtime {
|
|||||||
"Worker retention requires a stopped Worker".to_string(),
|
"Worker retention requires a stopped Worker".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if worker.has_pending_lifecycle_operation() {
|
if let Some(operation) = worker.pending_lifecycle_operation_name() {
|
||||||
return Err(RuntimeError::InvalidRequest(
|
return Err(RuntimeError::InvalidRequest(format!(
|
||||||
"Worker retention cannot remove pending restore reconciliation".to_string(),
|
"Worker retention cannot remove pending {operation} reconciliation"
|
||||||
));
|
)));
|
||||||
}
|
}
|
||||||
let result = provider.execute(request)?;
|
let result = provider.execute(request)?;
|
||||||
state.workers.remove(&request.worker_id);
|
state.workers.remove(&request.worker_id);
|
||||||
@@ -4783,8 +4801,18 @@ impl WorkerRecord {
|
|||||||
self.worker_state = Some(incoming.clone());
|
self.worker_state = Some(incoming.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn pending_lifecycle_operation_name(&self) -> Option<&'static str> {
|
||||||
|
if self.pending_stop.is_some() {
|
||||||
|
Some("stop")
|
||||||
|
} else if self.pending_restore.is_some() {
|
||||||
|
Some("restore")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn has_pending_lifecycle_operation(&self) -> bool {
|
fn has_pending_lifecycle_operation(&self) -> bool {
|
||||||
self.pending_restore.is_some() || self.pending_stop.is_some()
|
self.pending_lifecycle_operation_name().is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn belongs_to_workspace(&self, workspace_id: &str) -> bool {
|
fn belongs_to_workspace(&self, workspace_id: &str) -> bool {
|
||||||
@@ -5319,7 +5347,7 @@ fn input_protocol_event(input: &WorkerInput) -> Option<protocol::Event> {
|
|||||||
match input.kind {
|
match input.kind {
|
||||||
// Submit is projected only after the Worker commits UserInput. Queued
|
// Submit is projected only after the Worker commits UserInput. Queued
|
||||||
// payloads must never become model- or client-visible history early.
|
// payloads must never become model- or client-visible history early.
|
||||||
WorkerInputKind::User | WorkerInputKind::Notify => None,
|
WorkerInputKind::User | WorkerInputKind::UserIfIdle | WorkerInputKind::Notify => None,
|
||||||
WorkerInputKind::Compact
|
WorkerInputKind::Compact
|
||||||
| WorkerInputKind::ListRewindTargets
|
| WorkerInputKind::ListRewindTargets
|
||||||
| WorkerInputKind::RegisterPeer => Some(protocol::Event::SystemItem {
|
| WorkerInputKind::RegisterPeer => Some(protocol::Event::SystemItem {
|
||||||
@@ -7400,6 +7428,30 @@ mod tests {
|
|||||||
assert!(runtime.worker_operations.lock().unwrap().is_empty());
|
assert!(runtime.worker_operations.lock().unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn assert_pending_lifecycle_errors(runtime: &Runtime, worker_ref: &WorkerRef, operation: &str) {
|
||||||
|
let input_error = runtime
|
||||||
|
.send_input(worker_ref, WorkerInput::user("blocked while pending"))
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(
|
||||||
|
input_error.to_string(),
|
||||||
|
format!(
|
||||||
|
"worker {} execution is unavailable: worker {operation} reconciliation is pending",
|
||||||
|
worker_ref.worker_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
for error in [
|
||||||
|
runtime.cancel_worker(worker_ref, None).unwrap_err(),
|
||||||
|
runtime.delete_worker(worker_ref).unwrap_err(),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
error
|
||||||
|
.to_string()
|
||||||
|
.contains(&format!("pending {operation} reconciliation")),
|
||||||
|
"{error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn failed_stop_cleanup_retains_execution_for_retry() {
|
fn failed_stop_cleanup_retains_execution_for_retry() {
|
||||||
let (runtime, backend) = runtime_and_backend();
|
let (runtime, backend) = runtime_and_backend();
|
||||||
@@ -7436,6 +7488,7 @@ mod tests {
|
|||||||
backend.stop_operation_ids.lock().unwrap().as_slice(),
|
backend.stop_operation_ids.lock().unwrap().as_slice(),
|
||||||
&[operation_id]
|
&[operation_id]
|
||||||
);
|
);
|
||||||
|
assert_pending_lifecycle_errors(&runtime, &created.worker_ref, "stop");
|
||||||
|
|
||||||
let stopped = runtime.stop_worker(&created.worker_ref, None).unwrap();
|
let stopped = runtime.stop_worker(&created.worker_ref, None).unwrap();
|
||||||
assert_eq!(stopped.status, WorkerStatus::Stopped);
|
assert_eq!(stopped.status, WorkerStatus::Stopped);
|
||||||
@@ -9115,6 +9168,7 @@ mod tests {
|
|||||||
.pending_restore
|
.pending_restore
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.operation_id;
|
.operation_id;
|
||||||
|
assert_pending_lifecycle_errors(&runtime, &created.worker_ref, "restore");
|
||||||
let delete_error = runtime.delete_worker(&created.worker_ref).unwrap_err();
|
let delete_error = runtime.delete_worker(&created.worker_ref).unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
delete_error
|
delete_error
|
||||||
|
|||||||
@@ -2982,7 +2982,7 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
let (method, submission_request_id) = match input.kind {
|
let (method, submission_request_id) = match input.kind {
|
||||||
WorkerInputKind::User => {
|
WorkerInputKind::User | WorkerInputKind::UserIfIdle => {
|
||||||
let Some(submission_id) = input
|
let Some(submission_id) = input
|
||||||
.submission_request_id
|
.submission_request_id
|
||||||
.filter(|submission_id| !submission_id.trim().is_empty())
|
.filter(|submission_id| !submission_id.trim().is_empty())
|
||||||
@@ -2992,18 +2992,26 @@ where
|
|||||||
"Runtime user input is missing its internal submission id",
|
"Runtime user input is missing its internal submission id",
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
(
|
let segments = input
|
||||||
|
.segments
|
||||||
|
.unwrap_or_else(|| vec![Segment::text(input.content.trim().to_string())]);
|
||||||
|
let source = protocol::AuthenticatedInputSource::Backend {
|
||||||
|
operation_id: submission_id.clone(),
|
||||||
|
};
|
||||||
|
let method = if input.kind == WorkerInputKind::UserIfIdle {
|
||||||
|
Method::SubmitIfIdle {
|
||||||
|
submission_request_id: submission_id.clone(),
|
||||||
|
input: segments,
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
Method::SubmitTracked {
|
Method::SubmitTracked {
|
||||||
submission_request_id: submission_id.clone(),
|
submission_request_id: submission_id.clone(),
|
||||||
input: input.segments.unwrap_or_else(|| {
|
input: segments,
|
||||||
vec![Segment::text(input.content.trim().to_string())]
|
source,
|
||||||
}),
|
}
|
||||||
source: protocol::AuthenticatedInputSource::Backend {
|
};
|
||||||
operation_id: submission_id.clone(),
|
(method, Some(submission_id))
|
||||||
},
|
|
||||||
},
|
|
||||||
Some(submission_id),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
WorkerInputKind::Notify => {
|
WorkerInputKind::Notify => {
|
||||||
unreachable!("Notify input is dispatched before ordinary input mapping")
|
unreachable!("Notify input is dispatched before ordinary input mapping")
|
||||||
@@ -5581,6 +5589,124 @@ mod tests {
|
|||||||
assert_eq!(call_count.load(Ordering::SeqCst), 3);
|
assert_eq!(call_count.load(Ordering::SeqCst), 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(feature = "ws-server")]
|
||||||
|
#[serial_test::serial(worker_allocation)]
|
||||||
|
fn idle_only_input_acknowledges_idle_and_rejects_running_paused_and_stopped() {
|
||||||
|
let client = MockClient::sequential(vec![MockResponse::Hang(
|
||||||
|
simple_text_events().into_iter().take(2).collect(),
|
||||||
|
)]);
|
||||||
|
let runtime_base = tempfile::tempdir().unwrap();
|
||||||
|
let cwd = tempfile::tempdir().unwrap();
|
||||||
|
let store = tempfile::tempdir().unwrap();
|
||||||
|
let factory = MockFactory {
|
||||||
|
client,
|
||||||
|
runtime_base: runtime_base.path().to_path_buf(),
|
||||||
|
cwd: cwd.path().to_path_buf(),
|
||||||
|
store_dir: store.path().join("sessions"),
|
||||||
|
worker_metadata_dir: store.path().join("workers"),
|
||||||
|
observed_cwds: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
observed_workspace_clients: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
};
|
||||||
|
let backend = Arc::new(WorkerRuntimeExecutionBackend::new(factory).unwrap());
|
||||||
|
let runtime =
|
||||||
|
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), backend.clone())
|
||||||
|
.unwrap();
|
||||||
|
runtime.store_config_bundle(test_bundle()).unwrap();
|
||||||
|
let detail = runtime.create_worker(create_request("idle-only")).unwrap();
|
||||||
|
let input = |id: &str| {
|
||||||
|
let mut input = WorkerInput::user(id);
|
||||||
|
input.kind = WorkerInputKind::UserIfIdle;
|
||||||
|
input.submission_request_id = Some(id.to_string());
|
||||||
|
input
|
||||||
|
};
|
||||||
|
let ack = runtime
|
||||||
|
.send_input(&detail.worker_ref, input("idle-input"))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ack.submission.unwrap().submission_request_id, "idle-input");
|
||||||
|
wait_for_adapter_state(&backend, &detail.worker_ref, WorkerStatus::Running);
|
||||||
|
let error = runtime
|
||||||
|
.send_input(&detail.worker_ref, input("running-input"))
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(error.to_string().contains("WorkerNotify"), "{error}");
|
||||||
|
runtime
|
||||||
|
.send_protocol_method(
|
||||||
|
&detail.worker_ref,
|
||||||
|
Method::Pause {
|
||||||
|
command: adapter_command(&backend, &detail.worker_ref),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
wait_for_adapter_state(&backend, &detail.worker_ref, WorkerStatus::Paused);
|
||||||
|
let error = runtime
|
||||||
|
.send_input(&detail.worker_ref, input("paused-input"))
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(error.to_string().contains("WorkerNotify"), "{error}");
|
||||||
|
let handle = backend
|
||||||
|
.workers
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&detail.worker_ref)
|
||||||
|
.unwrap()
|
||||||
|
.handle
|
||||||
|
.clone();
|
||||||
|
let pending = backend
|
||||||
|
.run_on_adapter_runtime(async move {
|
||||||
|
let mut events = handle.subscribe();
|
||||||
|
handle
|
||||||
|
.send(Method::ListPendingSubmissions)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
|
loop {
|
||||||
|
if let Ok(Event::PendingSubmissionsChanged { pending }) =
|
||||||
|
events.recv().await
|
||||||
|
{
|
||||||
|
break pending;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(pending.submissions.is_empty());
|
||||||
|
// Human Submit still queues while paused, and Notify remains advisory.
|
||||||
|
let ack = runtime
|
||||||
|
.send_input(&detail.worker_ref, WorkerInput::user("human-input"))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
ack.submission.unwrap().disposition,
|
||||||
|
protocol::SubmissionDisposition::Queued
|
||||||
|
);
|
||||||
|
let mut notify = WorkerInput::user("advisory");
|
||||||
|
notify.kind = WorkerInputKind::Notify;
|
||||||
|
assert!(
|
||||||
|
runtime
|
||||||
|
.send_input(&detail.worker_ref, notify)
|
||||||
|
.unwrap()
|
||||||
|
.notification
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
wait_for_adapter_state(&backend, &detail.worker_ref, WorkerStatus::Paused);
|
||||||
|
let handle = backend
|
||||||
|
.workers
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&detail.worker_ref)
|
||||||
|
.unwrap()
|
||||||
|
.handle
|
||||||
|
.clone();
|
||||||
|
let entries = serde_json::to_string(&handle.committed_entries()).unwrap();
|
||||||
|
assert!(!entries.contains("running-input"));
|
||||||
|
assert!(!entries.contains("paused-input"));
|
||||||
|
runtime.stop_worker(&detail.worker_ref, None).unwrap();
|
||||||
|
let error = runtime
|
||||||
|
.send_input(&detail.worker_ref, input("stopped-input"))
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(error.to_string().contains("WorkerNotify"), "{error}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stopped_runtime_worker_can_restore_and_accept_input() {
|
fn stopped_runtime_worker_can_restore_and_accept_input() {
|
||||||
let client = MockClient::new(simple_text_events());
|
let client = MockClient::new(simple_text_events());
|
||||||
|
|||||||
+189
-20
@@ -65,6 +65,14 @@ pub struct WorkerHandle {
|
|||||||
|
|
||||||
impl WorkerHandle {
|
impl WorkerHandle {
|
||||||
pub async fn send(&self, method: Method) -> Result<(), mpsc::error::SendError<Method>> {
|
pub async fn send(&self, method: Method) -> Result<(), mpsc::error::SendError<Method>> {
|
||||||
|
// Reject known-busy sends before channel dispatch, including maintenance
|
||||||
|
// that cannot receive methods until it finishes. The controller checks
|
||||||
|
// again at admission to cover an Idle -> busy race after this snapshot.
|
||||||
|
if self.shared_state.snapshot().state != WorkerState::Idle
|
||||||
|
&& reject_idle_only_submission(&method, &self.working_event_tx)
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
self.method_tx.send(method).await
|
self.method_tx.send(method).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1523,6 +1531,21 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reject_idle_only_submission(method: &Method, events: &broadcast::Sender<Event>) -> bool {
|
||||||
|
let Method::SubmitIfIdle {
|
||||||
|
submission_request_id,
|
||||||
|
..
|
||||||
|
} = method
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let _ = events.send(Event::SubmissionRejected {
|
||||||
|
submission_request_id: submission_request_id.clone(),
|
||||||
|
message: "WorkerSendInput requires an Idle Worker and never queues input. Use WorkerNotify for information about work already in progress.".to_string(),
|
||||||
|
});
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn durably_accept_method_while_busy<St>(
|
fn durably_accept_method_while_busy<St>(
|
||||||
method: Method,
|
method: Method,
|
||||||
pending_submissions: &PendingSubmissionHandle<St>,
|
pending_submissions: &PendingSubmissionHandle<St>,
|
||||||
@@ -1531,6 +1554,9 @@ fn durably_accept_method_while_busy<St>(
|
|||||||
where
|
where
|
||||||
St: session_store::Store + Clone,
|
St: session_store::Store + Clone,
|
||||||
{
|
{
|
||||||
|
if reject_idle_only_submission(&method, working_event_tx) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
match method {
|
match method {
|
||||||
Method::Submit {
|
Method::Submit {
|
||||||
submission_request_id,
|
submission_request_id,
|
||||||
@@ -1640,6 +1666,10 @@ fn reject_method_while_attention_locked(
|
|||||||
| Method::SubmitTracked {
|
| Method::SubmitTracked {
|
||||||
submission_request_id,
|
submission_request_id,
|
||||||
..
|
..
|
||||||
|
}
|
||||||
|
| Method::SubmitIfIdle {
|
||||||
|
submission_request_id,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
let _ = working_event_tx.send(Event::SubmissionRejected {
|
let _ = working_event_tx.send(Event::SubmissionRejected {
|
||||||
submission_request_id: submission_request_id.clone(),
|
submission_request_id: submission_request_id.clone(),
|
||||||
@@ -1946,6 +1976,12 @@ async fn controller_loop<C, St>(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if shared_state.snapshot().state != WorkerState::Idle
|
||||||
|
&& reject_idle_only_submission(&method, &working_event_tx)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if worker.has_pending_compaction_cleanup() {
|
if worker.has_pending_compaction_cleanup() {
|
||||||
match worker.retry_pending_compaction_cleanup().await {
|
match worker.retry_pending_compaction_cleanup().await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
@@ -2032,6 +2068,11 @@ async fn controller_loop<C, St>(
|
|||||||
submission_request_id,
|
submission_request_id,
|
||||||
input,
|
input,
|
||||||
source,
|
source,
|
||||||
|
}
|
||||||
|
| Method::SubmitIfIdle {
|
||||||
|
submission_request_id,
|
||||||
|
input,
|
||||||
|
source,
|
||||||
} => {
|
} => {
|
||||||
let request_id = submission_request_id.clone();
|
let request_id = submission_request_id.clone();
|
||||||
let (source_namespace, provenance) =
|
let (source_namespace, provenance) =
|
||||||
@@ -2418,7 +2459,9 @@ async fn controller_loop<C, St>(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
if let Err(error) = result {
|
if let Err(error) = result
|
||||||
|
&& !matches!(&error, WorkerError::CompactCancelled)
|
||||||
|
{
|
||||||
let _ = working_event_tx.send(Event::Error {
|
let _ = working_event_tx.send(Event::Error {
|
||||||
code: worker_error_code(&error),
|
code: worker_error_code(&error),
|
||||||
message: error.to_string(),
|
message: error.to_string(),
|
||||||
@@ -2842,11 +2885,7 @@ where
|
|||||||
(WorkerStatus::Paused, shutdown_requested, false)
|
(WorkerStatus::Paused, shutdown_requested, false)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let status = if matches!(
|
let status = if worker_error_requires_attention(&e) {
|
||||||
&e,
|
|
||||||
WorkerError::SegmentActivationIncomplete { .. }
|
|
||||||
| WorkerError::CompactionCleanupPending { .. }
|
|
||||||
) {
|
|
||||||
// Metadata has already committed the replacement, but
|
// Metadata has already committed the replacement, but
|
||||||
// one live authority failed to publish. Keep the
|
// one live authority failed to publish. Keep the
|
||||||
// controller visibly busy/fail-closed; publishing Idle
|
// controller visibly busy/fail-closed; publishing Idle
|
||||||
@@ -2855,20 +2894,22 @@ where
|
|||||||
} else {
|
} else {
|
||||||
WorkerStatus::Idle
|
WorkerStatus::Idle
|
||||||
};
|
};
|
||||||
let code = worker_error_code(&e);
|
if !matches!(&e, WorkerError::RunFailureAlreadyDelivered { .. }) {
|
||||||
let message = e.to_string();
|
let code = worker_error_code(&e);
|
||||||
let _ = working_event_tx.send(Event::Error {
|
let message = e.to_string();
|
||||||
code,
|
let _ = working_event_tx.send(Event::Error {
|
||||||
message: message.clone(),
|
code,
|
||||||
});
|
message: message.clone(),
|
||||||
if parent_originated {
|
});
|
||||||
crate::ipc::event::fire_and_forget(
|
if parent_originated {
|
||||||
parent_socket.cloned(),
|
crate::ipc::event::fire_and_forget(
|
||||||
protocol::WorkerEvent::Errored {
|
parent_socket.cloned(),
|
||||||
worker_name: self_name.to_string(),
|
protocol::WorkerEvent::Errored {
|
||||||
message,
|
worker_name: self_name.to_string(),
|
||||||
},
|
message,
|
||||||
);
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
(status, shutdown_requested, false)
|
(status, shutdown_requested, false)
|
||||||
}
|
}
|
||||||
@@ -3017,6 +3058,9 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Some(method @ Method::SubmitIfIdle { .. }) => {
|
||||||
|
reject_idle_only_submission(&method, working_event_tx);
|
||||||
|
}
|
||||||
Some(Method::SubmitTracked {
|
Some(Method::SubmitTracked {
|
||||||
submission_request_id,
|
submission_request_id,
|
||||||
input,
|
input,
|
||||||
@@ -3331,8 +3375,22 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn worker_error_requires_attention(error: &WorkerError) -> bool {
|
||||||
|
match error {
|
||||||
|
WorkerError::SegmentActivationIncomplete { .. }
|
||||||
|
| WorkerError::CompactionCleanupPending { .. } => true,
|
||||||
|
WorkerError::RunFailureAlreadyDelivered { source }
|
||||||
|
| WorkerError::RunTerminalPersistence {
|
||||||
|
run_failure: source,
|
||||||
|
..
|
||||||
|
} => worker_error_requires_attention(source),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn worker_error_code(e: &WorkerError) -> ErrorCode {
|
fn worker_error_code(e: &WorkerError) -> ErrorCode {
|
||||||
match e {
|
match e {
|
||||||
|
WorkerError::RunFailureAlreadyDelivered { source } => worker_error_code(source),
|
||||||
WorkerError::Engine(we) => match we {
|
WorkerError::Engine(we) => match we {
|
||||||
EngineError::Tool(_) => ErrorCode::ToolError,
|
EngineError::Tool(_) => ErrorCode::ToolError,
|
||||||
EngineError::Client(_) => ErrorCode::ProviderError,
|
EngineError::Client(_) => ErrorCode::ProviderError,
|
||||||
@@ -3558,6 +3616,36 @@ mod tests {
|
|||||||
tokio::time::timeout(timeout, accept).await.ok().flatten()
|
tokio::time::timeout(timeout, accept).await.ok().flatten()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn idle_only_input_is_rejected_in_busy_maintenance_admission() {
|
||||||
|
let env = make_env().await;
|
||||||
|
let mut events = env.working_event_tx.subscribe();
|
||||||
|
let method = Method::SubmitIfIdle {
|
||||||
|
submission_request_id: "maintenance-input".to_string(),
|
||||||
|
input: vec![protocol::Segment::text("must not queue")],
|
||||||
|
source: protocol::AuthenticatedInputSource::Backend {
|
||||||
|
operation_id: "maintenance-input".to_string(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
durably_accept_method_while_busy(
|
||||||
|
method,
|
||||||
|
&env.pending_submissions,
|
||||||
|
&env.working_event_tx
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
let event = events.try_recv().unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(event, Event::SubmissionRejected { submission_request_id, message }
|
||||||
|
if submission_request_id == "maintenance-input" && message.contains("WorkerNotify"))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
events.try_recv().is_err(),
|
||||||
|
"must not emit durable acceptance or queue changes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn parent_originated_finished_fires_turn_ended() {
|
async fn parent_originated_finished_fires_turn_ended() {
|
||||||
let mut env = make_env().await;
|
let mut env = make_env().await;
|
||||||
@@ -3911,6 +3999,87 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delivered_run_failure_is_not_reported_again_by_controller() {
|
||||||
|
let mut env = make_env().await;
|
||||||
|
let listener = UnixListener::bind(&env.parent_socket_path).expect("bind listener");
|
||||||
|
let mut events = env.working_event_tx.subscribe();
|
||||||
|
let worker_future = async {
|
||||||
|
Err::<WorkerRunResult, _>(WorkerError::RunFailureAlreadyDelivered {
|
||||||
|
source: Box::new(WorkerError::CompactSummaryMissing),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let (status, _, may_drain_pending) = drive_turn(
|
||||||
|
worker_future,
|
||||||
|
&mut env.method_rx,
|
||||||
|
&env.working_event_tx,
|
||||||
|
&env.cancel_tx,
|
||||||
|
&env.pause_tx,
|
||||||
|
&env.shared_state,
|
||||||
|
&env.runtime_dir,
|
||||||
|
None,
|
||||||
|
&env.notify_buffer,
|
||||||
|
&env.pending_submissions,
|
||||||
|
Some(&env.parent_socket_path),
|
||||||
|
"child-worker",
|
||||||
|
&env.spawned_registry,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(status, WorkerStatus::Idle);
|
||||||
|
assert!(!may_drain_pending, "failed runs must not become successful");
|
||||||
|
assert!(events.try_recv().is_err(), "must not emit a generic Error");
|
||||||
|
assert!(
|
||||||
|
tokio::time::timeout(Duration::from_millis(200), listener.accept())
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"must not send a second parent Errored event"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn terminal_persistence_failure_reports_both_causes_once() {
|
||||||
|
let mut env = make_env().await;
|
||||||
|
let mut events = env.working_event_tx.subscribe();
|
||||||
|
let worker_future = async {
|
||||||
|
Err::<WorkerRunResult, _>(WorkerError::RunTerminalPersistence {
|
||||||
|
run_failure: Box::new(WorkerError::CompactSummaryMissing),
|
||||||
|
persist_error: Box::new(WorkerError::Store(session_store::StoreError::Io(
|
||||||
|
std::io::Error::other("terminal append failed"),
|
||||||
|
))),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let (status, _, may_drain_pending) = drive_turn(
|
||||||
|
worker_future,
|
||||||
|
&mut env.method_rx,
|
||||||
|
&env.working_event_tx,
|
||||||
|
&env.cancel_tx,
|
||||||
|
&env.pause_tx,
|
||||||
|
&env.shared_state,
|
||||||
|
&env.runtime_dir,
|
||||||
|
None,
|
||||||
|
&env.notify_buffer,
|
||||||
|
&env.pending_submissions,
|
||||||
|
None,
|
||||||
|
"child-worker",
|
||||||
|
&env.spawned_registry,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(status, WorkerStatus::Idle);
|
||||||
|
assert!(!may_drain_pending);
|
||||||
|
let Event::Error { message, .. } = events.try_recv().expect("one error event") else {
|
||||||
|
panic!("expected Error event");
|
||||||
|
};
|
||||||
|
assert!(message.contains("compact worker did not produce a summary"));
|
||||||
|
assert!(message.contains("terminal append failed"));
|
||||||
|
assert!(events.try_recv().is_err(), "must report once");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn non_parent_originated_worker_error_stays_silent() {
|
async fn non_parent_originated_worker_error_stays_silent() {
|
||||||
let mut env = make_env().await;
|
let mut env = make_env().await;
|
||||||
|
|||||||
@@ -245,11 +245,11 @@ impl WorkerControlService for WorkspaceWorkerControlService {
|
|||||||
"unknown Worker or permission not granted".to_string(),
|
"unknown Worker or permission not granted".to_string(),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
record
|
record.session.send(content).await.map_err(|error| {
|
||||||
.session
|
WorkspaceClientError::Request(format!(
|
||||||
.send(content)
|
"WorkerSendInput requires an Idle SubWorker and never queues input: {error}"
|
||||||
.await
|
))
|
||||||
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
|
})?;
|
||||||
Ok(WorkspaceResponse {
|
Ok(WorkspaceResponse {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: serde_json::json!({
|
body: serde_json::json!({
|
||||||
@@ -855,7 +855,7 @@ impl WorkerOperation {
|
|||||||
"Spawn a Backend/Runtime Worker session in one existing Workspace Workdir. The model-facing input remains singular and the shared lifecycle wraps it as the canonical attachment collection under the stable `workdir` alias. The Workdir id is authority; filesystem paths and Runtime URLs are not accepted. `initial_submit` carries the normal typed user submission. After the Orchestrator has committed a Ticket to `inprogress`, set `ticket_id` with a Flow segment in `initial_submit` to atomically assign the new Coder Worker; the operation id is derived from the durable tool call rather than model input."
|
"Spawn a Backend/Runtime Worker session in one existing Workspace Workdir. The model-facing input remains singular and the shared lifecycle wraps it as the canonical attachment collection under the stable `workdir` alias. The Workdir id is authority; filesystem paths and Runtime URLs are not accepted. `initial_submit` carries the normal typed user submission. After the Orchestrator has committed a Ticket to `inprogress`, set `ticket_id` with a Flow segment in `initial_submit` to atomically assign the new Coder Worker; the operation id is derived from the durable tool call rather than model input."
|
||||||
}
|
}
|
||||||
Self::SendInput => {
|
Self::SendInput => {
|
||||||
"Send user input to a known Runtime Worker. Idle Workers can start a fresh turn; Running or Paused Workers durably queue it for a later turn. Use WorkerNotify for advisory information during work already in progress. Direct SubWorkers require Idle."
|
"Start a fresh turn for an Idle Worker. Non-Idle Workers reject WorkerSendInput without queueing. Use WorkerNotify for advisory information during work already in progress. Direct SubWorkers also require Idle."
|
||||||
}
|
}
|
||||||
Self::Notify => {
|
Self::Notify => {
|
||||||
"Send an advisory notification to a known Runtime Worker so information can be incorporated into work already in progress without creating a queued Submit."
|
"Send an advisory notification to a known Runtime Worker so information can be incorporated into work already in progress without creating a queued Submit."
|
||||||
@@ -1536,6 +1536,17 @@ mod tests {
|
|||||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn send_input_rejection_is_a_tool_error_with_notify_guidance() {
|
||||||
|
let error = tool_output(WorkerOperation::SendInput, WorkspaceResponse {
|
||||||
|
status: 400,
|
||||||
|
body: "WorkerSendInput requires an Idle Worker. Use WorkerNotify for work already in progress.".to_string(),
|
||||||
|
}).unwrap_err();
|
||||||
|
assert!(matches!(error, ToolError::ExecutionFailed(_)));
|
||||||
|
assert!(error.to_string().contains("WorkerNotify"));
|
||||||
|
assert!(!error.to_string().contains("completed"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn control_service_routes_by_subject_without_cross_provider_fallback() {
|
async fn control_service_routes_by_subject_without_cross_provider_fallback() {
|
||||||
let client = Arc::new(RecordingWorkspaceClient::default());
|
let client = Arc::new(RecordingWorkspaceClient::default());
|
||||||
|
|||||||
@@ -271,6 +271,17 @@ pub(crate) enum InternalWorkerSessionStatus {
|
|||||||
Failed,
|
Failed,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Typed result of the most recent reusable Internal Worker turn.
|
||||||
|
///
|
||||||
|
/// Session status intentionally remains a compact presentation state. Owners that
|
||||||
|
/// make domain decisions (such as Compaction) must use this result instead of
|
||||||
|
/// guessing a stop cause from `Stopped` or parsing the presentation error string.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) enum InternalWorkerTurnOutcome {
|
||||||
|
Completed(WorkerRunResult),
|
||||||
|
Failed { message: String },
|
||||||
|
}
|
||||||
|
|
||||||
impl InternalWorkerSessionStatus {
|
impl InternalWorkerSessionStatus {
|
||||||
fn encode(self) -> u8 {
|
fn encode(self) -> u8 {
|
||||||
match self {
|
match self {
|
||||||
@@ -323,26 +334,53 @@ fn send_internal_worker_state(
|
|||||||
|
|
||||||
fn classify_internal_turn_result(
|
fn classify_internal_turn_result(
|
||||||
result: Result<WorkerRunResult, WorkerError>,
|
result: Result<WorkerRunResult, WorkerError>,
|
||||||
) -> (InternalWorkerSessionStatus, Option<String>) {
|
) -> (
|
||||||
|
InternalWorkerSessionStatus,
|
||||||
|
Option<String>,
|
||||||
|
InternalWorkerTurnOutcome,
|
||||||
|
) {
|
||||||
match result {
|
match result {
|
||||||
Ok(WorkerRunResult::Finished) => (InternalWorkerSessionStatus::Idle, None),
|
Ok(result @ WorkerRunResult::Finished) => (
|
||||||
Ok(WorkerRunResult::Paused) => (InternalWorkerSessionStatus::Paused, None),
|
InternalWorkerSessionStatus::Idle,
|
||||||
Ok(WorkerRunResult::LimitReached) => (
|
None,
|
||||||
InternalWorkerSessionStatus::Stopped,
|
InternalWorkerTurnOutcome::Completed(result),
|
||||||
Some("internal Worker reached its turn limit".to_string()),
|
|
||||||
),
|
),
|
||||||
Ok(WorkerRunResult::Interrupted { message, .. }) => {
|
Ok(result @ WorkerRunResult::Paused) => (
|
||||||
(InternalWorkerSessionStatus::Stopped, Some(message))
|
InternalWorkerSessionStatus::Paused,
|
||||||
|
None,
|
||||||
|
InternalWorkerTurnOutcome::Completed(result),
|
||||||
|
),
|
||||||
|
Ok(result @ WorkerRunResult::LimitReached) => {
|
||||||
|
let message = "internal Worker reached its turn limit".to_string();
|
||||||
|
(
|
||||||
|
InternalWorkerSessionStatus::Stopped,
|
||||||
|
Some(message),
|
||||||
|
InternalWorkerTurnOutcome::Completed(result),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Ok(WorkerRunResult::Cancelled) => (
|
Ok(WorkerRunResult::Interrupted { code, message }) => (
|
||||||
|
InternalWorkerSessionStatus::Stopped,
|
||||||
|
Some(message.clone()),
|
||||||
|
InternalWorkerTurnOutcome::Completed(WorkerRunResult::Interrupted { code, message }),
|
||||||
|
),
|
||||||
|
Ok(result @ WorkerRunResult::Cancelled) => (
|
||||||
InternalWorkerSessionStatus::Stopped,
|
InternalWorkerSessionStatus::Stopped,
|
||||||
Some("internal Worker run was cancelled".to_string()),
|
Some("internal Worker run was cancelled".to_string()),
|
||||||
|
InternalWorkerTurnOutcome::Completed(result),
|
||||||
),
|
),
|
||||||
Ok(WorkerRunResult::RolledBack) => (
|
Ok(result @ WorkerRunResult::RolledBack) => (
|
||||||
InternalWorkerSessionStatus::Stopped,
|
InternalWorkerSessionStatus::Stopped,
|
||||||
Some("internal Worker run was cancelled before AI output".to_string()),
|
Some("internal Worker run was cancelled before AI output".to_string()),
|
||||||
|
InternalWorkerTurnOutcome::Completed(result),
|
||||||
),
|
),
|
||||||
Err(error) => (InternalWorkerSessionStatus::Failed, Some(error.to_string())),
|
Err(error) => {
|
||||||
|
let message = error.to_string();
|
||||||
|
(
|
||||||
|
InternalWorkerSessionStatus::Failed,
|
||||||
|
Some(message.clone()),
|
||||||
|
InternalWorkerTurnOutcome::Failed { message },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,6 +427,7 @@ pub(crate) struct InternalWorkerSessionHandle {
|
|||||||
event_tx: broadcast::Sender<Event>,
|
event_tx: broadcast::Sender<Event>,
|
||||||
visibility: InternalWorkerVisibility,
|
visibility: InternalWorkerVisibility,
|
||||||
last_error: Arc<Mutex<Option<String>>>,
|
last_error: Arc<Mutex<Option<String>>>,
|
||||||
|
last_outcome: Arc<Mutex<Option<InternalWorkerTurnOutcome>>>,
|
||||||
child_registry: Option<Arc<SpawnedWorkerRegistry>>,
|
child_registry: Option<Arc<SpawnedWorkerRegistry>>,
|
||||||
sink: SegmentLogSink,
|
sink: SegmentLogSink,
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -488,6 +527,8 @@ impl InternalWorkerSessionHandle {
|
|||||||
InternalWorkerSessionStatus::Idle => InternalWorkerSessionError::Unavailable,
|
InternalWorkerSessionStatus::Idle => InternalWorkerSessionError::Unavailable,
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
|
*self.last_error.lock().unwrap() = None;
|
||||||
|
*self.last_outcome.lock().unwrap() = None;
|
||||||
if self
|
if self
|
||||||
.command_tx
|
.command_tx
|
||||||
.send(InternalWorkerSessionCommand::Run(input.into()))
|
.send(InternalWorkerSessionCommand::Run(input.into()))
|
||||||
@@ -501,6 +542,9 @@ impl InternalWorkerSessionHandle {
|
|||||||
self.state_changed.notify_waiters();
|
self.state_changed.notify_waiters();
|
||||||
let message = "internal Worker session actor is unavailable".to_owned();
|
let message = "internal Worker session actor is unavailable".to_owned();
|
||||||
*self.last_error.lock().unwrap() = Some(message.clone());
|
*self.last_error.lock().unwrap() = Some(message.clone());
|
||||||
|
*self.last_outcome.lock().unwrap() = Some(InternalWorkerTurnOutcome::Failed {
|
||||||
|
message: message.clone(),
|
||||||
|
});
|
||||||
let _ = self.event_tx.send(Event::Error {
|
let _ = self.event_tx.send(Event::Error {
|
||||||
code: protocol::ErrorCode::Internal,
|
code: protocol::ErrorCode::Internal,
|
||||||
message,
|
message,
|
||||||
@@ -524,6 +568,15 @@ impl InternalWorkerSessionHandle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wait for the active turn and return its typed outcome.
|
||||||
|
///
|
||||||
|
/// `None` means the actor stopped without publishing a turn result; callers
|
||||||
|
/// must treat that as unknown rather than manufacturing a cancellation.
|
||||||
|
pub(crate) async fn wait_for_turn_outcome(&self) -> Option<InternalWorkerTurnOutcome> {
|
||||||
|
self.wait_until_idle().await;
|
||||||
|
self.last_outcome.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn force_status(&self, status: InternalWorkerSessionStatus) {
|
pub(crate) fn force_status(&self, status: InternalWorkerSessionStatus) {
|
||||||
self.status
|
self.status
|
||||||
@@ -808,6 +861,7 @@ pub(crate) async fn prepare_internal_worker_session(
|
|||||||
));
|
));
|
||||||
let state_changed = Arc::new(tokio::sync::Notify::new());
|
let state_changed = Arc::new(tokio::sync::Notify::new());
|
||||||
let last_error = Arc::new(Mutex::new(None));
|
let last_error = Arc::new(Mutex::new(None));
|
||||||
|
let last_outcome = Arc::new(Mutex::new(None));
|
||||||
let handle = InternalWorkerSessionHandle {
|
let handle = InternalWorkerSessionHandle {
|
||||||
command_tx,
|
command_tx,
|
||||||
status: status.clone(),
|
status: status.clone(),
|
||||||
@@ -819,6 +873,7 @@ pub(crate) async fn prepare_internal_worker_session(
|
|||||||
event_tx: event_tx.clone(),
|
event_tx: event_tx.clone(),
|
||||||
visibility,
|
visibility,
|
||||||
last_error: last_error.clone(),
|
last_error: last_error.clone(),
|
||||||
|
last_outcome: last_outcome.clone(),
|
||||||
child_registry,
|
child_registry,
|
||||||
sink,
|
sink,
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -836,8 +891,10 @@ pub(crate) async fn prepare_internal_worker_session(
|
|||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
result = &mut run => {
|
result = &mut run => {
|
||||||
let (turn_status, error) = classify_internal_turn_result(result);
|
let (turn_status, error, outcome) =
|
||||||
|
classify_internal_turn_result(result);
|
||||||
actor_in_flight.clear();
|
actor_in_flight.clear();
|
||||||
|
*last_outcome.lock().unwrap() = Some(outcome);
|
||||||
status.store(turn_status.encode(), std::sync::atomic::Ordering::Release);
|
status.store(turn_status.encode(), std::sync::atomic::Ordering::Release);
|
||||||
if let Some(message) = error {
|
if let Some(message) = error {
|
||||||
*last_error.lock().unwrap() = Some(message.clone());
|
*last_error.lock().unwrap() = Some(message.clone());
|
||||||
@@ -1148,6 +1205,7 @@ pub(crate) fn test_internal_worker_session(
|
|||||||
event_tx: event_tx.clone(),
|
event_tx: event_tx.clone(),
|
||||||
visibility,
|
visibility,
|
||||||
last_error: Arc::new(Mutex::new(None)),
|
last_error: Arc::new(Mutex::new(None)),
|
||||||
|
last_outcome: Arc::new(Mutex::new(None)),
|
||||||
child_registry: None,
|
child_registry: None,
|
||||||
sink,
|
sink,
|
||||||
fail_stop: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
fail_stop: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
@@ -1354,6 +1412,11 @@ permission = "write"
|
|||||||
InternalWorkerSessionStatus::Stopped,
|
InternalWorkerSessionStatus::Stopped,
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
WorkerRunResult::Cancelled,
|
||||||
|
InternalWorkerSessionStatus::Stopped,
|
||||||
|
true,
|
||||||
|
),
|
||||||
(
|
(
|
||||||
WorkerRunResult::RolledBack,
|
WorkerRunResult::RolledBack,
|
||||||
InternalWorkerSessionStatus::Stopped,
|
InternalWorkerSessionStatus::Stopped,
|
||||||
@@ -1362,16 +1425,22 @@ permission = "write"
|
|||||||
];
|
];
|
||||||
|
|
||||||
for (result, expected_status, expects_error) in cases {
|
for (result, expected_status, expects_error) in cases {
|
||||||
let (status, error) = classify_internal_turn_result(Ok(result));
|
let expected_outcome = InternalWorkerTurnOutcome::Completed(result.clone());
|
||||||
|
let (status, error, outcome) = classify_internal_turn_result(Ok(result));
|
||||||
assert_eq!(status, expected_status);
|
assert_eq!(status, expected_status);
|
||||||
assert_eq!(error.is_some(), expects_error);
|
assert_eq!(error.is_some(), expects_error);
|
||||||
|
assert_eq!(outcome, expected_outcome);
|
||||||
}
|
}
|
||||||
|
|
||||||
let (status, error) = classify_internal_turn_result(Err(WorkerError::Engine(
|
let (status, error, outcome) = classify_internal_turn_result(Err(WorkerError::Engine(
|
||||||
EngineError::Aborted("fatal".to_string()),
|
EngineError::Aborted("fatal".to_string()),
|
||||||
)));
|
)));
|
||||||
assert_eq!(status, InternalWorkerSessionStatus::Failed);
|
assert_eq!(status, InternalWorkerSessionStatus::Failed);
|
||||||
assert!(error.is_some_and(|message| message.contains("fatal")));
|
assert!(error.is_some_and(|message| message.contains("fatal")));
|
||||||
|
assert!(matches!(
|
||||||
|
outcome,
|
||||||
|
InternalWorkerTurnOutcome::Failed { message } if message.contains("fatal")
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -937,8 +937,8 @@ mod tests {
|
|||||||
"missing canonical tool {name}"
|
"missing canonical tool {name}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
assert!(orchestration.contains("durably queue it for a later turn"));
|
assert!(orchestration.contains("requires an Idle Worker and never queues input"));
|
||||||
assert!(orchestration.contains("Direct SubWorkers still require Idle"));
|
assert!(orchestration.contains("Direct SubWorkers also require Idle"));
|
||||||
assert!(orchestration.contains("use `WorkerNotify` instead"));
|
assert!(orchestration.contains("use `WorkerNotify` instead"));
|
||||||
assert!(orchestration.contains("do not create a queued Submit"));
|
assert!(orchestration.contains("do not create a queued Submit"));
|
||||||
for alias in ["SubWorkerList", "SubWorkerSend", "SubWorkerStop"] {
|
for alias in ["SubWorkerList", "SubWorkerSend", "SubWorkerStop"] {
|
||||||
|
|||||||
+265
-88
@@ -4941,11 +4941,19 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
|
|
||||||
if let Some(block) = request_block {
|
if let Some(block) = request_block {
|
||||||
let error = automatic_compact_block_error(block);
|
let error = automatic_compact_block_error(block);
|
||||||
if matches!(result, EngineRunExit::Yielded) {
|
let error = if matches!(result, EngineRunExit::Yielded) {
|
||||||
self.persist_compaction_run_failure(
|
self.terminalize_compaction_run_failure(
|
||||||
request_block_message.expect("blocked request has a terminal reason"),
|
request_block_message.expect("blocked request has a terminal reason"),
|
||||||
);
|
error,
|
||||||
}
|
)
|
||||||
|
} else {
|
||||||
|
// `persist_turn` above already committed this non-yielded Run's
|
||||||
|
// compaction terminal. Preserve Err semantics while preventing a
|
||||||
|
// second Controller Error for the same durable entry.
|
||||||
|
WorkerError::RunFailureAlreadyDelivered {
|
||||||
|
source: Box::new(error),
|
||||||
|
}
|
||||||
|
};
|
||||||
if let Some(state) = &self.compact_state {
|
if let Some(state) = &self.compact_state {
|
||||||
state.finish_logical_run();
|
state.finish_logical_run();
|
||||||
}
|
}
|
||||||
@@ -4999,33 +5007,33 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn persist_compaction_run_failure(&mut self, message: String) {
|
fn persist_compaction_run_failure(&mut self, message: String) -> Result<(), WorkerError> {
|
||||||
// Keep the original compaction/preparation error as controller authority.
|
// The Store is the terminal authority. Only a successful append can be
|
||||||
// A terminal append failure is diagnosed separately and cannot publish a
|
// marked as already delivered to clients through the session-log sink.
|
||||||
// false persisted result through the session-log sink.
|
self.commit_entry(LogEntry::RunErrored {
|
||||||
match self.commit_entry(LogEntry::RunErrored {
|
|
||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
interrupted: false,
|
interrupted: false,
|
||||||
message,
|
message,
|
||||||
failure: Some(RunFailureKind::Compaction),
|
failure: Some(RunFailureKind::Compaction),
|
||||||
}) {
|
})?;
|
||||||
Ok(()) => {
|
self.last_run_interrupted = false;
|
||||||
self.last_run_interrupted = false;
|
self.engine_mut().set_active_run_turn_count(None);
|
||||||
self.engine_mut().set_active_run_turn_count(None);
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(persist_error) => {
|
|
||||||
warn!(
|
fn terminalize_compaction_run_failure(
|
||||||
error = %persist_error,
|
&mut self,
|
||||||
"failed to persist mid-run compaction terminal record"
|
message: String,
|
||||||
);
|
run_failure: WorkerError,
|
||||||
self.alert(
|
) -> WorkerError {
|
||||||
AlertLevel::Error,
|
match self.persist_compaction_run_failure(message) {
|
||||||
AlertSource::Worker,
|
Ok(()) => WorkerError::RunFailureAlreadyDelivered {
|
||||||
format!(
|
source: Box::new(run_failure),
|
||||||
"mid-run compaction failed and its Run terminal record could not be saved: {persist_error}"
|
},
|
||||||
),
|
Err(persist_error) => WorkerError::RunTerminalPersistence {
|
||||||
);
|
run_failure: Box::new(run_failure),
|
||||||
}
|
persist_error: Box::new(persist_error),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5078,9 +5086,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
}
|
}
|
||||||
let message =
|
let message =
|
||||||
format!("mid-run resume preparation failed after compaction: {error}");
|
format!("mid-run resume preparation failed after compaction: {error}");
|
||||||
self.persist_compaction_run_failure(message.clone());
|
return Err(self.terminalize_compaction_run_failure(message, error));
|
||||||
self.alert(AlertLevel::Error, AlertSource::Compactor, message);
|
|
||||||
return Err(error);
|
|
||||||
}
|
}
|
||||||
self.resume_prepared(RunResumeSource::Compaction).await
|
self.resume_prepared(RunResumeSource::Compaction).await
|
||||||
}
|
}
|
||||||
@@ -5102,14 +5108,11 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The yielded logical Run is no longer resumable. Commit its
|
// The yielded logical Run is no longer resumable. Commit its
|
||||||
// durable failure before any log-derived live event can claim
|
// durable failure before returning a typed handled error. The
|
||||||
// the terminal transition. If storage itself is unavailable,
|
// Controller suppresses only that exact handled variant; if
|
||||||
// the helper retains the original compaction error (and its
|
// persistence fails it instead reports both failures through
|
||||||
// fail-closed controller classification) while diagnosing the
|
// the non-durable error channel.
|
||||||
// missing terminal record separately.
|
Err(self.terminalize_compaction_run_failure(message, e))
|
||||||
self.persist_compaction_run_failure(message.clone());
|
|
||||||
self.alert(AlertLevel::Error, AlertSource::Compactor, message);
|
|
||||||
Err(e)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -5288,6 +5291,10 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
info!(new_segment_id = %new_segment_id, "Manual compaction succeeded");
|
info!(new_segment_id = %new_segment_id, "Manual compaction succeeded");
|
||||||
Ok(ManualCompactResult::Compacted { new_segment_id })
|
Ok(ManualCompactResult::Compacted { new_segment_id })
|
||||||
}
|
}
|
||||||
|
Err(WorkerError::CompactCancelled) => {
|
||||||
|
info!("Manual compaction was cancelled");
|
||||||
|
Err(WorkerError::CompactCancelled)
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(error = %e, "Manual compaction failed");
|
warn!(error = %e, "Manual compaction failed");
|
||||||
self.alert(
|
self.alert(
|
||||||
@@ -5951,25 +5958,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
// outer compaction boundary performs the single stop attempt.
|
// outer compaction boundary performs the single stop attempt.
|
||||||
return Err(WorkerError::InvalidState(error.to_string()));
|
return Err(WorkerError::InvalidState(error.to_string()));
|
||||||
}
|
}
|
||||||
match handle.wait_until_idle().await {
|
ensure_compaction_internal_turn_succeeded(handle.wait_for_turn_outcome().await)?;
|
||||||
crate::internal_worker::InternalWorkerSessionStatus::Idle => {}
|
|
||||||
crate::internal_worker::InternalWorkerSessionStatus::Stopped => {
|
|
||||||
return Err(WorkerError::CompactCancelled);
|
|
||||||
}
|
|
||||||
crate::internal_worker::InternalWorkerSessionStatus::Failed => {
|
|
||||||
return Err(WorkerError::InvalidState(
|
|
||||||
handle
|
|
||||||
.protocol_snapshot()
|
|
||||||
.error
|
|
||||||
.unwrap_or_else(|| "compactor Internal Worker failed".into()),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
status => {
|
|
||||||
return Err(WorkerError::InvalidState(format!(
|
|
||||||
"compactor Internal Worker ended in {status:?}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guard: nudge the worker once more if the expected outputs
|
// Guard: nudge the worker once more if the expected outputs
|
||||||
// (summary, and any auto-read nominations when default refs
|
// (summary, and any auto-read nominations when default refs
|
||||||
@@ -6003,20 +5992,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
.send(prompt)
|
.send(prompt)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| WorkerError::InvalidState(error.to_string()))?;
|
.map_err(|error| WorkerError::InvalidState(error.to_string()))?;
|
||||||
match handle.wait_until_idle().await {
|
ensure_compaction_internal_turn_succeeded(handle.wait_for_turn_outcome().await)?;
|
||||||
crate::internal_worker::InternalWorkerSessionStatus::Idle => {}
|
|
||||||
crate::internal_worker::InternalWorkerSessionStatus::Stopped => {
|
|
||||||
return Err(WorkerError::CompactCancelled);
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
return Err(WorkerError::InvalidState(
|
|
||||||
handle
|
|
||||||
.protocol_snapshot()
|
|
||||||
.error
|
|
||||||
.unwrap_or_else(|| "compactor Internal Worker failed".into()),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut final_ctx = ctx.lock().expect("compact ctx poisoned").clone();
|
let mut final_ctx = ctx.lock().expect("compact ctx poisoned").clone();
|
||||||
@@ -6035,20 +6011,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
.send(prompt)
|
.send(prompt)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| WorkerError::InvalidState(error.to_string()))?;
|
.map_err(|error| WorkerError::InvalidState(error.to_string()))?;
|
||||||
match handle.wait_until_idle().await {
|
ensure_compaction_internal_turn_succeeded(handle.wait_for_turn_outcome().await)?;
|
||||||
crate::internal_worker::InternalWorkerSessionStatus::Idle => {}
|
|
||||||
crate::internal_worker::InternalWorkerSessionStatus::Stopped => {
|
|
||||||
return Err(WorkerError::CompactCancelled);
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
return Err(WorkerError::InvalidState(
|
|
||||||
handle
|
|
||||||
.protocol_snapshot()
|
|
||||||
.error
|
|
||||||
.unwrap_or_else(|| "compactor Internal Worker failed".into()),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
final_ctx = ctx.lock().expect("compact ctx poisoned").clone();
|
final_ctx = ctx.lock().expect("compact ctx poisoned").clone();
|
||||||
summary_text = final_ctx
|
summary_text = final_ctx
|
||||||
.summary
|
.summary
|
||||||
@@ -7904,6 +7867,37 @@ fn restored_flow_runtime_state(
|
|||||||
.transpose()
|
.transpose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ensure_compaction_internal_turn_succeeded(
|
||||||
|
outcome: Option<crate::internal_worker::InternalWorkerTurnOutcome>,
|
||||||
|
) -> Result<(), WorkerError> {
|
||||||
|
use crate::internal_worker::InternalWorkerTurnOutcome;
|
||||||
|
|
||||||
|
match outcome {
|
||||||
|
Some(InternalWorkerTurnOutcome::Completed(WorkerRunResult::Finished)) => Ok(()),
|
||||||
|
Some(InternalWorkerTurnOutcome::Completed(WorkerRunResult::LimitReached)) => {
|
||||||
|
Err(WorkerError::Engine(EngineError::Aborted(
|
||||||
|
"compactor Internal Worker reached its turn limit".to_string(),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
Some(InternalWorkerTurnOutcome::Completed(WorkerRunResult::Interrupted {
|
||||||
|
message,
|
||||||
|
..
|
||||||
|
})) => Err(WorkerError::Engine(EngineError::Aborted(message))),
|
||||||
|
Some(InternalWorkerTurnOutcome::Completed(
|
||||||
|
WorkerRunResult::Cancelled | WorkerRunResult::RolledBack,
|
||||||
|
)) => Err(WorkerError::CompactCancelled),
|
||||||
|
Some(InternalWorkerTurnOutcome::Completed(WorkerRunResult::Paused)) => Err(
|
||||||
|
WorkerError::InvalidState("compactor Internal Worker paused unexpectedly".to_string()),
|
||||||
|
),
|
||||||
|
Some(InternalWorkerTurnOutcome::Failed { message }) => {
|
||||||
|
Err(WorkerError::InvalidState(message))
|
||||||
|
}
|
||||||
|
None => Err(WorkerError::InvalidState(
|
||||||
|
"compactor Internal Worker stopped without a typed turn outcome".to_string(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn automatic_compact_block_error(block: AutomaticCompactBlock) -> WorkerError {
|
fn automatic_compact_block_error(block: AutomaticCompactBlock) -> WorkerError {
|
||||||
match block {
|
match block {
|
||||||
AutomaticCompactBlock::Thrash => WorkerError::CompactThrash,
|
AutomaticCompactBlock::Thrash => WorkerError::CompactThrash,
|
||||||
@@ -8019,6 +8013,26 @@ pub enum WorkerError {
|
|||||||
#[error("compaction was cancelled")]
|
#[error("compaction was cancelled")]
|
||||||
CompactCancelled,
|
CompactCancelled,
|
||||||
|
|
||||||
|
/// The logical Run failed, and its terminal entry was already committed and
|
||||||
|
/// published through the session-log authority. Controllers must preserve the
|
||||||
|
/// error outcome without emitting a second generic error event.
|
||||||
|
#[error("{source}")]
|
||||||
|
RunFailureAlreadyDelivered {
|
||||||
|
#[source]
|
||||||
|
source: Box<WorkerError>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// The logical Run failed and its terminal entry could not be committed.
|
||||||
|
/// This remains unhandled so the Controller reports the run and persistence
|
||||||
|
/// failures together through its non-durable error channel.
|
||||||
|
#[error(
|
||||||
|
"run failed ({run_failure}) and its terminal record could not be persisted: {persist_error}"
|
||||||
|
)]
|
||||||
|
RunTerminalPersistence {
|
||||||
|
run_failure: Box<WorkerError>,
|
||||||
|
persist_error: Box<WorkerError>,
|
||||||
|
},
|
||||||
|
|
||||||
#[error("compact summary too large: {tokens} tokens exceeds max {max}")]
|
#[error("compact summary too large: {tokens} tokens exceeds max {max}")]
|
||||||
CompactSummaryTooLarge { tokens: u64, max: u64 },
|
CompactSummaryTooLarge { tokens: u64, max: u64 },
|
||||||
|
|
||||||
@@ -9104,6 +9118,83 @@ mod build_summary_prompt_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct FailRunTerminalStore {
|
||||||
|
inner: session_store::FsStore,
|
||||||
|
fail_run_terminal: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Store for FailRunTerminalStore {
|
||||||
|
fn append(
|
||||||
|
&self,
|
||||||
|
session_id: SessionId,
|
||||||
|
segment_id: SegmentId,
|
||||||
|
entry: &LogEntry,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
if matches!(entry, LogEntry::RunErrored { .. })
|
||||||
|
&& self.fail_run_terminal.swap(false, Ordering::SeqCst)
|
||||||
|
{
|
||||||
|
return Err(StoreError::Io(std::io::Error::other(
|
||||||
|
"synthetic terminal append failure",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
self.inner.append(session_id, segment_id, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_all(
|
||||||
|
&self,
|
||||||
|
session_id: SessionId,
|
||||||
|
segment_id: SegmentId,
|
||||||
|
) -> Result<Vec<LogEntry>, StoreError> {
|
||||||
|
self.inner.read_all(session_id, segment_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_sessions(&self) -> Result<Vec<SessionId>, StoreError> {
|
||||||
|
self.inner.list_sessions()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_segments(&self, session_id: SessionId) -> Result<Vec<SegmentId>, StoreError> {
|
||||||
|
self.inner.list_segments(session_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lookup_session_of(
|
||||||
|
&self,
|
||||||
|
segment_id: SegmentId,
|
||||||
|
) -> Result<Option<SessionId>, StoreError> {
|
||||||
|
self.inner.lookup_session_of(segment_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_segment(
|
||||||
|
&self,
|
||||||
|
session_id: SessionId,
|
||||||
|
segment_id: SegmentId,
|
||||||
|
entries: &[LogEntry],
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
self.inner.create_segment(session_id, segment_id, entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exists(&self, session_id: SessionId, segment_id: SegmentId) -> Result<bool, StoreError> {
|
||||||
|
self.inner.exists(session_id, segment_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_entry_count(
|
||||||
|
&self,
|
||||||
|
session_id: SessionId,
|
||||||
|
segment_id: SegmentId,
|
||||||
|
) -> Result<usize, StoreError> {
|
||||||
|
self.inner.read_entry_count(session_id, segment_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_trace(
|
||||||
|
&self,
|
||||||
|
session_id: SessionId,
|
||||||
|
segment_id: SegmentId,
|
||||||
|
entry: &session_store::TraceEntry,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
self.inner.append_trace(session_id, segment_id, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct NoopClient;
|
struct NoopClient;
|
||||||
|
|
||||||
@@ -9314,9 +9405,9 @@ mod build_summary_prompt_tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
worker.last_run_interrupted = true;
|
worker.last_run_interrupted = true;
|
||||||
|
|
||||||
worker.persist_compaction_run_failure(
|
worker
|
||||||
"mid-run compaction failed: summary unavailable".into(),
|
.persist_compaction_run_failure("mid-run compaction failed: summary unavailable".into())
|
||||||
);
|
.unwrap();
|
||||||
|
|
||||||
let entries = store
|
let entries = store
|
||||||
.read_all(worker.session_id(), worker.segment_id())
|
.read_all(worker.session_id(), worker.segment_id())
|
||||||
@@ -9348,6 +9439,92 @@ mod build_summary_prompt_tests {
|
|||||||
assert_eq!(worker.engine().active_run_turn_count(), None);
|
assert_eq!(worker.engine().active_run_turn_count(), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn failed_compaction_terminal_append_stays_unhandled_and_unpublished() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let store = FailRunTerminalStore {
|
||||||
|
inner: session_store::FsStore::new(dir.path().join("sessions")).unwrap(),
|
||||||
|
fail_run_terminal: Arc::new(AtomicBool::new(false)),
|
||||||
|
};
|
||||||
|
let mut worker = Worker::new(
|
||||||
|
minimal_manifest(),
|
||||||
|
Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(NoopClient),
|
||||||
|
store.clone(),
|
||||||
|
WorkerWorkspaceContext::no_workspace(),
|
||||||
|
WorkerFilesystemAuthority::None,
|
||||||
|
Scope::empty(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
worker.ensure_segment_head().await.unwrap();
|
||||||
|
store.fail_run_terminal.store(true, Ordering::SeqCst);
|
||||||
|
|
||||||
|
let error = worker.terminalize_compaction_run_failure(
|
||||||
|
"mid-run compaction failed: summary unavailable".into(),
|
||||||
|
WorkerError::CompactSummaryMissing,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
error,
|
||||||
|
WorkerError::RunTerminalPersistence {
|
||||||
|
run_failure,
|
||||||
|
persist_error,
|
||||||
|
} if matches!(*run_failure, WorkerError::CompactSummaryMissing)
|
||||||
|
&& persist_error.to_string().contains("synthetic terminal append failure")
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.read_all(worker.session_id(), worker.segment_id())
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.all(|entry| !matches!(entry, LogEntry::RunErrored { .. }))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
worker
|
||||||
|
.sink()
|
||||||
|
.subscribe_with_snapshot()
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.all(|entry| !matches!(entry, LogEntry::RunErrored { .. })),
|
||||||
|
"a failed append must not be published as durable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compaction_internal_turn_preserves_typed_stop_causes() {
|
||||||
|
use crate::internal_worker::InternalWorkerTurnOutcome;
|
||||||
|
|
||||||
|
let limit = ensure_compaction_internal_turn_succeeded(Some(
|
||||||
|
InternalWorkerTurnOutcome::Completed(WorkerRunResult::LimitReached),
|
||||||
|
))
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(limit, WorkerError::Engine(EngineError::Aborted(message)) if message.contains("turn limit"))
|
||||||
|
);
|
||||||
|
|
||||||
|
let interrupted = ensure_compaction_internal_turn_succeeded(Some(
|
||||||
|
InternalWorkerTurnOutcome::Completed(WorkerRunResult::Interrupted {
|
||||||
|
code: ErrorCode::ProviderError,
|
||||||
|
message: "summary provider failed".to_string(),
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(interrupted, WorkerError::Engine(EngineError::Aborted(message)) if message == "summary provider failed")
|
||||||
|
);
|
||||||
|
|
||||||
|
let cancelled = ensure_compaction_internal_turn_succeeded(Some(
|
||||||
|
InternalWorkerTurnOutcome::Completed(WorkerRunResult::Cancelled),
|
||||||
|
))
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(cancelled, WorkerError::CompactCancelled));
|
||||||
|
|
||||||
|
let unknown = ensure_compaction_internal_turn_succeeded(None).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(unknown, WorkerError::InvalidState(message) if message.contains("without a typed turn outcome"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn intentional_cancel_commits_a_non_failure_terminal() {
|
async fn intentional_cancel_commits_a_non_failure_terminal() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -991,12 +991,16 @@ async fn compact_failure_and_cancellation_emit_bounded_categories() {
|
|||||||
let encoded = serde_json::to_string(&failure.metric).unwrap();
|
let encoded = serde_json::to_string(&failure.metric).unwrap();
|
||||||
assert!(!encoded.contains("missing summary"));
|
assert!(!encoded.contains("missing summary"));
|
||||||
|
|
||||||
let (_cancel_tx, cancel_rx) = tokio::sync::watch::channel(true);
|
let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
|
||||||
|
cancel_tx.send(true).unwrap();
|
||||||
let error = worker
|
let error = worker
|
||||||
.manual_compact_with_cancel(cancel_rx)
|
.manual_compact_with_cancel(cancel_rx)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(error, worker::WorkerError::CompactCancelled));
|
assert!(
|
||||||
|
matches!(error, worker::WorkerError::CompactCancelled),
|
||||||
|
"unexpected cancellation error: {error:?}"
|
||||||
|
);
|
||||||
let metrics = session_metrics::read_session_metrics(worker.store(), session_id).unwrap();
|
let metrics = session_metrics::read_session_metrics(worker.store(), session_id).unwrap();
|
||||||
let cancelled = metrics
|
let cancelled = metrics
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1503,7 +1507,14 @@ async fn compacted_context_above_request_threshold_fails_before_provider_request
|
|||||||
.await
|
.await
|
||||||
.expect_err("unsafe compacted context must fail closed");
|
.expect_err("unsafe compacted context must fail closed");
|
||||||
|
|
||||||
assert!(matches!(error, WorkerError::CompactThrash));
|
assert!(
|
||||||
|
matches!(
|
||||||
|
error,
|
||||||
|
WorkerError::RunFailureAlreadyDelivered { ref source }
|
||||||
|
if matches!(**source, WorkerError::CompactThrash)
|
||||||
|
),
|
||||||
|
"unexpected error: {error:?}"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
call_count.load(Ordering::SeqCst),
|
call_count.load(Ordering::SeqCst),
|
||||||
3,
|
3,
|
||||||
@@ -1526,6 +1537,100 @@ async fn compacted_context_above_request_threshold_fails_before_provider_request
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn controller_does_not_repeat_a_persisted_mid_run_compaction_failure() {
|
||||||
|
let client = MockClient::new(vec![
|
||||||
|
grow_tool_use_events("grow-before-compaction", 1_000),
|
||||||
|
single_text_events("missing summary"),
|
||||||
|
single_text_events("still missing summary"),
|
||||||
|
]);
|
||||||
|
let (worker, store) = make_worker_with_manifest_and_store(MID_TURN_MANIFEST_TOML, client).await;
|
||||||
|
let session_id = worker.session_id();
|
||||||
|
let runtime_tmp = tempfile::tempdir().unwrap();
|
||||||
|
let bash_output_dir = runtime_tmp.path().join("bash-output");
|
||||||
|
let (handle, shutdown_receiver) =
|
||||||
|
WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut rx = handle.subscribe();
|
||||||
|
|
||||||
|
handle
|
||||||
|
.send(Method::submit_text(
|
||||||
|
protocol::new_submission_request_id(),
|
||||||
|
"trigger failing compaction",
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut observed = Vec::new();
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
||||||
|
loop {
|
||||||
|
let event = rx.recv().await.expect("event");
|
||||||
|
let idle = matches!(
|
||||||
|
event,
|
||||||
|
Event::WorkerState {
|
||||||
|
snapshot: protocol::WorkerStateSnapshot {
|
||||||
|
state: protocol::WorkerState::Idle,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
observed.push(event);
|
||||||
|
if idle {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("timeout waiting for failed compaction and Idle");
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
while let Ok(event) = rx.try_recv() {
|
||||||
|
observed.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
let delivered = handle
|
||||||
|
.committed_entries()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|entry| matches!(entry, LogEntry::RunErrored { .. }))
|
||||||
|
.count();
|
||||||
|
assert_eq!(delivered, 1, "the durable terminal is the only delivery");
|
||||||
|
assert!(
|
||||||
|
observed
|
||||||
|
.iter()
|
||||||
|
.all(|event| !matches!(event, Event::Error { .. })),
|
||||||
|
"Controller must not add a generic Error after durable delivery"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
observed.iter().all(|event| !matches!(
|
||||||
|
event,
|
||||||
|
Event::Alert(protocol::Alert {
|
||||||
|
level: protocol::AlertLevel::Error,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
)),
|
||||||
|
"mid-run terminal failure must not add an Error Alert"
|
||||||
|
);
|
||||||
|
|
||||||
|
let terminal_count = store
|
||||||
|
.list_segments(session_id)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|segment_id| store.read_all(session_id, segment_id).unwrap())
|
||||||
|
.filter(|entry| matches!(entry, LogEntry::RunErrored { .. }))
|
||||||
|
.count();
|
||||||
|
assert_eq!(terminal_count, 1);
|
||||||
|
|
||||||
|
handle
|
||||||
|
.send(Method::Shutdown {
|
||||||
|
command: protocol::WorkerCommandEnvelope::new(1),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(2), shutdown_receiver)
|
||||||
|
.await
|
||||||
|
.expect("controller shutdown timeout")
|
||||||
|
.expect("shutdown confirmation");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pre_run_compact_failure_clears_runtime_progress() {
|
async fn pre_run_compact_failure_clears_runtime_progress() {
|
||||||
// Only the first run has a response. Compaction will run the
|
// Only the first run has a response. Compaction will run the
|
||||||
@@ -1621,6 +1726,7 @@ async fn manual_compact_cancel_clears_progress_before_returning_idle() {
|
|||||||
.expect("send compact cancel");
|
.expect("send compact cancel");
|
||||||
let mut saw_interrupted = false;
|
let mut saw_interrupted = false;
|
||||||
let mut saw_idle = false;
|
let mut saw_idle = false;
|
||||||
|
let mut saw_cancel_error = false;
|
||||||
while !(saw_interrupted && saw_idle) {
|
while !(saw_interrupted && saw_idle) {
|
||||||
match tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
|
match tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
|
||||||
.await
|
.await
|
||||||
@@ -1630,6 +1736,11 @@ async fn manual_compact_cancel_clears_progress_before_returning_idle() {
|
|||||||
Event::CompactionProgress { compaction: None } => {
|
Event::CompactionProgress { compaction: None } => {
|
||||||
saw_interrupted = true;
|
saw_interrupted = true;
|
||||||
}
|
}
|
||||||
|
Event::Error { .. }
|
||||||
|
| Event::Alert(protocol::Alert {
|
||||||
|
level: protocol::AlertLevel::Error,
|
||||||
|
..
|
||||||
|
}) => saw_cancel_error = true,
|
||||||
Event::WorkerState { snapshot }
|
Event::WorkerState { snapshot }
|
||||||
if snapshot.catalog_status() == protocol::WorkerStatus::Idle =>
|
if snapshot.catalog_status() == protocol::WorkerStatus::Idle =>
|
||||||
{
|
{
|
||||||
@@ -1642,6 +1753,10 @@ async fn manual_compact_cancel_clears_progress_before_returning_idle() {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
assert!(
|
||||||
|
!saw_cancel_error,
|
||||||
|
"intentional manual cancellation must not be reported as an Error"
|
||||||
|
);
|
||||||
|
|
||||||
let compact = protocol::WorkerCommandEnvelope::new(3);
|
let compact = protocol::WorkerCommandEnvelope::new(3);
|
||||||
handle
|
handle
|
||||||
|
|||||||
@@ -2895,6 +2895,97 @@ async fn drain_until<F: FnMut(&Event) -> bool>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn idle_only_input(request_id: &str) -> Method {
|
||||||
|
Method::SubmitIfIdle {
|
||||||
|
submission_request_id: request_id.to_string(),
|
||||||
|
input: vec![protocol::Segment::text(request_id)],
|
||||||
|
source: protocol::AuthenticatedInputSource::Backend {
|
||||||
|
operation_id: request_id.to_string(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn idle_only_submit_rejects_admission_race_and_paused_without_persisting() {
|
||||||
|
let client = MockClient::sequential(vec![MockResponse::Hang(vec![
|
||||||
|
LlmEvent::text_block_start(0),
|
||||||
|
LlmEvent::text_delta(0, "partial"),
|
||||||
|
])]);
|
||||||
|
let handle = spawn_controller(make_worker(client.clone()).await).await;
|
||||||
|
let mut rx = handle.subscribe();
|
||||||
|
assert_eq!(handle.shared_state.catalog_status(), WorkerStatus::Idle);
|
||||||
|
// Both callers can observe Idle. Admission of the first request must make
|
||||||
|
// the second fail rather than silently scheduling a later turn.
|
||||||
|
handle.send(idle_only_input("first-idle")).await.unwrap();
|
||||||
|
handle.send(idle_only_input("racing-input")).await.unwrap();
|
||||||
|
let mut accepted_first = false;
|
||||||
|
assert!(
|
||||||
|
drain_until(&mut rx, std::time::Duration::from_secs(2), |event| {
|
||||||
|
match event {
|
||||||
|
Event::SubmissionAccepted {
|
||||||
|
submission_request_id,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert_eq!(submission_request_id, "first-idle");
|
||||||
|
accepted_first = true;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Event::SubmissionRejected {
|
||||||
|
submission_request_id,
|
||||||
|
message,
|
||||||
|
} => {
|
||||||
|
assert_eq!(submission_request_id, "racing-input");
|
||||||
|
assert!(message.contains("WorkerNotify"));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
);
|
||||||
|
assert!(accepted_first);
|
||||||
|
wait_for_status(&handle, WorkerStatus::Running).await;
|
||||||
|
handle
|
||||||
|
.send(Method::Pause {
|
||||||
|
command: worker_command(&handle),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
wait_for_status(&handle, WorkerStatus::Paused).await;
|
||||||
|
handle.send(idle_only_input("paused-input")).await.unwrap();
|
||||||
|
assert!(
|
||||||
|
drain_until(&mut rx, std::time::Duration::from_secs(2), |event| {
|
||||||
|
matches!(event, Event::SubmissionRejected { submission_request_id, message }
|
||||||
|
if submission_request_id == "paused-input" && message.contains("WorkerNotify"))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
);
|
||||||
|
assert_eq!(handle.shared_state.catalog_status(), WorkerStatus::Paused);
|
||||||
|
handle.send(Method::ListPendingSubmissions).await.unwrap();
|
||||||
|
assert!(
|
||||||
|
drain_until(&mut rx, std::time::Duration::from_secs(2), |event| {
|
||||||
|
if let Event::PendingSubmissionsChanged { pending } = event {
|
||||||
|
assert!(pending.submissions.is_empty());
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
);
|
||||||
|
assert_eq!(client.captured_requests().len(), 1);
|
||||||
|
let entries = serde_json::to_string(&handle.committed_entries()).unwrap();
|
||||||
|
assert!(!entries.contains("racing-input"));
|
||||||
|
assert!(!entries.contains("paused-input"));
|
||||||
|
handle
|
||||||
|
.send(Method::Cancel {
|
||||||
|
command: worker_command(&handle),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
wait_for_status(&handle, WorkerStatus::Idle).await;
|
||||||
|
}
|
||||||
|
|
||||||
/// Paused → Running → Idle. A notification accepted while Paused must not
|
/// Paused → Running → Idle. A notification accepted while Paused must not
|
||||||
/// auto-resume the Worker, and explicit Resume must inject it while preserving
|
/// auto-resume the Worker, and explicit Resume must inject it while preserving
|
||||||
/// the interrupted turn's history consistency.
|
/// the interrupted turn's history consistency.
|
||||||
|
|||||||
@@ -696,6 +696,8 @@ pub struct WorkerLifecycleResult {
|
|||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum WorkerInputKind {
|
pub enum WorkerInputKind {
|
||||||
User,
|
User,
|
||||||
|
/// Agent input that must never enter the human Submit queue.
|
||||||
|
UserIfIdle,
|
||||||
Notify,
|
Notify,
|
||||||
Compact,
|
Compact,
|
||||||
ListRewindTargets,
|
ListRewindTargets,
|
||||||
@@ -3037,6 +3039,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||||||
let input = EmbeddedWorkerInput {
|
let input = EmbeddedWorkerInput {
|
||||||
kind: match request.kind {
|
kind: match request.kind {
|
||||||
WorkerInputKind::User => EmbeddedWorkerInputKind::User,
|
WorkerInputKind::User => EmbeddedWorkerInputKind::User,
|
||||||
|
WorkerInputKind::UserIfIdle => EmbeddedWorkerInputKind::UserIfIdle,
|
||||||
WorkerInputKind::Notify => EmbeddedWorkerInputKind::Notify,
|
WorkerInputKind::Notify => EmbeddedWorkerInputKind::Notify,
|
||||||
WorkerInputKind::Compact => EmbeddedWorkerInputKind::Compact,
|
WorkerInputKind::Compact => EmbeddedWorkerInputKind::Compact,
|
||||||
WorkerInputKind::ListRewindTargets => EmbeddedWorkerInputKind::ListRewindTargets,
|
WorkerInputKind::ListRewindTargets => EmbeddedWorkerInputKind::ListRewindTargets,
|
||||||
@@ -5028,6 +5031,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
let input = EmbeddedWorkerInput {
|
let input = EmbeddedWorkerInput {
|
||||||
kind: match request.kind {
|
kind: match request.kind {
|
||||||
WorkerInputKind::User => EmbeddedWorkerInputKind::User,
|
WorkerInputKind::User => EmbeddedWorkerInputKind::User,
|
||||||
|
WorkerInputKind::UserIfIdle => EmbeddedWorkerInputKind::UserIfIdle,
|
||||||
WorkerInputKind::Notify => EmbeddedWorkerInputKind::Notify,
|
WorkerInputKind::Notify => EmbeddedWorkerInputKind::Notify,
|
||||||
WorkerInputKind::Compact => EmbeddedWorkerInputKind::Compact,
|
WorkerInputKind::Compact => EmbeddedWorkerInputKind::Compact,
|
||||||
WorkerInputKind::ListRewindTargets => EmbeddedWorkerInputKind::ListRewindTargets,
|
WorkerInputKind::ListRewindTargets => EmbeddedWorkerInputKind::ListRewindTargets,
|
||||||
@@ -5518,6 +5522,10 @@ fn sanitize_embedded_execution_message(
|
|||||||
) -> String {
|
) -> String {
|
||||||
let summary =
|
let summary =
|
||||||
format!("Embedded Worker execution backend rejected {operation:?} with {outcome:?}");
|
format!("Embedded Worker execution backend rejected {operation:?} with {outcome:?}");
|
||||||
|
sanitize_embedded_execution_detail(&summary, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_embedded_execution_detail(summary: &str, message: &str) -> String {
|
||||||
let mut redact_next = false;
|
let mut redact_next = false;
|
||||||
let detail = message
|
let detail = message
|
||||||
.split_whitespace()
|
.split_whitespace()
|
||||||
@@ -5554,7 +5562,7 @@ fn sanitize_embedded_execution_message(
|
|||||||
.join(" ");
|
.join(" ");
|
||||||
let detail = detail.trim();
|
let detail = detail.trim();
|
||||||
if detail.is_empty() {
|
if detail.is_empty() {
|
||||||
return summary;
|
return summary.to_string();
|
||||||
}
|
}
|
||||||
let truncated = detail.chars().count() > 512;
|
let truncated = detail.chars().count() > 512;
|
||||||
let mut detail = detail.chars().take(512).collect::<String>();
|
let mut detail = detail.chars().take(512).collect::<String>();
|
||||||
@@ -5576,11 +5584,18 @@ fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnosti
|
|||||||
HostDiagnosticSeverity::Warning,
|
HostDiagnosticSeverity::Warning,
|
||||||
"Embedded Runtime worker was not found".to_string(),
|
"Embedded Runtime worker was not found".to_string(),
|
||||||
),
|
),
|
||||||
EmbeddedRuntimeError::WorkerExecutionUnavailable { .. }
|
EmbeddedRuntimeError::WorkerExecutionUnavailable { message, .. } => diagnostic(
|
||||||
| EmbeddedRuntimeError::ExecutionBackendUnavailable { .. } => diagnostic(
|
|
||||||
"embedded_worker_execution_unavailable",
|
"embedded_worker_execution_unavailable",
|
||||||
HostDiagnosticSeverity::Warning,
|
HostDiagnosticSeverity::Warning,
|
||||||
"Embedded Worker has no execution backend attached".to_string(),
|
sanitize_embedded_execution_detail("Embedded Worker execution is unavailable", message),
|
||||||
|
),
|
||||||
|
EmbeddedRuntimeError::ExecutionBackendUnavailable { message } => diagnostic(
|
||||||
|
"embedded_worker_execution_unavailable",
|
||||||
|
HostDiagnosticSeverity::Warning,
|
||||||
|
sanitize_embedded_execution_detail(
|
||||||
|
"Embedded Runtime execution backend is unavailable",
|
||||||
|
message,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
EmbeddedRuntimeError::WorkerExecutionRejected {
|
EmbeddedRuntimeError::WorkerExecutionRejected {
|
||||||
operation,
|
operation,
|
||||||
@@ -6051,6 +6066,58 @@ mod tests {
|
|||||||
assert_eq!(result.diagnostics.len(), 1);
|
assert_eq!(result.diagnostics.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedded_execution_unavailable_preserves_the_actual_reason() {
|
||||||
|
for reason in [
|
||||||
|
"worker stop reconciliation is pending",
|
||||||
|
"worker restore reconciliation is pending",
|
||||||
|
"stopped worker requires an explicit restore",
|
||||||
|
"worker has no live execution handle",
|
||||||
|
"runtime has no execution backend",
|
||||||
|
] {
|
||||||
|
let diagnostic =
|
||||||
|
embedded_runtime_diagnostic(&EmbeddedRuntimeError::WorkerExecutionUnavailable {
|
||||||
|
worker_id: EmbeddedWorkerId::now_v7(),
|
||||||
|
message: reason.to_string(),
|
||||||
|
});
|
||||||
|
assert_eq!(diagnostic.code, "embedded_worker_execution_unavailable");
|
||||||
|
assert_eq!(diagnostic.severity, HostDiagnosticSeverity::Warning);
|
||||||
|
assert_eq!(
|
||||||
|
diagnostic.message,
|
||||||
|
format!("Embedded Worker execution is unavailable: {reason}")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let diagnostic =
|
||||||
|
embedded_runtime_diagnostic(&EmbeddedRuntimeError::ExecutionBackendUnavailable {
|
||||||
|
message: "Runtime has no execution backend".to_string(),
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
diagnostic.message,
|
||||||
|
"Embedded Runtime execution backend is unavailable: Runtime has no execution backend"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedded_execution_unavailable_detail_remains_bounded_and_redacted() {
|
||||||
|
let diagnostic = embedded_runtime_diagnostic(
|
||||||
|
&EmbeddedRuntimeError::WorkerExecutionUnavailable {
|
||||||
|
worker_id: EmbeddedWorkerId::now_v7(),
|
||||||
|
message: format!(
|
||||||
|
"worker stop reconciliation is pending /private/runtime Bearer sensitive-value {}",
|
||||||
|
"x".repeat(600)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
diagnostic
|
||||||
|
.message
|
||||||
|
.contains("stop reconciliation is pending")
|
||||||
|
);
|
||||||
|
assert!(!diagnostic.message.contains("/private/runtime"));
|
||||||
|
assert!(!diagnostic.message.contains("sensitive-value"));
|
||||||
|
assert!(diagnostic.message.chars().count() < 600);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn embedded_delete_persistence_failure_diagnostic_is_bounded_and_path_free() {
|
fn embedded_delete_persistence_failure_diagnostic_is_bounded_and_path_free() {
|
||||||
let diagnostic =
|
let diagnostic =
|
||||||
@@ -6337,6 +6404,28 @@ mod tests {
|
|||||||
assert_eq!(delivered.content, archive.content);
|
assert_eq!(delivered.content, archive.content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_input_contract_preserves_every_input_kind() {
|
||||||
|
for kind in [
|
||||||
|
EmbeddedWorkerInputKind::User,
|
||||||
|
EmbeddedWorkerInputKind::UserIfIdle,
|
||||||
|
EmbeddedWorkerInputKind::Notify,
|
||||||
|
EmbeddedWorkerInputKind::Compact,
|
||||||
|
EmbeddedWorkerInputKind::ListRewindTargets,
|
||||||
|
EmbeddedWorkerInputKind::RegisterPeer,
|
||||||
|
] {
|
||||||
|
let input = EmbeddedWorkerInput {
|
||||||
|
kind,
|
||||||
|
content: "contract test".to_string(),
|
||||||
|
submission_request_id: Some("contract-request".to_string()),
|
||||||
|
segments: Some(vec![protocol::Segment::text("contract test")]),
|
||||||
|
};
|
||||||
|
let wire: runtime_api::WorkerInput = runtime_contract_convert(input.clone()).unwrap();
|
||||||
|
let decoded: EmbeddedWorkerInput = runtime_contract_convert(wire).unwrap();
|
||||||
|
assert_eq!(decoded, input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn remote_profile_source_uses_workspace_config_archive_reference() {
|
fn remote_profile_source_uses_workspace_config_archive_reference() {
|
||||||
let request = embedded_spawn_request();
|
let request = embedded_spawn_request();
|
||||||
|
|||||||
@@ -17659,7 +17659,30 @@ async fn send_known_worker_input(
|
|||||||
&path.worker,
|
&path.worker,
|
||||||
permission,
|
permission,
|
||||||
)?;
|
)?;
|
||||||
scoped_send_runtime_worker_input(State(api), AxumPath(path), Json(request)).await
|
let mut request = worker_input_request_from_api(request)?;
|
||||||
|
let idle_only = request.kind == WorkerInputKind::User;
|
||||||
|
if idle_only {
|
||||||
|
request.kind = WorkerInputKind::UserIfIdle;
|
||||||
|
}
|
||||||
|
let Json(result) =
|
||||||
|
dispatch_runtime_worker_input(api, path.worker.runtime_id, path.worker.worker_id, request)?;
|
||||||
|
if result.state != server_api::WorkerOperationState::Accepted {
|
||||||
|
let detail = result
|
||||||
|
.diagnostics
|
||||||
|
.iter()
|
||||||
|
.map(|diagnostic| diagnostic.message.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("; ");
|
||||||
|
let message = if idle_only {
|
||||||
|
format!(
|
||||||
|
"WorkerSendInput requires an Idle Worker and never queues input. Use WorkerNotify for information about work already in progress. {detail}"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
detail
|
||||||
|
};
|
||||||
|
return Err(Error::InvalidInput(message).into());
|
||||||
|
}
|
||||||
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cancel_known_worker(
|
async fn cancel_known_worker(
|
||||||
@@ -24426,6 +24449,15 @@ async fn send_runtime_worker_input(
|
|||||||
Json(request): Json<server_api::RuntimeWorkerInputRequest>,
|
Json(request): Json<server_api::RuntimeWorkerInputRequest>,
|
||||||
) -> ApiResult<Json<server_api::RuntimeWorkerInputResult>> {
|
) -> ApiResult<Json<server_api::RuntimeWorkerInputResult>> {
|
||||||
let request = worker_input_request_from_api(request)?;
|
let request = worker_input_request_from_api(request)?;
|
||||||
|
dispatch_runtime_worker_input(api, runtime_id, worker_id, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatch_runtime_worker_input(
|
||||||
|
api: WorkspaceApi,
|
||||||
|
runtime_id: String,
|
||||||
|
worker_id: String,
|
||||||
|
request: WorkerInputRequest,
|
||||||
|
) -> ApiResult<Json<server_api::RuntimeWorkerInputResult>> {
|
||||||
let worker = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?;
|
let worker = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?;
|
||||||
if api
|
if api
|
||||||
.store
|
.store
|
||||||
@@ -24535,7 +24567,9 @@ pub(crate) fn authorize_browser_worker_method(
|
|||||||
message,
|
message,
|
||||||
source: source.clone(),
|
source: source.clone(),
|
||||||
}),
|
}),
|
||||||
protocol::Method::SubmitTracked { .. } | protocol::Method::NotifyTracked { .. } => {
|
protocol::Method::SubmitTracked { .. }
|
||||||
|
| protocol::Method::SubmitIfIdle { .. }
|
||||||
|
| protocol::Method::NotifyTracked { .. } => {
|
||||||
Err("authenticated Worker input source is server-owned")
|
Err("authenticated Worker input source is server-owned")
|
||||||
}
|
}
|
||||||
other => Ok(other),
|
other => Ok(other),
|
||||||
@@ -29862,6 +29896,7 @@ mod tests {
|
|||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
struct WorkdirlessFixtureRuntime {
|
struct WorkdirlessFixtureRuntime {
|
||||||
|
input_requests: Arc<Mutex<Vec<WorkerInputRequest>>>,
|
||||||
workers: Arc<Mutex<Vec<InternalWorkerSummary>>>,
|
workers: Arc<Mutex<Vec<InternalWorkerSummary>>>,
|
||||||
spawn_requests: Arc<Mutex<Vec<WorkerSpawnRequest>>>,
|
spawn_requests: Arc<Mutex<Vec<WorkerSpawnRequest>>>,
|
||||||
reject_next_spawn: Arc<Mutex<bool>>,
|
reject_next_spawn: Arc<Mutex<bool>>,
|
||||||
@@ -29988,6 +30023,30 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl crate::hosts::WorkspaceWorkerRuntime for WorkdirlessFixtureRuntime {
|
impl crate::hosts::WorkspaceWorkerRuntime for WorkdirlessFixtureRuntime {
|
||||||
|
fn send_input(
|
||||||
|
&self,
|
||||||
|
worker_id: &str,
|
||||||
|
request: WorkerInputRequest,
|
||||||
|
) -> crate::hosts::WorkerInputResult {
|
||||||
|
let idle_only = request.kind == WorkerInputKind::UserIfIdle;
|
||||||
|
self.input_requests.lock().unwrap().push(request);
|
||||||
|
crate::hosts::WorkerInputResult {
|
||||||
|
state: if idle_only {
|
||||||
|
InternalWorkerOperationState::Rejected
|
||||||
|
} else {
|
||||||
|
InternalWorkerOperationState::Accepted
|
||||||
|
},
|
||||||
|
disposition: if idle_only {
|
||||||
|
crate::hosts::WorkerInputDisposition::Rejected
|
||||||
|
} else {
|
||||||
|
crate::hosts::WorkerInputDisposition::Accepted
|
||||||
|
},
|
||||||
|
worker: RuntimeWorkerRef::new(Self::RUNTIME_ID, worker_id),
|
||||||
|
runtime_run_id: None,
|
||||||
|
diagnostics: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn runtime_id(&self) -> &str {
|
fn runtime_id(&self) -> &str {
|
||||||
Self::RUNTIME_ID
|
Self::RUNTIME_ID
|
||||||
}
|
}
|
||||||
@@ -33327,6 +33386,76 @@ mod tests {
|
|||||||
assert_eq!(current.assignment_id, "existing-coder-assignment");
|
assert_eq!(current.assignment_id, "existing-coder-assignment");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn worker_control_input_is_idle_only_and_returns_rejection_as_http_error() {
|
||||||
|
let fixture = guarded_spawn_fixture().await;
|
||||||
|
let response = post_guarded_spawn(
|
||||||
|
&fixture,
|
||||||
|
guarded_spawn_payload(&fixture, "idle-only-control"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||||
|
let created: BrowserCreateWorkerResponse = serde_json::from_slice(&bytes).unwrap();
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
"x-yoi-runtime-id",
|
||||||
|
fixture.controller.runtime_id.parse().unwrap(),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
"x-yoi-worker-id",
|
||||||
|
fixture.controller.worker_id.parse().unwrap(),
|
||||||
|
);
|
||||||
|
let path = || ScopedRuntimeWorkerPath {
|
||||||
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
|
worker: RuntimeWorkerRef::new(&created.runtime_id, &created.worker_id),
|
||||||
|
};
|
||||||
|
let request = |kind: &str| server_api::RuntimeWorkerInputRequest {
|
||||||
|
kind: Some(kind.to_string()),
|
||||||
|
content: "follow-up".to_string(),
|
||||||
|
segments: None,
|
||||||
|
};
|
||||||
|
let error = send_known_worker_input(
|
||||||
|
State(fixture.api.clone()),
|
||||||
|
AxumPath(path()),
|
||||||
|
headers.clone(),
|
||||||
|
Json(request("user")),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
let response = error.into_response();
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||||
|
assert!(String::from_utf8_lossy(&bytes).contains("WorkerNotify"));
|
||||||
|
// Machine Notify is unchanged; the human endpoint must still use User.
|
||||||
|
let Json(notify) = send_known_worker_input(
|
||||||
|
State(fixture.api.clone()),
|
||||||
|
AxumPath(path()),
|
||||||
|
headers,
|
||||||
|
Json(request("notify")),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(notify.state, server_api::WorkerOperationState::Accepted);
|
||||||
|
let Json(human) = scoped_send_runtime_worker_input(
|
||||||
|
State(fixture.api.clone()),
|
||||||
|
AxumPath(path()),
|
||||||
|
Json(request("user")),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(human.state, server_api::WorkerOperationState::Accepted);
|
||||||
|
let requests = fixture.runtime.input_requests.lock().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
requests.iter().map(|r| r.kind.clone()).collect::<Vec<_>>(),
|
||||||
|
vec![
|
||||||
|
WorkerInputKind::UserIfIdle,
|
||||||
|
WorkerInputKind::Notify,
|
||||||
|
WorkerInputKind::User
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn worker_control_spawn_retry_converges_on_one_worker_and_one_grant() {
|
async fn worker_control_spawn_retry_converges_on_one_worker_and_one_grant() {
|
||||||
let workspace = tempfile::tempdir().unwrap();
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ compaction = {
|
|||||||
threshold = 240000;
|
threshold = 240000;
|
||||||
request_threshold = 270000;
|
request_threshold = 270000;
|
||||||
worker_context_max_tokens = 100000;
|
worker_context_max_tokens = 100000;
|
||||||
|
worker_max_turns = 100;
|
||||||
|
model = { ref = "codex-oauth/gpt-6-luna"; };
|
||||||
};
|
};
|
||||||
|
|
||||||
feature = {
|
feature = {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
When SubWorker-management tools are available, create direct children with `SubWorkerSpawn`, discover them with `WorkerList`, continue them with `WorkerSendInput`, and release their delegated authority with `WorkerStop`. Pass the exact `{ kind: "sub_worker", name }` subject returned by `WorkerList`; do not invent direct-only aliases. SubWorker notifications are background signals for the parent Worker to handle at a natural stopping point. Do not ignore routine follow-up, but do not interrupt the current user request unnecessarily.
|
When SubWorker-management tools are available, create direct children with `SubWorkerSpawn`, discover them with `WorkerList`, continue them with `WorkerSendInput`, and release their delegated authority with `WorkerStop`. Pass the exact `{ kind: "sub_worker", name }` subject returned by `WorkerList`; do not invent direct-only aliases. SubWorker notifications are background signals for the parent Worker to handle at a natural stopping point. Do not ignore routine follow-up, but do not interrupt the current user request unnecessarily.
|
||||||
|
|
||||||
For Runtime Workers, `WorkerSendInput` submits user input: Idle Workers can start a fresh turn, while Running or Paused Workers durably queue it for a later turn. Queueing does not implicitly resume a paused run; Cancel/error retains pending input until explicitly continued. Direct SubWorkers still require Idle for `WorkerSendInput`. When `WorkerNotify` is available and information should be incorporated into work already in progress, use `WorkerNotify` instead; notifications are advisory and do not create a queued Submit.
|
`WorkerSendInput` requires an Idle Worker and never queues input. Non-Idle Workers reject it; when information should be incorporated into work already in progress, use `WorkerNotify` instead. Notifications are advisory and do not create a queued Submit. Direct SubWorkers also require Idle for `WorkerSendInput`; do not send follow-up input until they are Idle. The Submit queue is for human-controlled Web/TUI interaction, not Orchestrator scheduling. Do not retry SendInput in a polling loop or cancel/pause work to bypass this restriction.
|
||||||
|
|
||||||
The parent Worker does not need to keep a turn open or call tools solely to wait for a notification. Do not use `sleep` or polling loops just to wait for SubWorker output; if there is no useful immediate work, return control and handle the SubWorker when notified or when the user next asks.
|
The parent Worker does not need to keep a turn open or call tools solely to wait for a notification. Do not use `sleep` or polling loops just to wait for SubWorker output; if there is no useful immediate work, return control and handle the SubWorker when notified or when the user next asks.
|
||||||
|
|
||||||
|
|||||||
@@ -208,6 +208,15 @@ Build `web/workspace` before capture. No real keys, accounts, or deletion reques
|
|||||||
keyboard disclosure/focus return, full public bundle access, scoped errors, and exact-name deletion
|
keyboard disclosure/focus return, full public bundle access, scoped errors, and exact-name deletion
|
||||||
confirmation. Interaction screenshots are saved under `target/web-ux/settings-interactions`.
|
confirmation. Interaction screenshots are saved under `target/web-ux/settings-interactions`.
|
||||||
|
|
||||||
|
## Console pending input fixture
|
||||||
|
|
||||||
|
`scenarios/console-pending.json` uses the owned Console history fixture with `?pending=both`,
|
||||||
|
`queue`, `notifications`, `legacy`, or `empty`. Each state includes a task for typography comparison.
|
||||||
|
It captures 1440px, 768px, and 390px; `browser-tests/console_pending_shell_test.ts` additionally checks
|
||||||
|
320px and both themes, independent group visibility, full-width single groups, uniform 16px rows,
|
||||||
|
monospace inheritance, truncation, and retained-session cancellation gating. Live revision-fenced
|
||||||
|
cancellation and state transitions are covered by Console component tests. No real Worker is used.
|
||||||
|
|
||||||
## Adding a scenario
|
## Adding a scenario
|
||||||
|
|
||||||
1. Name the concrete user task and expected data state; do not write “looks correct”.
|
1. Name the concrete user task and expected data state; do not write “looks correct”.
|
||||||
|
|||||||
@@ -174,17 +174,30 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
|||||||
url.pathname ===
|
url.pathname ===
|
||||||
`/api/w/${workspaceId}/runtimes/${runtimeId}/workers/${workerId}/session`
|
`/api/w/${workspaceId}/runtimes/${runtimeId}/workers/${workerId}/session`
|
||||||
) {
|
) {
|
||||||
|
const pendingMode = new URL(request.headers.get("referer") ?? request.url).searchParams.get("pending");
|
||||||
|
const hasQueue = pendingMode === "both" || pendingMode === "queue";
|
||||||
|
const hasNotifications = pendingMode === "both" || pendingMode === "notifications" || pendingMode === "legacy";
|
||||||
|
const submissions = hasQueue ? [
|
||||||
|
{ submission_id: "queued-1", preview: "First queued input", accepted_at_ms: 1, segment_count: 1, byte_len: 18 },
|
||||||
|
{ submission_id: "queued-2", preview: "Long queued input — " + "preview text ".repeat(30), accepted_at_ms: 2, segment_count: 1, byte_len: 500 },
|
||||||
|
] : [];
|
||||||
|
const taskEntries = pendingMode ? [{
|
||||||
|
kind: "tool_call", entry_id: "fixture-task", timestamp: 13, provenance: "model_output",
|
||||||
|
call_id: "fixture-task", name: "TaskCreate",
|
||||||
|
arguments: JSON.stringify({ subject: "Inspect pending input layout", description: "Synthetic visual fixture" }),
|
||||||
|
}] : [];
|
||||||
return json({
|
return json({
|
||||||
availability: "retained_snapshot",
|
availability: "retained_snapshot",
|
||||||
identity: { session_id: "session-a", segment_id: "segment-a", entry_count: 36 },
|
identity: { session_id: "session-a", segment_id: "segment-a", entry_count: 36 },
|
||||||
snapshot: {
|
snapshot: {
|
||||||
pending_submissions: {
|
pending_submissions: {
|
||||||
revision: 0,
|
revision: 0,
|
||||||
notification_count: 0,
|
notification_count: hasNotifications ? 2 : 0,
|
||||||
head_id: null,
|
...(hasNotifications && pendingMode !== "legacy" ? { notification_previews: ["First notification", "Long notification — " + "notification text ".repeat(30)] } : {}),
|
||||||
submissions: [],
|
head_id: submissions[0]?.submission_id ?? null,
|
||||||
|
submissions,
|
||||||
},
|
},
|
||||||
entries: [7, 8, 9, 10, 11, 12].flatMap(historyTurnEntries),
|
entries: [...[7, 8, 9, 10, 11, 12].flatMap(historyTurnEntries), ...taskEntries],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/// <reference lib="dom" />
|
||||||
|
import { assert, assertEquals } from "@std/assert";
|
||||||
|
import { dirname, fromFileUrl, join } from "@std/path";
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
|
||||||
|
const root = join(dirname(fromFileUrl(import.meta.url)), "../../..");
|
||||||
|
Deno.test("Console pending scopes are independent and match Tasks row typography", async () => {
|
||||||
|
const build = await new Deno.Command(Deno.execPath(), {
|
||||||
|
args: ["task", "build"],
|
||||||
|
cwd: join(root, "web/workspace"),
|
||||||
|
stdout: "piped",
|
||||||
|
stderr: "piped",
|
||||||
|
}).output();
|
||||||
|
assert(build.success, new TextDecoder().decode(build.stderr));
|
||||||
|
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
||||||
|
const port = (listener.addr as Deno.NetAddr).port;
|
||||||
|
listener.close();
|
||||||
|
const url = `http://127.0.0.1:${port}`;
|
||||||
|
const server = new Deno.Command(Deno.execPath(), {
|
||||||
|
args: [
|
||||||
|
"run",
|
||||||
|
"--allow-net",
|
||||||
|
"--allow-read",
|
||||||
|
join(root, "tools/web-ux/browser-tests/console_history_fixture_server.ts"),
|
||||||
|
String(port),
|
||||||
|
join(root, "web/workspace/build"),
|
||||||
|
],
|
||||||
|
stdout: "null",
|
||||||
|
stderr: "null",
|
||||||
|
}).spawn();
|
||||||
|
try {
|
||||||
|
let started = false;
|
||||||
|
for (let attempt = 0; attempt < 100; attempt++) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${url}/health`);
|
||||||
|
await response.body?.cancel();
|
||||||
|
if (response.ok) {
|
||||||
|
started = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch { /* owned fixture is starting */ }
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
assert(started);
|
||||||
|
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("requestfailed", (request) => errors.push(request.url()));
|
||||||
|
for (const mode of ["both", "queue", "notifications", "legacy", "empty"]) {
|
||||||
|
await page.goto(
|
||||||
|
`${url}/w/console-history-review/workers/W-900-console-fixture/console?pending=${mode}`,
|
||||||
|
);
|
||||||
|
await page.locator(".task-mini-row").waitFor();
|
||||||
|
const queue = mode === "both" || mode === "queue";
|
||||||
|
const notifications = ["both", "notifications", "legacy"].includes(mode);
|
||||||
|
assertEquals(
|
||||||
|
await page.getByRole("group", { name: "Queued inputs", exact: true }).count(),
|
||||||
|
queue ? 1 : 0,
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
await page.getByRole("group", { name: "Notifications", exact: true }).count(),
|
||||||
|
notifications ? 1 : 0,
|
||||||
|
);
|
||||||
|
assertEquals(await page.getByRole("heading", { name: "0 Queued" }).count(), 0);
|
||||||
|
const region = page.getByRole("region", { name: "Pending activations" });
|
||||||
|
assertEquals(await region.count(), mode === "empty" ? 0 : 1);
|
||||||
|
if (mode === "empty") continue;
|
||||||
|
const geometry = await region.evaluate((region) => {
|
||||||
|
const task = document.querySelector<HTMLElement>(".task-mini-row")!;
|
||||||
|
const taskStyle = getComputedStyle(task);
|
||||||
|
const metrics = (node: Element) => {
|
||||||
|
const style = getComputedStyle(node);
|
||||||
|
return {
|
||||||
|
height: node.getBoundingClientRect().height,
|
||||||
|
family: style.fontFamily,
|
||||||
|
size: style.fontSize,
|
||||||
|
line: style.lineHeight,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
task: metrics(task),
|
||||||
|
rows: [...region.querySelectorAll("h2, li")].map(metrics),
|
||||||
|
lists: [...region.querySelectorAll("ol")].map((list) => ({
|
||||||
|
margin: getComputedStyle(list).margin,
|
||||||
|
padding: getComputedStyle(list).padding,
|
||||||
|
gap: getComputedStyle(list).rowGap,
|
||||||
|
})),
|
||||||
|
columns: [...region.querySelectorAll(".pending-column")].map((column) =>
|
||||||
|
column.getBoundingClientRect().toJSON()
|
||||||
|
),
|
||||||
|
width: region.getBoundingClientRect().width,
|
||||||
|
pageOverflow: document.documentElement.scrollWidth > innerWidth,
|
||||||
|
mainOverflow: document.querySelector("main")!.scrollWidth >
|
||||||
|
document.querySelector("main")!.clientWidth,
|
||||||
|
previews: [...region.querySelectorAll(".pending-submission-preview")].map((
|
||||||
|
preview,
|
||||||
|
) => ({
|
||||||
|
title: preview.getAttribute("title"),
|
||||||
|
whitespace: getComputedStyle(preview).whiteSpace,
|
||||||
|
overflow: getComputedStyle(preview).textOverflow,
|
||||||
|
})),
|
||||||
|
expectedGap: getComputedStyle(task.closest(".task-mini")!).rowGap,
|
||||||
|
taskMono: taskStyle.fontFamily.includes("mono"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assertEquals(geometry.task.height, 16);
|
||||||
|
assertEquals(geometry.taskMono, true);
|
||||||
|
for (const row of geometry.rows) {
|
||||||
|
assertEquals(
|
||||||
|
row,
|
||||||
|
geometry.task,
|
||||||
|
`${width}px/${mode}: heading and preview rows must match Tasks`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const list of geometry.lists) {
|
||||||
|
assertEquals(list, { margin: "0px", padding: "0px", gap: geometry.expectedGap });
|
||||||
|
}
|
||||||
|
if (mode === "both") assertEquals(geometry.columns[0].y, geometry.columns[1].y);
|
||||||
|
else {assertEquals(
|
||||||
|
geometry.columns[0].width,
|
||||||
|
geometry.width,
|
||||||
|
"a lone scope must not reserve an empty column",
|
||||||
|
);}
|
||||||
|
assertEquals(geometry.pageOverflow, false);
|
||||||
|
assertEquals(geometry.mainOverflow, false);
|
||||||
|
for (const preview of geometry.previews) {
|
||||||
|
assert(preview.title);
|
||||||
|
assertEquals(preview.whitespace, "nowrap");
|
||||||
|
assertEquals(preview.overflow, "ellipsis");
|
||||||
|
}
|
||||||
|
if (queue) {
|
||||||
|
const cancel = page.getByRole("button", { name: "Cancel queued input 1" });
|
||||||
|
assertEquals((await cancel.boundingBox())!.height, 16);
|
||||||
|
assertEquals(
|
||||||
|
await cancel.isDisabled(),
|
||||||
|
true,
|
||||||
|
"retained sessions cannot mutate the queue",
|
||||||
|
);
|
||||||
|
await page.locator(".pending-queue li").first().hover();
|
||||||
|
assertEquals(
|
||||||
|
await cancel.evaluate((button) => getComputedStyle(button).opacity),
|
||||||
|
"1",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mode === "legacy") {
|
||||||
|
await page.getByText("2 pending · Preview unavailable", { exact: true }).waitFor();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals(errors, []);
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
server.kill("SIGTERM");
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Deno.errors.NotFound)) throw error;
|
||||||
|
}
|
||||||
|
await server.status;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -97,6 +97,16 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
|||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Keyboard focus can preload the Tickets route before navigation.
|
||||||
|
if (path === "/orchestrator") {
|
||||||
|
return json({
|
||||||
|
workspace_id: workspaceId,
|
||||||
|
online: false,
|
||||||
|
disposition: "offline",
|
||||||
|
worker: null,
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
if (path === "/working-directories") {
|
if (path === "/working-directories") {
|
||||||
return json({ workspace_id: workspaceId, items: [], diagnostics: [] });
|
return json({ workspace_id: workspaceId, items: [], diagnostics: [] });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ Deno.test("production Home dashboard isolates failures, shows authoritative reco
|
|||||||
});
|
});
|
||||||
page.on("requestfailed", (request) => errors.push(request.url()));
|
page.on("requestfailed", (request) => errors.push(request.url()));
|
||||||
page.on("request", (request) => requests.push(request.url()));
|
page.on("request", (request) => requests.push(request.url()));
|
||||||
|
page.on("response", (response) => {
|
||||||
|
if (response.status() >= 400) errors.push(`${response.status()} ${response.url()}`);
|
||||||
|
});
|
||||||
for (const workspace of ["home-owner", "home-member", "home-long"]) {
|
for (const workspace of ["home-owner", "home-member", "home-long"]) {
|
||||||
await page.goto(`${url}/w/${workspace}`);
|
await page.goto(`${url}/w/${workspace}`);
|
||||||
await ready(page);
|
await ready(page);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts",
|
"web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts",
|
||||||
"check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts",
|
"check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts",
|
||||||
"test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests",
|
"test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests",
|
||||||
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts"
|
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/console_pending_shell_test.ts"
|
||||||
},
|
},
|
||||||
"fmt": {
|
"fmt": {
|
||||||
"lineWidth": 100,
|
"lineWidth": 100,
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "console-pending",
|
||||||
|
"title": "Console pending inputs and notifications",
|
||||||
|
"baseUrl": "http://127.0.0.1:15283",
|
||||||
|
"colorScheme": "light",
|
||||||
|
"reducedMotion": "reduce",
|
||||||
|
"personas": [
|
||||||
|
{
|
||||||
|
"id": "fixture",
|
||||||
|
"label": "Retained Console fixture",
|
||||||
|
"auth": {
|
||||||
|
"kind": "anonymous"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"viewports": [
|
||||||
|
{
|
||||||
|
"label": "desktop",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "narrow",
|
||||||
|
"width": 768,
|
||||||
|
"height": 900
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "mobile",
|
||||||
|
"width": 390,
|
||||||
|
"height": 844
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"id": "both",
|
||||||
|
"label": "both",
|
||||||
|
"path": "/w/console-history-review/workers/W-900-console-fixture/console?pending=both",
|
||||||
|
"goal": "Only populated scopes appear; headings and previews match Tasks typography and row height.",
|
||||||
|
"dataState": "Synthetic retained Console: both; two long/short previews when present; one task. Cancellation is unavailable on retained sessions.",
|
||||||
|
"ready": {
|
||||||
|
"kind": "selector",
|
||||||
|
"selector": ".task-mini-row"
|
||||||
|
},
|
||||||
|
"capturePoints": [
|
||||||
|
{
|
||||||
|
"id": "initial",
|
||||||
|
"label": "Pending input layout"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "queue",
|
||||||
|
"label": "queue",
|
||||||
|
"path": "/w/console-history-review/workers/W-900-console-fixture/console?pending=queue",
|
||||||
|
"goal": "Only populated scopes appear; headings and previews match Tasks typography and row height.",
|
||||||
|
"dataState": "Synthetic retained Console: queue; two long/short previews when present; one task. Cancellation is unavailable on retained sessions.",
|
||||||
|
"ready": {
|
||||||
|
"kind": "selector",
|
||||||
|
"selector": ".task-mini-row"
|
||||||
|
},
|
||||||
|
"capturePoints": [
|
||||||
|
{
|
||||||
|
"id": "initial",
|
||||||
|
"label": "Pending input layout"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "notifications",
|
||||||
|
"label": "notifications",
|
||||||
|
"path": "/w/console-history-review/workers/W-900-console-fixture/console?pending=notifications",
|
||||||
|
"goal": "Only populated scopes appear; headings and previews match Tasks typography and row height.",
|
||||||
|
"dataState": "Synthetic retained Console: notifications; two long/short previews when present; one task. Cancellation is unavailable on retained sessions.",
|
||||||
|
"ready": {
|
||||||
|
"kind": "selector",
|
||||||
|
"selector": ".task-mini-row"
|
||||||
|
},
|
||||||
|
"capturePoints": [
|
||||||
|
{
|
||||||
|
"id": "initial",
|
||||||
|
"label": "Pending input layout"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "legacy",
|
||||||
|
"label": "legacy",
|
||||||
|
"path": "/w/console-history-review/workers/W-900-console-fixture/console?pending=legacy",
|
||||||
|
"goal": "Only populated scopes appear; headings and previews match Tasks typography and row height.",
|
||||||
|
"dataState": "Synthetic retained Console: legacy; two long/short previews when present; one task. Cancellation is unavailable on retained sessions.",
|
||||||
|
"ready": {
|
||||||
|
"kind": "selector",
|
||||||
|
"selector": ".task-mini-row"
|
||||||
|
},
|
||||||
|
"capturePoints": [
|
||||||
|
{
|
||||||
|
"id": "initial",
|
||||||
|
"label": "Pending input layout"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "empty",
|
||||||
|
"label": "empty",
|
||||||
|
"path": "/w/console-history-review/workers/W-900-console-fixture/console?pending=empty",
|
||||||
|
"goal": "Only populated scopes appear; headings and previews match Tasks typography and row height.",
|
||||||
|
"dataState": "Synthetic retained Console: empty; two long/short previews when present; one task. Cancellation is unavailable on retained sessions.",
|
||||||
|
"ready": {
|
||||||
|
"kind": "selector",
|
||||||
|
"selector": ".task-mini-row"
|
||||||
|
},
|
||||||
|
"capturePoints": [
|
||||||
|
{
|
||||||
|
"id": "initial",
|
||||||
|
"label": "Pending input layout"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"processes": [
|
||||||
|
{
|
||||||
|
"id": "console-fixture",
|
||||||
|
"command": "deno",
|
||||||
|
"cwd": "..",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"--allow-net",
|
||||||
|
"--allow-read",
|
||||||
|
"browser-tests/console_history_fixture_server.ts",
|
||||||
|
"15283",
|
||||||
|
"../../web/workspace/build"
|
||||||
|
],
|
||||||
|
"readyUrl": "http://127.0.0.1:15283/health"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+12
-11
@@ -2,8 +2,13 @@
|
|||||||
|
|
||||||
SvelteKit static SPA for the Yoi workspace control plane.
|
SvelteKit static SPA for the Yoi workspace control plane.
|
||||||
|
|
||||||
The frontend is intentionally static. Workspace authority, validation, and API
|
The frontend is intentionally static. Workspace authority, validation, and API behavior live in the Rust `yoi-server` backend.
|
||||||
behavior live in the Rust `yoi-server` backend.
|
|
||||||
|
## Toolchain
|
||||||
|
|
||||||
|
Dependencies are pinned in `deno.json` and `package.json`, with resolutions in `deno.lock`. Run `deno install --frozen` to reproduce the installed versions. The current toolchain is SvelteKit 3, Svelte 5, Vite 8, and Vitest 5. TypeScript is kept on 6.0.3 because SvelteKit 3.0.0 and svelte-check 4.7.6 do not yet declare support for TypeScript 7.
|
||||||
|
|
||||||
|
SvelteKit and adapter settings live in `vite.config.ts`. Internal library imports use the `#lib/*` package subpath mapping with explicit file extensions, shared by Vite, TypeScript, and Deno. `tsconfig.json` extends `$app/tsconfig`; generated WASM JavaScript is consumed through its generated `.d.ts`, not checked as handwritten JavaScript. Deno-only tests under `test/` are type-checked by `deno task test`.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -23,9 +28,7 @@ cd web/workspace
|
|||||||
deno task dev
|
deno task dev
|
||||||
```
|
```
|
||||||
|
|
||||||
The Vite dev server proxies `/api/*` to `http://127.0.0.1:8787`, so frontend hot
|
The Vite dev server proxies `/api/*` to `http://127.0.0.1:8787`, so frontend hot reload works while the Rust backend serves the workspace API. Open the Vite URL printed by `deno task dev`.
|
||||||
reload works while the Rust backend serves the workspace API. Open the Vite URL
|
|
||||||
printed by `deno task dev`.
|
|
||||||
|
|
||||||
If you want to run the backend from the repository root instead:
|
If you want to run the backend from the repository root instead:
|
||||||
|
|
||||||
@@ -33,10 +36,7 @@ If you want to run the backend from the repository root instead:
|
|||||||
cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787
|
cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787
|
||||||
```
|
```
|
||||||
|
|
||||||
The backend reads Workspace records from the Yoi server DB at
|
The backend reads Workspace records from the Yoi server DB at `<data_dir>/server/server.db`. Run `cargo run -p yoi-workspace-server --bin yoi-server -- init --workspace .` first when the server DB has not been initialized.
|
||||||
`<data_dir>/server/server.db`. Run
|
|
||||||
`cargo run -p yoi-workspace-server --bin yoi-server -- init --workspace .` first when the server
|
|
||||||
DB has not been initialized.
|
|
||||||
|
|
||||||
## Static build
|
## Static build
|
||||||
|
|
||||||
@@ -46,13 +46,14 @@ Build the SPA:
|
|||||||
deno task build
|
deno task build
|
||||||
```
|
```
|
||||||
|
|
||||||
Static asset packaging is a deployment concern. Local development normally uses
|
Static asset packaging is a deployment concern. Local development normally uses the Vite dev server proxy plus the Rust backend command above.
|
||||||
the Vite dev server proxy plus the Rust backend command above.
|
|
||||||
|
|
||||||
## Checks
|
## Checks
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
deno task check
|
deno task check
|
||||||
|
deno task test
|
||||||
|
deno task test:component
|
||||||
deno task build
|
deno task build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+23
-24
@@ -3,40 +3,39 @@
|
|||||||
"nodeModulesDir": "auto",
|
"nodeModulesDir": "auto",
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"install": "deno install",
|
"install": "deno install",
|
||||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
"dev": "deno run -A npm:vite@8.3.2 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@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 --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/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 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": "deno test --allow-read=src,test,tests,../../docs --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/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 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@3.2.4 run --config vitest.config.ts",
|
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
|
||||||
"build": "deno run -A npm:vite@7.2.7 build",
|
"build": "deno run -A npm:vite@8.3.2 build",
|
||||||
"preview": "deno run -A npm:vite@7.2.7 preview",
|
"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",
|
"generate:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types",
|
||||||
"check:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types -- --check",
|
"check:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types -- --check",
|
||||||
"generate:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types",
|
"generate:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types",
|
||||||
"check:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types -- --check"
|
"check:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types -- --check"
|
||||||
},
|
},
|
||||||
"imports": {
|
"imports": {
|
||||||
"$lib/": "./src/lib/",
|
"@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@4.0.0",
|
||||||
"@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@3.0.9",
|
"@sveltejs/kit": "npm:@sveltejs/kit@3.0.0",
|
||||||
"@sveltejs/kit": "npm:@sveltejs/kit@2.49.4",
|
"@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@7.3.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1",
|
"@codemirror/autocomplete": "npm:@codemirror/autocomplete@6.20.3",
|
||||||
"@codemirror/autocomplete": "npm:@codemirror/autocomplete@6.20.0",
|
"@codemirror/commands": "npm:@codemirror/commands@6.11.1",
|
||||||
"@codemirror/commands": "npm:@codemirror/commands@6.9.0",
|
|
||||||
"@codemirror/language": "npm:@codemirror/language@6.12.4",
|
"@codemirror/language": "npm:@codemirror/language@6.12.4",
|
||||||
"@codemirror/state": "npm:@codemirror/state@6.7.1",
|
"@codemirror/state": "npm:@codemirror/state@6.7.6",
|
||||||
"@codemirror/view": "npm:@codemirror/view@6.43.8",
|
"@codemirror/view": "npm:@codemirror/view@6.43.13",
|
||||||
"@lezer/common": "npm:@lezer/common@1.5.2",
|
"@lezer/common": "npm:@lezer/common@1.5.3",
|
||||||
"@lezer/highlight": "npm:@lezer/highlight@1.2.3",
|
"@lezer/highlight": "npm:@lezer/highlight@1.2.5",
|
||||||
"decodal-codemirror": "npm:decodal-codemirror@0.3.0",
|
"decodal-codemirror": "npm:decodal-codemirror@0.3.1",
|
||||||
"clsx": "npm:clsx@2.1.1",
|
"clsx": "npm:clsx@2.1.1",
|
||||||
"cookie": "npm:cookie@0.6.0",
|
"cookie": "npm:cookie@2.0.1",
|
||||||
"devalue": "npm:devalue@5.6.4",
|
"devalue": "npm:devalue@6.0.2",
|
||||||
"set-cookie-parser": "npm:set-cookie-parser@2.7.2",
|
"set-cookie-parser": "npm:set-cookie-parser@3.1.2",
|
||||||
"shiki": "npm:shiki@4.4.2",
|
"shiki": "npm:shiki@4.5.0",
|
||||||
"svelte": "npm:svelte@5.45.6",
|
"svelte": "npm:svelte@5.57.1",
|
||||||
"svelte-check": "npm:svelte-check@4.3.4",
|
"svelte-check": "npm:svelte-check@4.7.6",
|
||||||
"typescript": "npm:typescript@5.9.3",
|
"typescript": "npm:typescript@6.0.3",
|
||||||
"vite": "npm:vite@7.2.7"
|
"vite": "npm:vite@8.3.2"
|
||||||
},
|
},
|
||||||
"fmt": {
|
"fmt": {
|
||||||
"proseWrap": "never"
|
"proseWrap": "never"
|
||||||
|
|||||||
Generated
+336
-603
File diff suppressed because it is too large
Load Diff
@@ -5,14 +5,17 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/ibm-plex-mono": "5.3.0",
|
"@fontsource/ibm-plex-mono": "5.3.0",
|
||||||
"@humanspeak/svelte-markdown": "1.9.1",
|
"@humanspeak/svelte-markdown": "2.0.0",
|
||||||
"gen-interface-jp": "0.8.0",
|
"gen-interface-jp": "0.8.0",
|
||||||
"shiki": "4.4.2",
|
"shiki": "4.5.0",
|
||||||
"yaml": "2.9.0"
|
"yaml": "2.9.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/svelte": "5.2.8",
|
"@testing-library/svelte": "5.4.2",
|
||||||
"happy-dom": "20.12.0",
|
"happy-dom": "20.14.5",
|
||||||
"vitest": "3.2.4"
|
"vitest": "5.0.3"
|
||||||
|
},
|
||||||
|
"imports": {
|
||||||
|
"#lib/*": "./src/lib/*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
workspaceSkillCatalogPath,
|
workspaceSkillCatalogPath,
|
||||||
workspaceSkillDetailPath,
|
workspaceSkillDetailPath,
|
||||||
} from "./http.ts";
|
} from "./http.ts";
|
||||||
import { SKILL_API_LIMITS } from "$lib/generated/skill-api.ts";
|
import { SKILL_API_LIMITS } from "#lib/generated/skill-api.ts";
|
||||||
|
|
||||||
declare const Deno: {
|
declare const Deno: {
|
||||||
test(name: string, fn: () => Promise<void> | void): void;
|
test(name: string, fn: () => Promise<void> | void): void;
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { SKILL_API_LIMITS } from "$lib/generated/skill-api.ts";
|
import { SKILL_API_LIMITS } from "#lib/generated/skill-api.ts";
|
||||||
import type {
|
import type {
|
||||||
SkillCatalogResponse,
|
SkillCatalogResponse,
|
||||||
SkillDetailResponse,
|
SkillDetailResponse,
|
||||||
} from "$lib/generated/skill-api.ts";
|
} from "#lib/generated/skill-api.ts";
|
||||||
import {
|
import {
|
||||||
parseSkillCatalogResponse,
|
parseSkillCatalogResponse,
|
||||||
parseSkillDetailResponse,
|
parseSkillDetailResponse,
|
||||||
SkillApiContractError,
|
SkillApiContractError,
|
||||||
} from "$lib/workspace/skills/api.ts";
|
} from "#lib/workspace/skills/api.ts";
|
||||||
|
|
||||||
export type ApiResult<T> = {
|
export type ApiResult<T> = {
|
||||||
data: T | null;
|
data: T | null;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type {
|
|||||||
MergeRequestDetailResponse,
|
MergeRequestDetailResponse,
|
||||||
MergeRequestListResponse,
|
MergeRequestListResponse,
|
||||||
MergeRequestThreadEvent,
|
MergeRequestThreadEvent,
|
||||||
} from "$lib/generated/ticket-api";
|
} from "#lib/generated/ticket-api.ts";
|
||||||
import { workspaceApiPath } from "./http";
|
import { workspaceApiPath } from "./http";
|
||||||
|
|
||||||
export type MergeRequestDetail = MergeRequestDetailResponse;
|
export type MergeRequestDetail = MergeRequestDetailResponse;
|
||||||
|
|||||||
@@ -3,19 +3,19 @@ import type {
|
|||||||
CreateWorkspaceRepositoryResponse,
|
CreateWorkspaceRepositoryResponse,
|
||||||
RepositoryDetailResponse,
|
RepositoryDetailResponse,
|
||||||
RepositoryListResponse,
|
RepositoryListResponse,
|
||||||
} from "$lib/generated/repository-api.ts";
|
} from "#lib/generated/repository-api.ts";
|
||||||
import {
|
import {
|
||||||
type ApiResult,
|
type ApiResult,
|
||||||
loadJson,
|
loadJson,
|
||||||
readBoundedJson,
|
readBoundedJson,
|
||||||
workspaceApiPath,
|
workspaceApiPath,
|
||||||
} from "$lib/workspace/api/http.ts";
|
} from "#lib/workspace/api/http.ts";
|
||||||
import {
|
import {
|
||||||
parseCreateWorkspaceRepositoryResponse,
|
parseCreateWorkspaceRepositoryResponse,
|
||||||
parseRepositoryApiError,
|
parseRepositoryApiError,
|
||||||
parseRepositoryDetailResponse,
|
parseRepositoryDetailResponse,
|
||||||
parseRepositoryListResponse,
|
parseRepositoryListResponse,
|
||||||
} from "$lib/workspace/api/workspace-model.ts";
|
} from "#lib/workspace/api/workspace-model.ts";
|
||||||
|
|
||||||
export const REPOSITORY_API_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
export const REPOSITORY_API_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type {
|
|||||||
RuntimeConnectionTestFailureKind,
|
RuntimeConnectionTestFailureKind,
|
||||||
RuntimeConnectionTestResponse,
|
RuntimeConnectionTestResponse,
|
||||||
RuntimeVerificationEvidenceSummary,
|
RuntimeVerificationEvidenceSummary,
|
||||||
} from "$lib/generated/runtime-api";
|
} from "#lib/generated/runtime-api.ts";
|
||||||
|
|
||||||
const RESPONSE_KEYS = [
|
const RESPONSE_KEYS = [
|
||||||
"workspace_id",
|
"workspace_id",
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import type {
|
|||||||
WorkspaceRuntimeDetail,
|
WorkspaceRuntimeDetail,
|
||||||
WorkspaceRuntimeListResponse,
|
WorkspaceRuntimeListResponse,
|
||||||
WorkspaceRuntimeResource,
|
WorkspaceRuntimeResource,
|
||||||
} from "$lib/generated/runtime-api.ts";
|
} from "#lib/generated/runtime-api.ts";
|
||||||
import { workspaceApiPath } from "./http.ts";
|
import { workspaceApiPath } from "./http.ts";
|
||||||
|
|
||||||
export type WorkspaceRuntimeList = Omit<WorkspaceRuntimeListResponse, "diagnostics"> & {
|
export type WorkspaceRuntimeList = Omit<WorkspaceRuntimeListResponse, "diagnostics"> & {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type {
|
|||||||
RuntimeWorkerLifecycleResult,
|
RuntimeWorkerLifecycleResult,
|
||||||
WorkerOperationState,
|
WorkerOperationState,
|
||||||
WorkerRetentionResponse,
|
WorkerRetentionResponse,
|
||||||
} from "$lib/generated/runtime-api";
|
} from "#lib/generated/runtime-api.ts";
|
||||||
|
|
||||||
const MAX_STRING_BYTES = 4_096;
|
const MAX_STRING_BYTES = 4_096;
|
||||||
const MAX_CANDIDATES = 1_000;
|
const MAX_CANDIDATES = 1_000;
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import type {
|
|||||||
TicketRoleAssignmentRecord,
|
TicketRoleAssignmentRecord,
|
||||||
TicketRoleAssignmentSummary,
|
TicketRoleAssignmentSummary,
|
||||||
TicketTarget,
|
TicketTarget,
|
||||||
} from "$lib/generated/ticket-api.ts";
|
} from "#lib/generated/ticket-api.ts";
|
||||||
|
|
||||||
export const TICKET_BROWSER_API_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
export const TICKET_BROWSER_API_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
||||||
export const TICKET_BROWSER_API_LOAD_POLICY = {
|
export const TICKET_BROWSER_API_LOAD_POLICY = {
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ import type {
|
|||||||
WorkerWorkdirAttachmentSummary,
|
WorkerWorkdirAttachmentSummary,
|
||||||
WorkerWorkspaceSummary,
|
WorkerWorkspaceSummary,
|
||||||
WorkingDirectoryRepositoryOption,
|
WorkingDirectoryRepositoryOption,
|
||||||
} from "$lib/generated/worker-launch-api";
|
} from "#lib/generated/worker-launch-api.ts";
|
||||||
import type { Segment } from "$lib/generated/protocol";
|
import type { Segment } from "#lib/generated/protocol.ts";
|
||||||
import {
|
import {
|
||||||
parseExternalWorkdirPermissions,
|
parseExternalWorkdirPermissions,
|
||||||
parseWorkingDirectorySummary,
|
parseWorkingDirectorySummary,
|
||||||
} from "$lib/workspace/api/workdirs";
|
} from "#lib/workspace/api/workdirs.ts";
|
||||||
|
|
||||||
const DIAGNOSTIC_SEVERITIES = new Set<DiagnosticSeverity>([
|
const DIAGNOSTIC_SEVERITIES = new Set<DiagnosticSeverity>([
|
||||||
"info",
|
"info",
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import {
|
|||||||
type RepositorySummary,
|
type RepositorySummary,
|
||||||
type WorkspaceCreateResponse,
|
type WorkspaceCreateResponse,
|
||||||
type WorkspaceSummary,
|
type WorkspaceSummary,
|
||||||
} from "$lib/workspace/api/workspace-model";
|
} from "#lib/workspace/api/workspace-model.ts";
|
||||||
import { loadWorkspaceRepositoryList } from "$lib/workspace/api/repositories.ts";
|
import { loadWorkspaceRepositoryList } from "#lib/workspace/api/repositories.ts";
|
||||||
|
|
||||||
export type WorkspaceCatalogRecord = WorkspaceSummary;
|
export type WorkspaceCatalogRecord = WorkspaceSummary;
|
||||||
export type WorkspaceCatalogItem = WorkspaceCatalogRecord & {
|
export type WorkspaceCatalogItem = WorkspaceCatalogRecord & {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ApiResult } from "$lib/workspace/api/http";
|
import type { ApiResult } from "#lib/workspace/api/http.ts";
|
||||||
import type {
|
import type {
|
||||||
WorkspaceAuthConfig,
|
WorkspaceAuthConfig,
|
||||||
WorkspaceCatalogListResponse,
|
WorkspaceCatalogListResponse,
|
||||||
@@ -15,7 +15,7 @@ import type {
|
|||||||
WorkspaceRepositoryRecord,
|
WorkspaceRepositoryRecord,
|
||||||
WorkspaceResponse,
|
WorkspaceResponse,
|
||||||
WorkspaceSummary,
|
WorkspaceSummary,
|
||||||
} from "$lib/generated/legacy-server-api.ts";
|
} from "#lib/generated/legacy-server-api.ts";
|
||||||
import type {
|
import type {
|
||||||
CreateWorkspaceRepositoryResponse,
|
CreateWorkspaceRepositoryResponse,
|
||||||
Diagnostic,
|
Diagnostic,
|
||||||
@@ -35,7 +35,7 @@ import type {
|
|||||||
RepositorySshConnectionTrustState,
|
RepositorySshConnectionTrustState,
|
||||||
RepositorySshHostKeyCandidate,
|
RepositorySshHostKeyCandidate,
|
||||||
RepositorySummary,
|
RepositorySummary,
|
||||||
} from "$lib/generated/repository-api.ts";
|
} from "#lib/generated/repository-api.ts";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
WorkspaceCatalogListResponse,
|
WorkspaceCatalogListResponse,
|
||||||
@@ -45,7 +45,7 @@ export type {
|
|||||||
WorkspacePermissionSummary,
|
WorkspacePermissionSummary,
|
||||||
WorkspaceResponse,
|
WorkspaceResponse,
|
||||||
WorkspaceSummary,
|
WorkspaceSummary,
|
||||||
} from "$lib/generated/legacy-server-api.ts";
|
} from "#lib/generated/legacy-server-api.ts";
|
||||||
export type {
|
export type {
|
||||||
CreateWorkspaceRepositoryRequest,
|
CreateWorkspaceRepositoryRequest,
|
||||||
CreateWorkspaceRepositoryResponse,
|
CreateWorkspaceRepositoryResponse,
|
||||||
@@ -63,7 +63,7 @@ export type {
|
|||||||
RepositorySshConnectionProbeResponse,
|
RepositorySshConnectionProbeResponse,
|
||||||
RepositorySshHostKeyCandidate,
|
RepositorySshHostKeyCandidate,
|
||||||
RepositorySummary,
|
RepositorySummary,
|
||||||
} from "$lib/generated/repository-api.ts";
|
} from "#lib/generated/repository-api.ts";
|
||||||
|
|
||||||
type JsonObject = Record<string, unknown>;
|
type JsonObject = Record<string, unknown>;
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type {
|
|||||||
PasskeyLoginOptionsRequest,
|
PasskeyLoginOptionsRequest,
|
||||||
PasskeyRegistrationCompleteRequest,
|
PasskeyRegistrationCompleteRequest,
|
||||||
PasskeyRegistrationOptionsRequest,
|
PasskeyRegistrationOptionsRequest,
|
||||||
} from "$lib/generated/auth-api.ts";
|
} from "#lib/generated/auth-api.ts";
|
||||||
import {
|
import {
|
||||||
authenticationCredentialToJson,
|
authenticationCredentialToJson,
|
||||||
type AuthUser,
|
type AuthUser,
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
prepareRegistrationOptions,
|
prepareRegistrationOptions,
|
||||||
registrationCredentialToJson,
|
registrationCredentialToJson,
|
||||||
type WhoamiResponse,
|
type WhoamiResponse,
|
||||||
} from "$lib/workspace/auth/model";
|
} from "#lib/workspace/auth/model.ts";
|
||||||
|
|
||||||
const MAX_AUTH_RESPONSE_BYTES = 256 * 1024;
|
const MAX_AUTH_RESPONSE_BYTES = 256 * 1024;
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type {
|
|||||||
PasskeyRegistrationOptionsResponse,
|
PasskeyRegistrationOptionsResponse,
|
||||||
RequestActor,
|
RequestActor,
|
||||||
WhoamiResponse,
|
WhoamiResponse,
|
||||||
} from "$lib/generated/auth-api.ts";
|
} from "#lib/generated/auth-api.ts";
|
||||||
|
|
||||||
export type AuthUser = AuthenticatedUser;
|
export type AuthUser = AuthenticatedUser;
|
||||||
export type PasskeyUserResponse = AuthUserResponse;
|
export type PasskeyUserResponse = AuthUserResponse;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type {
|
|||||||
DiagnosticSeverity,
|
DiagnosticSeverity,
|
||||||
WorkspaceWorkerDiscoveryItem,
|
WorkspaceWorkerDiscoveryItem,
|
||||||
WorkspaceWorkerSubject,
|
WorkspaceWorkerSubject,
|
||||||
} from "$lib/generated/companion-api";
|
} from "#lib/generated/companion-api.ts";
|
||||||
|
|
||||||
const MAX_TRANSCRIPT_ITEMS = 200;
|
const MAX_TRANSCRIPT_ITEMS = 200;
|
||||||
const MAX_DIAGNOSTICS = 100;
|
const MAX_DIAGNOSTICS = 100;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import DecodalSourceEditor from "$lib/workspace/settings/DecodalSourceEditor.svelte";
|
import DecodalSourceEditor from "#lib/workspace/settings/DecodalSourceEditor.svelte";
|
||||||
import {
|
import {
|
||||||
commitConfigTree,
|
commitConfigTree,
|
||||||
fetchConfigTree,
|
fetchConfigTree,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export type {
|
|||||||
ToolchainContract,
|
ToolchainContract,
|
||||||
WorkspaceConfigSchemaBundle,
|
WorkspaceConfigSchemaBundle,
|
||||||
WorkspaceConfigTreeResponse,
|
WorkspaceConfigTreeResponse,
|
||||||
} from "$lib/generated/legacy-server-api.ts";
|
} from "#lib/generated/legacy-server-api.ts";
|
||||||
|
|
||||||
// Browser-local Decodal/WASM projections remain owned by config-source.
|
// Browser-local Decodal/WASM projections remain owned by config-source.
|
||||||
export type { ConfigDiagnostic } from "./generated/types/ConfigDiagnostic.ts";
|
export type { ConfigDiagnostic } from "./generated/types/ConfigDiagnostic.ts";
|
||||||
|
|||||||
@@ -22,12 +22,12 @@
|
|||||||
isolateHistory,
|
isolateHistory,
|
||||||
} from "@codemirror/commands";
|
} from "@codemirror/commands";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import type { Segment } from "$lib/generated/protocol.ts";
|
import type { Segment } from "#lib/generated/protocol.ts";
|
||||||
import {
|
import {
|
||||||
handleComposerImagePaste,
|
handleComposerImagePaste,
|
||||||
measureComposerPaste,
|
measureComposerPaste,
|
||||||
type ComposerPasteMeasurement,
|
type ComposerPasteMeasurement,
|
||||||
} from "$lib/workspace/console/composer-paste.ts";
|
} from "#lib/workspace/console/composer-paste.ts";
|
||||||
import {
|
import {
|
||||||
composerDeletionRange,
|
composerDeletionRange,
|
||||||
composerPasteAtoms,
|
composerPasteAtoms,
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
type ComposerDraftSnapshot,
|
type ComposerDraftSnapshot,
|
||||||
type ComposerPaste,
|
type ComposerPaste,
|
||||||
type ComposerTextPaste,
|
type ComposerTextPaste,
|
||||||
} from "$lib/workspace/console/composer-draft.ts";
|
} from "#lib/workspace/console/composer-draft.ts";
|
||||||
import {
|
import {
|
||||||
ComposerHistory,
|
ComposerHistory,
|
||||||
loadComposerHistory,
|
loadComposerHistory,
|
||||||
@@ -45,9 +45,9 @@
|
|||||||
shouldBrowseComposerHistory,
|
shouldBrowseComposerHistory,
|
||||||
type ComposerHistoryDirection,
|
type ComposerHistoryDirection,
|
||||||
type ComposerHistoryEntry,
|
type ComposerHistoryEntry,
|
||||||
} from "$lib/workspace/console/composer-history.ts";
|
} from "#lib/workspace/console/composer-history.ts";
|
||||||
import { applyCompletion, completionSelection, completionTokenAt, localCommandCompletions, type ComposerCompletionEntry, type ComposerCompletionToken } from "./composer-completion";
|
import { applyCompletion, completionSelection, completionTokenAt, localCommandCompletions, type ComposerCompletionEntry, type ComposerCompletionToken } from "./composer-completion";
|
||||||
import { shouldSubmitChatKey } from "$lib/workspace/console/chat-submit.ts";
|
import { shouldSubmitChatKey } from "#lib/workspace/console/chat-submit.ts";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import AnsiText from '$lib/workspace/console/AnsiText.svelte';
|
import AnsiText from '#lib/workspace/console/AnsiText.svelte';
|
||||||
import RichMarkdown from '$lib/workspace/console/RichMarkdown.svelte';
|
import RichMarkdown from '#lib/workspace/console/RichMarkdown.svelte';
|
||||||
import type { ConsoleLine } from '$lib/workspace/console/model';
|
import type { ConsoleLine } from '#lib/workspace/console/model.ts';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item: ConsoleLine;
|
item: ConsoleLine;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Renderers } from "@humanspeak/svelte-markdown";
|
import type { Renderers } from "@humanspeak/svelte-markdown";
|
||||||
import { ShikiCode } from "@humanspeak/svelte-markdown/extensions/shiki";
|
import { ShikiCode } from "@humanspeak/svelte-markdown/extensions/shiki";
|
||||||
import SafeMarkdown from "$lib/workspace/markdown/SafeMarkdown.svelte";
|
import SafeMarkdown from "#lib/workspace/markdown/SafeMarkdown.svelte";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
text: string;
|
text: string;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import type {
|
import type {
|
||||||
InFlightCompaction,
|
InFlightCompaction,
|
||||||
WorkerStateSnapshot,
|
WorkerStateSnapshot,
|
||||||
} from "$lib/generated/protocol.ts";
|
} from "#lib/generated/protocol.ts";
|
||||||
import Spinner from "./Spinner.svelte";
|
import Spinner from "./Spinner.svelte";
|
||||||
import {
|
import {
|
||||||
formatRunElapsed,
|
formatRunElapsed,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type {
|
import type {
|
||||||
AttachmentUploadGrantResponse,
|
AttachmentUploadGrantResponse,
|
||||||
WorkerFileUploadResponse,
|
WorkerFileUploadResponse,
|
||||||
} from "$lib/generated/runtime-api.ts";
|
} from "#lib/generated/runtime-api.ts";
|
||||||
import type { UploadedFileRef } from "$lib/generated/protocol.ts";
|
import type { UploadedFileRef } from "#lib/generated/protocol.ts";
|
||||||
|
|
||||||
export const MAX_UPLOADED_FILE_BYTES = 10 * 1024 * 1024;
|
export const MAX_UPLOADED_FILE_BYTES = 10 * 1024 * 1024;
|
||||||
export const MAX_FILES_PER_SUBMISSION = 8;
|
export const MAX_FILES_PER_SUBMISSION = 8;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Segment } from "$lib/generated/protocol";
|
import type { Segment } from "#lib/generated/protocol.ts";
|
||||||
|
|
||||||
export type WorkerConsoleInputKind =
|
export type WorkerConsoleInputKind =
|
||||||
| "user"
|
| "user"
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { Segment } from "$lib/generated/protocol.ts";
|
import type { Segment } from "#lib/generated/protocol.ts";
|
||||||
import {
|
import {
|
||||||
composerDeletionRange,
|
composerDeletionRange,
|
||||||
type ComposerPaste,
|
type ComposerPaste,
|
||||||
composerPasteToken,
|
composerPasteToken,
|
||||||
pasteChipLabel,
|
pasteChipLabel,
|
||||||
snapshotComposerDraft,
|
snapshotComposerDraft,
|
||||||
} from "$lib/workspace/console/composer-draft.ts";
|
} from "#lib/workspace/console/composer-draft.ts";
|
||||||
import { buildComposerSegmentsRequest } from "$lib/workspace/console/composer-command.ts";
|
import { buildComposerSegmentsRequest } from "#lib/workspace/console/composer-command.ts";
|
||||||
import { measureComposerPaste } from "$lib/workspace/console/composer-paste.ts";
|
import { measureComposerPaste } from "#lib/workspace/console/composer-paste.ts";
|
||||||
|
|
||||||
declare const Deno: {
|
declare const Deno: {
|
||||||
test(name: string, fn: () => void): void;
|
test(name: string, fn: () => void): void;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Segment } from "$lib/generated/protocol.ts";
|
import type { Segment } from "#lib/generated/protocol.ts";
|
||||||
|
|
||||||
const PASTE_TOKEN_PREFIX = "\uFFF9";
|
const PASTE_TOKEN_PREFIX = "\uFFF9";
|
||||||
const PASTE_TOKEN_SUFFIX = "\uFFFB";
|
const PASTE_TOKEN_SUFFIX = "\uFFFB";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Segment } from "$lib/generated/protocol";
|
import type { Segment } from "#lib/generated/protocol.ts";
|
||||||
|
|
||||||
export const COMPOSER_HISTORY_LIMIT = 30;
|
export const COMPOSER_HISTORY_LIMIT = 30;
|
||||||
const COMPOSER_HISTORY_VERSION = 1;
|
const COMPOSER_HISTORY_VERSION = 1;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type {
|
|||||||
SessionConversationTurn,
|
SessionConversationTurn,
|
||||||
SessionHistoryPage,
|
SessionHistoryPage,
|
||||||
SessionSnapshotEntry,
|
SessionSnapshotEntry,
|
||||||
} from "$lib/generated/protocol";
|
} from "#lib/generated/protocol.ts";
|
||||||
import {
|
import {
|
||||||
applyConsoleHistoryPage,
|
applyConsoleHistoryPage,
|
||||||
beginConsoleHistoryRequest,
|
beginConsoleHistoryRequest,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type {
|
|||||||
SessionConversationTurn,
|
SessionConversationTurn,
|
||||||
SessionHistoryPage,
|
SessionHistoryPage,
|
||||||
SessionSnapshotEntry,
|
SessionSnapshotEntry,
|
||||||
} from "$lib/generated/protocol";
|
} from "#lib/generated/protocol.ts";
|
||||||
|
|
||||||
export const CONSOLE_HISTORY_PAGE_TURNS = 5;
|
export const CONSOLE_HISTORY_PAGE_TURNS = 5;
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type {
|
|||||||
SessionSnapshotEntry,
|
SessionSnapshotEntry,
|
||||||
WorkerStateSnapshot,
|
WorkerStateSnapshot,
|
||||||
WorkerStatus,
|
WorkerStatus,
|
||||||
} from "$lib/generated/protocol";
|
} from "#lib/generated/protocol.ts";
|
||||||
import {
|
import {
|
||||||
type ConsoleEventInput,
|
type ConsoleEventInput,
|
||||||
type ConsoleLine,
|
type ConsoleLine,
|
||||||
@@ -338,6 +338,13 @@ Deno.test("durable compaction failure reconciles live alert and error in both vi
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
eventId: "controller-error",
|
||||||
|
event: {
|
||||||
|
event: "error",
|
||||||
|
data: { code: "internal", message: "summary unavailable" },
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
eventId: "failed",
|
eventId: "failed",
|
||||||
event: transition("failed-entry", {
|
event: transition("failed-entry", {
|
||||||
@@ -346,13 +353,6 @@ Deno.test("durable compaction failure reconciles live alert and error in both vi
|
|||||||
failure: "compaction",
|
failure: "compaction",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
eventId: "controller-error",
|
|
||||||
event: {
|
|
||||||
event: "error",
|
|
||||||
data: { code: "internal", message: "summary unavailable" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const errors = projection.lines.filter((line) => line.kind === "error");
|
const errors = projection.lines.filter((line) => line.kind === "error");
|
||||||
@@ -362,6 +362,47 @@ Deno.test("durable compaction failure reconciles live alert and error in both vi
|
|||||||
assertEquals(errors[0].entryId, "failed-entry");
|
assertEquals(errors[0].entryId, "failed-entry");
|
||||||
assertEquals(projectConsoleLines(projection.lines, "normal"), errors);
|
assertEquals(projectConsoleLines(projection.lines, "normal"), errors);
|
||||||
assertEquals(projectConsoleLines(projection.lines, "overview"), errors);
|
assertEquals(projectConsoleLines(projection.lines, "overview"), errors);
|
||||||
|
|
||||||
|
const history = projectSessionHistoryEntries([{
|
||||||
|
entry_id: "failed-entry",
|
||||||
|
timestamp: 10,
|
||||||
|
provenance: "legacy_unknown",
|
||||||
|
kind: "run_error",
|
||||||
|
message,
|
||||||
|
failure: "compaction",
|
||||||
|
}], null);
|
||||||
|
const merged = mergeCommittedHistoryLines(history, projection.lines);
|
||||||
|
assertEquals(merged.filter((line) => line.kind === "error").length, 1);
|
||||||
|
assertEquals(merged.find((line) => line.kind === "error")?.entryId, "failed-entry");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("reapplying one committed run_error entry is idempotent in both views", () => {
|
||||||
|
const committed = (eventId: string): ConsoleEventInput => ({
|
||||||
|
eventId,
|
||||||
|
event: {
|
||||||
|
event: "session_entry_committed",
|
||||||
|
data: {
|
||||||
|
entry: {
|
||||||
|
entry_id: "same-run-error",
|
||||||
|
timestamp: 10,
|
||||||
|
provenance: "legacy_unknown",
|
||||||
|
kind: "run_error",
|
||||||
|
message: "mid-run compaction failed: summary unavailable",
|
||||||
|
failure: "compaction",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const projection = projectConsole([
|
||||||
|
committed("live-delivery"),
|
||||||
|
committed("snapshot-replay"),
|
||||||
|
]);
|
||||||
|
const errors = projection.lines.filter((line) => line.kind === "error");
|
||||||
|
assertEquals(errors.length, 1);
|
||||||
|
assertEquals(errors[0].entryId, "same-run-error");
|
||||||
|
assertEquals(projectConsoleLines(projection.lines, "normal").length, 1);
|
||||||
|
assertEquals(projectConsoleLines(projection.lines, "overview").length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("snapshot restores compaction terminal without reviving transition progress", () => {
|
Deno.test("snapshot restores compaction terminal without reviving transition progress", () => {
|
||||||
@@ -3232,6 +3273,26 @@ Deno.test("committed history reconciles overlapping Read groups before counting
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("committed history keeps identical run failures with distinct entry identities", () => {
|
||||||
|
const message = "the same failure happened again";
|
||||||
|
const runError = (entryId: string): SessionSnapshotEntry => ({
|
||||||
|
kind: "run_error",
|
||||||
|
entry_id: entryId,
|
||||||
|
timestamp: 1,
|
||||||
|
provenance: "legacy_unknown",
|
||||||
|
message,
|
||||||
|
failure: "compaction",
|
||||||
|
});
|
||||||
|
const retained = projectSessionHistoryEntries([runError("run-error-1")], null);
|
||||||
|
const current = projectSessionHistoryEntries([runError("run-error-2")], null);
|
||||||
|
|
||||||
|
const merged = mergeCommittedHistoryLines(retained, current);
|
||||||
|
assertEquals(merged.map((line) => line.entryId), ["run-error-1", "run-error-2"]);
|
||||||
|
assertEquals(merged.map((line) => line.body), [message, message]);
|
||||||
|
assertEquals(projectConsoleLines(merged, "normal").length, 2);
|
||||||
|
assertEquals(projectConsoleLines(merged, "overview").length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("committed history matches tool calls across live and durable result identities", () => {
|
Deno.test("committed history matches tool calls across live and durable result identities", () => {
|
||||||
const retained: ConsoleLine = {
|
const retained: ConsoleLine = {
|
||||||
...consoleLine("tool-call-a", "tool"), entryId: "call-entry-a",
|
...consoleLine("tool-call-a", "tool"), entryId: "call-entry-a",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type {
|
|||||||
WorkerState,
|
WorkerState,
|
||||||
WorkerStateSnapshot,
|
WorkerStateSnapshot,
|
||||||
WorkerStatus,
|
WorkerStatus,
|
||||||
} from "$lib/generated/protocol";
|
} from "#lib/generated/protocol.ts";
|
||||||
import { stringify as stringifyYaml } from "yaml";
|
import { stringify as stringifyYaml } from "yaml";
|
||||||
import {
|
import {
|
||||||
applyRunActivityEvent,
|
applyRunActivityEvent,
|
||||||
@@ -1287,15 +1287,19 @@ function hasDurableRunFailure(
|
|||||||
function appendDurableRunFailure(
|
function appendDurableRunFailure(
|
||||||
projection: ConsoleProjection,
|
projection: ConsoleProjection,
|
||||||
eventId: string,
|
eventId: string,
|
||||||
|
entryId: string | undefined,
|
||||||
message: string,
|
message: string,
|
||||||
failure: string | undefined,
|
failure: string | undefined,
|
||||||
): void {
|
): void {
|
||||||
|
if (
|
||||||
|
entryId && projection.lines.some((line) => line.entryId === entryId)
|
||||||
|
) return;
|
||||||
projection.lines = projection.lines.filter((line) => {
|
projection.lines = projection.lines.filter((line) => {
|
||||||
if (isDurableRunFailure(line)) return true;
|
if (isDurableRunFailure(line)) return true;
|
||||||
return !(line.kind === "error" && runFailureMessagesMatch(line.body, message));
|
return !(line.kind === "error" && runFailureMessagesMatch(line.body, message));
|
||||||
});
|
});
|
||||||
projection.lines.push(
|
projection.lines.push({
|
||||||
line(
|
...line(
|
||||||
eventId,
|
eventId,
|
||||||
"error",
|
"error",
|
||||||
failure === "compaction" ? "Compaction failed" : "Run error",
|
failure === "compaction" ? "Compaction failed" : "Run error",
|
||||||
@@ -1304,7 +1308,8 @@ function appendDurableRunFailure(
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
);
|
entryId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendAlertLine(
|
function appendAlertLine(
|
||||||
@@ -2198,6 +2203,7 @@ function applySessionEntry(
|
|||||||
appendDurableRunFailure(
|
appendDurableRunFailure(
|
||||||
projection,
|
projection,
|
||||||
eventId,
|
eventId,
|
||||||
|
stringField(value, "entry_id"),
|
||||||
stringField(value, "message") ?? "Worker run failed.",
|
stringField(value, "message") ?? "Worker run failed.",
|
||||||
stringField(value, "failure"),
|
stringField(value, "failure"),
|
||||||
);
|
);
|
||||||
@@ -2208,7 +2214,10 @@ function applySessionEntry(
|
|||||||
const entryId = stringField(value, "entry_id");
|
const entryId = stringField(value, "entry_id");
|
||||||
if (entryId) {
|
if (entryId) {
|
||||||
for (
|
for (
|
||||||
let index = Math.max(0, lineCountBefore - 1);
|
let index = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(lineCountBefore, projection.lines.length - 1),
|
||||||
|
);
|
||||||
index < projection.lines.length;
|
index < projection.lines.length;
|
||||||
index += 1
|
index += 1
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type {
|
|||||||
Event as ProtocolEvent,
|
Event as ProtocolEvent,
|
||||||
InFlightCompaction,
|
InFlightCompaction,
|
||||||
WorkerStateSnapshot,
|
WorkerStateSnapshot,
|
||||||
} from "$lib/generated/protocol";
|
} from "#lib/generated/protocol.ts";
|
||||||
|
|
||||||
export type RunActivityStats = {
|
export type RunActivityStats = {
|
||||||
startedAtMs: number | null;
|
startedAtMs: number | null;
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function consoleTurns(lines: readonly ConsoleLine[]): ConsoleTurn[] {
|
|||||||
return turns;
|
return turns;
|
||||||
}
|
}
|
||||||
|
|
||||||
import type { SessionConversationTurn } from "$lib/generated/protocol";
|
import type { SessionConversationTurn } from "#lib/generated/protocol.ts";
|
||||||
import { type ConsoleTurnPreview, conversationTurnPreviews } from "./history";
|
import { type ConsoleTurnPreview, conversationTurnPreviews } from "./history";
|
||||||
|
|
||||||
export type TurnNavigationItem = ConsoleTurnPreview;
|
export type TurnNavigationItem = ConsoleTurnPreview;
|
||||||
|
|||||||
@@ -69,11 +69,11 @@ Deno.test("workspace feature css is owned outside app css", async () => {
|
|||||||
"app.css should not own workspace, worker, or settings page implementation classes",
|
"app.css should not own workspace, worker, or settings page implementation classes",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
workspaceLayout.includes("$lib/workspace/styles/workspace-pages.css") &&
|
workspaceLayout.includes("#lib/workspace/styles/workspace-pages.css") &&
|
||||||
workspaceLayout.includes("$lib/workspace/styles/workers.css") &&
|
workspaceLayout.includes("#lib/workspace/styles/workers.css") &&
|
||||||
settingsLayout.includes("$lib/workspace/styles/settings.css") &&
|
settingsLayout.includes("#lib/workspace/styles/settings.css") &&
|
||||||
accountPage.includes("$lib/workspace/styles/settings.css") &&
|
accountPage.includes("#lib/workspace/styles/settings.css") &&
|
||||||
deviceLoginPage.includes("$lib/workspace/styles/settings.css"),
|
deviceLoginPage.includes("#lib/workspace/styles/settings.css"),
|
||||||
"feature-owned CSS should be imported by the layouts/pages that need it",
|
"feature-owned CSS should be imported by the layouts/pages that need it",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import WorkspaceSwitcher from "$lib/workspace/sidebar/WorkspaceSwitcher.svelte";
|
import WorkspaceSwitcher from "#lib/workspace/sidebar/WorkspaceSwitcher.svelte";
|
||||||
import type { WorkspaceResponse } from "$lib/workspace/sidebar/types";
|
import type { WorkspaceResponse } from "#lib/workspace/sidebar/types.ts";
|
||||||
import { buildWorkspaceBreadcrumbs } from "./breadcrumb-model";
|
import { buildWorkspaceBreadcrumbs } from "./breadcrumb-model";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import BevelLine from '$lib/workspace/ui/BevelLine.svelte';
|
import BevelLine from '#lib/workspace/ui/BevelLine.svelte';
|
||||||
import type { DashboardFeed } from './dashboard';
|
import type { DashboardFeed } from './dashboard';
|
||||||
|
|
||||||
let { id, title, empty, result }: { id: string; title: string; empty: string; result: Promise<DashboardFeed> } = $props();
|
let { id, title, empty, result }: { id: string; title: string; empty: string; result: Promise<DashboardFeed> } = $props();
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import type { TicketListItemSummary } from "$lib/generated/ticket-api";
|
import type { TicketListItemSummary } from "#lib/generated/ticket-api.ts";
|
||||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
||||||
import {
|
import {
|
||||||
parseMergeRequestListResponse,
|
parseMergeRequestListResponse,
|
||||||
parseObjectiveListResponse,
|
parseObjectiveListResponse,
|
||||||
parseTicketDetail,
|
parseTicketDetail,
|
||||||
parseTicketListResponse,
|
parseTicketListResponse,
|
||||||
TICKET_BROWSER_API_LOAD_POLICY,
|
TICKET_BROWSER_API_LOAD_POLICY,
|
||||||
} from "$lib/workspace/api/ticket-browser";
|
} from "#lib/workspace/api/ticket-browser.ts";
|
||||||
import { mergeRequestPagePath } from "$lib/workspace/api/merge-requests";
|
import { mergeRequestPagePath } from "#lib/workspace/api/merge-requests.ts";
|
||||||
import { objectiveHref, ticketHref } from "$lib/workspace/resource-links";
|
import { objectiveHref, ticketHref } from "#lib/workspace/resource-links.ts";
|
||||||
|
|
||||||
export const HOME_LIMIT = 5;
|
export const HOME_LIMIT = 5;
|
||||||
export type DashboardRow = {
|
export type DashboardRow = {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
type Renderers,
|
type Renderers,
|
||||||
type SvelteMarkdownProps,
|
type SvelteMarkdownProps,
|
||||||
} from "@humanspeak/svelte-markdown";
|
} from "@humanspeak/svelte-markdown";
|
||||||
import MarkdownLink from "$lib/workspace/markdown/MarkdownLink.svelte";
|
import MarkdownLink from "#lib/workspace/markdown/MarkdownLink.svelte";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
text: string;
|
text: string;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MEMORY_API_LIMITS } from "$lib/generated/memory-api";
|
import { MEMORY_API_LIMITS } from "#lib/generated/memory-api.ts";
|
||||||
import type {
|
import type {
|
||||||
Diagnostic,
|
Diagnostic,
|
||||||
DiagnosticSeverity,
|
DiagnosticSeverity,
|
||||||
@@ -12,7 +12,7 @@ import type {
|
|||||||
MemoryStagingEvidence,
|
MemoryStagingEvidence,
|
||||||
MemoryStagingListResponse,
|
MemoryStagingListResponse,
|
||||||
MemoryStagingRecord,
|
MemoryStagingRecord,
|
||||||
} from "$lib/generated/memory-api";
|
} from "#lib/generated/memory-api.ts";
|
||||||
|
|
||||||
const MAX_STAGING_ITEMS = MEMORY_API_LIMITS.maxCollectionItems;
|
const MAX_STAGING_ITEMS = MEMORY_API_LIMITS.maxCollectionItems;
|
||||||
const MAX_EVIDENCE_PER_RECORD = MEMORY_API_LIMITS.maxCollectionItems;
|
const MAX_EVIDENCE_PER_RECORD = MEMORY_API_LIMITS.maxCollectionItems;
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/env';
|
||||||
import type {
|
import type {
|
||||||
EventSubscriptionSelector,
|
EventSubscriptionSelector,
|
||||||
Method,
|
Method,
|
||||||
SubscriptionFrame,
|
SubscriptionFrame,
|
||||||
SubscriptionId,
|
SubscriptionId,
|
||||||
} from '$lib/generated/protocol';
|
} from '#lib/generated/protocol.ts';
|
||||||
import {
|
import {
|
||||||
decodeSubscriptionFrame,
|
decodeSubscriptionFrame,
|
||||||
subscriptionFrameMatchesSelector,
|
subscriptionFrameMatchesSelector,
|
||||||
} from '$lib/generated/protocol-validator';
|
} from '#lib/generated/protocol-validator.ts';
|
||||||
import { workspaceApiPath } from '$lib/workspace/api/http';
|
import { workspaceApiPath } from '#lib/workspace/api/http.ts';
|
||||||
|
|
||||||
type Listener = {
|
type Listener = {
|
||||||
onFrame(frame: SubscriptionFrame): void;
|
onFrame(frame: SubscriptionFrame): void;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { EventSubscriptionSelector, SubscriptionFrame } from '$lib/generated/protocol';
|
import type { EventSubscriptionSelector, SubscriptionFrame } from '#lib/generated/protocol.ts';
|
||||||
import {
|
import {
|
||||||
decodeSubscriptionFrame,
|
decodeSubscriptionFrame,
|
||||||
MAX_SUBSCRIPTION_STRING_BYTES,
|
MAX_SUBSCRIPTION_STRING_BYTES,
|
||||||
RUST_SERIALIZED_SUBSCRIPTION_FRAME_FIXTURES,
|
RUST_SERIALIZED_SUBSCRIPTION_FRAME_FIXTURES,
|
||||||
subscriptionFrameMatchesSelector,
|
subscriptionFrameMatchesSelector,
|
||||||
} from '$lib/generated/protocol-validator';
|
} from '#lib/generated/protocol-validator.ts';
|
||||||
|
|
||||||
declare const Deno: {
|
declare const Deno: {
|
||||||
test(name: string, fn: () => void | Promise<void>): void;
|
test(name: string, fn: () => void | Promise<void>): void;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { WorkspaceRuntimeResource } from "$lib/generated/runtime-api";
|
import type { WorkspaceRuntimeResource } from "#lib/generated/runtime-api.ts";
|
||||||
|
|
||||||
export type RepositorySshProbeSelection<T> = Readonly<{
|
export type RepositorySshProbeSelection<T> = Readonly<{
|
||||||
changed: boolean;
|
changed: boolean;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
type WorkerSessionRequestIdentity,
|
type WorkerSessionRequestIdentity,
|
||||||
workerSessionRequestInit,
|
workerSessionRequestInit,
|
||||||
} from "./session-observation";
|
} from "./session-observation";
|
||||||
import type { SessionSnapshot } from "$lib/generated/protocol";
|
import type { SessionSnapshot } from "#lib/generated/protocol.ts";
|
||||||
|
|
||||||
declare const Deno: {
|
declare const Deno: {
|
||||||
test(name: string, fn: () => Promise<void> | void): void;
|
test(name: string, fn: () => Promise<void> | void): void;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { SessionSnapshot } from "$lib/generated/protocol";
|
import type { SessionSnapshot } from "#lib/generated/protocol.ts";
|
||||||
|
|
||||||
export type WorkerSessionObservation =
|
export type WorkerSessionObservation =
|
||||||
| { availability: "live_protocol" }
|
| { availability: "live_protocol" }
|
||||||
|
|||||||
@@ -14,11 +14,11 @@
|
|||||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection, type ViewUpdate } from '@codemirror/view';
|
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection, type ViewUpdate } from '@codemirror/view';
|
||||||
import { tags } from '@lezer/highlight';
|
import { tags } from '@lezer/highlight';
|
||||||
import { decodal } from 'decodal-codemirror';
|
import { decodal } from 'decodal-codemirror';
|
||||||
import { shouldStartCompletionAfterTyping } from '$lib/workspace/config-source/completion.ts';
|
import { shouldStartCompletionAfterTyping } from '#lib/workspace/config-source/completion.ts';
|
||||||
import {
|
import {
|
||||||
fixedSchemaWrapperExtension,
|
fixedSchemaWrapperExtension,
|
||||||
moveSelectionIntoFixedWrapper,
|
moveSelectionIntoFixedWrapper,
|
||||||
} from '$lib/workspace/config-source/fixed-schema-wrapper.ts';
|
} from '#lib/workspace/config-source/fixed-schema-wrapper.ts';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
value = '',
|
value = '',
|
||||||
|
|||||||
@@ -4,13 +4,13 @@
|
|||||||
import type {
|
import type {
|
||||||
Diagnostic, WorkspaceMetadataSettingsResponse, WorkspaceSigningIdentityResponse,
|
Diagnostic, WorkspaceMetadataSettingsResponse, WorkspaceSigningIdentityResponse,
|
||||||
WorkspaceDeletionPreflightResponse, WorkspaceDeletionOperationResponse, WorkspaceDeletionRequest,
|
WorkspaceDeletionPreflightResponse, WorkspaceDeletionOperationResponse, WorkspaceDeletionRequest,
|
||||||
} from '$lib/generated/legacy-server-api';
|
} from '#lib/generated/legacy-server-api.ts';
|
||||||
import { workspaceApiPath } from '$lib/workspace/api/http';
|
import { workspaceApiPath } from '#lib/workspace/api/http.ts';
|
||||||
import { disposeWorkspaceMultiplexer } from '$lib/workspace/multiplexer';
|
import { disposeWorkspaceMultiplexer } from '#lib/workspace/multiplexer.ts';
|
||||||
import { disposeWorkspaceWorkersStore } from '$lib/workspace/sidebar/worker-subscription';
|
import { disposeWorkspaceWorkersStore } from '#lib/workspace/sidebar/worker-subscription.ts';
|
||||||
import { fetchWorkspaceMetadata, updateWorkspaceMetadata, fetchWorkspaceSigningIdentity, provisionWorkspaceSigningIdentity } from './profile-api';
|
import { fetchWorkspaceMetadata, updateWorkspaceMetadata, fetchWorkspaceSigningIdentity, provisionWorkspaceSigningIdentity } from './profile-api';
|
||||||
import { preflightWorkspaceDeletion, startWorkspaceDeletion, getWorkspaceDeletion } from './workspace-deletion-api';
|
import { preflightWorkspaceDeletion, startWorkspaceDeletion, getWorkspaceDeletion } from './workspace-deletion-api';
|
||||||
import BevelLine from '$lib/workspace/ui/BevelLine.svelte';
|
import BevelLine from '#lib/workspace/ui/BevelLine.svelte';
|
||||||
|
|
||||||
// The route keys this component by Workspace and owner permission. Requests from
|
// The route keys this component by Workspace and owner permission. Requests from
|
||||||
// a disposed scope must not publish state, focus, or navigation into a new one.
|
// a disposed scope must not publish state, focus, or navigation into a new one.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Diagnostic as ServerApiDiagnostic } from "$lib/generated/legacy-server-api";
|
import type { Diagnostic as ServerApiDiagnostic } from "#lib/generated/legacy-server-api.ts";
|
||||||
|
|
||||||
export type Diagnostic = ServerApiDiagnostic;
|
export type Diagnostic = ServerApiDiagnostic;
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import type {
|
|||||||
WorkspaceSigningIdentityPublic,
|
WorkspaceSigningIdentityPublic,
|
||||||
WorkspaceSigningIdentityResponse,
|
WorkspaceSigningIdentityResponse,
|
||||||
WorkspaceSigningIdentityState,
|
WorkspaceSigningIdentityState,
|
||||||
} from "$lib/generated/legacy-server-api";
|
} from "#lib/generated/legacy-server-api.ts";
|
||||||
import { readBoundedJson } from "$lib/workspace/api/http.ts";
|
import { readBoundedJson } from "#lib/workspace/api/http.ts";
|
||||||
|
|
||||||
const MAX_PROFILE_RESPONSE_BYTES = 4 * 1024 * 1024;
|
const MAX_PROFILE_RESPONSE_BYTES = 4 * 1024 * 1024;
|
||||||
const MAX_PROFILE_ERROR_BYTES = 4 * 1024;
|
const MAX_PROFILE_ERROR_BYTES = 4 * 1024;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ExternalWorkdirPermissions } from "$lib/generated/workdir-api";
|
import type { ExternalWorkdirPermissions } from "#lib/generated/workdir-api.ts";
|
||||||
|
|
||||||
/** Render an effective attachment/session capability intersection without widening it. */
|
/** Render an effective attachment/session capability intersection without widening it. */
|
||||||
export function formatWorkdirPermissions(
|
export function formatWorkdirPermissions(
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import type {
|
|||||||
WorkspaceDeletionOperationResponse,
|
WorkspaceDeletionOperationResponse,
|
||||||
WorkspaceDeletionPreflightResponse,
|
WorkspaceDeletionPreflightResponse,
|
||||||
WorkspaceDeletionRequest,
|
WorkspaceDeletionRequest,
|
||||||
} from "$lib/generated/legacy-server-api";
|
} from "#lib/generated/legacy-server-api.ts";
|
||||||
import { loadJson } from "$lib/workspace/api/http";
|
import { loadJson } from "#lib/workspace/api/http.ts";
|
||||||
import {
|
import {
|
||||||
parseWorkspaceDeletionOperationResponse,
|
parseWorkspaceDeletionOperationResponse,
|
||||||
parseWorkspaceDeletionPreflightResponse,
|
parseWorkspaceDeletionPreflightResponse,
|
||||||
} from "$lib/workspace/api/workspace-model";
|
} from "#lib/workspace/api/workspace-model.ts";
|
||||||
|
|
||||||
const deletionResponsePolicy = {
|
const deletionResponsePolicy = {
|
||||||
maxResponseBytes: 2 * 1024 * 1024,
|
maxResponseBytes: 2 * 1024 * 1024,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { WorkspaceCatalogRecord } from "$lib/workspace/api/workspace-catalog";
|
import type { WorkspaceCatalogRecord } from "#lib/workspace/api/workspace-catalog.ts";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { workspaceRoute } from "$lib/workspace/api/http";
|
import { workspaceRoute } from "#lib/workspace/api/http.ts";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
currentPath?: string;
|
currentPath?: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { mergeRequestPagePath } from "$lib/workspace/api/merge-requests";
|
import { mergeRequestPagePath } from "#lib/workspace/api/merge-requests.ts";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
currentPath?: string;
|
currentPath?: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { workspaceRoute } from '$lib/workspace/api/http';
|
import { workspaceRoute } from '#lib/workspace/api/http.ts';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
currentPath?: string;
|
currentPath?: string;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { workspaceRoute } from '$lib/workspace/api/http';
|
import { workspaceRoute } from '#lib/workspace/api/http.ts';
|
||||||
import { SETTINGS_SECTIONS, settingsSectionHref } from '$lib/workspace/settings/model';
|
import { SETTINGS_SECTIONS, settingsSectionHref } from '#lib/workspace/settings/model.ts';
|
||||||
import type { SettingsSectionId } from '$lib/workspace/settings/model';
|
import type { SettingsSectionId } from '#lib/workspace/settings/model.ts';
|
||||||
import type { SidebarSnippet } from './context';
|
import type { SidebarSnippet } from './context';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, type Snippet } from 'svelte';
|
import { onDestroy, type Snippet } from 'svelte';
|
||||||
import Bevel from '$lib/workspace/ui/Bevel.svelte';
|
import Bevel from '#lib/workspace/ui/Bevel.svelte';
|
||||||
import SidebarToggleIcon from './SidebarToggleIcon.svelte';
|
import SidebarToggleIcon from './SidebarToggleIcon.svelte';
|
||||||
import './sidebar.css';
|
import './sidebar.css';
|
||||||
|
|
||||||
@@ -93,6 +93,7 @@
|
|||||||
<div class="sidebar-frame__surface">
|
<div class="sidebar-frame__surface">
|
||||||
<div
|
<div
|
||||||
class="sidebar-hover-region"
|
class="sidebar-hover-region"
|
||||||
|
role="presentation"
|
||||||
onpointerenter={enter}
|
onpointerenter={enter}
|
||||||
onpointerleave={leave}
|
onpointerleave={leave}
|
||||||
onpointercancel={leave}
|
onpointercancel={leave}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { workspaceRoute } from '$lib/workspace/api/http';
|
import { workspaceRoute } from '#lib/workspace/api/http.ts';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
currentPath?: string;
|
currentPath?: string;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Spinner from '$lib/workspace/console/Spinner.svelte';
|
import Spinner from '#lib/workspace/console/Spinner.svelte';
|
||||||
import { workerConsoleHref } from '$lib/workspace/resource-links';
|
import { workerConsoleHref } from '#lib/workspace/resource-links.ts';
|
||||||
import { pushWorkspaceAlert } from '$lib/workspace/alerts/store';
|
import { pushWorkspaceAlert } from '#lib/workspace/alerts/store.ts';
|
||||||
import {
|
import {
|
||||||
canStopSidebarWorker,
|
canStopSidebarWorker,
|
||||||
canDeleteSidebarWorker,
|
canDeleteSidebarWorker,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import './sidebar.css';
|
import './sidebar.css';
|
||||||
import { workspaceRoute } from '$lib/workspace/api/http';
|
import { workspaceRoute } from '#lib/workspace/api/http.ts';
|
||||||
import { ownsRoutePath } from './route-ownership';
|
import { ownsRoutePath } from './route-ownership';
|
||||||
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
|
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
|
||||||
import MemoryNavSection from './MemoryNavSection.svelte';
|
import MemoryNavSection from './MemoryNavSection.svelte';
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import {
|
import {
|
||||||
listWorkspaces,
|
listWorkspaces,
|
||||||
type WorkspaceCatalogRecord,
|
type WorkspaceCatalogRecord,
|
||||||
} from "$lib/workspace/api/workspace-catalog";
|
} from "#lib/workspace/api/workspace-catalog.ts";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
currentWorkspaceId,
|
currentWorkspaceId,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type {
|
|||||||
CleanupWorkerCandidate,
|
CleanupWorkerCandidate,
|
||||||
RuntimeCleanupExecutionResponse,
|
RuntimeCleanupExecutionResponse,
|
||||||
RuntimeCleanupPlanResponse,
|
RuntimeCleanupPlanResponse,
|
||||||
} from "$lib/generated/runtime-api";
|
} from "#lib/generated/runtime-api.ts";
|
||||||
import type {
|
import type {
|
||||||
BrowserCreateWorkerResponse as SharedBrowserCreateWorkerResponse,
|
BrowserCreateWorkerResponse as SharedBrowserCreateWorkerResponse,
|
||||||
BrowserWorkerWorkingDirectorySelection
|
BrowserWorkerWorkingDirectorySelection
|
||||||
@@ -17,7 +17,7 @@ import type {
|
|||||||
WorkerSummary as SharedWorkerSummary,
|
WorkerSummary as SharedWorkerSummary,
|
||||||
WorkerWorkdirAttachmentSummary as SharedWorkerWorkdirAttachmentSummary,
|
WorkerWorkdirAttachmentSummary as SharedWorkerWorkdirAttachmentSummary,
|
||||||
WorkingDirectoryRepositoryOption as SharedWorkingDirectoryRepositoryOption,
|
WorkingDirectoryRepositoryOption as SharedWorkingDirectoryRepositoryOption,
|
||||||
} from "$lib/generated/worker-launch-api";
|
} from "#lib/generated/worker-launch-api.ts";
|
||||||
import type {
|
import type {
|
||||||
WorkingDirectoryCreateRequest,
|
WorkingDirectoryCreateRequest,
|
||||||
WorkingDirectoryCreateResponse,
|
WorkingDirectoryCreateResponse,
|
||||||
@@ -25,14 +25,14 @@ import type {
|
|||||||
WorkingDirectoryListResponse,
|
WorkingDirectoryListResponse,
|
||||||
WorkingDirectoryOccupancy,
|
WorkingDirectoryOccupancy,
|
||||||
WorkingDirectorySummary,
|
WorkingDirectorySummary,
|
||||||
} from "$lib/generated/workdir-api";
|
} from "#lib/generated/workdir-api.ts";
|
||||||
import type {
|
import type {
|
||||||
Event as PodProtocolEvent,
|
Event as PodProtocolEvent,
|
||||||
Method as PodProtocolMethod,
|
Method as PodProtocolMethod,
|
||||||
Segment as PodProtocolSegment,
|
Segment as PodProtocolSegment,
|
||||||
SubscriptionWorkerJob,
|
SubscriptionWorkerJob,
|
||||||
WorkerStateSnapshot,
|
WorkerStateSnapshot,
|
||||||
} from "$lib/generated/protocol";
|
} from "#lib/generated/protocol.ts";
|
||||||
import type {
|
import type {
|
||||||
GitCommitSummary as SharedGitCommitSummary,
|
GitCommitSummary as SharedGitCommitSummary,
|
||||||
GitRemoteSummary as SharedGitRemoteSummary,
|
GitRemoteSummary as SharedGitRemoteSummary,
|
||||||
@@ -42,7 +42,7 @@ import type {
|
|||||||
RepositoryLogResponse as SharedRepositoryLogResponse,
|
RepositoryLogResponse as SharedRepositoryLogResponse,
|
||||||
RepositorySummary as SharedRepositorySummary,
|
RepositorySummary as SharedRepositorySummary,
|
||||||
WorkspaceResponse as SharedWorkspaceResponse,
|
WorkspaceResponse as SharedWorkspaceResponse,
|
||||||
} from "$lib/workspace/api/workspace-model";
|
} from "#lib/workspace/api/workspace-model.ts";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
PodProtocolEvent,
|
PodProtocolEvent,
|
||||||
@@ -157,7 +157,7 @@ export type {
|
|||||||
TicketDetail,
|
TicketDetail,
|
||||||
TicketEventDetail,
|
TicketEventDetail,
|
||||||
TicketListResponse,
|
TicketListResponse,
|
||||||
} from "$lib/generated/ticket-api";
|
} from "#lib/generated/ticket-api.ts";
|
||||||
export type {
|
export type {
|
||||||
TicketDetailDerivedRelation as DerivedTicketRelation,
|
TicketDetailDerivedRelation as DerivedTicketRelation,
|
||||||
TicketDetailRelation as TicketRelation,
|
TicketDetailRelation as TicketRelation,
|
||||||
@@ -165,7 +165,7 @@ export type {
|
|||||||
TicketDetailRelationNotice as TicketRelationNotice,
|
TicketDetailRelationNotice as TicketRelationNotice,
|
||||||
TicketDetailRelationView as TicketRelationView,
|
TicketDetailRelationView as TicketRelationView,
|
||||||
TicketListItemSummary as TicketSummary,
|
TicketListItemSummary as TicketSummary,
|
||||||
} from "$lib/generated/ticket-api";
|
} from "#lib/generated/ticket-api.ts";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
CompanionCancelRequest,
|
CompanionCancelRequest,
|
||||||
@@ -178,4 +178,4 @@ export type {
|
|||||||
CompanionTranscriptProjection,
|
CompanionTranscriptProjection,
|
||||||
CompanionTranscriptRole,
|
CompanionTranscriptRole,
|
||||||
CompanionTransportSummary,
|
CompanionTransportSummary,
|
||||||
} from "$lib/generated/companion-api";
|
} from "#lib/generated/companion-api.ts";
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { workspaceApiPath } from "$lib/workspace/api/http";
|
import { workspaceApiPath } from "#lib/workspace/api/http.ts";
|
||||||
import {
|
import {
|
||||||
parseRuntimeCleanupExecution,
|
parseRuntimeCleanupExecution,
|
||||||
parseRuntimeCleanupPlan,
|
parseRuntimeCleanupPlan,
|
||||||
parseRuntimeWorkerLifecycleResult,
|
parseRuntimeWorkerLifecycleResult,
|
||||||
} from "$lib/workspace/api/runtime-workers";
|
} from "#lib/workspace/api/runtime-workers.ts";
|
||||||
import type { Diagnostic, Worker } from "./types";
|
import type { Diagnostic, Worker } from "./types";
|
||||||
|
|
||||||
type FetchFn = typeof fetch;
|
type FetchFn = typeof fetch;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type {
|
import type {
|
||||||
BrowserWorkerWorkingDirectorySelection,
|
BrowserWorkerWorkingDirectorySelection,
|
||||||
CreateWorkspaceWorkerRequest,
|
CreateWorkspaceWorkerRequest,
|
||||||
} from "$lib/generated/worker-launch-api";
|
} from "#lib/generated/worker-launch-api.ts";
|
||||||
import { parseCreateWorkspaceWorkerRequest } from "$lib/workspace/api/workers";
|
import { parseCreateWorkspaceWorkerRequest } from "#lib/workspace/api/workers.ts";
|
||||||
|
|
||||||
import type { WorkerLaunchOptionsResponse } from "./types";
|
import type { WorkerLaunchOptionsResponse } from "./types";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { WorkerStateSnapshot } from "$lib/generated/protocol";
|
import type { WorkerStateSnapshot } from "#lib/generated/protocol.ts";
|
||||||
|
|
||||||
export function liveWorkerState(worker: {
|
export function liveWorkerState(worker: {
|
||||||
state: string;
|
state: string;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { afterEach, expect, test, vi } from "vitest";
|
|||||||
import type {
|
import type {
|
||||||
SubscriptionFrame,
|
SubscriptionFrame,
|
||||||
SubscriptionWorker,
|
SubscriptionWorker,
|
||||||
} from "$lib/generated/protocol";
|
} from "#lib/generated/protocol.ts";
|
||||||
import WorkersNavSection from "./WorkersNavSection.svelte";
|
import WorkersNavSection from "./WorkersNavSection.svelte";
|
||||||
import { disposeWorkspaceWorkersStore } from "./worker-subscription";
|
import { disposeWorkspaceWorkersStore } from "./worker-subscription";
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ const transport = vi.hoisted(() => ({
|
|||||||
listener: null as { onFrame(frame: SubscriptionFrame): void } | null,
|
listener: null as { onFrame(frame: SubscriptionFrame): void } | null,
|
||||||
close: vi.fn(),
|
close: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("$lib/workspace/multiplexer", () => ({
|
vi.mock("#lib/workspace/multiplexer.ts", () => ({
|
||||||
workspaceMultiplexer: () => ({
|
workspaceMultiplexer: () => ({
|
||||||
subscribe: (
|
subscribe: (
|
||||||
_selector: unknown,
|
_selector: unknown,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type {
|
|||||||
SubscriptionEventPayload,
|
SubscriptionEventPayload,
|
||||||
SubscriptionFrame,
|
SubscriptionFrame,
|
||||||
SubscriptionWorker,
|
SubscriptionWorker,
|
||||||
} from '$lib/generated/protocol';
|
} from '#lib/generated/protocol.ts';
|
||||||
|
|
||||||
export type WorkspaceWorkersProjection = {
|
export type WorkspaceWorkersProjection = {
|
||||||
workers: Map<string, SubscriptionWorker>;
|
workers: Map<string, SubscriptionWorker>;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { SubscriptionFrame, SubscriptionWorker } from '$lib/generated/protocol';
|
import type { SubscriptionFrame, SubscriptionWorker } from '#lib/generated/protocol.ts';
|
||||||
|
|
||||||
function assertEquals(actual: unknown, expected: unknown): void {
|
function assertEquals(actual: unknown, expected: unknown): void {
|
||||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { type Readable, readable } from "svelte/store";
|
import { type Readable, readable } from "svelte/store";
|
||||||
import type { WorkingDirectorySummary } from "$lib/generated/workdir-api";
|
import type { WorkingDirectorySummary } from "#lib/generated/workdir-api.ts";
|
||||||
import type { SubscriptionWorker } from "$lib/generated/protocol";
|
import type { SubscriptionWorker } from "#lib/generated/protocol.ts";
|
||||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
||||||
import { parseWorkingDirectoryListResponse } from "$lib/workspace/api/workdirs";
|
import { parseWorkingDirectoryListResponse } from "#lib/workspace/api/workdirs.ts";
|
||||||
import { workspaceMultiplexer } from "$lib/workspace/multiplexer";
|
import { workspaceMultiplexer } from "#lib/workspace/multiplexer.ts";
|
||||||
import {
|
import {
|
||||||
applyWorkspaceWorkersFrame,
|
applyWorkspaceWorkersFrame,
|
||||||
createWorkspaceWorkersProjection,
|
createWorkspaceWorkersProjection,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { WorkingDirectorySummary } from "$lib/generated/workdir-api";
|
import type { WorkingDirectorySummary } from "#lib/generated/workdir-api.ts";
|
||||||
import {
|
import {
|
||||||
type SidebarWorkdirAttachment,
|
type SidebarWorkdirAttachment,
|
||||||
sidebarWorkdirMeta,
|
sidebarWorkdirMeta,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { WorkingDirectorySummary } from "$lib/generated/workdir-api";
|
import type { WorkingDirectorySummary } from "#lib/generated/workdir-api.ts";
|
||||||
import type { SubscriptionWorkerWorkdirAttachment } from "$lib/generated/protocol";
|
import type { SubscriptionWorkerWorkdirAttachment } from "#lib/generated/protocol.ts";
|
||||||
|
|
||||||
const SHORT_REFERENCE_LENGTH = 12;
|
const SHORT_REFERENCE_LENGTH = 12;
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
type SkillProvenance,
|
type SkillProvenance,
|
||||||
type SkillResourceRef,
|
type SkillResourceRef,
|
||||||
type SkillSourceKind,
|
type SkillSourceKind,
|
||||||
} from "$lib/generated/skill-api.ts";
|
} from "#lib/generated/skill-api.ts";
|
||||||
|
|
||||||
export class SkillApiContractError extends Error {
|
export class SkillApiContractError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { BrowserWorkspaceOrchestratorResponse } from "$lib/generated/worker-launch-api";
|
import type { BrowserWorkspaceOrchestratorResponse } from "#lib/generated/worker-launch-api.ts";
|
||||||
import type {
|
import type {
|
||||||
TicketDetail,
|
TicketDetail,
|
||||||
TicketListItemSummary,
|
TicketListItemSummary,
|
||||||
} from "$lib/generated/ticket-api";
|
} from "#lib/generated/ticket-api.ts";
|
||||||
|
|
||||||
export const TICKET_STATES = [
|
export const TICKET_STATES = [
|
||||||
"planning",
|
"planning",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user