feat(background-task): rendu live des tâches de fond — subscriber UI + canal IPC (#58)
Câble un canal attachable au flux de sortie d'une tâche de fond (runner infrastructure + commande app-tauri + port/adaptateurs frontend) et le panneau ProjectWorkStatePanel s'y abonne pour un rendu live au lieu d'un état figé au dernier snapshot. Validations obtenues avant commit : - cargo test -p infrastructure --test background_task_runner : vert - cargo check -p backend -p app-tauri : vert - npx vitest run src/features/workstate/workstate.test.tsx : vert - npm run typecheck : vert - verdict QA #58 : vert Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
30
Cargo.lock
generated
30
Cargo.lock
generated
@ -1192,6 +1192,16 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fs4"
|
||||||
|
version = "0.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c29c30684418547d476f0b48e84f4821639119c483b1eccd566c8cd0cd05f521"
|
||||||
|
dependencies = [
|
||||||
|
"rustix",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fsevent-sys"
|
name = "fsevent-sys"
|
||||||
version = "4.1.0"
|
version = "4.1.0"
|
||||||
@ -1970,6 +1980,7 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"domain",
|
"domain",
|
||||||
"fastembed",
|
"fastembed",
|
||||||
|
"fs4",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"git2",
|
"git2",
|
||||||
"hex",
|
"hex",
|
||||||
@ -2290,6 +2301,12 @@ dependencies = [
|
|||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.4.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
@ -3526,6 +3543,19 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "0.38.44"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.12.1",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.40"
|
version = "0.23.40"
|
||||||
|
|||||||
@ -21,6 +21,7 @@ serde_json = "1"
|
|||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
|
fs4 = "0.12"
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time"] }
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
|||||||
@ -30,6 +30,7 @@ use application::{
|
|||||||
UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
|
UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
|
||||||
UpdateProjectSystemPermissionsInput, UpdateSkillInput,
|
UpdateProjectSystemPermissionsInput, UpdateSkillInput,
|
||||||
};
|
};
|
||||||
|
use backend::stream::OutputSink;
|
||||||
use domain::ports::ModelServerRuntime;
|
use domain::ports::ModelServerRuntime;
|
||||||
use domain::ports::PtyHandle;
|
use domain::ports::PtyHandle;
|
||||||
|
|
||||||
@ -38,9 +39,9 @@ use crate::dto::{
|
|||||||
parse_layout_id, parse_memory_slug, parse_model_server_id, parse_node_id, parse_profile_id,
|
parse_layout_id, parse_memory_slug, parse_model_server_id, parse_node_id, parse_profile_id,
|
||||||
parse_project_id, parse_session_id, parse_skill_id, parse_task_id, parse_template_id,
|
parse_project_id, parse_session_id, parse_skill_id, parse_task_id, parse_template_id,
|
||||||
parse_ticket_id, save_model_server_input, AgentDriftListDto, AgentDto, AgentListDto,
|
parse_ticket_id, save_model_server_input, AgentDriftListDto, AgentDto, AgentListDto,
|
||||||
AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachLiveAgentRequestDto,
|
AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachBackgroundTaskResultDto,
|
||||||
AttachLiveAgentResponseDto, BackgroundTaskDto, ChangeAgentProfileDto,
|
AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, BackgroundTaskDto,
|
||||||
ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto,
|
ChangeAgentProfileDto, ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto,
|
||||||
CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
|
CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
|
||||||
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
|
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
|
||||||
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
|
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
|
||||||
@ -77,6 +78,7 @@ use crate::embedded_server::{
|
|||||||
};
|
};
|
||||||
use crate::pty::{PtyBridge, PtyChunk};
|
use crate::pty::{PtyBridge, PtyChunk};
|
||||||
use crate::state::{AppState, FocusedProjectDto};
|
use crate::state::{AppState, FocusedProjectDto};
|
||||||
|
use crate::stream::TauriChannelSink;
|
||||||
use domain::{DeviceId, SkillRef, SkillScope};
|
use domain::{DeviceId, SkillRef, SkillScope};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@ -3745,6 +3747,54 @@ pub async fn retry_background_task(
|
|||||||
.map_err(ErrorDto::from)
|
.map_err(ErrorDto::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `attach_background_task` — attach a UI output subscriber to a command-backed
|
||||||
|
/// background task.
|
||||||
|
///
|
||||||
|
/// If the task is still live, the response contains PTY scrollback and a fresh
|
||||||
|
/// live subscription is pumped to `on_output`. If it is already terminal, the
|
||||||
|
/// response contains only the persisted output tail and no PTY subscription is
|
||||||
|
/// attempted.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] (`INVALID` for malformed id, `NOT_FOUND` if the task
|
||||||
|
/// has no live handle and no terminal result, `PROCESS`/`STORE` on backend
|
||||||
|
/// failure).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn attach_background_task(
|
||||||
|
task_id: String,
|
||||||
|
on_output: Channel<PtyChunk>,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<AttachBackgroundTaskResultDto, ErrorDto> {
|
||||||
|
let id = parse_task_id(&task_id)?;
|
||||||
|
let plan = state
|
||||||
|
.background_task_attach_plan(id)
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?;
|
||||||
|
let live = plan.live_handle.is_some();
|
||||||
|
|
||||||
|
if let Some(handle) = plan.live_handle {
|
||||||
|
match state.pty_port.subscribe_output(&handle) {
|
||||||
|
Ok(stream) => {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let sink = TauriChannelSink::new(on_output);
|
||||||
|
for chunk in stream {
|
||||||
|
if sink.send(chunk).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => return Err(ErrorDto::from(AppError::from(e))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(AttachBackgroundTaskResultDto {
|
||||||
|
task_id: plan.task_id.to_string(),
|
||||||
|
scrollback: plan.scrollback,
|
||||||
|
live,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// `list_background_tasks` — read the background-task read-model for a project,
|
/// `list_background_tasks` — read the background-task read-model for a project,
|
||||||
/// optionally narrowed to one owning agent.
|
/// optionally narrowed to one owning agent.
|
||||||
///
|
///
|
||||||
|
|||||||
@ -353,6 +353,7 @@ pub fn run() {
|
|||||||
commands::spawn_background_command,
|
commands::spawn_background_command,
|
||||||
commands::cancel_background_task,
|
commands::cancel_background_task,
|
||||||
commands::retry_background_task,
|
commands::retry_background_task,
|
||||||
|
commands::attach_background_task,
|
||||||
commands::list_background_tasks,
|
commands::list_background_tasks,
|
||||||
plugins::plugin_list_plugins,
|
plugins::plugin_list_plugins,
|
||||||
plugins::plugin_review_package,
|
plugins::plugin_review_package,
|
||||||
|
|||||||
@ -562,6 +562,20 @@ pub struct ReattachResultDto {
|
|||||||
pub scrollback: Vec<u8>,
|
pub scrollback: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Response DTO for `attach_background_task`: the retained bytes to repaint for
|
||||||
|
/// a background command task before optional live bytes arrive on the channel.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AttachBackgroundTaskResultDto {
|
||||||
|
/// The task that was attached (echoed back for the frontend).
|
||||||
|
pub task_id: String,
|
||||||
|
/// Recent output bytes: PTY scrollback for a live task, persisted tail for a
|
||||||
|
/// terminal task.
|
||||||
|
pub scrollback: Vec<u8>,
|
||||||
|
/// Whether a live PTY subscription was installed for subsequent output.
|
||||||
|
pub live: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// Request DTO for `write_terminal`.
|
/// Request DTO for `write_terminal`.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|||||||
@ -60,19 +60,19 @@ use domain::ports::{
|
|||||||
EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator,
|
EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator,
|
||||||
IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall, MemoryStore,
|
IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall, MemoryStore,
|
||||||
ModelArtifactDownloader, PermissionStore, PluginManifestValidator, PluginMcpSupervisor,
|
ModelArtifactDownloader, PermissionStore, PluginManifestValidator, PluginMcpSupervisor,
|
||||||
PluginPackageStore, PluginRegistryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort,
|
PluginPackageStore, PluginRegistryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyHandle,
|
||||||
RuntimePermissionProbe, ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore,
|
PtyPort, RuntimePermissionProbe, ScheduledTask, Scheduler, SecretStore, SkillStore,
|
||||||
StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, ToolInvoker,
|
SprintStore, StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore,
|
||||||
WakeError, WakeReason, WindowStateStore,
|
ToolInvoker, WakeError, WakeReason, WindowStateStore,
|
||||||
};
|
};
|
||||||
use domain::profile::{
|
use domain::profile::{
|
||||||
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||||
};
|
};
|
||||||
use domain::remote::RemoteKind;
|
use domain::remote::RemoteKind;
|
||||||
use domain::{
|
use domain::{
|
||||||
AgentId, AgentInbox, BackgroundTask, BackgroundTaskWakePolicy, DomainEvent, EmbedderProfile,
|
AgentId, AgentInbox, BackgroundTask, BackgroundTaskResult, BackgroundTaskWakePolicy,
|
||||||
InboxError, InboxItem, InboxItemKind, InboxReceiptStatus, InboxSource, Project, ProjectId,
|
DomainEvent, EmbedderProfile, InboxError, InboxItem, InboxItemKind, InboxReceiptStatus,
|
||||||
TaskId, TicketId,
|
InboxSource, Project, ProjectId, TaskId, TicketId,
|
||||||
};
|
};
|
||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@ -1099,6 +1099,8 @@ pub struct BackendCore {
|
|||||||
pub cancel_background_task: Arc<CancelBackgroundTask>,
|
pub cancel_background_task: Arc<CancelBackgroundTask>,
|
||||||
/// Retry a terminal command task under a fresh task id.
|
/// Retry a terminal command task under a fresh task id.
|
||||||
pub retry_background_task: Arc<RetryBackgroundTask>,
|
pub retry_background_task: Arc<RetryBackgroundTask>,
|
||||||
|
/// Concrete command runner retained for infrastructure-only live PTY attach.
|
||||||
|
pub background_command_runner: Arc<CommandBackgroundRunner>,
|
||||||
/// Store handle used by `list_background_tasks` to read the task read-model.
|
/// Store handle used by `list_background_tasks` to read the task read-model.
|
||||||
pub background_task_store: Arc<dyn BackgroundTaskStore>,
|
pub background_task_store: Arc<dyn BackgroundTaskStore>,
|
||||||
// --- Plugins (#43) ---
|
// --- Plugins (#43) ---
|
||||||
@ -1255,7 +1257,93 @@ pub struct BackendCore {
|
|||||||
pub template_tool_binder: Arc<LateBoundTemplateToolProvider>,
|
pub template_tool_binder: Arc<LateBoundTemplateToolProvider>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Backend decision for attaching a UI subscriber to a background task.
|
||||||
|
pub struct BackgroundTaskAttachPlan {
|
||||||
|
/// Attached task id.
|
||||||
|
pub task_id: TaskId,
|
||||||
|
/// Bytes to repaint immediately.
|
||||||
|
pub scrollback: Vec<u8>,
|
||||||
|
/// Live PTY handle to subscribe to. `None` means the task is terminal and
|
||||||
|
/// the persisted tail is the whole attach payload.
|
||||||
|
pub live_handle: Option<PtyHandle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_background_task_port_error(err: BackgroundTaskPortError) -> AppError {
|
||||||
|
match err {
|
||||||
|
BackgroundTaskPortError::NotFound => AppError::NotFound("background task".to_owned()),
|
||||||
|
BackgroundTaskPortError::AlreadyExists => {
|
||||||
|
AppError::Invalid("background task already exists".to_owned())
|
||||||
|
}
|
||||||
|
BackgroundTaskPortError::Invalid(msg) => AppError::Invalid(msg),
|
||||||
|
BackgroundTaskPortError::Runner(msg) => AppError::Process(msg),
|
||||||
|
BackgroundTaskPortError::Store(msg) => AppError::Store(msg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn background_task_tail_bytes(task: &BackgroundTask) -> Vec<u8> {
|
||||||
|
match &task.result {
|
||||||
|
Some(BackgroundTaskResult::Success {
|
||||||
|
stdout_tail,
|
||||||
|
stderr_tail,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
| Some(BackgroundTaskResult::Failure {
|
||||||
|
stdout_tail,
|
||||||
|
stderr_tail,
|
||||||
|
..
|
||||||
|
}) => [stdout_tail.as_deref(), stderr_tail.as_deref()]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("")
|
||||||
|
.into_bytes(),
|
||||||
|
Some(BackgroundTaskResult::Cancelled { reason, .. })
|
||||||
|
| Some(BackgroundTaskResult::Expired { reason, .. }) => reason.clone().into_bytes(),
|
||||||
|
None => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl BackendCore {
|
impl BackendCore {
|
||||||
|
/// Builds an attach plan for a background task output view.
|
||||||
|
///
|
||||||
|
/// A running task uses the runner's live PTY handle plus PTY scrollback.
|
||||||
|
/// A completed task intentionally returns only the persisted output tail and
|
||||||
|
/// no handle, so callers do not subscribe to a dead PTY.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`AppError::NotFound`] when the task is neither live in the
|
||||||
|
/// runner nor terminal in the store, and [`AppError::Process`] /
|
||||||
|
/// [`AppError::Store`] for PTY/store failures.
|
||||||
|
pub async fn background_task_attach_plan(
|
||||||
|
&self,
|
||||||
|
task_id: TaskId,
|
||||||
|
) -> Result<BackgroundTaskAttachPlan, AppError> {
|
||||||
|
if let Some(handle) = self.background_command_runner.pty_handle_for(task_id) {
|
||||||
|
let scrollback = self.pty_port.scrollback(&handle).map_err(AppError::from)?;
|
||||||
|
return Ok(BackgroundTaskAttachPlan {
|
||||||
|
task_id,
|
||||||
|
scrollback,
|
||||||
|
live_handle: Some(handle),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let task = self
|
||||||
|
.background_task_store
|
||||||
|
.get(task_id)
|
||||||
|
.await
|
||||||
|
.map_err(map_background_task_port_error)?
|
||||||
|
.ok_or_else(|| AppError::NotFound("background task".to_owned()))?;
|
||||||
|
if task.is_terminal() {
|
||||||
|
return Ok(BackgroundTaskAttachPlan {
|
||||||
|
task_id,
|
||||||
|
scrollback: background_task_tail_bytes(&task),
|
||||||
|
live_handle: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(AppError::NotFound("background task live handle".to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
/// **Composition root.** Builds all adapters and use cases.
|
/// **Composition root.** Builds all adapters and use cases.
|
||||||
///
|
///
|
||||||
/// `app_data_dir` is the machine-local IDE data directory (ARCHITECTURE
|
/// `app_data_dir` is the machine-local IDE data directory (ARCHITECTURE
|
||||||
@ -2827,6 +2915,7 @@ impl BackendCore {
|
|||||||
spawn_background_command,
|
spawn_background_command,
|
||||||
cancel_background_task,
|
cancel_background_task,
|
||||||
retry_background_task,
|
retry_background_task,
|
||||||
|
background_command_runner: Arc::clone(&background_runner),
|
||||||
background_task_store: Arc::clone(&background_tasks_port),
|
background_task_store: Arc::clone(&background_tasks_port),
|
||||||
review_plugin_package,
|
review_plugin_package,
|
||||||
install_plugin_from_archive,
|
install_plugin_from_archive,
|
||||||
|
|||||||
@ -17,6 +17,7 @@ tokio = { workspace = true, features = ["process", "time"] }
|
|||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
futures-util = { workspace = true }
|
futures-util = { workspace = true }
|
||||||
|
fs4 = { workspace = true }
|
||||||
# Ergonomic error enums for the MCP adapter (tool-mapping / transport errors).
|
# Ergonomic error enums for the MCP adapter (tool-mapping / transport errors).
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
|||||||
@ -101,6 +101,17 @@ impl CommandBackgroundRunner {
|
|||||||
u64::try_from(self.clock.now_millis().max(0)).unwrap_or(0)
|
u64::try_from(self.clock.now_millis().max(0)).unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the live PTY handle for a running task, when this runner still
|
||||||
|
/// owns one.
|
||||||
|
#[must_use]
|
||||||
|
pub fn pty_handle_for(&self, task_id: TaskId) -> Option<PtyHandle> {
|
||||||
|
self.running
|
||||||
|
.lock()
|
||||||
|
.expect("runner registry poisoned")
|
||||||
|
.get(&task_id)
|
||||||
|
.map(|control| control.pty_handle.clone())
|
||||||
|
}
|
||||||
|
|
||||||
/// Detached worker driving one command to its single completion.
|
/// Detached worker driving one command to its single completion.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn run_to_completion(
|
async fn run_to_completion(
|
||||||
|
|||||||
@ -25,6 +25,7 @@ pub mod id;
|
|||||||
pub mod input;
|
pub mod input;
|
||||||
pub mod inspector;
|
pub mod inspector;
|
||||||
pub mod issues;
|
pub mod issues;
|
||||||
|
pub mod lock;
|
||||||
pub mod mailbox;
|
pub mod mailbox;
|
||||||
pub mod model_catalogue;
|
pub mod model_catalogue;
|
||||||
pub mod model_server;
|
pub mod model_server;
|
||||||
@ -68,6 +69,7 @@ pub use inspector::{
|
|||||||
transcript_activity_token, ClaudeTranscriptInspector, ClaudeTranscriptTurnWatcher,
|
transcript_activity_token, ClaudeTranscriptInspector, ClaudeTranscriptTurnWatcher,
|
||||||
};
|
};
|
||||||
pub use issues::{FsIssueNumberAllocator, FsIssueStore};
|
pub use issues::{FsIssueNumberAllocator, FsIssueStore};
|
||||||
|
pub use lock::{acquire_app_data_dir_lock, AppDataDirLock, AppDataDirLockError};
|
||||||
pub use mailbox::InMemoryMailbox;
|
pub use mailbox::InMemoryMailbox;
|
||||||
pub use model_catalogue::{
|
pub use model_catalogue::{
|
||||||
EmbeddedCompatibilityMatrix, HttpProviderModelCatalogue, ProcessCliVersionReader,
|
EmbeddedCompatibilityMatrix, HttpProviderModelCatalogue, ProcessCliVersionReader,
|
||||||
|
|||||||
@ -53,15 +53,6 @@ impl FakePty {
|
|||||||
self.state.lock().unwrap().scrollback = bytes.to_vec();
|
self.state.lock().unwrap().scrollback = bytes.to_vec();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn last_handle(&self) -> PtyHandle {
|
|
||||||
self.state
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.handle
|
|
||||||
.clone()
|
|
||||||
.expect("spawned handle")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn kill_count(&self) -> usize {
|
fn kill_count(&self) -> usize {
|
||||||
self.state.lock().unwrap().kills
|
self.state.lock().unwrap().kills
|
||||||
}
|
}
|
||||||
@ -205,7 +196,9 @@ async fn ui_subscriber_does_not_interfere_with_runner_completion() {
|
|||||||
.spawn(spawn_spec(task_id, None))
|
.spawn(spawn_spec(task_id, None))
|
||||||
.await
|
.await
|
||||||
.expect("spawn succeeds");
|
.expect("spawn succeeds");
|
||||||
let handle = pty.last_handle();
|
let handle = runner
|
||||||
|
.pty_handle_for(task_id)
|
||||||
|
.expect("runner exposes live PTY handle");
|
||||||
let _ui_stream = pty.subscribe_output(&handle).expect("ui subscribes");
|
let _ui_stream = pty.subscribe_output(&handle).expect("ui subscribes");
|
||||||
pty.complete(Some(0));
|
pty.complete(Some(0));
|
||||||
|
|
||||||
@ -229,6 +222,68 @@ async fn ui_subscriber_does_not_interfere_with_runner_completion() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn double_ui_subscriber_uses_independent_pty_streams() {
|
||||||
|
let pty = Arc::new(FakePty::default());
|
||||||
|
let clock = Arc::new(FakeClock::default());
|
||||||
|
let (runner, _completions) = runner_with(Arc::clone(&pty), clock);
|
||||||
|
let task_id = TaskId::from_uuid(id(14));
|
||||||
|
|
||||||
|
runner
|
||||||
|
.spawn(spawn_spec(task_id, None))
|
||||||
|
.await
|
||||||
|
.expect("spawn succeeds");
|
||||||
|
let handle = runner
|
||||||
|
.pty_handle_for(task_id)
|
||||||
|
.expect("runner exposes live PTY handle");
|
||||||
|
|
||||||
|
let _first = pty.subscribe_output(&handle).expect("first ui subscribes");
|
||||||
|
let _second = pty.subscribe_output(&handle).expect("second ui subscribes");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
pty.subscribe_count(),
|
||||||
|
2,
|
||||||
|
"each UI attach must get its own PTY subscription"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
runner.pty_handle_for(task_id),
|
||||||
|
Some(handle),
|
||||||
|
"UI subscribers must not remove the runner's live handle"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn completed_task_no_longer_exposes_live_pty_handle_for_attach() {
|
||||||
|
let pty = Arc::new(FakePty::default());
|
||||||
|
pty.set_scrollback(b"final tail");
|
||||||
|
let clock = Arc::new(FakeClock::default());
|
||||||
|
let (runner, mut completions) = runner_with(Arc::clone(&pty), clock);
|
||||||
|
let task_id = TaskId::from_uuid(id(15));
|
||||||
|
|
||||||
|
runner
|
||||||
|
.spawn(spawn_spec(task_id, None))
|
||||||
|
.await
|
||||||
|
.expect("spawn succeeds");
|
||||||
|
assert!(runner.pty_handle_for(task_id).is_some());
|
||||||
|
pty.complete(Some(0));
|
||||||
|
|
||||||
|
let completion = tokio::time::timeout(
|
||||||
|
Duration::from_secs(1),
|
||||||
|
tokio::task::spawn_blocking(move || completions.next().expect("completion")),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("completion arrives")
|
||||||
|
.expect("completion thread joins");
|
||||||
|
|
||||||
|
assert_eq!(completion.task_id, task_id);
|
||||||
|
assert_eq!(runner.pty_handle_for(task_id), None);
|
||||||
|
assert_eq!(
|
||||||
|
pty.subscribe_count(),
|
||||||
|
0,
|
||||||
|
"attach after completion must not subscribe to a dead PTY handle"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn deadline_expires_and_kills_when_wait_never_resolves() {
|
async fn deadline_expires_and_kills_when_wait_never_resolves() {
|
||||||
let pty = Arc::new(FakePty::default());
|
let pty = Arc::new(FakePty::default());
|
||||||
|
|||||||
@ -71,8 +71,10 @@ import type {
|
|||||||
SkillGateway,
|
SkillGateway,
|
||||||
TemplateGateway,
|
TemplateGateway,
|
||||||
WorkStateGateway,
|
WorkStateGateway,
|
||||||
|
BackgroundTaskAttachment,
|
||||||
} from "@/ports";
|
} from "@/ports";
|
||||||
import { normalizeProjectWorkState } from "../workStateNormalization";
|
import { normalizeProjectWorkState } from "../workStateNormalization";
|
||||||
|
import { unsupportedOnWeb } from "./unsupported";
|
||||||
import { normalizeTurnPage } from "../conversationNormalization";
|
import { normalizeTurnPage } from "../conversationNormalization";
|
||||||
import { normalizeProfileModelCatalog } from "../profileCatalog";
|
import { normalizeProfileModelCatalog } from "../profileCatalog";
|
||||||
import type { HttpInvoker } from "./httpInvoker";
|
import type { HttpInvoker } from "./httpInvoker";
|
||||||
@ -443,6 +445,12 @@ export class HttpWorkStateGateway implements WorkStateGateway {
|
|||||||
const state = await this.http.invoke<unknown>("get_project_work_state", { projectId });
|
const state = await this.http.invoke<unknown>("get_project_work_state", { projectId });
|
||||||
return normalizeProjectWorkState(state);
|
return normalizeProjectWorkState(state);
|
||||||
}
|
}
|
||||||
|
async attachBackgroundTask(
|
||||||
|
_taskId: string,
|
||||||
|
_onData: (bytes: Uint8Array) => void,
|
||||||
|
): Promise<BackgroundTaskAttachment> {
|
||||||
|
return unsupportedOnWeb("Attaching to a background task live stream");
|
||||||
|
}
|
||||||
async cancelBackgroundTask(taskId: string): Promise<void> {
|
async cancelBackgroundTask(taskId: string): Promise<void> {
|
||||||
await this.http.invoke<unknown>("cancel_background_task", { taskId });
|
await this.http.invoke<unknown>("cancel_background_task", { taskId });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -124,6 +124,7 @@ import type {
|
|||||||
FocusedProject,
|
FocusedProject,
|
||||||
FocusedProjectGateway,
|
FocusedProjectGateway,
|
||||||
WorkStateGateway,
|
WorkStateGateway,
|
||||||
|
BackgroundTaskAttachment,
|
||||||
} from "@/ports";
|
} from "@/ports";
|
||||||
import { normalizeProjectWorkState } from "../workStateNormalization";
|
import { normalizeProjectWorkState } from "../workStateNormalization";
|
||||||
import { applyOperation, singleLeafTree } from "@/features/layout/layout";
|
import { applyOperation, singleLeafTree } from "@/features/layout/layout";
|
||||||
@ -2519,18 +2520,76 @@ export class MockPermissionGateway implements PermissionGateway {
|
|||||||
|
|
||||||
export class MockWorkStateGateway implements WorkStateGateway {
|
export class MockWorkStateGateway implements WorkStateGateway {
|
||||||
private states = new Map<string, ProjectWorkState>();
|
private states = new Map<string, ProjectWorkState>();
|
||||||
|
private backgroundTaskStreams = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
scrollback: Uint8Array;
|
||||||
|
live: boolean;
|
||||||
|
sinks: Set<(bytes: Uint8Array) => void>;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
/** Seeds the read-model returned for a project (deterministic tests/dev). */
|
/** Seeds the read-model returned for a project (deterministic tests/dev). */
|
||||||
_setProjectWorkState(projectId: string, state: unknown): void {
|
_setProjectWorkState(projectId: string, state: unknown): void {
|
||||||
this.states.set(projectId, normalizeProjectWorkState(state));
|
this.states.set(projectId, normalizeProjectWorkState(state));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Seeds attach output for a background task (deterministic tests/dev). */
|
||||||
|
_setBackgroundTaskAttachment(
|
||||||
|
taskId: string,
|
||||||
|
attachment: { scrollback?: string | Uint8Array; live?: boolean },
|
||||||
|
): void {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const scrollback =
|
||||||
|
typeof attachment.scrollback === "string"
|
||||||
|
? encoder.encode(attachment.scrollback)
|
||||||
|
: attachment.scrollback ?? new Uint8Array();
|
||||||
|
this.backgroundTaskStreams.set(taskId, {
|
||||||
|
scrollback,
|
||||||
|
live: attachment.live ?? false,
|
||||||
|
sinks: new Set(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emits a live output chunk to current subscribers (deterministic tests/dev). */
|
||||||
|
_emitBackgroundTaskOutput(taskId: string, bytes: string | Uint8Array): void {
|
||||||
|
const stream = this.backgroundTaskStreams.get(taskId);
|
||||||
|
if (!stream) return;
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const chunk = typeof bytes === "string" ? encoder.encode(bytes) : bytes;
|
||||||
|
for (const sink of stream.sinks) sink(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
_backgroundTaskSubscriberCount(taskId: string): number {
|
||||||
|
return this.backgroundTaskStreams.get(taskId)?.sinks.size ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
async getProjectWorkState(projectId: string): Promise<ProjectWorkState> {
|
async getProjectWorkState(projectId: string): Promise<ProjectWorkState> {
|
||||||
return structuredClone(
|
return structuredClone(
|
||||||
this.states.get(projectId) ?? { agents: [], conversations: [] },
|
this.states.get(projectId) ?? { agents: [], conversations: [] },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async attachBackgroundTask(
|
||||||
|
taskId: string,
|
||||||
|
onData: (bytes: Uint8Array) => void,
|
||||||
|
): Promise<BackgroundTaskAttachment> {
|
||||||
|
const stream = this.backgroundTaskStreams.get(taskId) ?? {
|
||||||
|
scrollback: new Uint8Array(),
|
||||||
|
live: false,
|
||||||
|
sinks: new Set<(bytes: Uint8Array) => void>(),
|
||||||
|
};
|
||||||
|
if (stream.live) stream.sinks.add(onData);
|
||||||
|
return {
|
||||||
|
taskId,
|
||||||
|
scrollback: Uint8Array.from(stream.scrollback),
|
||||||
|
live: stream.live,
|
||||||
|
detach: () => {
|
||||||
|
stream.sinks.delete(onData);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async cancelBackgroundTask(_taskId: string): Promise<void> {
|
async cancelBackgroundTask(_taskId: string): Promise<void> {
|
||||||
// No-op in the mock; real refresh is driven by domain events.
|
// No-op in the mock; real refresh is driven by domain events.
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,18 +5,44 @@
|
|||||||
* command name; features consume the gateway port through DI.
|
* command name; features consume the gateway port through DI.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { Channel, invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
import type { ProjectWorkState } from "@/domain";
|
import type { ProjectWorkState } from "@/domain";
|
||||||
import type { WorkStateGateway } from "@/ports";
|
import type { BackgroundTaskAttachment, WorkStateGateway } from "@/ports";
|
||||||
import { normalizeProjectWorkState } from "./workStateNormalization";
|
import { normalizeProjectWorkState } from "./workStateNormalization";
|
||||||
|
|
||||||
|
interface AttachBackgroundTaskResponse {
|
||||||
|
taskId: string;
|
||||||
|
scrollback: number[];
|
||||||
|
live: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class TauriWorkStateGateway implements WorkStateGateway {
|
export class TauriWorkStateGateway implements WorkStateGateway {
|
||||||
async getProjectWorkState(projectId: string): Promise<ProjectWorkState> {
|
async getProjectWorkState(projectId: string): Promise<ProjectWorkState> {
|
||||||
const state = await invoke<unknown>("get_project_work_state", { projectId });
|
const state = await invoke<unknown>("get_project_work_state", { projectId });
|
||||||
return normalizeProjectWorkState(state);
|
return normalizeProjectWorkState(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async attachBackgroundTask(
|
||||||
|
taskId: string,
|
||||||
|
onData: (bytes: Uint8Array) => void,
|
||||||
|
): Promise<BackgroundTaskAttachment> {
|
||||||
|
const channel = new Channel<number[]>();
|
||||||
|
channel.onmessage = (chunk) => onData(Uint8Array.from(chunk));
|
||||||
|
const res = await invoke<AttachBackgroundTaskResponse>("attach_background_task", {
|
||||||
|
taskId,
|
||||||
|
onOutput: channel,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
taskId: res.taskId,
|
||||||
|
scrollback: Uint8Array.from(res.scrollback),
|
||||||
|
live: res.live,
|
||||||
|
detach: () => {
|
||||||
|
channel.onmessage = () => {};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async cancelBackgroundTask(taskId: string): Promise<void> {
|
async cancelBackgroundTask(taskId: string): Promise<void> {
|
||||||
await invoke<unknown>("cancel_background_task", { taskId });
|
await invoke<unknown>("cancel_background_task", { taskId });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -66,6 +66,12 @@ function fixedWorkState(): WorkStateGateway {
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
getProjectWorkState: async () => structuredClone(state),
|
getProjectWorkState: async () => structuredClone(state),
|
||||||
|
attachBackgroundTask: async (taskId) => ({
|
||||||
|
taskId,
|
||||||
|
scrollback: new Uint8Array(),
|
||||||
|
live: false,
|
||||||
|
detach: () => {},
|
||||||
|
}),
|
||||||
cancelBackgroundTask: async () => {},
|
cancelBackgroundTask: async () => {},
|
||||||
retryBackgroundTask: async () => {},
|
retryBackgroundTask: async () => {},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
* and idle/busy state from the backend read-model, plus the current input queue.
|
* and idle/busy state from the backend read-model, plus the current input queue.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, type ReactNode, useState } from "react";
|
import { Component, type ReactNode, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AgentTicketState,
|
AgentTicketState,
|
||||||
@ -14,6 +14,7 @@ import type {
|
|||||||
InboxItem,
|
InboxItem,
|
||||||
LeafCell,
|
LeafCell,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
|
import type { BackgroundTaskAttachment } from "@/ports";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
import { leaves } from "@/features/layout/layout";
|
import { leaves } from "@/features/layout/layout";
|
||||||
import { useLayout } from "@/features/layout/useLayout";
|
import { useLayout } from "@/features/layout/useLayout";
|
||||||
@ -337,11 +338,36 @@ function BackgroundTaskRow({
|
|||||||
}) {
|
}) {
|
||||||
const { workState } = useGateways();
|
const { workState } = useGateways();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [liveOpen, setLiveOpen] = useState(false);
|
||||||
|
const [liveOutput, setLiveOutput] = useState("");
|
||||||
const [actionBusy, setActionBusy] = useState(false);
|
const [actionBusy, setActionBusy] = useState(false);
|
||||||
|
const [liveBusy, setLiveBusy] = useState(false);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const attachmentRef = useRef<BackgroundTaskAttachment | null>(null);
|
||||||
|
const attachSeqRef = useRef(0);
|
||||||
const hasOutput = Boolean(task.stdoutTail || task.stderrTail);
|
const hasOutput = Boolean(task.stdoutTail || task.stderrTail);
|
||||||
const canCancel = task.status === "running" || task.status === "pending";
|
const canCancel = task.status === "running" || task.status === "pending";
|
||||||
const canRetry = task.status === "failed" || task.status === "cancelled";
|
const canRetry = task.status === "failed" || task.status === "cancelled";
|
||||||
|
const canAttachLive = canCancel;
|
||||||
|
|
||||||
|
function detachLive(): void {
|
||||||
|
attachSeqRef.current += 1;
|
||||||
|
attachmentRef.current?.detach();
|
||||||
|
attachmentRef.current = null;
|
||||||
|
setLiveOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
attachSeqRef.current += 1;
|
||||||
|
attachmentRef.current?.detach();
|
||||||
|
attachmentRef.current = null;
|
||||||
|
};
|
||||||
|
}, [task.taskId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canAttachLive && attachmentRef.current) detachLive();
|
||||||
|
}, [canAttachLive]);
|
||||||
|
|
||||||
async function runAction(
|
async function runAction(
|
||||||
action: (taskId: string) => Promise<void>,
|
action: (taskId: string) => Promise<void>,
|
||||||
@ -358,6 +384,36 @@ function BackgroundTaskRow({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleLive(): Promise<void> {
|
||||||
|
if (liveOpen || attachmentRef.current) {
|
||||||
|
detachLive();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seq = attachSeqRef.current + 1;
|
||||||
|
attachSeqRef.current = seq;
|
||||||
|
setLiveBusy(true);
|
||||||
|
setMessage(null);
|
||||||
|
setLiveOutput("");
|
||||||
|
try {
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
const attachment = await workState.attachBackgroundTask(task.taskId, (bytes) => {
|
||||||
|
setLiveOutput((prev) => prev + decoder.decode(bytes));
|
||||||
|
});
|
||||||
|
if (seq !== attachSeqRef.current) {
|
||||||
|
attachment.detach();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
attachmentRef.current = attachment;
|
||||||
|
setLiveOutput(decoder.decode(attachment.scrollback));
|
||||||
|
setLiveOpen(true);
|
||||||
|
} catch (e) {
|
||||||
|
setMessage(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
if (seq === attachSeqRef.current) setLiveBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="min-w-0 text-xs text-muted">
|
<li className="min-w-0 text-xs text-muted">
|
||||||
<div className="flex min-w-0 items-start gap-2">
|
<div className="flex min-w-0 items-start gap-2">
|
||||||
@ -386,6 +442,15 @@ function BackgroundTaskRow({
|
|||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!canAttachLive || liveBusy}
|
||||||
|
loading={liveBusy}
|
||||||
|
onClick={() => void toggleLive()}
|
||||||
|
>
|
||||||
|
{liveOpen ? "Detach" : "Live"}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -436,6 +501,14 @@ function BackgroundTaskRow({
|
|||||||
{task.stderrTail ? `stderr\n${task.stderrTail}` : ""}
|
{task.stderrTail ? `stderr\n${task.stderrTail}` : ""}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
|
{liveOpen && (
|
||||||
|
<pre
|
||||||
|
aria-label={`task ${shortTicket(task.taskId)} live output`}
|
||||||
|
className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap rounded border border-border bg-canvas p-2 text-[11px] text-muted"
|
||||||
|
>
|
||||||
|
{liveOutput || "No live output yet."}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -336,6 +336,64 @@ describe("ProjectWorkStatePanel", () => {
|
|||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("attaches, repaints, streams, and detaches live background task output", async () => {
|
||||||
|
const workState = new MockWorkStateGateway();
|
||||||
|
workState._setProjectWorkState(PROJECT_ID, {
|
||||||
|
agents: [
|
||||||
|
{
|
||||||
|
agentId: "agent-live-task",
|
||||||
|
name: "Runner",
|
||||||
|
profileId: "codex",
|
||||||
|
busy: { state: "idle" },
|
||||||
|
backgroundTasks: [
|
||||||
|
{
|
||||||
|
taskId: "task-live-output",
|
||||||
|
kind: "command",
|
||||||
|
state: "running",
|
||||||
|
createdAtMs: 10,
|
||||||
|
updatedAtMs: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
workState._setBackgroundTaskAttachment("task-live-output", {
|
||||||
|
scrollback: "already printed\n",
|
||||||
|
live: true,
|
||||||
|
});
|
||||||
|
const attachSpy = vi.spyOn(workState, "attachBackgroundTask");
|
||||||
|
|
||||||
|
const view = renderPanel(workState);
|
||||||
|
const list = await screen.findByLabelText("Runner background tasks");
|
||||||
|
|
||||||
|
fireEvent.click(within(list).getByRole("button", { name: "Live" }));
|
||||||
|
|
||||||
|
expect(attachSpy).toHaveBeenCalledWith("task-live-output", expect.any(Function));
|
||||||
|
expect(
|
||||||
|
(await within(list).findByLabelText("task task-liv live output")).textContent,
|
||||||
|
).toContain("already printed");
|
||||||
|
expect(workState._backgroundTaskSubscriberCount("task-live-output")).toBe(1);
|
||||||
|
|
||||||
|
workState._emitBackgroundTaskOutput("task-live-output", "new line\n");
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
within(list).getByLabelText("task task-liv live output").textContent,
|
||||||
|
).toContain("new line"),
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(within(list).getByRole("button", { name: "Detach" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(workState._backgroundTaskSubscriberCount("task-live-output")).toBe(0),
|
||||||
|
);
|
||||||
|
expect(within(list).queryByLabelText("task task-liv live output")).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(within(list).getByRole("button", { name: "Live" }));
|
||||||
|
await within(list).findByLabelText("task task-liv live output");
|
||||||
|
expect(workState._backgroundTaskSubscriberCount("task-live-output")).toBe(1);
|
||||||
|
view.unmount();
|
||||||
|
expect(workState._backgroundTaskSubscriberCount("task-live-output")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("renders a legacy agent without tickets", async () => {
|
it("renders a legacy agent without tickets", async () => {
|
||||||
const workState = new MockWorkStateGateway();
|
const workState = new MockWorkStateGateway();
|
||||||
workState._setProjectWorkState(PROJECT_ID, {
|
workState._setProjectWorkState(PROJECT_ID, {
|
||||||
|
|||||||
@ -388,6 +388,18 @@ export interface ReattachResult {
|
|||||||
scrollback: Uint8Array;
|
scrollback: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A UI subscription attached to one background task output stream. */
|
||||||
|
export interface BackgroundTaskAttachment {
|
||||||
|
/** Attached task id, echoed by the backend. */
|
||||||
|
taskId: string;
|
||||||
|
/** Retained bytes to repaint immediately before live chunks arrive. */
|
||||||
|
scrollback: Uint8Array;
|
||||||
|
/** Whether subsequent live bytes are expected on the supplied callback. */
|
||||||
|
live: boolean;
|
||||||
|
/** Detaches the local UI subscriber without cancelling the task. */
|
||||||
|
detach(): void;
|
||||||
|
}
|
||||||
|
|
||||||
/** Projects: create/open/close/list (L2). */
|
/** Projects: create/open/close/list (L2). */
|
||||||
export interface ProjectGateway {
|
export interface ProjectGateway {
|
||||||
/** Lists the projects known to the registry. */
|
/** Lists the projects known to the registry. */
|
||||||
@ -932,6 +944,15 @@ export interface PermissionGateway {
|
|||||||
export interface WorkStateGateway {
|
export interface WorkStateGateway {
|
||||||
/** Reads the current per-agent live/offline and idle/busy state for a project. */
|
/** Reads the current per-agent live/offline and idle/busy state for a project. */
|
||||||
getProjectWorkState(projectId: string): Promise<ProjectWorkState>;
|
getProjectWorkState(projectId: string): Promise<ProjectWorkState>;
|
||||||
|
/**
|
||||||
|
* Attaches a UI output subscriber to a background task. Running tasks replay
|
||||||
|
* PTY scrollback and then stream live chunks; terminal tasks return only their
|
||||||
|
* persisted output tail.
|
||||||
|
*/
|
||||||
|
attachBackgroundTask(
|
||||||
|
taskId: string,
|
||||||
|
onData: (bytes: Uint8Array) => void,
|
||||||
|
): Promise<BackgroundTaskAttachment>;
|
||||||
/**
|
/**
|
||||||
* Cancels a running/pending background task by id. The read-model refreshes
|
* Cancels a running/pending background task by id. The read-model refreshes
|
||||||
* through the `backgroundTaskChanged` domain event.
|
* through the `backgroundTaskChanged` domain event.
|
||||||
|
|||||||
Reference in New Issue
Block a user