From 9b2cae32eaee9c1fb9c416a793d5f86b795726f4 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 26 Jun 2026 01:31:09 +0900 Subject: [PATCH 01/17] feat: add memory worker runtime crate --- Cargo.lock | 8 + Cargo.toml | 3 + crates/worker-runtime/Cargo.toml | 10 + crates/worker-runtime/src/catalog.rs | 179 ++++++ crates/worker-runtime/src/diagnostics.rs | 21 + crates/worker-runtime/src/error.rs | 38 ++ crates/worker-runtime/src/identity.rs | 82 +++ crates/worker-runtime/src/interaction.rs | 44 ++ crates/worker-runtime/src/lib.rs | 17 + crates/worker-runtime/src/management.rs | 66 ++ crates/worker-runtime/src/observation.rs | 95 +++ crates/worker-runtime/src/runtime.rs | 762 +++++++++++++++++++++++ 12 files changed, 1325 insertions(+) create mode 100644 crates/worker-runtime/Cargo.toml create mode 100644 crates/worker-runtime/src/catalog.rs create mode 100644 crates/worker-runtime/src/diagnostics.rs create mode 100644 crates/worker-runtime/src/error.rs create mode 100644 crates/worker-runtime/src/identity.rs create mode 100644 crates/worker-runtime/src/interaction.rs create mode 100644 crates/worker-runtime/src/lib.rs create mode 100644 crates/worker-runtime/src/management.rs create mode 100644 crates/worker-runtime/src/observation.rs create mode 100644 crates/worker-runtime/src/runtime.rs diff --git a/Cargo.lock b/Cargo.lock index 0e89b577..35c287d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5901,6 +5901,14 @@ dependencies = [ "yoi-plugin-pdk", ] +[[package]] +name = "worker-runtime" +version = "0.1.0" +dependencies = [ + "serde", + "thiserror 2.0.18", +] + [[package]] name = "workflow" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 7f5ab38f..36662456 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/manifest", "crates/mcp", "crates/worker", + "crates/worker-runtime", "crates/plugin-pdk", "crates/yoi", "crates/pod-store", @@ -36,6 +37,7 @@ default-members = [ "crates/manifest", "crates/mcp", "crates/worker", + "crates/worker-runtime", "crates/plugin-pdk", "crates/yoi", "crates/pod-store", @@ -70,6 +72,7 @@ memory = { path = "crates/memory" } ticket = { path = "crates/ticket" } project-record = { path = "crates/project-record" } worker = { path = "crates/worker" } +worker-runtime = { path = "crates/worker-runtime" } yoi-plugin-pdk = { path = "crates/plugin-pdk" } yoi = { path = "crates/yoi" } pod-registry = { path = "crates/pod-registry" } diff --git a/crates/worker-runtime/Cargo.toml b/crates/worker-runtime/Cargo.toml new file mode 100644 index 00000000..e9b53282 --- /dev/null +++ b/crates/worker-runtime/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "worker-runtime" +description = "Embedded memory-backed Runtime API for Worker management" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] +serde = { workspace = true, features = ["derive"] } +thiserror = { workspace = true } diff --git a/crates/worker-runtime/src/catalog.rs b/crates/worker-runtime/src/catalog.rs new file mode 100644 index 00000000..cf9cd735 --- /dev/null +++ b/crates/worker-runtime/src/catalog.rs @@ -0,0 +1,179 @@ +use crate::identity::{RuntimeId, WorkerId, WorkerRef}; +use serde::{Deserialize, Serialize}; + +/// Intent supplied when a Worker is created. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum WorkerIntent { + Assistant { + #[serde(default, skip_serializing_if = "Option::is_none")] + purpose: Option, + }, + Task { + objective: String, + }, + Role { + role: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + purpose: Option, + }, +} + +impl Default for WorkerIntent { + fn default() -> Self { + Self::Assistant { purpose: None } + } +} + +/// Profile selector boundary. This is a selector, not a resolved config bundle. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum ProfileSelector { + RuntimeDefault, + Builtin(String), + Named(String), +} + +impl Default for ProfileSelector { + fn default() -> Self { + Self::RuntimeDefault + } +} + +/// Placeholder for future config-bundle synchronization. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ConfigBundleRef { + pub id: String, +} + +/// Requested capability name plus optional human-readable reason. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityRequest { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +impl CapabilityRequest { + pub fn named(name: impl Into) -> Self { + Self { + name: name.into(), + reason: None, + } + } +} + +/// Opaque workspace reference supplied by a caller. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceRef { + pub name: String, + pub reference: String, +} + +/// Opaque mount reference supplied by a caller. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct MountRef { + pub name: String, + pub reference: String, +} + +/// Worker creation request for the catalog/lifecycle API. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CreateWorkerRequest { + pub intent: WorkerIntent, + pub profile: ProfileSelector, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_bundle: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub requested_capabilities: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub workspace_refs: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mount_refs: Vec, +} + +impl Default for CreateWorkerRequest { + fn default() -> Self { + Self { + intent: WorkerIntent::default(), + profile: ProfileSelector::default(), + config_bundle: None, + requested_capabilities: Vec::new(), + workspace_refs: Vec::new(), + mount_refs: Vec::new(), + } + } +} + +impl CreateWorkerRequest { + /// Create a tools-less Worker using runtime-local default resources. + pub fn tools_less(intent: WorkerIntent, profile: ProfileSelector) -> Self { + Self { + intent, + profile, + config_bundle: None, + requested_capabilities: Vec::new(), + workspace_refs: Vec::new(), + mount_refs: Vec::new(), + } + } +} + +/// Worker lifecycle status for the in-memory embedded runtime. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkerStatus { + Running, + Stopped, + Cancelled, +} + +impl WorkerStatus { + pub fn is_active(self) -> bool { + matches!(self, Self::Running) + } +} + +/// Lightweight catalog row. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkerSummary { + pub worker_ref: WorkerRef, + pub runtime_id: RuntimeId, + pub worker_id: WorkerId, + pub status: WorkerStatus, + pub intent: WorkerIntent, + pub profile: ProfileSelector, + pub requested_capability_count: usize, + pub has_config_bundle: bool, + pub transcript_len: usize, + pub last_event_id: u64, +} + +/// Full Worker catalog/lifecycle detail. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkerDetail { + pub worker_ref: WorkerRef, + pub runtime_id: RuntimeId, + pub worker_id: WorkerId, + pub status: WorkerStatus, + pub intent: WorkerIntent, + pub profile: ProfileSelector, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_bundle: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub requested_capabilities: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub workspace_refs: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mount_refs: Vec, + pub transcript_len: usize, + pub last_event_id: u64, +} + +/// Acknowledgement returned by stop/cancel lifecycle operations. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkerLifecycleAck { + pub worker_ref: WorkerRef, + pub status: WorkerStatus, + pub event_id: u64, +} diff --git a/crates/worker-runtime/src/diagnostics.rs b/crates/worker-runtime/src/diagnostics.rs new file mode 100644 index 00000000..3140f0c8 --- /dev/null +++ b/crates/worker-runtime/src/diagnostics.rs @@ -0,0 +1,21 @@ +use crate::identity::WorkerRef; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DiagnosticSeverity { + Info, + Warning, + Error, +} + +/// Runtime diagnostic emitted by memory-runtime operations. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeDiagnostic { + pub id: u64, + pub severity: DiagnosticSeverity, + pub code: String, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worker_ref: Option, +} diff --git a/crates/worker-runtime/src/error.rs b/crates/worker-runtime/src/error.rs new file mode 100644 index 00000000..41c2e7ec --- /dev/null +++ b/crates/worker-runtime/src/error.rs @@ -0,0 +1,38 @@ +use crate::identity::{RuntimeId, WorkerId}; + +/// Errors returned by the embedded Runtime API. +#[derive(Debug, thiserror::Error)] +pub enum RuntimeError { + #[error("runtime {runtime_id} is stopped")] + RuntimeStopped { runtime_id: RuntimeId }, + + #[error( + "worker {worker_id} belongs to runtime {actual_runtime_id}, not runtime {expected_runtime_id}" + )] + WrongRuntime { + expected_runtime_id: RuntimeId, + actual_runtime_id: RuntimeId, + worker_id: WorkerId, + }, + + #[error("cursor belongs to runtime {actual_runtime_id}, not runtime {expected_runtime_id}")] + WrongRuntimeCursor { + expected_runtime_id: RuntimeId, + actual_runtime_id: RuntimeId, + }, + + #[error("worker {worker_id} was not found in runtime {runtime_id}")] + WorkerNotFound { + runtime_id: RuntimeId, + worker_id: WorkerId, + }, + + #[error("limit {requested} exceeds maximum {max}")] + LimitTooLarge { requested: usize, max: usize }, + + #[error("invalid request: {0}")] + InvalidRequest(String), + + #[error("runtime state lock was poisoned")] + StatePoisoned, +} diff --git a/crates/worker-runtime/src/identity.rs b/crates/worker-runtime/src/identity.rs new file mode 100644 index 00000000..fd660b21 --- /dev/null +++ b/crates/worker-runtime/src/identity.rs @@ -0,0 +1,82 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// Public Runtime identity. +/// +/// This is the first half of Worker authority. Runtime APIs that operate on a +/// Worker require this id alongside a [`WorkerId`]; socket paths, session paths, +/// and display names are deliberately not authority. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct RuntimeId(String); + +impl RuntimeId { + pub fn new(value: impl Into) -> Option { + let value = value.into(); + if value.trim().is_empty() { + None + } else { + Some(Self(value)) + } + } + + pub(crate) fn generated(sequence: u64) -> Self { + Self(format!("runtime-mem-{sequence:016x}")) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for RuntimeId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +/// Runtime-local Worker identity. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct WorkerId(String); + +impl WorkerId { + pub fn new(value: impl Into) -> Option { + let value = value.into(); + if value.trim().is_empty() { + None + } else { + Some(Self(value)) + } + } + + pub(crate) fn generated(sequence: u64) -> Self { + Self(format!("worker-{sequence:08x}")) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for WorkerId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +/// Complete public authority reference for Worker operations. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct WorkerRef { + pub runtime_id: RuntimeId, + pub worker_id: WorkerId, +} + +impl WorkerRef { + pub fn new(runtime_id: RuntimeId, worker_id: WorkerId) -> Self { + Self { + runtime_id, + worker_id, + } + } +} diff --git a/crates/worker-runtime/src/interaction.rs b/crates/worker-runtime/src/interaction.rs new file mode 100644 index 00000000..a9dd440d --- /dev/null +++ b/crates/worker-runtime/src/interaction.rs @@ -0,0 +1,44 @@ +use crate::catalog::WorkerStatus; +use crate::identity::WorkerRef; +use serde::{Deserialize, Serialize}; + +/// Input kind accepted by the embedded interaction API. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkerInputKind { + User, + System, +} + +/// Worker input request. v0 stores the input in an in-memory transcript and +/// does not execute providers/tools. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkerInput { + pub kind: WorkerInputKind, + pub content: String, +} + +impl WorkerInput { + pub fn user(content: impl Into) -> Self { + Self { + kind: WorkerInputKind::User, + content: content.into(), + } + } + + pub fn system(content: impl Into) -> Self { + Self { + kind: WorkerInputKind::System, + content: content.into(), + } + } +} + +/// Acknowledgement returned after input is accepted into the in-memory Worker. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkerInteractionAck { + pub worker_ref: WorkerRef, + pub status: WorkerStatus, + pub transcript_sequence: u64, + pub event_id: u64, +} diff --git a/crates/worker-runtime/src/lib.rs b/crates/worker-runtime/src/lib.rs new file mode 100644 index 00000000..96bcf3c1 --- /dev/null +++ b/crates/worker-runtime/src/lib.rs @@ -0,0 +1,17 @@ +//! Embedded Runtime domain API for Worker management. +//! +//! `worker-runtime` intentionally stays independent from HTTP/WebSocket servers, +//! filesystem persistence, provider execution, and the existing Worker host. It +//! defines the in-process Runtime authority surface that higher layers can later +//! adapt into registries or web APIs. + +pub mod catalog; +pub mod diagnostics; +pub mod error; +pub mod identity; +pub mod interaction; +pub mod management; +pub mod observation; +mod runtime; + +pub use runtime::Runtime; diff --git a/crates/worker-runtime/src/management.rs b/crates/worker-runtime/src/management.rs new file mode 100644 index 00000000..5fa2b5da --- /dev/null +++ b/crates/worker-runtime/src/management.rs @@ -0,0 +1,66 @@ +use crate::identity::RuntimeId; +use serde::{Deserialize, Serialize}; + +/// Runtime backend kind. v0 intentionally ships only an embedded memory backend. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeBackendKind { + Memory, +} + +/// Runtime lifecycle state. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeStatus { + Running, + Stopped, +} + +/// Guardrails for bounded observation/projection APIs. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeLimits { + pub max_transcript_projection_items: usize, + pub max_event_batch_items: usize, +} + +impl Default for RuntimeLimits { + fn default() -> Self { + Self { + max_transcript_projection_items: 256, + max_event_batch_items: 256, + } + } +} + +/// Options used to construct an embedded memory Runtime. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeOptions { + pub runtime_id: Option, + pub display_name: Option, + pub limits: RuntimeLimits, +} + +impl Default for RuntimeOptions { + fn default() -> Self { + Self { + runtime_id: None, + display_name: None, + limits: RuntimeLimits::default(), + } + } +} + +/// Management-plane summary for a Runtime. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeSummary { + pub runtime_id: RuntimeId, + pub display_name: Option, + pub backend: RuntimeBackendKind, + pub status: RuntimeStatus, + pub worker_count: usize, + pub active_worker_count: usize, + pub stopped_worker_count: usize, + pub cancelled_worker_count: usize, + pub diagnostic_count: usize, + pub limits: RuntimeLimits, +} diff --git a/crates/worker-runtime/src/observation.rs b/crates/worker-runtime/src/observation.rs new file mode 100644 index 00000000..50ee2318 --- /dev/null +++ b/crates/worker-runtime/src/observation.rs @@ -0,0 +1,95 @@ +use crate::identity::{RuntimeId, WorkerRef}; +use serde::{Deserialize, Serialize}; + +/// Transcript role used by bounded projection. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TranscriptRole { + User, + System, +} + +/// One projected transcript item. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TranscriptEntry { + pub sequence: u64, + pub worker_ref: WorkerRef, + pub role: TranscriptRole, + pub content: String, + pub event_id: u64, +} + +/// Bounded transcript query. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TranscriptQuery { + pub start: usize, + pub limit: usize, +} + +impl TranscriptQuery { + pub fn new(start: usize, limit: usize) -> Self { + Self { start, limit } + } +} + +/// Bounded transcript projection response. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TranscriptProjection { + pub worker_ref: WorkerRef, + pub start: usize, + pub limit: usize, + pub total_items: usize, + pub items: Vec, + pub next_start: Option, +} + +/// Event cursor. `next_event_id` is the first event id that should be returned +/// by the next poll. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EventCursor { + pub runtime_id: RuntimeId, + pub next_event_id: u64, +} + +/// Placeholder subscription handle for future streaming APIs. v0 is explicit +/// poll-only so HTTP/WS/SSE dependencies are not pulled into this crate. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EventSubscription { + pub runtime_id: RuntimeId, + pub cursor: EventCursor, + pub mode: EventSubscriptionMode, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EventSubscriptionMode { + PollOnly, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeEvent { + pub id: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worker_ref: Option, + pub kind: RuntimeEventKind, + pub message: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RuntimeEventKind { + RuntimeStarted, + RuntimeStopped, + WorkerCreated, + WorkerInputAccepted, + WorkerStopped, + WorkerCancelled, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeEventBatch { + pub runtime_id: RuntimeId, + pub cursor: EventCursor, + pub events: Vec, + pub has_more: bool, +} diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs new file mode 100644 index 00000000..aee5b0b8 --- /dev/null +++ b/crates/worker-runtime/src/runtime.rs @@ -0,0 +1,762 @@ +use crate::catalog::{ + CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerStatus, WorkerSummary, +}; +use crate::diagnostics::{DiagnosticSeverity, RuntimeDiagnostic}; +use crate::error::RuntimeError; +use crate::identity::{RuntimeId, WorkerId, WorkerRef}; +use crate::interaction::{WorkerInput, WorkerInputKind, WorkerInteractionAck}; +use crate::management::{ + RuntimeBackendKind, RuntimeLimits, RuntimeOptions, RuntimeStatus, RuntimeSummary, +}; +use crate::observation::{ + EventCursor, EventSubscription, EventSubscriptionMode, RuntimeEvent, RuntimeEventBatch, + RuntimeEventKind, TranscriptEntry, TranscriptProjection, TranscriptQuery, TranscriptRole, +}; +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; + +static NEXT_RUNTIME_SEQUENCE: AtomicU64 = AtomicU64::new(1); + +/// Concrete embedded Runtime domain entity. +/// +/// The current implementation is memory-backed and tools/provider-less by +/// design. It provides a typed API boundary that can later be adapted by +/// backend registries or web servers without making sockets, sessions, or paths +/// public authority. +#[derive(Clone, Debug)] +pub struct Runtime { + inner: Arc>, +} + +impl Runtime { + /// Create a memory-backed Runtime with generated identity and default limits. + pub fn new_memory() -> Self { + Self::with_options(RuntimeOptions::default()) + } + + /// Create a memory-backed Runtime with explicit options. + pub fn with_options(options: RuntimeOptions) -> Self { + let runtime_id = options.runtime_id.unwrap_or_else(|| { + RuntimeId::generated(NEXT_RUNTIME_SEQUENCE.fetch_add(1, Ordering::Relaxed)) + }); + let mut state = RuntimeState::new(runtime_id, options.display_name, options.limits); + state.push_event(None, RuntimeEventKind::RuntimeStarted, "runtime started"); + Self { + inner: Arc::new(Mutex::new(state)), + } + } + + /// Runtime id half of public Worker authority. + pub fn runtime_id(&self) -> Result { + Ok(self.lock()?.runtime_id.clone()) + } + + /// Management-plane summary. + pub fn summary(&self) -> Result { + let state = self.lock()?; + let mut active_worker_count = 0; + let mut stopped_worker_count = 0; + let mut cancelled_worker_count = 0; + for worker in state.workers.values() { + match worker.status { + WorkerStatus::Running => active_worker_count += 1, + WorkerStatus::Stopped => stopped_worker_count += 1, + WorkerStatus::Cancelled => cancelled_worker_count += 1, + } + } + + Ok(RuntimeSummary { + runtime_id: state.runtime_id.clone(), + display_name: state.display_name.clone(), + backend: state.backend, + status: state.status, + worker_count: state.workers.len(), + active_worker_count, + stopped_worker_count, + cancelled_worker_count, + diagnostic_count: state.diagnostics.len(), + limits: state.limits.clone(), + }) + } + + /// Current Runtime lifecycle state. + pub fn status(&self) -> Result { + Ok(self.lock()?.status) + } + + /// Stop the Runtime. v0 keeps data readable after stop, but rejects new + /// create/send/worker lifecycle mutations. + pub fn stop_runtime(&self) -> Result { + let mut state = self.lock()?; + if state.status == RuntimeStatus::Stopped { + return Ok(state.last_event_id()); + } + state.status = RuntimeStatus::Stopped; + let runtime_id = state.runtime_id.clone(); + for worker in state.workers.values_mut() { + if worker.status.is_active() { + worker.status = WorkerStatus::Stopped; + } + } + Ok(state.push_event( + None, + RuntimeEventKind::RuntimeStopped, + format!("runtime {runtime_id} stopped"), + )) + } + + /// Create a Worker in the embedded catalog. + pub fn create_worker( + &self, + request: CreateWorkerRequest, + ) -> Result { + let mut state = self.lock()?; + state.ensure_running()?; + validate_create_worker_request(&request)?; + + let worker_id = WorkerId::generated(state.next_worker_sequence); + state.next_worker_sequence += 1; + let worker_ref = WorkerRef::new(state.runtime_id.clone(), worker_id.clone()); + let event_id = state.push_event( + Some(worker_ref.clone()), + RuntimeEventKind::WorkerCreated, + format!("worker {worker_id} created"), + ); + + let record = WorkerRecord { + worker_ref, + worker_id: worker_id.clone(), + status: WorkerStatus::Running, + request, + transcript: Vec::new(), + next_transcript_sequence: 1, + last_event_id: event_id, + }; + let detail = record.detail(&state.runtime_id); + state.emit_create_diagnostics(&detail); + state.workers.insert(worker_id, record); + Ok(detail) + } + + /// List Workers known to this Runtime. + pub fn list_workers(&self) -> Result, RuntimeError> { + let state = self.lock()?; + Ok(state + .workers + .values() + .map(|worker| worker.summary(&state.runtime_id)) + .collect()) + } + + /// Fetch Worker detail. The supplied [`WorkerRef`] must match this Runtime. + pub fn worker_detail(&self, worker_ref: &WorkerRef) -> Result { + let state = self.lock()?; + let worker = state.worker(worker_ref)?; + Ok(worker.detail(&state.runtime_id)) + } + + /// Accept input into a Worker transcript. + pub fn send_input( + &self, + worker_ref: &WorkerRef, + input: WorkerInput, + ) -> Result { + let mut state = self.lock()?; + state.ensure_running()?; + validate_worker_input(&input)?; + state.ensure_worker_ref(worker_ref)?; + { + let worker = state.worker(worker_ref)?; + if !worker.status.is_active() { + return Err(RuntimeError::InvalidRequest(format!( + "worker {} is not running", + worker_ref.worker_id + ))); + } + } + + let event_id = state.push_event( + Some(worker_ref.clone()), + RuntimeEventKind::WorkerInputAccepted, + "worker input accepted", + ); + let worker = state.worker_mut(worker_ref)?; + + let role = match input.kind { + WorkerInputKind::User => TranscriptRole::User, + WorkerInputKind::System => TranscriptRole::System, + }; + let transcript_sequence = worker.next_transcript_sequence; + worker.next_transcript_sequence += 1; + worker.last_event_id = event_id; + worker.transcript.push(TranscriptEntry { + sequence: transcript_sequence, + worker_ref: worker_ref.clone(), + role, + content: input.content, + event_id, + }); + + Ok(WorkerInteractionAck { + worker_ref: worker_ref.clone(), + status: worker.status, + transcript_sequence, + event_id, + }) + } + + /// Stop a Worker. Repeated stops are idempotent and return the last event id. + pub fn stop_worker( + &self, + worker_ref: &WorkerRef, + reason: Option, + ) -> Result { + self.transition_worker( + worker_ref, + WorkerStatus::Stopped, + RuntimeEventKind::WorkerStopped, + reason.unwrap_or_else(|| "worker stopped".to_string()), + ) + } + + /// Cancel a Worker. Repeated cancels are idempotent and return the last event id. + pub fn cancel_worker( + &self, + worker_ref: &WorkerRef, + reason: Option, + ) -> Result { + self.transition_worker( + worker_ref, + WorkerStatus::Cancelled, + RuntimeEventKind::WorkerCancelled, + reason.unwrap_or_else(|| "worker cancelled".to_string()), + ) + } + + /// Bounded transcript projection for a Worker. + pub fn transcript_projection( + &self, + worker_ref: &WorkerRef, + query: TranscriptQuery, + ) -> Result { + let state = self.lock()?; + if query.limit > state.limits.max_transcript_projection_items { + return Err(RuntimeError::LimitTooLarge { + requested: query.limit, + max: state.limits.max_transcript_projection_items, + }); + } + let worker = state.worker(worker_ref)?; + let total_items = worker.transcript.len(); + let end = query.start.saturating_add(query.limit).min(total_items); + let items = if query.start >= total_items { + Vec::new() + } else { + worker.transcript[query.start..end].to_vec() + }; + let next_start = (end < total_items).then_some(end); + Ok(TranscriptProjection { + worker_ref: worker_ref.clone(), + start: query.start, + limit: query.limit, + total_items, + items, + next_start, + }) + } + + /// Cursor pointing to the beginning of Runtime events. + pub fn event_cursor_from_start(&self) -> Result { + let state = self.lock()?; + Ok(EventCursor { + runtime_id: state.runtime_id.clone(), + next_event_id: 1, + }) + } + + /// Cursor pointing after the current last event. + pub fn event_cursor_now(&self) -> Result { + let state = self.lock()?; + Ok(EventCursor { + runtime_id: state.runtime_id.clone(), + next_event_id: state.last_event_id() + 1, + }) + } + + /// Poll Runtime events from a cursor. + pub fn read_events( + &self, + cursor: &EventCursor, + limit: usize, + ) -> Result { + let state = self.lock()?; + if cursor.runtime_id != state.runtime_id { + return Err(RuntimeError::WrongRuntimeCursor { + expected_runtime_id: state.runtime_id.clone(), + actual_runtime_id: cursor.runtime_id.clone(), + }); + } + if limit > state.limits.max_event_batch_items { + return Err(RuntimeError::LimitTooLarge { + requested: limit, + max: state.limits.max_event_batch_items, + }); + } + + let mut events = Vec::new(); + for event in state + .events + .iter() + .filter(|event| event.id >= cursor.next_event_id) + .take(limit) + { + events.push(event.clone()); + } + let next_event_id = events + .last() + .map(|event| event.id + 1) + .unwrap_or(cursor.next_event_id); + let has_more = state.events.iter().any(|event| event.id >= next_event_id); + Ok(RuntimeEventBatch { + runtime_id: state.runtime_id.clone(), + cursor: EventCursor { + runtime_id: state.runtime_id.clone(), + next_event_id, + }, + events, + has_more, + }) + } + + /// Create a poll-only placeholder subscription boundary for future streaming. + pub fn subscribe_events(&self, cursor: EventCursor) -> Result { + let state = self.lock()?; + if cursor.runtime_id != state.runtime_id { + return Err(RuntimeError::WrongRuntimeCursor { + expected_runtime_id: state.runtime_id.clone(), + actual_runtime_id: cursor.runtime_id, + }); + } + Ok(EventSubscription { + runtime_id: state.runtime_id.clone(), + cursor, + mode: EventSubscriptionMode::PollOnly, + }) + } + + /// Snapshot current diagnostics. + pub fn diagnostics(&self) -> Result, RuntimeError> { + Ok(self.lock()?.diagnostics.clone()) + } + + fn transition_worker( + &self, + worker_ref: &WorkerRef, + status: WorkerStatus, + event_kind: RuntimeEventKind, + reason: String, + ) -> Result { + let mut state = self.lock()?; + state.ensure_running()?; + state.ensure_worker_ref(worker_ref)?; + + let already_status = { + let worker = state.worker(worker_ref)?; + worker.status == status + }; + if already_status { + let worker = state.worker(worker_ref)?; + return Ok(WorkerLifecycleAck { + worker_ref: worker_ref.clone(), + status: worker.status, + event_id: worker.last_event_id, + }); + } + + let event_id = state.push_event(Some(worker_ref.clone()), event_kind, reason); + let worker = state.worker_mut(worker_ref)?; + worker.status = status; + worker.last_event_id = event_id; + Ok(WorkerLifecycleAck { + worker_ref: worker_ref.clone(), + status: worker.status, + event_id, + }) + } + + fn lock(&self) -> Result, RuntimeError> { + self.inner.lock().map_err(|_| RuntimeError::StatePoisoned) + } +} + +#[derive(Debug)] +struct RuntimeState { + runtime_id: RuntimeId, + display_name: Option, + backend: RuntimeBackendKind, + status: RuntimeStatus, + limits: RuntimeLimits, + next_worker_sequence: u64, + next_event_id: u64, + next_diagnostic_id: u64, + workers: BTreeMap, + events: Vec, + diagnostics: Vec, +} + +impl RuntimeState { + fn new(runtime_id: RuntimeId, display_name: Option, limits: RuntimeLimits) -> Self { + Self { + runtime_id, + display_name, + backend: RuntimeBackendKind::Memory, + status: RuntimeStatus::Running, + limits, + next_worker_sequence: 1, + next_event_id: 1, + next_diagnostic_id: 1, + workers: BTreeMap::new(), + events: Vec::new(), + diagnostics: Vec::new(), + } + } + + fn ensure_running(&self) -> Result<(), RuntimeError> { + if self.status == RuntimeStatus::Stopped { + Err(RuntimeError::RuntimeStopped { + runtime_id: self.runtime_id.clone(), + }) + } else { + Ok(()) + } + } + + fn ensure_worker_ref(&self, worker_ref: &WorkerRef) -> Result<(), RuntimeError> { + if worker_ref.runtime_id != self.runtime_id { + return Err(RuntimeError::WrongRuntime { + expected_runtime_id: self.runtime_id.clone(), + actual_runtime_id: worker_ref.runtime_id.clone(), + worker_id: worker_ref.worker_id.clone(), + }); + } + if !self.workers.contains_key(&worker_ref.worker_id) { + return Err(RuntimeError::WorkerNotFound { + runtime_id: self.runtime_id.clone(), + worker_id: worker_ref.worker_id.clone(), + }); + } + Ok(()) + } + + fn worker(&self, worker_ref: &WorkerRef) -> Result<&WorkerRecord, RuntimeError> { + self.ensure_worker_ref(worker_ref)?; + self.workers + .get(&worker_ref.worker_id) + .ok_or_else(|| RuntimeError::WorkerNotFound { + runtime_id: self.runtime_id.clone(), + worker_id: worker_ref.worker_id.clone(), + }) + } + + fn worker_mut(&mut self, worker_ref: &WorkerRef) -> Result<&mut WorkerRecord, RuntimeError> { + self.ensure_worker_ref(worker_ref)?; + self.workers + .get_mut(&worker_ref.worker_id) + .ok_or_else(|| RuntimeError::WorkerNotFound { + runtime_id: self.runtime_id.clone(), + worker_id: worker_ref.worker_id.clone(), + }) + } + + fn push_event( + &mut self, + worker_ref: Option, + kind: RuntimeEventKind, + message: impl Into, + ) -> u64 { + let id = self.next_event_id; + self.next_event_id += 1; + self.events.push(RuntimeEvent { + id, + worker_ref, + kind, + message: message.into(), + }); + id + } + + fn last_event_id(&self) -> u64 { + self.next_event_id.saturating_sub(1) + } + + fn push_diagnostic( + &mut self, + severity: DiagnosticSeverity, + code: impl Into, + message: impl Into, + worker_ref: Option, + ) { + let id = self.next_diagnostic_id; + self.next_diagnostic_id += 1; + self.diagnostics.push(RuntimeDiagnostic { + id, + severity, + code: code.into(), + message: message.into(), + worker_ref, + }); + } + + fn emit_create_diagnostics(&mut self, detail: &WorkerDetail) { + if detail.config_bundle.is_none() { + self.push_diagnostic( + DiagnosticSeverity::Info, + "runtime.local_default_resources", + "worker created without ConfigBundleRef; runtime-local defaults are assumed", + Some(detail.worker_ref.clone()), + ); + } + if detail.requested_capabilities.is_empty() { + self.push_diagnostic( + DiagnosticSeverity::Info, + "worker.tools_less", + "worker created without requested tool capabilities", + Some(detail.worker_ref.clone()), + ); + } + } +} + +#[derive(Debug)] +struct WorkerRecord { + worker_ref: WorkerRef, + worker_id: WorkerId, + status: WorkerStatus, + request: CreateWorkerRequest, + transcript: Vec, + next_transcript_sequence: u64, + last_event_id: u64, +} + +impl WorkerRecord { + fn summary(&self, runtime_id: &RuntimeId) -> WorkerSummary { + WorkerSummary { + worker_ref: self.worker_ref.clone(), + runtime_id: runtime_id.clone(), + worker_id: self.worker_id.clone(), + status: self.status, + intent: self.request.intent.clone(), + profile: self.request.profile.clone(), + requested_capability_count: self.request.requested_capabilities.len(), + has_config_bundle: self.request.config_bundle.is_some(), + transcript_len: self.transcript.len(), + last_event_id: self.last_event_id, + } + } + + fn detail(&self, runtime_id: &RuntimeId) -> WorkerDetail { + WorkerDetail { + worker_ref: self.worker_ref.clone(), + runtime_id: runtime_id.clone(), + worker_id: self.worker_id.clone(), + status: self.status, + intent: self.request.intent.clone(), + profile: self.request.profile.clone(), + config_bundle: self.request.config_bundle.clone(), + requested_capabilities: self.request.requested_capabilities.clone(), + workspace_refs: self.request.workspace_refs.clone(), + mount_refs: self.request.mount_refs.clone(), + transcript_len: self.transcript.len(), + last_event_id: self.last_event_id, + } + } +} + +fn validate_create_worker_request(request: &CreateWorkerRequest) -> Result<(), RuntimeError> { + if let crate::catalog::WorkerIntent::Task { objective } = &request.intent { + if objective.trim().is_empty() { + return Err(RuntimeError::InvalidRequest( + "task objective must not be empty".to_string(), + )); + } + } + for capability in &request.requested_capabilities { + if capability.name.trim().is_empty() { + return Err(RuntimeError::InvalidRequest( + "capability name must not be empty".to_string(), + )); + } + } + Ok(()) +} + +fn validate_worker_input(input: &WorkerInput) -> Result<(), RuntimeError> { + if input.content.trim().is_empty() { + return Err(RuntimeError::InvalidRequest( + "worker input content must not be empty".to_string(), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::catalog::{CapabilityRequest, ConfigBundleRef, ProfileSelector, WorkerIntent}; + use crate::management::RuntimeLimits; + + fn task_request(objective: &str) -> CreateWorkerRequest { + CreateWorkerRequest { + intent: WorkerIntent::Task { + objective: objective.to_string(), + }, + profile: ProfileSelector::Builtin("builtin:coder".to_string()), + config_bundle: Some(ConfigBundleRef { + id: "bundle-1".to_string(), + }), + requested_capabilities: vec![CapabilityRequest::named("read")], + workspace_refs: Vec::new(), + mount_refs: Vec::new(), + } + } + + #[test] + fn create_list_and_detail_preserve_runtime_worker_authority() { + let runtime = Runtime::new_memory(); + let detail = runtime.create_worker(task_request("implement v0")).unwrap(); + + assert_eq!(detail.worker_ref.runtime_id, runtime.runtime_id().unwrap()); + assert_eq!(detail.status, WorkerStatus::Running); + assert!(detail.config_bundle.is_some()); + + let list = runtime.list_workers().unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].worker_ref, detail.worker_ref); + assert_eq!(list[0].requested_capability_count, 1); + + let fetched = runtime.worker_detail(&detail.worker_ref).unwrap(); + assert_eq!(fetched.worker_id, detail.worker_id); + assert_eq!(fetched.intent, detail.intent); + } + + #[test] + fn rejects_worker_refs_from_another_runtime() { + let runtime_a = Runtime::new_memory(); + let runtime_b = Runtime::new_memory(); + let detail = runtime_a.create_worker(task_request("runtime a")).unwrap(); + + let err = runtime_b.worker_detail(&detail.worker_ref).unwrap_err(); + assert!(matches!(err, RuntimeError::WrongRuntime { .. })); + } + + #[test] + fn tools_less_worker_without_config_bundle_uses_local_defaults_and_diagnostics() { + let runtime = Runtime::new_memory(); + let detail = runtime + .create_worker(CreateWorkerRequest::tools_less( + WorkerIntent::default(), + ProfileSelector::RuntimeDefault, + )) + .unwrap(); + + assert!(detail.config_bundle.is_none()); + assert!(detail.requested_capabilities.is_empty()); + let diagnostics = runtime.diagnostics().unwrap(); + assert_eq!(diagnostics.len(), 2); + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "runtime.local_default_resources") + ); + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "worker.tools_less") + ); + } + + #[test] + fn send_input_and_project_bounded_transcript() { + let runtime = Runtime::with_options(RuntimeOptions { + limits: RuntimeLimits { + max_transcript_projection_items: 2, + max_event_batch_items: 16, + }, + ..RuntimeOptions::default() + }); + let detail = runtime.create_worker(task_request("chat")).unwrap(); + + let first = runtime + .send_input(&detail.worker_ref, WorkerInput::user("hello")) + .unwrap(); + assert_eq!(first.transcript_sequence, 1); + runtime + .send_input(&detail.worker_ref, WorkerInput::system("note")) + .unwrap(); + runtime + .send_input(&detail.worker_ref, WorkerInput::user("again")) + .unwrap(); + + let projection = runtime + .transcript_projection(&detail.worker_ref, TranscriptQuery::new(0, 2)) + .unwrap(); + assert_eq!(projection.total_items, 3); + assert_eq!(projection.items.len(), 2); + assert_eq!(projection.items[0].content, "hello"); + assert_eq!(projection.items[1].role, TranscriptRole::System); + assert_eq!(projection.next_start, Some(2)); + + let err = runtime + .transcript_projection(&detail.worker_ref, TranscriptQuery::new(0, 3)) + .unwrap_err(); + assert!(matches!(err, RuntimeError::LimitTooLarge { .. })); + } + + #[test] + fn stop_and_cancel_workers_update_summary() { + let runtime = Runtime::new_memory(); + let stopped = runtime.create_worker(task_request("stop me")).unwrap(); + let cancelled = runtime.create_worker(task_request("cancel me")).unwrap(); + + let stop_ack = runtime + .stop_worker(&stopped.worker_ref, Some("done".to_string())) + .unwrap(); + assert_eq!(stop_ack.status, WorkerStatus::Stopped); + + let cancel_ack = runtime + .cancel_worker(&cancelled.worker_ref, Some("abort".to_string())) + .unwrap(); + assert_eq!(cancel_ack.status, WorkerStatus::Cancelled); + + let summary = runtime.summary().unwrap(); + assert_eq!(summary.worker_count, 2); + assert_eq!(summary.active_worker_count, 0); + assert_eq!(summary.stopped_worker_count, 1); + assert_eq!(summary.cancelled_worker_count, 1); + } + + #[test] + fn event_cursor_and_poll_only_subscription_are_bounded_placeholders() { + let runtime = Runtime::new_memory(); + let cursor = runtime.event_cursor_from_start().unwrap(); + let subscription = runtime.subscribe_events(cursor.clone()).unwrap(); + assert_eq!(subscription.mode, EventSubscriptionMode::PollOnly); + + let worker = runtime.create_worker(task_request("events")).unwrap(); + runtime + .send_input(&worker.worker_ref, WorkerInput::user("eventful")) + .unwrap(); + + let batch = runtime.read_events(&cursor, 2).unwrap(); + assert_eq!(batch.events.len(), 2); + assert!(batch.has_more); + assert_eq!(batch.events[0].kind, RuntimeEventKind::RuntimeStarted); + assert_eq!(batch.events[1].kind, RuntimeEventKind::WorkerCreated); + + let next = runtime.read_events(&batch.cursor, 2).unwrap(); + assert_eq!(next.events.len(), 1); + assert_eq!(next.events[0].kind, RuntimeEventKind::WorkerInputAccepted); + assert!(!next.has_more); + } +} From 593db95175ff8537af2401a5def21fc32dd4846d Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 26 Jun 2026 01:36:57 +0900 Subject: [PATCH 02/17] fix: update nix cargo hash --- package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.nix b/package.nix index 0a9bbba1..b1dcfab8 100644 --- a/package.nix +++ b/package.nix @@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec { filter = sourceFilter; }; - cargoHash = "sha256-+wsw/NKSCrouBhXgm4Mt5yk2gU87uTRYWwRSvJyiMLI="; + cargoHash = "sha256-RHo2b6dVClqu32wpgES/RQeBMXaqyqXZaooeSH5SveM="; depsExtraArgs = { # Older fetchCargoVendor utilities used crates.io's API download endpoint, From 9877a20778c3e8d2d616da7dc969316345d6a42d Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 26 Jun 2026 01:49:58 +0900 Subject: [PATCH 03/17] ticket: record worker runtime continuation authority --- .yoi/tickets/00001KVZBCQH4/item.md | 2 +- .yoi/tickets/00001KVZBCQH4/thread.md | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVZBCQH4/item.md b/.yoi/tickets/00001KVZBCQH4/item.md index 75094ed1..3d69d6d2 100644 --- a/.yoi/tickets/00001KVZBCQH4/item.md +++ b/.yoi/tickets/00001KVZBCQH4/item.md @@ -2,7 +2,7 @@ title: 'worker-runtime core crateと組み込みRuntime APIを作る' state: 'inprogress' created_at: '2026-06-25T12:17:05Z' -updated_at: '2026-06-25T16:46:17Z' +updated_at: '2026-06-25T16:49:53Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-25T16:20:10Z' diff --git a/.yoi/tickets/00001KVZBCQH4/thread.md b/.yoi/tickets/00001KVZBCQH4/thread.md index d6d3f080..cc438d49 100644 --- a/.yoi/tickets/00001KVZBCQH4/thread.md +++ b/.yoi/tickets/00001KVZBCQH4/thread.md @@ -555,3 +555,19 @@ Review result: request_changes cargo/nix の再実行は、read-only 指示と上記 blocker があるため実施していません。 --- + + + +## Decision + +Human follow-up: + +ユーザー確認: 「OK、よろしく」。 + +運用判断: +- 現在 queued の dependent Tickets は planning 差し戻し不要として扱う。 +- ただし、未解消 dependency があるものは queued のまま待機し、依存が解消したものだけ再 routing して `queued -> inprogress` を個別に記録してから implementation side effect に進む。 +- 途中で concrete missing decision / information が出た場合は、勝手に固定せず Ticket thread に理由を残して停止または planning return を行う。 +- 現在の最優先は `00001KVZBCQH4` の reviewer blocker 解消、再 review、merge、validation、done 記録。 + +--- From fbd358a1956e7fb6cedb893e649e4d791632d645 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 26 Jun 2026 01:50:11 +0900 Subject: [PATCH 04/17] fix: keep worker terminal lifecycle stable --- crates/worker-runtime/src/runtime.rs | 108 ++++++++++++++++++++++++--- 1 file changed, 98 insertions(+), 10 deletions(-) diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index aee5b0b8..54c72795 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -361,17 +361,15 @@ impl Runtime { state.ensure_running()?; state.ensure_worker_ref(worker_ref)?; - let already_status = { + { let worker = state.worker(worker_ref)?; - worker.status == status - }; - if already_status { - let worker = state.worker(worker_ref)?; - return Ok(WorkerLifecycleAck { - worker_ref: worker_ref.clone(), - status: worker.status, - event_id: worker.last_event_id, - }); + if !worker.status.is_active() { + return Ok(WorkerLifecycleAck { + worker_ref: worker_ref.clone(), + status: worker.status, + event_id: worker.last_event_id, + }); + } } let event_id = state.push_event(Some(worker_ref.clone()), event_kind, reason); @@ -736,6 +734,96 @@ mod tests { assert_eq!(summary.cancelled_worker_count, 1); } + #[test] + fn stop_then_cancel_preserves_stopped_terminal_state() { + let runtime = Runtime::new_memory(); + let cursor = runtime.event_cursor_from_start().unwrap(); + let worker = runtime + .create_worker(task_request("stable stopped")) + .unwrap(); + + let stop_ack = runtime + .stop_worker(&worker.worker_ref, Some("done".to_string())) + .unwrap(); + let cancel_ack = runtime + .cancel_worker(&worker.worker_ref, Some("late cancel".to_string())) + .unwrap(); + + assert_eq!(stop_ack.status, WorkerStatus::Stopped); + assert_eq!(cancel_ack.status, WorkerStatus::Stopped); + assert_eq!(cancel_ack.event_id, stop_ack.event_id); + assert_eq!( + runtime.worker_detail(&worker.worker_ref).unwrap().status, + WorkerStatus::Stopped + ); + + let summary = runtime.summary().unwrap(); + assert_eq!(summary.active_worker_count, 0); + assert_eq!(summary.stopped_worker_count, 1); + assert_eq!(summary.cancelled_worker_count, 0); + + let events = runtime.read_events(&cursor, 10).unwrap().events; + assert_eq!( + events + .iter() + .filter(|event| event.kind == RuntimeEventKind::WorkerStopped) + .count(), + 1 + ); + assert_eq!( + events + .iter() + .filter(|event| event.kind == RuntimeEventKind::WorkerCancelled) + .count(), + 0 + ); + } + + #[test] + fn cancel_then_stop_preserves_cancelled_terminal_state() { + let runtime = Runtime::new_memory(); + let cursor = runtime.event_cursor_from_start().unwrap(); + let worker = runtime + .create_worker(task_request("stable cancelled")) + .unwrap(); + + let cancel_ack = runtime + .cancel_worker(&worker.worker_ref, Some("abort".to_string())) + .unwrap(); + let stop_ack = runtime + .stop_worker(&worker.worker_ref, Some("late stop".to_string())) + .unwrap(); + + assert_eq!(cancel_ack.status, WorkerStatus::Cancelled); + assert_eq!(stop_ack.status, WorkerStatus::Cancelled); + assert_eq!(stop_ack.event_id, cancel_ack.event_id); + assert_eq!( + runtime.worker_detail(&worker.worker_ref).unwrap().status, + WorkerStatus::Cancelled + ); + + let summary = runtime.summary().unwrap(); + assert_eq!(summary.active_worker_count, 0); + assert_eq!(summary.stopped_worker_count, 0); + assert_eq!(summary.cancelled_worker_count, 1); + + let events = runtime.read_events(&cursor, 10).unwrap().events; + assert_eq!( + events + .iter() + .filter(|event| event.kind == RuntimeEventKind::WorkerCancelled) + .count(), + 1 + ); + assert_eq!( + events + .iter() + .filter(|event| event.kind == RuntimeEventKind::WorkerStopped) + .count(), + 0 + ); + } + #[test] fn event_cursor_and_poll_only_subscription_are_bounded_placeholders() { let runtime = Runtime::new_memory(); From 6a17366fcf5f4a6b1e7548c8b263618864f90992 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 26 Jun 2026 01:50:53 +0900 Subject: [PATCH 05/17] ticket: record worker runtime lifecycle fix --- .yoi/tickets/00001KVZBCQH4/item.md | 2 +- .yoi/tickets/00001KVZBCQH4/thread.md | 32 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVZBCQH4/item.md b/.yoi/tickets/00001KVZBCQH4/item.md index 3d69d6d2..9045d0b3 100644 --- a/.yoi/tickets/00001KVZBCQH4/item.md +++ b/.yoi/tickets/00001KVZBCQH4/item.md @@ -2,7 +2,7 @@ title: 'worker-runtime core crateと組み込みRuntime APIを作る' state: 'inprogress' created_at: '2026-06-25T12:17:05Z' -updated_at: '2026-06-25T16:49:53Z' +updated_at: '2026-06-25T16:50:47Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-25T16:20:10Z' diff --git a/.yoi/tickets/00001KVZBCQH4/thread.md b/.yoi/tickets/00001KVZBCQH4/thread.md index cc438d49..562f9313 100644 --- a/.yoi/tickets/00001KVZBCQH4/thread.md +++ b/.yoi/tickets/00001KVZBCQH4/thread.md @@ -571,3 +571,35 @@ Human follow-up: - 現在の最優先は `00001KVZBCQH4` の reviewer blocker 解消、再 review、merge、validation、done 記録。 --- + + + +## Implementation report + +Follow-up implementation report after review request_changes: + +対応内容: +- `crates/worker-runtime/src/runtime.rs` + - `Runtime::transition_worker` を修正。 + - `Stopped` / `Cancelled` の terminal 状態にある Worker に反対側 terminal 操作が呼ばれても、既存 terminal 状態を保持し、同じ `event_id` で ack を返すようにした。 + - 反対側 terminal 操作では追加 event を発生させない。 +- 追加テスト: + - `stop_then_cancel_preserves_stopped_terminal_state` + - `cancel_then_stop_preserves_cancelled_terminal_state` + - summary count と event history が反対 terminal 操作で変化しないことを検証。 + +Commit: +- `fbd358a1 fix: keep worker terminal lifecycle stable` + +Validation: +- `cargo fmt --all`: success +- `cargo test -p worker-runtime`: success (8 tests passed) +- `cargo check -p yoi`: success +- `git diff --check`: success +- `nix build .#yoi --no-link`: success(実行時点で dirty tree warning は出たが build 自体は成功) + +Residual risks / review focus: +- terminal 状態への反対操作は reject ではなく「既存 terminal 状態を保持する idempotent 扱い」を選択。既存 API の ack semantics には自然だが、将来 UX/API で明示的 lifecycle conflict を出したい場合は typed error 方針へ切り替える余地がある。 +- child worktree は commit 後 clean。 + +--- From 138b1fd860b9d01a77eab8055e366282b21cfd1e Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 26 Jun 2026 01:52:50 +0900 Subject: [PATCH 06/17] ticket: approve worker runtime core --- .yoi/tickets/00001KVZBCQH4/item.md | 2 +- .yoi/tickets/00001KVZBCQH4/thread.md | 31 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVZBCQH4/item.md b/.yoi/tickets/00001KVZBCQH4/item.md index 9045d0b3..5b517a3f 100644 --- a/.yoi/tickets/00001KVZBCQH4/item.md +++ b/.yoi/tickets/00001KVZBCQH4/item.md @@ -2,7 +2,7 @@ title: 'worker-runtime core crateと組み込みRuntime APIを作る' state: 'inprogress' created_at: '2026-06-25T12:17:05Z' -updated_at: '2026-06-25T16:50:47Z' +updated_at: '2026-06-25T16:52:45Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-25T16:20:10Z' diff --git a/.yoi/tickets/00001KVZBCQH4/thread.md b/.yoi/tickets/00001KVZBCQH4/thread.md index 562f9313..16354ba8 100644 --- a/.yoi/tickets/00001KVZBCQH4/thread.md +++ b/.yoi/tickets/00001KVZBCQH4/thread.md @@ -603,3 +603,34 @@ Residual risks / review focus: - child worktree は commit 後 clean。 --- + + + +## Review: approve + +Review result: approve + +確認結果: + +- 前回 blocker の terminal lifecycle は解消されています。 + - `Runtime::transition_worker` は既に `Stopped` / `Cancelled` の Worker に対して、反対側の terminal 操作を受けても state を上書きせず、既存 `status` / `last_event_id` を返し、新規 event も追加しません。 + - 該当: `crates/worker-runtime/src/runtime.rs:353-384` +- 追加テストも妥当です。 + - `stop_then_cancel_preserves_stopped_terminal_state` + - `cancel_then_stop_preserves_cancelled_terminal_state` + - summary counts と event history の非変化を確認しており、前回指摘した invariant を直接カバーしています。 +- `runtime_id + worker_id` / `WorkerRef` authority 境界に regression は見当たりません。 +- scope creep は見当たりません。変更範囲は `worker-runtime` crate、workspace 追加、`package.nix` cargoHash 更新に収まっています。 +- `worker-runtime` の依存は `serde` / `thiserror` のみで、HTTP/WS/FS/server/client 系依存の追加はありません。 +- `package.nix` の cargoHash 更新は維持されています。 + +実施した確認: +- `git status`, `git log`, `git show fbd358a1` +- `runtime.rs` の lifecycle 実装・追加テスト読解 +- `worker-runtime` crate の依存/API 境界確認 +- legacy pod/socket/session authority や HTTP/WS/FS 依存の grep 確認 +- `git diff --check f8d3b1cc..HEAD`: success + +補足: read-only 指示を優先し、`cargo test` / `nix build` は再実行していません。 + +--- From 31c8d99187f9e6f050e6e04f52e17e319806a741 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 26 Jun 2026 01:53:52 +0900 Subject: [PATCH 07/17] ticket: mark worker runtime core done --- .yoi/tickets/00001KVZBCQH4/item.md | 4 +-- .yoi/tickets/00001KVZBCQH4/thread.md | 52 ++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KVZBCQH4/item.md b/.yoi/tickets/00001KVZBCQH4/item.md index 5b517a3f..3ab19e34 100644 --- a/.yoi/tickets/00001KVZBCQH4/item.md +++ b/.yoi/tickets/00001KVZBCQH4/item.md @@ -1,8 +1,8 @@ --- title: 'worker-runtime core crateと組み込みRuntime APIを作る' -state: 'inprogress' +state: 'done' created_at: '2026-06-25T12:17:05Z' -updated_at: '2026-06-25T16:52:45Z' +updated_at: '2026-06-25T16:53:46Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-25T16:20:10Z' diff --git a/.yoi/tickets/00001KVZBCQH4/thread.md b/.yoi/tickets/00001KVZBCQH4/thread.md index 16354ba8..224ea424 100644 --- a/.yoi/tickets/00001KVZBCQH4/thread.md +++ b/.yoi/tickets/00001KVZBCQH4/thread.md @@ -634,3 +634,55 @@ Review result: approve 補足: read-only 指示を優先し、`cargo test` / `nix build` は再実行していません。 --- + + + +## Implementation report + +Integration / validation report: + +Integration: +- Implementation branch `work/00001KVZBCQH4-worker-runtime-core` を orchestration branch に merge 済み。 +- Merge commit: `56bdf955 merge: 00001KVZBCQH4 worker runtime core` + +Included implementation commits: +- `9b2cae32 feat: add memory worker runtime crate` +- `593db951 fix: update nix cargo hash` +- `fbd358a1 fix: keep worker terminal lifecycle stable` + +Validation in Orchestrator worktree: +- `cargo fmt --all --check`: success +- `cargo test -p worker-runtime`: success(8 tests passed) +- `cargo check -p yoi`: success +- `git diff --check`: success +- `nix build .#yoi --no-link`: success + +Review: +- Reviewer approve 済み。前回 blocker だった `stop_worker` / `cancel_worker` terminal lifecycle invariant は解消済み。 + +Outcome: +- Acceptance criteria を満たしたため `done` へ進める。 + +--- + + + +## State changed + +worker-runtime core crate の実装、review、merge、Orchestrator validation が完了した。 + +Done evidence: +- Merge commit: `56bdf955 merge: 00001KVZBCQH4 worker runtime core` +- Reviewer approve 済み。 +- Orchestrator validation: + - `cargo fmt --all --check`: success + - `cargo test -p worker-runtime`: success(8 tests passed) + - `cargo check -p yoi`: success + - `git diff --check`: success + - `nix build .#yoi --no-link`: success + +Scope: +- `crates/worker-runtime` の memory-backed core Runtime API を追加。 +- HTTP/WS/FS/Backend integration/config bundle sync は Non-goals として未実装。 + +--- From 07a913f51f4728726ec4ffe6e1ce2a801d291980 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 26 Jun 2026 01:54:24 +0900 Subject: [PATCH 08/17] ticket: record worker runtime core cleanup --- .yoi/tickets/00001KVZBCQH4/item.md | 2 +- .yoi/tickets/00001KVZBCQH4/thread.md | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVZBCQH4/item.md b/.yoi/tickets/00001KVZBCQH4/item.md index 3ab19e34..c8ed5225 100644 --- a/.yoi/tickets/00001KVZBCQH4/item.md +++ b/.yoi/tickets/00001KVZBCQH4/item.md @@ -2,7 +2,7 @@ title: 'worker-runtime core crateと組み込みRuntime APIを作る' state: 'done' created_at: '2026-06-25T12:17:05Z' -updated_at: '2026-06-25T16:53:46Z' +updated_at: '2026-06-25T16:54:19Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-25T16:20:10Z' diff --git a/.yoi/tickets/00001KVZBCQH4/thread.md b/.yoi/tickets/00001KVZBCQH4/thread.md index 224ea424..2427048d 100644 --- a/.yoi/tickets/00001KVZBCQH4/thread.md +++ b/.yoi/tickets/00001KVZBCQH4/thread.md @@ -686,3 +686,23 @@ Scope: - HTTP/WS/FS/Backend integration/config bundle sync は Non-goals として未実装。 --- + + + +## Implementation report + +Cleanup report: + +- Child Workers stopped and scope reclaimed: + - `yoi-coder-00001KVZBCQH4-worker-runtime` + - `yoi-coder-00001KVZBCQH4-runtime-packaging` + - `yoi-reviewer-00001KVZBCQH4-worker-runtime` +- Child implementation worktree removed: + - `/home/hare/Projects/yoi/.worktree/00001KVZBCQH4-worker-runtime-core` +- Child implementation branch removed: + - `work/00001KVZBCQH4-worker-runtime-core` + +Remaining note: +- Historical worker-rename child worktree remains separate and was not touched by this cleanup. + +--- From 40d4138068e4ec87ee9353a978fffce08084627a Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 26 Jun 2026 01:56:16 +0900 Subject: [PATCH 09/17] ticket: accept next worker runtime branches --- .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KVZKST83/item.md | 4 +- .yoi/tickets/00001KVZKST83/thread.md | 73 +++++++++++++++++++ .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KVZKSTE2/item.md | 2 +- .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KVZKSV6C/item.md | 4 +- .yoi/tickets/00001KVZKSV6C/thread.md | 70 ++++++++++++++++++ .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KVZQHPNY/item.md | 2 +- 10 files changed, 153 insertions(+), 6 deletions(-) diff --git a/.yoi/tickets/00001KVZKST83/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVZKST83/artifacts/orchestration-plan.jsonl index 3694c330..df219c5c 100644 --- a/.yoi/tickets/00001KVZKST83/artifacts/orchestration-plan.jsonl +++ b/.yoi/tickets/00001KVZKST83/artifacts/orchestration-plan.jsonl @@ -1 +1,2 @@ {"id":"orch-plan-20260625-164354-1","ticket_id":"00001KVZKST83","kind":"blocked_by","related_ticket":"00001KVZBCQH4","note":"Queue routing checked after Dashboard Queue. FS store feature depends on worker-runtime core `00001KVZBCQH4`, which is currently inprogress and under review. Do not start FS persistence implementation until core API is reviewed/merged/done.","author":"yoi-orchestrator","at":"2026-06-25T16:43:54Z"} +{"id":"orch-plan-20260625-165525-2","ticket_id":"00001KVZKST83","kind":"accepted_plan","note":"`00001KVZBCQH4` が done になり dependency blocker 解消。Backend Registry foundation Ticket とは主対象が `crates/worker-runtime` vs `crates/workspace-server` で分離できるため並行受理候補。ただし Cargo/package lock conflict は merge時に Orchestrator が解消する。","accepted_plan":{"summary":"worker-runtime core done 後の optional `fs-store` feature slice。core no-default-features を維持しつつ FS persistence backend、layout、atomic writes、bounded reads、corrupt diagnostics、memory/FS tests を追加する。REST/Backend integration は扱わない。","branch":"work/00001KVZKST83-worker-runtime-fs-store","worktree":"/home/hare/Projects/yoi/.worktree/00001KVZKST83-worker-runtime-fs-store","role_plan":"Orchestrator が dedicated child worktree を作成し、coder Worker に `crates/worker-runtime` と lock/package files の write scope を委譲する。reviewer Worker は read-only で feature gating、atomic/corrupt diagnostics、runtime_id+worker_id authority、no standalone worker-store を確認する。merge/validation/done/cleanup は Orchestrator が行う。"},"author":"yoi-orchestrator","at":"2026-06-25T16:55:25Z"} diff --git a/.yoi/tickets/00001KVZKST83/item.md b/.yoi/tickets/00001KVZKST83/item.md index 67139434..b2f70c83 100644 --- a/.yoi/tickets/00001KVZKST83/item.md +++ b/.yoi/tickets/00001KVZKST83/item.md @@ -1,8 +1,8 @@ --- title: 'worker-runtimeにFS永続化featureを追加する' -state: 'queued' +state: 'inprogress' created_at: '2026-06-25T14:44:02Z' -updated_at: '2026-06-25T16:44:04Z' +updated_at: '2026-06-25T16:55:50Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-25T16:39:26Z' diff --git a/.yoi/tickets/00001KVZKST83/thread.md b/.yoi/tickets/00001KVZKST83/thread.md index dd606ab4..8bbf2b37 100644 --- a/.yoi/tickets/00001KVZKST83/thread.md +++ b/.yoi/tickets/00001KVZKST83/thread.md @@ -59,3 +59,76 @@ Escalate if: - FS feature を core Ticket に巻き戻す必要が出る。 --- + + + +## Decision + +Routing decision: implementation_ready + +Reason: +- `00001KVZBCQH4` worker-runtime core は done。dependency blocker は解消済み。 +- 本 Ticket は optional FS persistence feature であり、REST server / Backend Registry integration とは分離されている。 +- queued/inprogress 再確認時点で `00001KVZKSV6C` を受理したが、主変更面は Backend Registry foundation (`crates/workspace-server`) と FS feature (`crates/worker-runtime`) で分離できる。Cargo/package files の機械的 conflict は Orchestrator merge時に解消可能と判断する。 + +Evidence checked: +- Ticket body: `fs-store` feature、runtime/worker scoped layout、atomic write、corrupt diagnostics、bounded reads、Non-goals。 +- Relations: outgoing dependency `00001KVZBCQH4` は done。incoming remote Runtime Ticket は後続であり blocker ではない。 +- Orchestration plan: accepted plan `orch-plan-20260625-165525-2` を記録。 +- Workspace state: orchestration worktree clean。worker-runtime core merge/validation/done/cleanup 済み。 + +IntentPacket: + +Intent: +- `worker-runtime` に optional `fs-store` feature と filesystem persistence backend を追加する。 + +Binding decisions / invariants: +- Feature disabled 時に core library は FS store dependency を強制しない。 +- Store authority は `runtime_id + worker_id`。legacy pod path / socket path / session path を authority にしない。 +- Standalone `worker-store` crate は作らない。`worker-runtime` 内 feature/module として実装する。 +- REST command server / event stream / Backend integration / legacy Pod session migration / SQLite store は実装しない。 +- Existing memory store/core tests を壊さない。 + +Requirements / acceptance criteria: +- `fs-store` feature がある。 +- `cargo test -p worker-runtime --no-default-features` が通る。 +- `cargo test -p worker-runtime --features fs-store` が通る。 +- Worker create/state update/event append/transcript append/bounded read が FS store で動く。 +- Runtime/Worker scoped layout、atomic write、directory creation、corrupt/missing diagnostics を扱う。 + +Implementation latitude: +- FS layout、module split、serialization structs、atomic write helper、diagnostics typeの詳細は Coder が既存 style に合わせて選べる。 +- 旧 `pod-store` の atomic write pattern は参考にしてよいが、依存/責務移植は不要。 + +Escalate if: +- core Runtime store abstraction の大幅 rewrite が必要になる。 +- REST/Backend integration を同時実装しないと acceptance を満たせないと判明する。 +- `worker-store` standalone crate が必要に見える。 + +Validation: +- `cargo fmt --all` +- `cargo test -p worker-runtime --no-default-features` +- `cargo test -p worker-runtime --features fs-store` +- `cargo check -p yoi` +- `git diff --check` +- 可能なら `nix build .#yoi --no-link` + +Critical risks / reviewer focus: +- Feature gating failure causing core to pull FS dependencies。 +- FS layout leaking legacy Pod/session authority。 +- Atomic write / corrupt record handling が不十分。 +- Memory store semantics regression。 + +--- + + + +## State changed + +Routing decision: implementation_ready。 + +Dependency `00001KVZBCQH4` worker-runtime core は done。Ticket body / relations / workspace state / orchestration plan を確認し、optional FS store feature は unblocked と判断した。accepted plan と IntentPacket は thread / orchestration plan に記録済み。 + +これ以降、worktree creation / coder Worker routing などの implementation side effect に進める。 + +--- diff --git a/.yoi/tickets/00001KVZKSTE2/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVZKSTE2/artifacts/orchestration-plan.jsonl index c80bc7a9..fdb9855b 100644 --- a/.yoi/tickets/00001KVZKSTE2/artifacts/orchestration-plan.jsonl +++ b/.yoi/tickets/00001KVZKSTE2/artifacts/orchestration-plan.jsonl @@ -1 +1,2 @@ {"id":"orch-plan-20260625-164410-1","ticket_id":"00001KVZKSTE2","kind":"blocked_by","related_ticket":"00001KVZBCQH4","note":"Queue routing checked after Dashboard Queue. REST command server depends on worker-runtime core `00001KVZBCQH4`, which is currently inprogress and under review. Do not start HTTP/server implementation until core API is reviewed/merged/done.","author":"yoi-orchestrator","at":"2026-06-25T16:44:10Z"} +{"id":"orch-plan-20260625-165601-2","ticket_id":"00001KVZKSTE2","kind":"waiting_capacity_note","note":"Core dependency is now done, but this REST/http-server Ticket is left queued in this acceptance pass because FS store and Backend Registry foundation were accepted first. `http-server` is likely to modify `crates/worker-runtime` feature/dependency/package surfaces and conflict with FS store work; start after FS store branch stabilizes or Orchestrator explicitly serializes merge conflict handling.","author":"yoi-orchestrator","at":"2026-06-25T16:56:01Z"} diff --git a/.yoi/tickets/00001KVZKSTE2/item.md b/.yoi/tickets/00001KVZKSTE2/item.md index b15f71f0..557782c6 100644 --- a/.yoi/tickets/00001KVZKSTE2/item.md +++ b/.yoi/tickets/00001KVZKSTE2/item.md @@ -2,7 +2,7 @@ title: 'worker-runtimeにREST command serverを追加する' state: 'queued' created_at: '2026-06-25T14:44:02Z' -updated_at: '2026-06-25T16:44:20Z' +updated_at: '2026-06-25T16:56:01Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-25T16:39:39Z' diff --git a/.yoi/tickets/00001KVZKSV6C/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVZKSV6C/artifacts/orchestration-plan.jsonl index 4f66e707..2687a278 100644 --- a/.yoi/tickets/00001KVZKSV6C/artifacts/orchestration-plan.jsonl +++ b/.yoi/tickets/00001KVZKSV6C/artifacts/orchestration-plan.jsonl @@ -1 +1,2 @@ {"id":"orch-plan-20260625-163206-1","ticket_id":"00001KVZKSV6C","kind":"blocked_by","related_ticket":"00001KVZBCQH4","note":"Queue routing checked after Dashboard Queue. This Backend RuntimeRegistry foundation Ticket depends on `00001KVZBCQH4` worker-runtime core. That dependency is currently inprogress and only at coder implementation report stage, not reviewed/merged/done, so implementation side effects for this Ticket are blocked.","author":"yoi-orchestrator","at":"2026-06-25T16:32:06Z"} +{"id":"orch-plan-20260625-165451-2","ticket_id":"00001KVZKSV6C","kind":"accepted_plan","note":"`00001KVZBCQH4` が done になり dependency blocker 解消。FS store Ticket とは主対象が workspace-server vs worker-runtime feature で分離できるため並行受理候補。","accepted_plan":{"summary":"worker-runtime core done 後の Backend RuntimeRegistry foundation slice。embedded/remote 実 handle は作らず、workspace-server の Registry identity/projection/error boundary と local compatibility source naming/diagnostics を worker-runtime domain model に合わせる。","branch":"work/00001KVZKSV6C-backend-runtime-registry","worktree":"/home/hare/Projects/yoi/.worktree/00001KVZKSV6C-backend-runtime-registry","role_plan":"Orchestrator が dedicated child worktree を作成し、coder Worker に `crates/workspace-server` 中心の narrow write scope を委譲する。reviewer Worker は read-only で Registry identity/projection/error boundary と existing API behavior preservation を確認する。merge/validation/done/cleanup は Orchestrator が行う。"},"author":"yoi-orchestrator","at":"2026-06-25T16:54:51Z"} diff --git a/.yoi/tickets/00001KVZKSV6C/item.md b/.yoi/tickets/00001KVZKSV6C/item.md index e2a40eb5..d831d678 100644 --- a/.yoi/tickets/00001KVZKSV6C/item.md +++ b/.yoi/tickets/00001KVZKSV6C/item.md @@ -1,8 +1,8 @@ --- title: 'Backend RuntimeRegistryの基盤をworker-runtime向けに整理する' -state: 'queued' +state: 'inprogress' created_at: '2026-06-25T14:44:03Z' -updated_at: '2026-06-25T16:32:17Z' +updated_at: '2026-06-25T16:55:16Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-25T16:31:28Z' diff --git a/.yoi/tickets/00001KVZKSV6C/thread.md b/.yoi/tickets/00001KVZKSV6C/thread.md index 611a40f7..1195d223 100644 --- a/.yoi/tickets/00001KVZKSV6C/thread.md +++ b/.yoi/tickets/00001KVZKSV6C/thread.md @@ -80,3 +80,73 @@ Escalate if: - Backend Registry foundation 側で worker-runtime core の追加変更が必要になる。 --- + + + +## Decision + +Routing decision: implementation_ready + +Reason: +- `00001KVZBCQH4` worker-runtime core は done。dependency blocker は解消済み。 +- 本 Ticket の目的は Backend RuntimeRegistry foundation の整理で、embedded runtime 接続や REST/FS 実装そのものではない。 +- queued/inprogress 再確認時点で `inprogress` は 0 件。FS store Ticket とは主変更面が `crates/workspace-server` vs `crates/worker-runtime` feature で概ね分離できるため、本 Ticket は受理可能。 + +Evidence checked: +- Ticket body: Backend RuntimeRegistry foundation、runtime_id / worker_id authority、existing local compatibility source、workspace-server hosts/server target、Non-goals。 +- Relations: outgoing dependency `00001KVZBCQH4` は done。incoming dependent `00001KVZSGT0Q`, `00001KVZSGT14` は後続であり blocker ではない。 +- Orchestration plan: accepted plan `orch-plan-20260625-165451-2` を記録。 +- Workspace state: orchestration worktree clean。worker-runtime core merge/validation/done/cleanup 済み。 + +IntentPacket: + +Intent: +- Backend `RuntimeRegistry` の domain boundary を、legacy process/source abstraction から worker-runtime を受けられる形へ整理する。 + +Binding decisions / invariants: +- Backend Registry の authority は Runtime / Worker domain identity を扱い、raw socket/session/path/pod name を public authority にしない。 +- この Ticket では embedded `worker_runtime::Runtime` を実際に接続しない。handle/trait/enum/boundary と diagnostics/projection 整理まで。 +- Remote Runtime HTTP client / REST server / Web Console / dynamic Runtime registration は実装しない。 +- 既存 local compatibility source の behavior は壊さない。 +- `worker-runtime` core crate の API を大きく変更しない。必要になれば escalation。 + +Requirements / acceptance criteria: +- `workspace-server` の RuntimeRegistry foundation が worker-runtime 向けの identity / projection / error boundary を持つ。 +- Existing local/process compatibility source と将来 embedded/remote source の境界が型で分かる。 +- Browser/API response へ socket/session/path authority を露出しない。 +- Existing workspace-server tests が通る。 + +Implementation latitude: +- Type/module split、内部 trait/enum naming、diagnostic representation は既存 `crates/workspace-server/src/hosts.rs` の形に合わせてよい。 +- Minimal compile/test slice でよい。実 embedded handle は placeholder/boundary でよい。 + +Escalate if: +- `worker-runtime` crate の public API 変更が必要になる。 +- Backend embedded connection を実装しないと acceptance を満たせないと判明する。 +- API response schema 互換性や dashboard expectations を大きく変える必要がある。 + +Validation: +- `cargo fmt --all` +- `cargo test -p yoi-workspace-server` +- `cargo check -p yoi` +- `git diff --check` +- 可能なら `nix build .#yoi --no-link` + +Critical risks / reviewer focus: +- Registry boundary が legacy Pod/socket/session authority を引きずること。 +- `00001KVZSGT0Q` の実 embedded接続まで scope creep すること。 +- Existing local compatibility source の API/diagnostics regression。 + +--- + + + +## State changed + +Routing decision: implementation_ready。 + +Dependency `00001KVZBCQH4` worker-runtime core は done。Ticket body / relations / workspace state / orchestration plan を確認し、Backend RuntimeRegistry foundation は unblocked と判断した。accepted plan と IntentPacket は thread / orchestration plan に記録済み。 + +これ以降、worktree creation / coder Worker routing などの implementation side effect に進める。 + +--- diff --git a/.yoi/tickets/00001KVZQHPNY/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVZQHPNY/artifacts/orchestration-plan.jsonl index 797945f2..548df084 100644 --- a/.yoi/tickets/00001KVZQHPNY/artifacts/orchestration-plan.jsonl +++ b/.yoi/tickets/00001KVZQHPNY/artifacts/orchestration-plan.jsonl @@ -1 +1,2 @@ {"id":"orch-plan-20260625-164457-1","ticket_id":"00001KVZQHPNY","kind":"blocked_by","related_ticket":"00001KVZBCQH4","note":"Queue routing checked after Dashboard Queue. Profile/config bundle sync depends on worker-runtime core `00001KVZBCQH4`, which is currently inprogress and under review. Do not start sync implementation until core CreateWorkerRequest/Profile boundary is reviewed/merged/done.","author":"yoi-orchestrator","at":"2026-06-25T16:44:57Z"} +{"id":"orch-plan-20260625-165606-2","ticket_id":"00001KVZQHPNY","kind":"waiting_capacity_note","note":"Core dependency is now done, but this bundle-sync Ticket is left queued in this acceptance pass because Backend Registry foundation and FS store were accepted first. Bundle sync likely touches `worker-runtime` creation/profile boundary and Backend Registry availability semantics, so it should start after at least the foundation branch shape is reviewed or merged to avoid design/API churn.","author":"yoi-orchestrator","at":"2026-06-25T16:56:06Z"} diff --git a/.yoi/tickets/00001KVZQHPNY/item.md b/.yoi/tickets/00001KVZQHPNY/item.md index a7df9195..8b1a8f35 100644 --- a/.yoi/tickets/00001KVZQHPNY/item.md +++ b/.yoi/tickets/00001KVZQHPNY/item.md @@ -2,7 +2,7 @@ title: 'RuntimeへProfile/config bundleを同期する' state: 'queued' created_at: '2026-06-25T15:49:30Z' -updated_at: '2026-06-25T16:45:08Z' +updated_at: '2026-06-25T16:56:06Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-25T16:44:39Z' From 7d9fed814441258dc4f2c6dbb34b5f9c04ad9c68 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 26 Jun 2026 01:58:53 +0900 Subject: [PATCH 10/17] ticket: record spawn launcher blocker --- .yoi/tickets/00001KVZKST83/item.md | 2 +- .yoi/tickets/00001KVZKST83/thread.md | 23 +++++++++++++++++++++++ .yoi/tickets/00001KVZKSV6C/item.md | 2 +- .yoi/tickets/00001KVZKSV6C/thread.md | 25 +++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KVZKST83/item.md b/.yoi/tickets/00001KVZKST83/item.md index b2f70c83..e70c0b7b 100644 --- a/.yoi/tickets/00001KVZKST83/item.md +++ b/.yoi/tickets/00001KVZKST83/item.md @@ -2,7 +2,7 @@ title: 'worker-runtimeにFS永続化featureを追加する' state: 'inprogress' created_at: '2026-06-25T14:44:02Z' -updated_at: '2026-06-25T16:55:50Z' +updated_at: '2026-06-25T16:58:41Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-25T16:39:26Z' diff --git a/.yoi/tickets/00001KVZKST83/thread.md b/.yoi/tickets/00001KVZKST83/thread.md index 8bbf2b37..faf0c2e9 100644 --- a/.yoi/tickets/00001KVZKST83/thread.md +++ b/.yoi/tickets/00001KVZKST83/thread.md @@ -132,3 +132,26 @@ Dependency `00001KVZBCQH4` worker-runtime core は done。Ticket body / relation これ以降、worktree creation / coder Worker routing などの implementation side effect に進める。 --- + + + +## Implementation report + +Implementation startup blocked after acceptance: + +- Worktree created: + - `/home/hare/Projects/yoi/.worktree/00001KVZKST83-worker-runtime-fs-store` + - branch `work/00001KVZKST83-worker-runtime-fs-store` +- Coder Worker was not spawned because the immediately preceding SpawnPod attempt for parallel accepted Ticket `00001KVZKSV6C` failed with: + - `yoi: unknown command pod` + +Interpretation: +- No implementation code changes have been made in this worktree yet. +- This appears to be the same runtime/launcher compatibility issue after the Pod→Worker CLI rename. +- Because this Ticket was already accepted (`queued -> inprogress`) and worktree creation completed, it remains inprogress with a recovery note rather than being silently returned to queued. + +Recovery needed: +- Restart/restore Orchestrator on a consistent runtime binary or repair the launcher compatibility path, then spawn the Coder Worker for this accepted worktree. +- If retry remains impossible, use an explicit human-authorized alternative implementation route. + +--- diff --git a/.yoi/tickets/00001KVZKSV6C/item.md b/.yoi/tickets/00001KVZKSV6C/item.md index d831d678..68fe15f4 100644 --- a/.yoi/tickets/00001KVZKSV6C/item.md +++ b/.yoi/tickets/00001KVZKSV6C/item.md @@ -2,7 +2,7 @@ title: 'Backend RuntimeRegistryの基盤をworker-runtime向けに整理する' state: 'inprogress' created_at: '2026-06-25T14:44:03Z' -updated_at: '2026-06-25T16:55:16Z' +updated_at: '2026-06-25T16:58:32Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-25T16:31:28Z' diff --git a/.yoi/tickets/00001KVZKSV6C/thread.md b/.yoi/tickets/00001KVZKSV6C/thread.md index 1195d223..4788e26d 100644 --- a/.yoi/tickets/00001KVZKSV6C/thread.md +++ b/.yoi/tickets/00001KVZKSV6C/thread.md @@ -150,3 +150,28 @@ Dependency `00001KVZBCQH4` worker-runtime core は done。Ticket body / relation これ以降、worktree creation / coder Worker routing などの implementation side effect に進める。 --- + + + +## Implementation report + +Implementation startup blocked after acceptance: + +- Worktree created: + - `/home/hare/Projects/yoi/.worktree/00001KVZKSV6C-backend-runtime-registry` + - branch `work/00001KVZKSV6C-backend-runtime-registry` +- Coder Worker spawn attempted: + - `yoi-coder-00001KVZKSV6C-runtime-registry` +- Spawn failed before child socket appeared: + - `yoi: unknown command pod` + +Interpretation: +- No implementation code changes have been made in this worktree yet. +- This appears to be a runtime/launcher compatibility issue after the Pod→Worker CLI rename: the SpawnPod tool path attempted to invoke a `pod` subcommand that the child-launch binary rejected. +- Because this failure happened after `queued -> inprogress` acceptance and worktree creation, the Ticket remains inprogress with a recovery note rather than being silently returned to queued. + +Recovery needed: +- Restart/restore Orchestrator on a consistent runtime binary or repair the launcher compatibility path, then retry Coder Worker spawn for this accepted worktree. +- If retry remains impossible, use an explicit human-authorized alternative implementation route. + +--- From 928074fd6422655e152736757da652a7fb8123a8 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 26 Jun 2026 04:25:03 +0900 Subject: [PATCH 11/17] ticket: resume accepted worker runtime branches --- .yoi/tickets/00001KVZKST83/item.md | 2 +- .yoi/tickets/00001KVZKST83/thread.md | 20 ++++++++++++++++++++ .yoi/tickets/00001KVZKSV6C/item.md | 2 +- .yoi/tickets/00001KVZKSV6C/thread.md | 20 ++++++++++++++++++++ 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KVZKST83/item.md b/.yoi/tickets/00001KVZKST83/item.md index e70c0b7b..9e17f7e7 100644 --- a/.yoi/tickets/00001KVZKST83/item.md +++ b/.yoi/tickets/00001KVZKST83/item.md @@ -2,7 +2,7 @@ title: 'worker-runtimeにFS永続化featureを追加する' state: 'inprogress' created_at: '2026-06-25T14:44:02Z' -updated_at: '2026-06-25T16:58:41Z' +updated_at: '2026-06-25T19:24:59Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-25T16:39:26Z' diff --git a/.yoi/tickets/00001KVZKST83/thread.md b/.yoi/tickets/00001KVZKST83/thread.md index faf0c2e9..7a35fce4 100644 --- a/.yoi/tickets/00001KVZKST83/thread.md +++ b/.yoi/tickets/00001KVZKST83/thread.md @@ -155,3 +155,23 @@ Recovery needed: - If retry remains impossible, use an explicit human-authorized alternative implementation route. --- + + + +## Implementation report + +Implementation recovery / kickoff: + +- User reported the previous SpawnPod failure was caused by a development environment variable forcing child launch from `target` binary. The variable was removed and installed binary is now used. +- Retried Coder Worker spawn for the already accepted worktree; spawn succeeded. + +Active implementation: +- Worktree: `/home/hare/Projects/yoi/.worktree/00001KVZKST83-worker-runtime-fs-store` +- Branch: `work/00001KVZKST83-worker-runtime-fs-store` +- Coder Worker: `yoi-coder-00001KVZKST83-fs-store` +- Scope: read implementation worktree + non-recursive workspace root, write `crates/worker-runtime/**`, root `Cargo.toml`, `Cargo.lock`, and `package.nix` only. + +Next action: +- Wait for Coder implementation report, then route to reviewer. + +--- diff --git a/.yoi/tickets/00001KVZKSV6C/item.md b/.yoi/tickets/00001KVZKSV6C/item.md index 68fe15f4..ef0b2fa6 100644 --- a/.yoi/tickets/00001KVZKSV6C/item.md +++ b/.yoi/tickets/00001KVZKSV6C/item.md @@ -2,7 +2,7 @@ title: 'Backend RuntimeRegistryの基盤をworker-runtime向けに整理する' state: 'inprogress' created_at: '2026-06-25T14:44:03Z' -updated_at: '2026-06-25T16:58:32Z' +updated_at: '2026-06-25T19:24:51Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-25T16:31:28Z' diff --git a/.yoi/tickets/00001KVZKSV6C/thread.md b/.yoi/tickets/00001KVZKSV6C/thread.md index 4788e26d..7f9d3a1b 100644 --- a/.yoi/tickets/00001KVZKSV6C/thread.md +++ b/.yoi/tickets/00001KVZKSV6C/thread.md @@ -175,3 +175,23 @@ Recovery needed: - If retry remains impossible, use an explicit human-authorized alternative implementation route. --- + + + +## Implementation report + +Implementation recovery / kickoff: + +- User reported the previous SpawnPod failure was caused by a development environment variable forcing child launch from `target` binary. The variable was removed and installed binary is now used. +- Retried Coder Worker spawn for the already accepted worktree; spawn succeeded. + +Active implementation: +- Worktree: `/home/hare/Projects/yoi/.worktree/00001KVZKSV6C-backend-runtime-registry` +- Branch: `work/00001KVZKSV6C-backend-runtime-registry` +- Coder Worker: `yoi-coder-00001KVZKSV6C-runtime-registry` +- Scope: read implementation worktree + non-recursive workspace root, write `crates/workspace-server/**` only. + +Next action: +- Wait for Coder implementation report, then route to reviewer. + +--- From f6fe9fba7f9bb15b5f8b1b3dda6bd0d44294e5ff Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 26 Jun 2026 04:33:14 +0900 Subject: [PATCH 12/17] ticket: record runtime registry implementation --- .yoi/tickets/00001KVZKSV6C/item.md | 2 +- .yoi/tickets/00001KVZKSV6C/thread.md | 40 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVZKSV6C/item.md b/.yoi/tickets/00001KVZKSV6C/item.md index ef0b2fa6..39660581 100644 --- a/.yoi/tickets/00001KVZKSV6C/item.md +++ b/.yoi/tickets/00001KVZKSV6C/item.md @@ -2,7 +2,7 @@ title: 'Backend RuntimeRegistryの基盤をworker-runtime向けに整理する' state: 'inprogress' created_at: '2026-06-25T14:44:03Z' -updated_at: '2026-06-25T19:24:51Z' +updated_at: '2026-06-25T19:33:11Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-25T16:31:28Z' diff --git a/.yoi/tickets/00001KVZKSV6C/thread.md b/.yoi/tickets/00001KVZKSV6C/thread.md index 7f9d3a1b..a2c76204 100644 --- a/.yoi/tickets/00001KVZKSV6C/thread.md +++ b/.yoi/tickets/00001KVZKSV6C/thread.md @@ -195,3 +195,43 @@ Next action: - Wait for Coder implementation report, then route to reviewer. --- + + + +## Implementation report + +Implementation report: + +実装完了。Ticket ファイル/project records は変更していない。merge/close/cleanup 前で停止。 + +Commit: +- `f6fd7b63 feat: add workspace runtime registry source boundary` + +変更ファイル: +- `crates/workspace-server/src/hosts.rs` +- `crates/workspace-server/src/server.rs` + +実装/API 概要: +- `WorkerRuntimeRegistry` を `RuntimeRegistry` に改名し、workspace-server 内の Runtime/Worker domain 側の registry 名を明確化。 +- `RuntimeSummary` に `source: RuntimeSourceSummary` を追加。 + - `RuntimeSourceKind`: `local_compatibility`, `embedded_worker_runtime`, `remote_http` + - `RuntimeSourceStatus`: `active`, `reserved` + - `RuntimeIdentityAuthority`: `runtime_registry_projection` +- 現行 local Pod metadata 由来の互換 source は `local_compatibility` として明示。 +- embedded worker-runtime / remote HTTP は reserved boundary として型だけ用意し、実接続・HTTP client/server・dynamic registration は未実装のまま保持。 +- API response に source boundary / identity authority を追加し、Runtime public identity が socket/session/path ではなく registry projection であることをテストで確認。 +- 既存 local compatibility behavior は維持し、既存テストに source boundary の assert を追加。 + +Validation: +- `cargo fmt --all`: success +- `cargo test -p yoi-workspace-server`: success(19 passed) +- `cargo check -p yoi`: success +- `git diff --check`: success +- `nix build .#yoi --no-link`: success(dirty warning は出たが build 成功) + +Residual risks / review focus: +- API top-level `source: "worker_runtime_registry"` は既存互換のため維持。一方で Runtime item 側に typed `source.kind` / `identity_authority` を追加。 +- embedded/remote source は reserved constructor と enum boundary のみで、実際の registry composition や Runtime 接続は次 Ticket 側の責務。 +- `RuntimeSourceSummary.note` は human-readable な API payload なので、Web 側で表示するなら文言の粒度を確認してほしい。 + +--- From 150d5c49ebbcd75209e1b6939e8857f95062263b Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 26 Jun 2026 04:37:50 +0900 Subject: [PATCH 13/17] ticket: request runtime registry authority fix --- .yoi/tickets/00001KVZKSV6C/item.md | 2 +- .yoi/tickets/00001KVZKSV6C/thread.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVZKSV6C/item.md b/.yoi/tickets/00001KVZKSV6C/item.md index 39660581..ea687828 100644 --- a/.yoi/tickets/00001KVZKSV6C/item.md +++ b/.yoi/tickets/00001KVZKSV6C/item.md @@ -2,7 +2,7 @@ title: 'Backend RuntimeRegistryの基盤をworker-runtime向けに整理する' state: 'inprogress' created_at: '2026-06-25T14:44:03Z' -updated_at: '2026-06-25T19:33:11Z' +updated_at: '2026-06-25T19:37:41Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-25T16:31:28Z' diff --git a/.yoi/tickets/00001KVZKSV6C/thread.md b/.yoi/tickets/00001KVZKSV6C/thread.md index a2c76204..257a5ffc 100644 --- a/.yoi/tickets/00001KVZKSV6C/thread.md +++ b/.yoi/tickets/00001KVZKSV6C/thread.md @@ -235,3 +235,31 @@ Residual risks / review focus: - `RuntimeSourceSummary.note` は human-readable な API payload なので、Web 側で表示するなら文言の粒度を確認してほしい。 --- + + + +## Review: request changes + +Review result: request_changes + +ブロッカー: + +1. Runtime/Worker authority がまだ `runtime_id + worker_id` になっていない。 + - `RuntimeRegistry::worker(&self, worker_id)` は `runtime_id` を受け取らず、全 Runtime を `worker_id` だけで走査している。 + - `RuntimeRegistryError` にも `UnknownRuntime` 相当がない。 + - Ticket の binding decision / 要件では Worker authority は `runtime_id + worker_id`、unknown runtime / unknown worker 等の error boundary を typed に分ける必要がある。 + - 将来 embedded / remote source が増えた時に `worker_id` 衝突や誤 routing が起き得るため、foundation として不足。 + - 該当: `crates/workspace-server/src/hosts.rs` `RuntimeRegistry::worker` / `RuntimeRegistryError` + +2. runtime-scoped routing/error mapping のテストが不足している。 + - 追加テストは `local_compatibility` source / identity_authority / path 非露出は確認しているが、複数 Runtime または unknown runtime を想定した `runtime_id` scoped lookup / error boundary を確認していない。 + - Acceptance の “Runtime identity / Worker routing key / error boundary” を満たすには、typed `runtime_id` lookup と unknown-runtime / unknown-worker の focused test が必要。 + +確認できた良い点: +- `RuntimeSourceKind::{LocalCompatibility, EmbeddedWorkerRuntime, RemoteHttp}` と `RuntimeSourceSummary` により、local compatibility / future embedded / future remote の source 境界は型として追加されている。 +- local source は `source.kind = local_compatibility`、`identity_authority = runtime_registry_projection` として API/test で確認されている。 +- socket/session/path authority の直接公開や embedded `worker_runtime::Runtime` 接続、remote HTTP client/server 実装などの scope creep は見当たらない。 +- 変更範囲は `workspace-server` の `hosts.rs` / `server.rs` のみで、`worker-runtime` public API 変更は見当たらない。 +- read-only で `git diff --check f6fd7b63^ f6fd7b63` は問題なし。Cargo/Nix 検証は read-only 指示を優先して再実行していない。 + +--- From 407134399685537da9590d424df6b1db05402965 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 26 Jun 2026 04:40:58 +0900 Subject: [PATCH 14/17] feat: add worker runtime fs store --- Cargo.lock | 1 + crates/worker-runtime/Cargo.toml | 5 + crates/worker-runtime/src/error.rs | 22 + crates/worker-runtime/src/fs_store.rs | 755 ++++++++++++++++++++++++ crates/worker-runtime/src/lib.rs | 11 +- crates/worker-runtime/src/management.rs | 4 +- crates/worker-runtime/src/runtime.rs | 452 +++++++++++++- package.nix | 2 +- 8 files changed, 1240 insertions(+), 12 deletions(-) create mode 100644 crates/worker-runtime/src/fs_store.rs diff --git a/Cargo.lock b/Cargo.lock index 35c287d4..f2f468eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5906,6 +5906,7 @@ name = "worker-runtime" version = "0.1.0" dependencies = [ "serde", + "serde_json", "thiserror 2.0.18", ] diff --git a/crates/worker-runtime/Cargo.toml b/crates/worker-runtime/Cargo.toml index e9b53282..aca3ecb1 100644 --- a/crates/worker-runtime/Cargo.toml +++ b/crates/worker-runtime/Cargo.toml @@ -5,6 +5,11 @@ version = "0.1.0" edition.workspace = true license.workspace = true +[features] +default = [] +fs-store = ["dep:serde_json"] + [dependencies] serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true, optional = true } thiserror = { workspace = true } diff --git a/crates/worker-runtime/src/error.rs b/crates/worker-runtime/src/error.rs index 41c2e7ec..76b1c6b4 100644 --- a/crates/worker-runtime/src/error.rs +++ b/crates/worker-runtime/src/error.rs @@ -1,4 +1,5 @@ use crate::identity::{RuntimeId, WorkerId}; +use std::path::PathBuf; /// Errors returned by the embedded Runtime API. #[derive(Debug, thiserror::Error)] @@ -33,6 +34,27 @@ pub enum RuntimeError { #[error("invalid request: {0}")] InvalidRequest(String), + #[error("runtime store {operation} failed at {}: {source}", path.display())] + StoreIo { + operation: &'static str, + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("runtime store {operation} missing data at {}", path.display())] + StoreMissing { + operation: &'static str, + path: PathBuf, + }, + + #[error("runtime store {operation} found corrupt data at {}: {message}", path.display())] + StoreCorrupt { + operation: &'static str, + path: PathBuf, + message: String, + }, + #[error("runtime state lock was poisoned")] StatePoisoned, } diff --git a/crates/worker-runtime/src/fs_store.rs b/crates/worker-runtime/src/fs_store.rs new file mode 100644 index 00000000..54f1c41c --- /dev/null +++ b/crates/worker-runtime/src/fs_store.rs @@ -0,0 +1,755 @@ +use crate::catalog::{CreateWorkerRequest, WorkerStatus}; +use crate::diagnostics::RuntimeDiagnostic; +use crate::error::RuntimeError; +use crate::identity::{RuntimeId, WorkerId, WorkerRef}; +use crate::management::{RuntimeBackendKind, RuntimeLimits, RuntimeStatus}; +use crate::observation::{ + EventCursor, RuntimeEvent, RuntimeEventBatch, TranscriptEntry, TranscriptProjection, + TranscriptQuery, +}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fs::{self, File, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +const SCHEMA_VERSION: u32 = 1; +const RUNTIMES_DIR: &str = "runtimes"; +const RUNTIME_FILE: &str = "runtime.json"; +const EVENTS_FILE: &str = "events.jsonl"; +const WORKERS_DIR: &str = "workers"; +const WORKER_FILE: &str = "worker.json"; +const TRANSCRIPT_FILE: &str = "transcript.jsonl"; + +static NEXT_TMP_SEQUENCE: AtomicU64 = AtomicU64::new(1); + +/// Options for constructing a filesystem-backed Runtime store. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FsRuntimeStoreOptions { + /// Root directory containing all Runtime-scoped store data. + pub root: PathBuf, + pub runtime_id: Option, + pub display_name: Option, + pub limits: RuntimeLimits, +} + +impl FsRuntimeStoreOptions { + pub fn new(root: impl Into) -> Self { + Self { + root: root.into(), + runtime_id: None, + display_name: None, + limits: RuntimeLimits::default(), + } + } +} + +/// Filesystem persistence boundary for Worker Runtime state. +/// +/// Authority is the typed `runtime_id + worker_id` pair. Those ids are encoded +/// into path components only after validation; legacy pod paths, socket paths, +/// and session paths are deliberately not part of the layout or lookup API. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FsRuntimeStore { + root: PathBuf, + runtime_id: RuntimeId, + runtime_dir: PathBuf, +} + +impl FsRuntimeStore { + pub fn root(&self) -> &Path { + &self.root + } + + pub fn runtime_id(&self) -> &RuntimeId { + &self.runtime_id + } + + pub fn runtime_dir(&self) -> &Path { + &self.runtime_dir + } + + /// Read persisted Runtime events directly from the event log with the same + /// bounded cursor semantics as [`crate::Runtime::read_events`]. + pub fn read_events( + &self, + cursor: &EventCursor, + limit: usize, + max_limit: usize, + ) -> Result { + if cursor.runtime_id != self.runtime_id { + return Err(RuntimeError::WrongRuntimeCursor { + expected_runtime_id: self.runtime_id.clone(), + actual_runtime_id: cursor.runtime_id.clone(), + }); + } + if limit > max_limit { + return Err(RuntimeError::LimitTooLarge { + requested: limit, + max: max_limit, + }); + } + + let events = read_json_lines::(&self.events_path(), "read events")?; + let mut selected = Vec::new(); + for event in events + .iter() + .filter(|event| event.id >= cursor.next_event_id) + .take(limit) + { + selected.push(event.clone()); + } + let next_event_id = selected + .last() + .map(|event| event.id + 1) + .unwrap_or(cursor.next_event_id); + let has_more = events.iter().any(|event| event.id >= next_event_id); + + Ok(RuntimeEventBatch { + runtime_id: self.runtime_id.clone(), + cursor: EventCursor { + runtime_id: self.runtime_id.clone(), + next_event_id, + }, + events: selected, + has_more, + }) + } + + /// Read a persisted Worker transcript directly from its Worker-scoped log. + pub fn read_transcript( + &self, + worker_ref: &WorkerRef, + query: TranscriptQuery, + max_limit: usize, + ) -> Result { + self.ensure_worker_ref(worker_ref)?; + if query.limit > max_limit { + return Err(RuntimeError::LimitTooLarge { + requested: query.limit, + max: max_limit, + }); + } + + let path = self.transcript_path(&worker_ref.worker_id); + let entries = read_json_lines::(&path, "read transcript")?; + let total_items = entries.len(); + let end = query.start.saturating_add(query.limit).min(total_items); + let items = if query.start >= total_items { + Vec::new() + } else { + entries[query.start..end].to_vec() + }; + let next_start = (end < total_items).then_some(end); + + Ok(TranscriptProjection { + worker_ref: worker_ref.clone(), + start: query.start, + limit: query.limit, + total_items, + items, + next_start, + }) + } + + pub(crate) fn open_or_create( + root: PathBuf, + runtime_id: RuntimeId, + ) -> Result { + fs::create_dir_all(root.join(RUNTIMES_DIR)).map_err(|source| RuntimeError::StoreIo { + operation: "create store root", + path: root.join(RUNTIMES_DIR), + source, + })?; + + let runtime_dir = runtime_dir(&root, &runtime_id); + let existed = runtime_dir.exists(); + if existed && !runtime_dir.is_dir() { + return Err(RuntimeError::StoreCorrupt { + operation: "open runtime store", + path: runtime_dir, + message: "runtime path exists but is not a directory".to_string(), + }); + } + + fs::create_dir_all(runtime_dir.join(WORKERS_DIR)).map_err(|source| { + RuntimeError::StoreIo { + operation: "create runtime store", + path: runtime_dir.join(WORKERS_DIR), + source, + } + })?; + + let store = Self { + root, + runtime_id, + runtime_dir, + }; + let state = if existed { + Some(store.load_runtime_state()?) + } else { + None + }; + Ok(OpenedFsRuntimeStore { store, state }) + } + + pub(crate) fn write_runtime_snapshot( + &self, + state: &PersistedRuntimeState, + ) -> Result<(), RuntimeError> { + let snapshot = RuntimeSnapshot::from_persisted(state); + atomic_write_json(&self.runtime_path(), &snapshot, "write runtime snapshot") + } + + pub(crate) fn write_worker_snapshot( + &self, + worker: &PersistedWorkerRecord, + ) -> Result<(), RuntimeError> { + self.ensure_worker_ref(&worker.worker_ref)?; + let worker_dir = self.worker_dir(&worker.worker_id); + fs::create_dir_all(&worker_dir).map_err(|source| RuntimeError::StoreIo { + operation: "create worker store", + path: worker_dir.clone(), + source, + })?; + atomic_write_json( + &worker_dir.join(WORKER_FILE), + &WorkerSnapshot::from_persisted(worker), + "write worker snapshot", + )?; + ensure_file_exists(&worker_dir.join(TRANSCRIPT_FILE), "create transcript log") + } + + pub(crate) fn append_event(&self, event: &RuntimeEvent) -> Result<(), RuntimeError> { + if let Some(worker_ref) = &event.worker_ref { + self.ensure_worker_ref(worker_ref)?; + } + append_json_line(&self.events_path(), event, "append event") + } + + pub(crate) fn append_transcript_entry( + &self, + entry: &TranscriptEntry, + ) -> Result<(), RuntimeError> { + self.ensure_worker_ref(&entry.worker_ref)?; + append_json_line( + &self.transcript_path(&entry.worker_ref.worker_id), + entry, + "append transcript", + ) + } + + pub(crate) fn load_runtime_state(&self) -> Result { + let runtime_path = self.runtime_path(); + let snapshot: RuntimeSnapshot = read_json(&runtime_path, "read runtime snapshot")?; + snapshot.validate(&self.runtime_id, &runtime_path)?; + + let events = read_json_lines::(&self.events_path(), "read events")?; + let workers_dir = self.runtime_dir.join(WORKERS_DIR); + if !workers_dir.exists() { + return Err(RuntimeError::StoreMissing { + operation: "read workers", + path: workers_dir, + }); + } + if !workers_dir.is_dir() { + return Err(RuntimeError::StoreCorrupt { + operation: "read workers", + path: workers_dir, + message: "workers path exists but is not a directory".to_string(), + }); + } + + let mut workers = BTreeMap::new(); + let mut worker_dirs = fs::read_dir(&workers_dir) + .map_err(|source| RuntimeError::StoreIo { + operation: "read workers", + path: workers_dir.clone(), + source, + })? + .collect::, _>>() + .map_err(|source| RuntimeError::StoreIo { + operation: "read workers", + path: workers_dir.clone(), + source, + })?; + worker_dirs.sort_by_key(|entry| entry.path()); + + for entry in worker_dirs { + let path = entry.path(); + if !path.is_dir() { + return Err(RuntimeError::StoreCorrupt { + operation: "read worker", + path, + message: "worker path exists but is not a directory".to_string(), + }); + } + let worker_snapshot_path = path.join(WORKER_FILE); + let worker_snapshot: WorkerSnapshot = + read_json(&worker_snapshot_path, "read worker snapshot")?; + worker_snapshot.validate(&self.runtime_id, &worker_snapshot_path)?; + let transcript = + read_json_lines::(&path.join(TRANSCRIPT_FILE), "read transcript")?; + for entry in &transcript { + self.ensure_worker_ref(&entry.worker_ref)?; + if entry.worker_ref.worker_id != worker_snapshot.worker_id { + return Err(RuntimeError::StoreCorrupt { + operation: "read transcript", + path: path.join(TRANSCRIPT_FILE), + message: format!( + "transcript entry belongs to worker {}, expected {}", + entry.worker_ref.worker_id, worker_snapshot.worker_id + ), + }); + } + } + let worker = worker_snapshot.into_persisted(transcript); + if workers.insert(worker.worker_id.clone(), worker).is_some() { + return Err(RuntimeError::StoreCorrupt { + operation: "read workers", + path: workers_dir.clone(), + message: "duplicate worker id in store".to_string(), + }); + } + } + + Ok(snapshot.into_persisted(events, workers)) + } + + fn ensure_worker_ref(&self, worker_ref: &WorkerRef) -> Result<(), RuntimeError> { + if worker_ref.runtime_id == self.runtime_id { + Ok(()) + } else { + Err(RuntimeError::WrongRuntime { + expected_runtime_id: self.runtime_id.clone(), + actual_runtime_id: worker_ref.runtime_id.clone(), + worker_id: worker_ref.worker_id.clone(), + }) + } + } + + fn runtime_path(&self) -> PathBuf { + self.runtime_dir.join(RUNTIME_FILE) + } + + fn events_path(&self) -> PathBuf { + self.runtime_dir.join(EVENTS_FILE) + } + + fn worker_dir(&self, worker_id: &WorkerId) -> PathBuf { + self.runtime_dir + .join(WORKERS_DIR) + .join(encoded_component(worker_id.as_str())) + } + + fn transcript_path(&self, worker_id: &WorkerId) -> PathBuf { + self.worker_dir(worker_id).join(TRANSCRIPT_FILE) + } +} + +#[derive(Debug)] +pub(crate) struct OpenedFsRuntimeStore { + pub(crate) store: FsRuntimeStore, + pub(crate) state: Option, +} + +#[derive(Clone, Debug)] +pub(crate) struct PersistedRuntimeState { + pub(crate) runtime_id: RuntimeId, + pub(crate) display_name: Option, + pub(crate) status: RuntimeStatus, + pub(crate) limits: RuntimeLimits, + pub(crate) next_worker_sequence: u64, + pub(crate) next_event_id: u64, + pub(crate) next_diagnostic_id: u64, + pub(crate) workers: BTreeMap, + pub(crate) events: Vec, + pub(crate) diagnostics: Vec, +} + +#[derive(Clone, Debug)] +pub(crate) struct PersistedWorkerRecord { + pub(crate) worker_ref: WorkerRef, + pub(crate) worker_id: WorkerId, + pub(crate) status: WorkerStatus, + pub(crate) request: CreateWorkerRequest, + pub(crate) transcript: Vec, + pub(crate) next_transcript_sequence: u64, + pub(crate) last_event_id: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct RuntimeSnapshot { + schema_version: u32, + runtime_id: RuntimeId, + display_name: Option, + backend: RuntimeBackendKind, + status: RuntimeStatus, + limits: RuntimeLimits, + next_worker_sequence: u64, + next_event_id: u64, + next_diagnostic_id: u64, + diagnostics: Vec, +} + +impl RuntimeSnapshot { + fn from_persisted(state: &PersistedRuntimeState) -> Self { + Self { + schema_version: SCHEMA_VERSION, + runtime_id: state.runtime_id.clone(), + display_name: state.display_name.clone(), + backend: RuntimeBackendKind::FsStore, + status: state.status, + limits: state.limits.clone(), + next_worker_sequence: state.next_worker_sequence, + next_event_id: state.next_event_id, + next_diagnostic_id: state.next_diagnostic_id, + diagnostics: state.diagnostics.clone(), + } + } + + fn validate(&self, expected_runtime_id: &RuntimeId, path: &Path) -> Result<(), RuntimeError> { + if self.schema_version != SCHEMA_VERSION { + return Err(RuntimeError::StoreCorrupt { + operation: "read runtime snapshot", + path: path.to_path_buf(), + message: format!( + "unsupported schema version {}, expected {}", + self.schema_version, SCHEMA_VERSION + ), + }); + } + if &self.runtime_id != expected_runtime_id { + return Err(RuntimeError::StoreCorrupt { + operation: "read runtime snapshot", + path: path.to_path_buf(), + message: format!( + "runtime snapshot id {} does not match requested runtime {}", + self.runtime_id, expected_runtime_id + ), + }); + } + if self.backend != RuntimeBackendKind::FsStore { + return Err(RuntimeError::StoreCorrupt { + operation: "read runtime snapshot", + path: path.to_path_buf(), + message: format!("runtime snapshot backend is {:?}", self.backend), + }); + } + Ok(()) + } + + fn into_persisted( + self, + events: Vec, + workers: BTreeMap, + ) -> PersistedRuntimeState { + PersistedRuntimeState { + runtime_id: self.runtime_id, + display_name: self.display_name, + status: self.status, + limits: self.limits, + next_worker_sequence: self.next_worker_sequence, + next_event_id: self.next_event_id, + next_diagnostic_id: self.next_diagnostic_id, + workers, + events, + diagnostics: self.diagnostics, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct WorkerSnapshot { + schema_version: u32, + worker_ref: WorkerRef, + worker_id: WorkerId, + status: WorkerStatus, + request: CreateWorkerRequest, + next_transcript_sequence: u64, + last_event_id: u64, +} + +impl WorkerSnapshot { + fn from_persisted(worker: &PersistedWorkerRecord) -> Self { + Self { + schema_version: SCHEMA_VERSION, + worker_ref: worker.worker_ref.clone(), + worker_id: worker.worker_id.clone(), + status: worker.status, + request: worker.request.clone(), + next_transcript_sequence: worker.next_transcript_sequence, + last_event_id: worker.last_event_id, + } + } + + fn validate(&self, expected_runtime_id: &RuntimeId, path: &Path) -> Result<(), RuntimeError> { + if self.schema_version != SCHEMA_VERSION { + return Err(RuntimeError::StoreCorrupt { + operation: "read worker snapshot", + path: path.to_path_buf(), + message: format!( + "unsupported schema version {}, expected {}", + self.schema_version, SCHEMA_VERSION + ), + }); + } + if self.worker_ref.runtime_id != *expected_runtime_id { + return Err(RuntimeError::StoreCorrupt { + operation: "read worker snapshot", + path: path.to_path_buf(), + message: format!( + "worker belongs to runtime {}, expected {}", + self.worker_ref.runtime_id, expected_runtime_id + ), + }); + } + if self.worker_ref.worker_id != self.worker_id { + return Err(RuntimeError::StoreCorrupt { + operation: "read worker snapshot", + path: path.to_path_buf(), + message: format!( + "worker_ref id {} does not match worker_id {}", + self.worker_ref.worker_id, self.worker_id + ), + }); + } + Ok(()) + } + + fn into_persisted(self, transcript: Vec) -> PersistedWorkerRecord { + PersistedWorkerRecord { + worker_ref: self.worker_ref, + worker_id: self.worker_id, + status: self.status, + request: self.request, + transcript, + next_transcript_sequence: self.next_transcript_sequence, + last_event_id: self.last_event_id, + } + } +} + +fn runtime_dir(root: &Path, runtime_id: &RuntimeId) -> PathBuf { + root.join(RUNTIMES_DIR) + .join(encoded_component(runtime_id.as_str())) +} + +fn encoded_component(value: &str) -> String { + let mut encoded = String::with_capacity(3 + value.len() * 2); + encoded.push_str("id-"); + for byte in value.as_bytes() { + encoded.push(hex_digit(byte >> 4)); + encoded.push(hex_digit(byte & 0x0f)); + } + encoded +} + +fn hex_digit(value: u8) -> char { + match value { + 0..=9 => (b'0' + value) as char, + 10..=15 => (b'a' + (value - 10)) as char, + _ => unreachable!("hex digit nybble is always <= 15"), + } +} + +fn read_json(path: &Path, operation: &'static str) -> Result +where + T: for<'de> Deserialize<'de>, +{ + let file = File::open(path).map_err(|source| match source.kind() { + std::io::ErrorKind::NotFound => RuntimeError::StoreMissing { + operation, + path: path.to_path_buf(), + }, + _ => RuntimeError::StoreIo { + operation, + path: path.to_path_buf(), + source, + }, + })?; + serde_json::from_reader(BufReader::new(file)).map_err(|source| RuntimeError::StoreCorrupt { + operation, + path: path.to_path_buf(), + message: source.to_string(), + }) +} + +fn read_json_lines(path: &Path, operation: &'static str) -> Result, RuntimeError> +where + T: for<'de> Deserialize<'de>, +{ + let file = File::open(path).map_err(|source| match source.kind() { + std::io::ErrorKind::NotFound => RuntimeError::StoreMissing { + operation, + path: path.to_path_buf(), + }, + _ => RuntimeError::StoreIo { + operation, + path: path.to_path_buf(), + source, + }, + })?; + let reader = BufReader::new(file); + let mut items = Vec::new(); + for (index, line) in reader.lines().enumerate() { + let line = line.map_err(|source| RuntimeError::StoreIo { + operation, + path: path.to_path_buf(), + source, + })?; + if line.trim().is_empty() { + continue; + } + let item = serde_json::from_str(&line).map_err(|source| RuntimeError::StoreCorrupt { + operation, + path: path.to_path_buf(), + message: format!("line {}: {source}", index + 1), + })?; + items.push(item); + } + Ok(items) +} + +fn atomic_write_json(path: &Path, value: &T, operation: &'static str) -> Result<(), RuntimeError> +where + T: Serialize, +{ + let parent = path.parent().ok_or_else(|| RuntimeError::StoreCorrupt { + operation, + path: path.to_path_buf(), + message: "path has no parent directory".to_string(), + })?; + fs::create_dir_all(parent).map_err(|source| RuntimeError::StoreIo { + operation, + path: parent.to_path_buf(), + source, + })?; + + let tmp_path = tmp_path_for(path); + let write_result = (|| { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&tmp_path) + .map_err(|source| RuntimeError::StoreIo { + operation, + path: tmp_path.clone(), + source, + })?; + serde_json::to_writer_pretty(&mut file, value).map_err(|source| { + RuntimeError::StoreCorrupt { + operation, + path: tmp_path.clone(), + message: format!("serialize json: {source}"), + } + })?; + file.write_all(b"\n") + .map_err(|source| RuntimeError::StoreIo { + operation, + path: tmp_path.clone(), + source, + })?; + file.sync_all().map_err(|source| RuntimeError::StoreIo { + operation, + path: tmp_path.clone(), + source, + })?; + drop(file); + fs::rename(&tmp_path, path).map_err(|source| RuntimeError::StoreIo { + operation, + path: path.to_path_buf(), + source, + })?; + sync_directory(parent, operation) + })(); + + if write_result.is_err() { + let _ = fs::remove_file(&tmp_path); + } + write_result +} + +fn append_json_line(path: &Path, value: &T, operation: &'static str) -> Result<(), RuntimeError> +where + T: Serialize, +{ + let parent = path.parent().ok_or_else(|| RuntimeError::StoreCorrupt { + operation, + path: path.to_path_buf(), + message: "path has no parent directory".to_string(), + })?; + fs::create_dir_all(parent).map_err(|source| RuntimeError::StoreIo { + operation, + path: parent.to_path_buf(), + source, + })?; + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(path) + .map_err(|source| RuntimeError::StoreIo { + operation, + path: path.to_path_buf(), + source, + })?; + serde_json::to_writer(&mut file, value).map_err(|source| RuntimeError::StoreCorrupt { + operation, + path: path.to_path_buf(), + message: format!("serialize json: {source}"), + })?; + file.write_all(b"\n") + .and_then(|()| file.flush()) + .and_then(|()| file.sync_all()) + .map_err(|source| RuntimeError::StoreIo { + operation, + path: path.to_path_buf(), + source, + }) +} + +fn ensure_file_exists(path: &Path, operation: &'static str) -> Result<(), RuntimeError> { + let parent = path.parent().ok_or_else(|| RuntimeError::StoreCorrupt { + operation, + path: path.to_path_buf(), + message: "path has no parent directory".to_string(), + })?; + fs::create_dir_all(parent).map_err(|source| RuntimeError::StoreIo { + operation, + path: parent.to_path_buf(), + source, + })?; + OpenOptions::new() + .create(true) + .append(true) + .open(path) + .and_then(|file| file.sync_all()) + .map_err(|source| RuntimeError::StoreIo { + operation, + path: path.to_path_buf(), + source, + }) +} + +fn tmp_path_for(path: &Path) -> PathBuf { + let sequence = NEXT_TMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("store"); + path.with_file_name(format!( + ".{file_name}.tmp-{}-{sequence}", + std::process::id() + )) +} + +fn sync_directory(path: &Path, operation: &'static str) -> Result<(), RuntimeError> { + File::open(path) + .and_then(|file| file.sync_all()) + .map_err(|source| RuntimeError::StoreIo { + operation, + path: path.to_path_buf(), + source, + }) +} diff --git a/crates/worker-runtime/src/lib.rs b/crates/worker-runtime/src/lib.rs index 96bcf3c1..5b3b844f 100644 --- a/crates/worker-runtime/src/lib.rs +++ b/crates/worker-runtime/src/lib.rs @@ -1,17 +1,22 @@ //! Embedded Runtime domain API for Worker management. //! //! `worker-runtime` intentionally stays independent from HTTP/WebSocket servers, -//! filesystem persistence, provider execution, and the existing Worker host. It -//! defines the in-process Runtime authority surface that higher layers can later -//! adapt into registries or web APIs. +//! provider execution, and the existing Worker host. Filesystem persistence is +//! available only through the optional `fs-store` feature. The crate defines the +//! in-process Runtime authority surface that higher layers can later adapt into +//! registries or web APIs. pub mod catalog; pub mod diagnostics; pub mod error; +#[cfg(feature = "fs-store")] +pub mod fs_store; pub mod identity; pub mod interaction; pub mod management; pub mod observation; mod runtime; +#[cfg(feature = "fs-store")] +pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions}; pub use runtime::Runtime; diff --git a/crates/worker-runtime/src/management.rs b/crates/worker-runtime/src/management.rs index 5fa2b5da..e9a926f0 100644 --- a/crates/worker-runtime/src/management.rs +++ b/crates/worker-runtime/src/management.rs @@ -1,11 +1,13 @@ use crate::identity::RuntimeId; use serde::{Deserialize, Serialize}; -/// Runtime backend kind. v0 intentionally ships only an embedded memory backend. +/// Runtime backend kind. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RuntimeBackendKind { Memory, + #[cfg(feature = "fs-store")] + FsStore, } /// Runtime lifecycle state. diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 54c72795..75eccdc5 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -3,6 +3,10 @@ use crate::catalog::{ }; use crate::diagnostics::{DiagnosticSeverity, RuntimeDiagnostic}; use crate::error::RuntimeError; +#[cfg(feature = "fs-store")] +use crate::fs_store::{ + FsRuntimeStore, FsRuntimeStoreOptions, PersistedRuntimeState, PersistedWorkerRecord, +}; use crate::identity::{RuntimeId, WorkerId, WorkerRef}; use crate::interaction::{WorkerInput, WorkerInputKind, WorkerInteractionAck}; use crate::management::{ @@ -20,8 +24,9 @@ static NEXT_RUNTIME_SEQUENCE: AtomicU64 = AtomicU64::new(1); /// Concrete embedded Runtime domain entity. /// -/// The current implementation is memory-backed and tools/provider-less by -/// design. It provides a typed API boundary that can later be adapted by +/// The default implementation is memory-backed and tools/provider-less by +/// design. An optional `fs-store` feature adds filesystem persistence while +/// preserving the same typed authority boundary. It can later be adapted by /// backend registries or web servers without making sockets, sessions, or paths /// public authority. #[derive(Clone, Debug)] @@ -47,6 +52,38 @@ impl Runtime { } } + /// Create or restore a filesystem-backed Runtime. + /// + /// The store is scoped by typed Runtime identity under `options.root`; if the + /// Runtime directory already exists, persisted state is loaded and validated. + /// If it does not exist, a fresh Runtime is initialized and durable files are + /// created before the Runtime is returned. + #[cfg(feature = "fs-store")] + pub fn with_fs_store(options: FsRuntimeStoreOptions) -> Result { + let runtime_id = options.runtime_id.unwrap_or_else(|| { + RuntimeId::generated(NEXT_RUNTIME_SEQUENCE.fetch_add(1, Ordering::Relaxed)) + }); + let opened = FsRuntimeStore::open_or_create(options.root, runtime_id.clone())?; + let state = if let Some(persisted) = opened.state { + RuntimeState::from_persisted(persisted, opened.store)? + } else { + let mut state = RuntimeState::new_fs_backed( + runtime_id, + options.display_name, + options.limits, + opened.store, + ); + let event_id = + state.push_event(None, RuntimeEventKind::RuntimeStarted, "runtime started"); + state.persist_runtime_snapshot()?; + state.persist_event_by_id(event_id)?; + state + }; + Ok(Self { + inner: Arc::new(Mutex::new(state)), + }) + } + /// Runtime id half of public Worker authority. pub fn runtime_id(&self) -> Result { Ok(self.lock()?.runtime_id.clone()) @@ -99,11 +136,15 @@ impl Runtime { worker.status = WorkerStatus::Stopped; } } - Ok(state.push_event( + let event_id = state.push_event( None, RuntimeEventKind::RuntimeStopped, format!("runtime {runtime_id} stopped"), - )) + ); + state.persist_runtime_snapshot()?; + state.persist_workers()?; + state.persist_event_by_id(event_id)?; + Ok(event_id) } /// Create a Worker in the embedded catalog. @@ -135,7 +176,10 @@ impl Runtime { }; let detail = record.detail(&state.runtime_id); state.emit_create_diagnostics(&detail); - state.workers.insert(worker_id, record); + state.workers.insert(worker_id.clone(), record); + state.persist_runtime_snapshot()?; + state.persist_worker(&worker_id)?; + state.persist_event_by_id(event_id)?; Ok(detail) } @@ -198,9 +242,15 @@ impl Runtime { event_id, }); + let status = worker.status; + state.persist_runtime_snapshot()?; + state.persist_worker(&worker_ref.worker_id)?; + state.persist_event_by_id(event_id)?; + state.persist_transcript_entry(&worker_ref.worker_id, transcript_sequence)?; + Ok(WorkerInteractionAck { worker_ref: worker_ref.clone(), - status: worker.status, + status, transcript_sequence, event_id, }) @@ -376,9 +426,13 @@ impl Runtime { let worker = state.worker_mut(worker_ref)?; worker.status = status; worker.last_event_id = event_id; + let status = worker.status; + state.persist_runtime_snapshot()?; + state.persist_worker(&worker_ref.worker_id)?; + state.persist_event_by_id(event_id)?; Ok(WorkerLifecycleAck { worker_ref: worker_ref.clone(), - status: worker.status, + status, event_id, }) } @@ -388,11 +442,21 @@ impl Runtime { } } +#[cfg_attr(not(feature = "fs-store"), allow(dead_code))] +#[derive(Clone, Debug)] +enum RuntimePersistence { + Memory, + #[cfg(feature = "fs-store")] + Fs(FsRuntimeStore), +} + #[derive(Debug)] struct RuntimeState { runtime_id: RuntimeId, display_name: Option, backend: RuntimeBackendKind, + #[cfg_attr(not(feature = "fs-store"), allow(dead_code))] + persistence: RuntimePersistence, status: RuntimeStatus, limits: RuntimeLimits, next_worker_sequence: u64, @@ -409,6 +473,7 @@ impl RuntimeState { runtime_id, display_name, backend: RuntimeBackendKind::Memory, + persistence: RuntimePersistence::Memory, status: RuntimeStatus::Running, limits, next_worker_sequence: 1, @@ -420,6 +485,215 @@ impl RuntimeState { } } + #[cfg(feature = "fs-store")] + fn new_fs_backed( + runtime_id: RuntimeId, + display_name: Option, + limits: RuntimeLimits, + store: FsRuntimeStore, + ) -> Self { + Self { + runtime_id, + display_name, + backend: RuntimeBackendKind::FsStore, + persistence: RuntimePersistence::Fs(store), + status: RuntimeStatus::Running, + limits, + next_worker_sequence: 1, + next_event_id: 1, + next_diagnostic_id: 1, + workers: BTreeMap::new(), + events: Vec::new(), + diagnostics: Vec::new(), + } + } + + #[cfg(feature = "fs-store")] + fn from_persisted( + persisted: PersistedRuntimeState, + store: FsRuntimeStore, + ) -> Result { + if persisted.runtime_id != *store.runtime_id() { + return Err(RuntimeError::StoreCorrupt { + operation: "restore runtime state", + path: store.runtime_dir().to_path_buf(), + message: format!( + "persisted runtime id {} does not match store runtime {}", + persisted.runtime_id, + store.runtime_id() + ), + }); + } + + let mut workers = BTreeMap::new(); + for (worker_id, worker) in persisted.workers { + workers.insert( + worker_id, + WorkerRecord { + worker_ref: worker.worker_ref, + worker_id: worker.worker_id, + status: worker.status, + request: worker.request, + transcript: worker.transcript, + next_transcript_sequence: worker.next_transcript_sequence, + last_event_id: worker.last_event_id, + }, + ); + } + + Ok(Self { + runtime_id: persisted.runtime_id, + display_name: persisted.display_name, + backend: RuntimeBackendKind::FsStore, + persistence: RuntimePersistence::Fs(store), + status: persisted.status, + limits: persisted.limits, + next_worker_sequence: persisted.next_worker_sequence, + next_event_id: persisted.next_event_id, + next_diagnostic_id: persisted.next_diagnostic_id, + workers, + events: persisted.events, + diagnostics: persisted.diagnostics, + }) + } + + #[cfg(feature = "fs-store")] + fn persisted_state(&self) -> PersistedRuntimeState { + PersistedRuntimeState { + runtime_id: self.runtime_id.clone(), + display_name: self.display_name.clone(), + status: self.status, + limits: self.limits.clone(), + next_worker_sequence: self.next_worker_sequence, + next_event_id: self.next_event_id, + next_diagnostic_id: self.next_diagnostic_id, + workers: self + .workers + .iter() + .map(|(worker_id, worker)| (worker_id.clone(), worker.persisted_record())) + .collect(), + events: self.events.clone(), + diagnostics: self.diagnostics.clone(), + } + } + + #[cfg(feature = "fs-store")] + fn fs_store(&self) -> Option<&FsRuntimeStore> { + match &self.persistence { + RuntimePersistence::Memory => None, + RuntimePersistence::Fs(store) => Some(store), + } + } + + #[cfg(feature = "fs-store")] + fn persist_runtime_snapshot(&self) -> Result<(), RuntimeError> { + if let Some(store) = self.fs_store() { + store.write_runtime_snapshot(&self.persisted_state())?; + } + Ok(()) + } + + #[cfg(feature = "fs-store")] + fn persist_worker(&self, worker_id: &WorkerId) -> Result<(), RuntimeError> { + if let Some(store) = self.fs_store() { + let worker = + self.workers + .get(worker_id) + .ok_or_else(|| RuntimeError::WorkerNotFound { + runtime_id: self.runtime_id.clone(), + worker_id: worker_id.clone(), + })?; + store.write_worker_snapshot(&worker.persisted_record())?; + } + Ok(()) + } + + #[cfg(feature = "fs-store")] + fn persist_event_by_id(&self, event_id: u64) -> Result<(), RuntimeError> { + if let Some(store) = self.fs_store() { + let event = self + .events + .iter() + .find(|event| event.id == event_id) + .ok_or_else(|| RuntimeError::StoreCorrupt { + operation: "persist event", + path: store.runtime_dir().to_path_buf(), + message: format!("event {event_id} is missing from runtime state"), + })?; + store.append_event(event)?; + } + Ok(()) + } + + #[cfg(feature = "fs-store")] + fn persist_transcript_entry( + &self, + worker_id: &WorkerId, + sequence: u64, + ) -> Result<(), RuntimeError> { + if let Some(store) = self.fs_store() { + let worker = + self.workers + .get(worker_id) + .ok_or_else(|| RuntimeError::WorkerNotFound { + runtime_id: self.runtime_id.clone(), + worker_id: worker_id.clone(), + })?; + let entry = worker + .transcript + .iter() + .find(|entry| entry.sequence == sequence) + .ok_or_else(|| RuntimeError::StoreCorrupt { + operation: "persist transcript", + path: store.runtime_dir().to_path_buf(), + message: format!( + "transcript sequence {sequence} is missing from worker {worker_id}" + ), + })?; + store.append_transcript_entry(entry)?; + } + Ok(()) + } + + #[cfg(feature = "fs-store")] + fn persist_workers(&self) -> Result<(), RuntimeError> { + if self.fs_store().is_some() { + for worker_id in self.workers.keys() { + self.persist_worker(worker_id)?; + } + } + Ok(()) + } + + #[cfg(not(feature = "fs-store"))] + fn persist_runtime_snapshot(&self) -> Result<(), RuntimeError> { + Ok(()) + } + + #[cfg(not(feature = "fs-store"))] + fn persist_worker(&self, _worker_id: &WorkerId) -> Result<(), RuntimeError> { + Ok(()) + } + + #[cfg(not(feature = "fs-store"))] + fn persist_event_by_id(&self, _event_id: u64) -> Result<(), RuntimeError> { + Ok(()) + } + + #[cfg(not(feature = "fs-store"))] + fn persist_transcript_entry( + &self, + _worker_id: &WorkerId, + _sequence: u64, + ) -> Result<(), RuntimeError> { + Ok(()) + } + + #[cfg(not(feature = "fs-store"))] + fn persist_workers(&self) -> Result<(), RuntimeError> { + Ok(()) + } + fn ensure_running(&self) -> Result<(), RuntimeError> { if self.status == RuntimeStatus::Stopped { Err(RuntimeError::RuntimeStopped { @@ -569,6 +843,19 @@ impl WorkerRecord { last_event_id: self.last_event_id, } } + + #[cfg(feature = "fs-store")] + fn persisted_record(&self) -> PersistedWorkerRecord { + PersistedWorkerRecord { + worker_ref: self.worker_ref.clone(), + worker_id: self.worker_id.clone(), + status: self.status, + request: self.request.clone(), + transcript: self.transcript.clone(), + next_transcript_sequence: self.next_transcript_sequence, + last_event_id: self.last_event_id, + } + } } fn validate_create_worker_request(request: &CreateWorkerRequest) -> Result<(), RuntimeError> { @@ -847,4 +1134,155 @@ mod tests { assert_eq!(next.events[0].kind, RuntimeEventKind::WorkerInputAccepted); assert!(!next.has_more); } + + #[cfg(feature = "fs-store")] + static NEXT_FS_TEST_ROOT: AtomicU64 = AtomicU64::new(1); + + #[cfg(feature = "fs-store")] + fn fs_store_root(label: &str) -> std::path::PathBuf { + let sequence = NEXT_FS_TEST_ROOT.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "worker-runtime-fs-store-{label}-{}-{sequence}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + root + } + + #[cfg(feature = "fs-store")] + fn runtime_store(runtime: &Runtime) -> FsRuntimeStore { + let state = runtime.lock().unwrap(); + match &state.persistence { + RuntimePersistence::Fs(store) => store.clone(), + RuntimePersistence::Memory => panic!("expected fs-backed runtime"), + } + } + + #[cfg(feature = "fs-store")] + #[test] + fn fs_store_restores_workers_events_and_transcripts() { + let root = fs_store_root("restore"); + let runtime_id = RuntimeId::new("runtime-fs-authority").unwrap(); + let runtime = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions { + root: root.clone(), + runtime_id: Some(runtime_id.clone()), + display_name: Some("filesystem runtime".to_string()), + limits: RuntimeLimits { + max_transcript_projection_items: 2, + max_event_batch_items: 2, + }, + }) + .unwrap(); + assert_eq!( + runtime.summary().unwrap().backend, + RuntimeBackendKind::FsStore + ); + + let worker = runtime.create_worker(task_request("persist me")).unwrap(); + runtime + .send_input(&worker.worker_ref, WorkerInput::user("first")) + .unwrap(); + runtime + .send_input(&worker.worker_ref, WorkerInput::system("second")) + .unwrap(); + runtime + .stop_worker(&worker.worker_ref, Some("finished".to_string())) + .unwrap(); + let store = runtime_store(&runtime); + drop(runtime); + + let restored = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions { + root: root.clone(), + runtime_id: Some(runtime_id.clone()), + display_name: None, + limits: RuntimeLimits::default(), + }) + .unwrap(); + let restored_worker = restored.worker_detail(&worker.worker_ref).unwrap(); + assert_eq!(restored_worker.status, WorkerStatus::Stopped); + assert_eq!(restored_worker.transcript_len, 2); + + let projection = restored + .transcript_projection(&worker.worker_ref, TranscriptQuery::new(0, 1)) + .unwrap(); + assert_eq!(projection.total_items, 2); + assert_eq!(projection.items[0].content, "first"); + assert_eq!(projection.next_start, Some(1)); + + let cursor = restored.event_cursor_from_start().unwrap(); + let batch = restored.read_events(&cursor, 2).unwrap(); + assert_eq!(batch.events.len(), 2); + assert!(batch.has_more); + assert_eq!(batch.events[0].kind, RuntimeEventKind::RuntimeStarted); + assert_eq!(batch.events[1].kind, RuntimeEventKind::WorkerCreated); + + let direct_events = store.read_events(&cursor, 2, 2).unwrap(); + assert_eq!(direct_events.events, batch.events); + let direct_transcript = store + .read_transcript(&worker.worker_ref, TranscriptQuery::new(1, 1), 2) + .unwrap(); + assert_eq!(direct_transcript.items[0].content, "second"); + + let _ = std::fs::remove_dir_all(root); + } + + #[cfg(feature = "fs-store")] + #[test] + fn fs_store_reports_corrupt_and_missing_data() { + let corrupt_root = fs_store_root("corrupt"); + let corrupt_runtime_id = RuntimeId::new("runtime-corrupt").unwrap(); + let corrupt_runtime = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions { + root: corrupt_root.clone(), + runtime_id: Some(corrupt_runtime_id.clone()), + display_name: None, + limits: RuntimeLimits::default(), + }) + .unwrap(); + let corrupt_store = runtime_store(&corrupt_runtime); + std::fs::write( + corrupt_store.runtime_dir().join("runtime.json"), + b"not json", + ) + .unwrap(); + drop(corrupt_runtime); + let err = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions { + root: corrupt_root.clone(), + runtime_id: Some(corrupt_runtime_id), + display_name: None, + limits: RuntimeLimits::default(), + }) + .unwrap_err(); + assert!(matches!(err, RuntimeError::StoreCorrupt { .. })); + let _ = std::fs::remove_dir_all(corrupt_root); + + let missing_root = fs_store_root("missing"); + let missing_runtime_id = RuntimeId::new("runtime-missing").unwrap(); + let missing_runtime = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions { + root: missing_root.clone(), + runtime_id: Some(missing_runtime_id.clone()), + display_name: None, + limits: RuntimeLimits::default(), + }) + .unwrap(); + missing_runtime + .create_worker(task_request("missing worker snapshot")) + .unwrap(); + let missing_store = runtime_store(&missing_runtime); + let mut worker_dirs = std::fs::read_dir(missing_store.runtime_dir().join("workers")) + .unwrap() + .collect::, _>>() + .unwrap(); + worker_dirs.sort_by_key(|entry| entry.path()); + std::fs::remove_file(worker_dirs[0].path().join("worker.json")).unwrap(); + drop(missing_runtime); + let err = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions { + root: missing_root.clone(), + runtime_id: Some(missing_runtime_id), + display_name: None, + limits: RuntimeLimits::default(), + }) + .unwrap_err(); + assert!(matches!(err, RuntimeError::StoreMissing { .. })); + let _ = std::fs::remove_dir_all(missing_root); + } } diff --git a/package.nix b/package.nix index b1dcfab8..61ccfcfc 100644 --- a/package.nix +++ b/package.nix @@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec { filter = sourceFilter; }; - cargoHash = "sha256-RHo2b6dVClqu32wpgES/RQeBMXaqyqXZaooeSH5SveM="; + cargoHash = "sha256-PFh+ZgmktkpeLRnIDLsxdT2QcA/j5rcJzkq7A9B6E44="; depsExtraArgs = { # Older fetchCargoVendor utilities used crates.io's API download endpoint, From fb8dc9fe516d38c629f42c982ac1d8f487f282f4 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 26 Jun 2026 04:41:55 +0900 Subject: [PATCH 15/17] ticket: record worker runtime fs store implementation --- .yoi/tickets/00001KVZKST83/item.md | 2 +- .yoi/tickets/00001KVZKST83/thread.md | 64 ++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVZKST83/item.md b/.yoi/tickets/00001KVZKST83/item.md index 9e17f7e7..b0fa540c 100644 --- a/.yoi/tickets/00001KVZKST83/item.md +++ b/.yoi/tickets/00001KVZKST83/item.md @@ -2,7 +2,7 @@ title: 'worker-runtimeにFS永続化featureを追加する' state: 'inprogress' created_at: '2026-06-25T14:44:02Z' -updated_at: '2026-06-25T19:24:59Z' +updated_at: '2026-06-25T19:41:50Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-25T16:39:26Z' diff --git a/.yoi/tickets/00001KVZKST83/thread.md b/.yoi/tickets/00001KVZKST83/thread.md index 7a35fce4..a55e86ef 100644 --- a/.yoi/tickets/00001KVZKST83/thread.md +++ b/.yoi/tickets/00001KVZKST83/thread.md @@ -175,3 +175,67 @@ Next action: - Wait for Coder implementation report, then route to reviewer. --- + + + +## Implementation report + +Implementation report: + +実装完了。merge/close/cleanup は未実施。 + +Commit: +- `407134399685537da9590d424df6b1db05402965 feat: add worker runtime fs store` + +変更概要: +- `worker-runtime` に optional feature `fs-store = ["dep:serde_json"]` を追加。default feature は空のまま。 +- `#[cfg(feature = "fs-store")]` で `fs_store` module を公開。 +- `Runtime::with_fs_store(FsRuntimeStoreOptions)` を追加。 + - 既存 store があれば復元。 + - なければ Runtime scoped layout を作成して初期化。 +- `FsRuntimeStore`, `FsRuntimeStoreOptions` を feature 有効時に re-export。 +- `RuntimeBackendKind::FsStore` を feature 有効時のみ追加。 + +FS store: +- layout は `runtime_id + worker_id` authority ベース。 + - `/runtimes//runtime.json` + - `/runtimes//events.jsonl` + - `/runtimes//workers//worker.json` + - `/runtimes//workers//transcript.jsonl` +- legacy pod path / socket / session path は authority として使用していない。 +- Worker create / state update / event append / transcript append を Runtime mutation から永続化。 +- snapshot 系は same-directory temp file + `sync_all` + rename の atomic write helper。 +- event/transcript は JSONL append。 +- restore 時に missing / corrupt / wrong runtime/worker scope を `RuntimeError::{StoreIo, StoreMissing, StoreCorrupt}` として診断。 +- `FsRuntimeStore::read_events` / `read_transcript` で bounded read を提供。 + +テスト追加: +- `fs-store` feature 有効時のみ: + - create/send/stop 後に drop/reopen して worker/event/transcript が復元されること。 + - direct store bounded read が Runtime read と一致すること。 + - corrupt `runtime.json` と missing `worker.json` が診断されること。 + +変更ファイル: +- `crates/worker-runtime/Cargo.toml` +- `crates/worker-runtime/src/fs_store.rs` +- `crates/worker-runtime/src/runtime.rs` +- `crates/worker-runtime/src/lib.rs` +- `crates/worker-runtime/src/management.rs` +- `crates/worker-runtime/src/error.rs` +- `Cargo.lock` +- `package.nix` + +Validation: +- `cargo fmt --all`: success +- `cargo test -p worker-runtime --no-default-features`: success(8 passed) +- `cargo test -p worker-runtime --features fs-store`: success(10 passed) +- `cargo check -p yoi`: success +- `git diff --check`: success +- `nix build .#yoi --no-link`: success + +Residual risks / review focus: +- 永続化は per-file atomic write / append で、複数ファイルをまたぐ transaction ではない。途中 I/O failure 時の cross-file 整合性は review focus。 +- FS store は Runtime API に統合しているが、REST / Backend integration / legacy Pod migration / SQLite は未実装。 +- Event/transcript read は bounded output だが、現状は JSONL 全体を読み込んでから slice している。大規模 log での streaming pagination は将来最適化候補。 + +--- From 8d7ab0c053fb78cf04ca66be4ba2473a7e94977b Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 26 Jun 2026 04:46:07 +0900 Subject: [PATCH 16/17] ticket: approve worker runtime fs store --- .yoi/tickets/00001KVZKST83/item.md | 2 +- .yoi/tickets/00001KVZKST83/thread.md | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVZKST83/item.md b/.yoi/tickets/00001KVZKST83/item.md index b0fa540c..231df15f 100644 --- a/.yoi/tickets/00001KVZKST83/item.md +++ b/.yoi/tickets/00001KVZKST83/item.md @@ -2,7 +2,7 @@ title: 'worker-runtimeにFS永続化featureを追加する' state: 'inprogress' created_at: '2026-06-25T14:44:02Z' -updated_at: '2026-06-25T19:41:50Z' +updated_at: '2026-06-25T19:46:00Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-25T16:39:26Z' diff --git a/.yoi/tickets/00001KVZKST83/thread.md b/.yoi/tickets/00001KVZKST83/thread.md index a55e86ef..fc6afa2d 100644 --- a/.yoi/tickets/00001KVZKST83/thread.md +++ b/.yoi/tickets/00001KVZKST83/thread.md @@ -239,3 +239,28 @@ Residual risks / review focus: - Event/transcript read は bounded output だが、現状は JSONL 全体を読み込んでから slice している。大規模 log での streaming pagination は将来最適化候補。 --- + + + +## Review: approve + +Review result: approve + +確認結果として、Ticket intent / binding decisions / acceptance criteria を満たしていると判断する。 + +確認した点: +- `worker-runtime` に `fs-store = ["dep:serde_json"]` が追加され、`default = []` のまま維持されている。 +- `fs_store` module / re-export / `Runtime::with_fs_store` / `RuntimeBackendKind::FsStore` は feature-gated。 +- `cargo tree -p worker-runtime --no-default-features` で `serde_json` が入らないことを確認。`--features fs-store` では `serde_json` のみ追加される。 +- FS layout は `runtimes//workers//...` で、pod path / socket path / session path を authority にしていない。 +- standalone `worker-store` crate、REST command server、Backend integration、legacy migration、SQLite store の追加は見当たらない。 +- Runtime/Worker snapshot、event JSONL、transcript JSONL の永続化が実装され、runtime/worker 作成・state update・event append・transcript append・bounded read を満たしている。 +- snapshot 書き込みは same-dir temp + `sync_all` + rename + directory sync。missing/corrupt は `RuntimeError::{StoreIo, StoreMissing, StoreCorrupt}` で診断される。 +- memory backend 側は feature-off 時に persistence が no-op となり、既存 memory semantics を保つ構成。 +- `package.nix` の `cargoHash` 更新も確認済み。 +- `git diff --check` は問題なし。 + +補足: +- read-only 指示に従い、差分・実装・依存ツリー・scope creep の静的確認に留めた。`cargo test` / `nix build` は作業ツリーへ build artifact を作るため再実行せず、報告済み validation を前提確認として扱った。 + +--- From 7110e078cdf58754f8a81fb608910e0a59fc5a1c Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 26 Jun 2026 04:49:07 +0900 Subject: [PATCH 17/17] ticket: mark worker runtime fs store done --- .yoi/tickets/00001KVZKST83/item.md | 4 +-- .yoi/tickets/00001KVZKST83/thread.md | 52 ++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KVZKST83/item.md b/.yoi/tickets/00001KVZKST83/item.md index 231df15f..75159a69 100644 --- a/.yoi/tickets/00001KVZKST83/item.md +++ b/.yoi/tickets/00001KVZKST83/item.md @@ -1,8 +1,8 @@ --- title: 'worker-runtimeにFS永続化featureを追加する' -state: 'inprogress' +state: 'done' created_at: '2026-06-25T14:44:02Z' -updated_at: '2026-06-25T19:46:00Z' +updated_at: '2026-06-25T19:49:00Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-25T16:39:26Z' diff --git a/.yoi/tickets/00001KVZKST83/thread.md b/.yoi/tickets/00001KVZKST83/thread.md index fc6afa2d..6594306c 100644 --- a/.yoi/tickets/00001KVZKST83/thread.md +++ b/.yoi/tickets/00001KVZKST83/thread.md @@ -264,3 +264,55 @@ Review result: approve - read-only 指示に従い、差分・実装・依存ツリー・scope creep の静的確認に留めた。`cargo test` / `nix build` は作業ツリーへ build artifact を作るため再実行せず、報告済み validation を前提確認として扱った。 --- + + + +## Implementation report + +Integration / validation report: + +Integration: +- Implementation branch `work/00001KVZKST83-worker-runtime-fs-store` を orchestration branch に merge 済み。 +- Merge commit: `36ff7238 merge: 00001KVZKST83 worker runtime fs store` + +Included implementation commit: +- `407134399685537da9590d424df6b1db05402965 feat: add worker runtime fs store` + +Validation in Orchestrator worktree: +- `cargo fmt --all --check`: success +- `cargo test -p worker-runtime --no-default-features`: success(8 tests passed) +- `cargo test -p worker-runtime --features fs-store`: success(10 tests passed) +- `cargo check -p yoi`: success +- `git diff --check`: success +- `nix build .#yoi --no-link`: success + +Review: +- Reviewer approve 済み。feature gating、runtime_id/worker_id layout、atomic write/corrupt diagnostics、scope boundary に blocker なし。 + +Outcome: +- Acceptance criteria を満たしたため `done` へ進める。 + +--- + + + +## State changed + +worker-runtime FS store feature の実装、review、merge、Orchestrator validation が完了した。 + +Done evidence: +- Merge commit: `36ff7238 merge: 00001KVZKST83 worker runtime fs store` +- Reviewer approve 済み。 +- Orchestrator validation: + - `cargo fmt --all --check`: success + - `cargo test -p worker-runtime --no-default-features`: success(8 tests passed) + - `cargo test -p worker-runtime --features fs-store`: success(10 tests passed) + - `cargo check -p yoi`: success + - `git diff --check`: success + - `nix build .#yoi --no-link`: success + +Scope: +- optional `fs-store` feature と FS persistence backend を追加。 +- REST command server / Backend integration / legacy migration / SQLite store は Non-goals として未実装。 + +---