Compare commits

..

No commits in common. "44deacb3f4499844091da975703ac16c97141db4" and "a8236ff3b4a5ac251978e8433ed3252453ccc441" have entirely different histories.

18 changed files with 451 additions and 635 deletions

12
.dockerignore Normal file
View File

@ -0,0 +1,12 @@
target/
.git/
.yoi/
.direnv/
result
result-*
.env
.env.*
**/node_modules/
web/workspace/build/
web/workspace/.svelte-kit/
web/workspace/.vite/

88
Dockerfile Normal file
View File

@ -0,0 +1,88 @@
# syntax=docker/dockerfile:1.7
ARG RUST_VERSION=1.95.0
ARG DEBIAN_VERSION=bookworm
ARG DENO_VERSION=2.7.14
ARG NGINX_VERSION=1.27-alpine
FROM rust:${RUST_VERSION}-${DEBIAN_VERSION} AS rust-source
WORKDIR /src
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
git \
libssl-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
COPY Cargo.toml Cargo.lock ./
COPY crates ./crates
COPY resources ./resources
COPY tests ./tests
FROM rust-source AS runtime-builder
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/src/target \
cargo build --release -p worker-runtime --bin worker-runtime-rest-server --features ws-server,fs-store \
&& mkdir -p /out \
&& cp /src/target/release/worker-runtime-rest-server /out/worker-runtime-rest-server
FROM rust-source AS server-builder
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/src/target \
cargo build --release -p yoi-workspace-server --bin yoi-workspace-server \
&& mkdir -p /out \
&& cp /src/target/release/yoi-workspace-server /out/yoi-workspace-server
FROM denoland/deno:${DENO_VERSION} AS web-builder
WORKDIR /src/web/workspace
COPY web/workspace ./
RUN deno task build
FROM debian:${DEBIAN_VERSION}-slim AS runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
git \
libssl3 \
openssh-client \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 10001 --gid root --home-dir /runtime-data --shell /usr/sbin/nologin yoi \
&& mkdir -p /runtime-data /workdirs \
&& chown -R yoi:root /runtime-data /workdirs
COPY --from=runtime-builder /out/worker-runtime-rest-server /usr/local/bin/worker-runtime-rest-server
USER yoi
EXPOSE 38800
VOLUME ["/runtime-data", "/workdirs"]
ENTRYPOINT ["worker-runtime-rest-server"]
CMD ["--bind", "0.0.0.0:38800", "--display-name", "Docker Runtime", "--fs-root", "/runtime-data", "--workdir-target", "/workdirs"]
FROM debian:${DEBIAN_VERSION}-slim AS server
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
git \
libssl3 \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 10002 --gid root --home-dir /server-data --shell /usr/sbin/nologin yoi \
&& mkdir -p /server-data /workspace \
&& chown -R yoi:root /server-data /workspace
COPY --from=server-builder /out/yoi-workspace-server /usr/local/bin/yoi-workspace-server
USER yoi
EXPOSE 8787
VOLUME ["/server-data", "/workspace"]
ENTRYPOINT ["yoi-workspace-server"]
CMD ["serve", "--workspace", "/workspace", "--db", "/server-data/workspace.db", "--listen", "0.0.0.0:8787"]
FROM nginx:${NGINX_VERSION} AS webui
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=web-builder /src/web/workspace/build /usr/share/nginx/html
EXPOSE 80

View File

@ -1,38 +0,0 @@
name: yoi
services:
runtime:
image: yoi-runtime:latest
pull_policy: never
restart: unless-stopped
expose:
- "38800"
volumes:
- runtime-data:/runtime-data
- runtime-workdirs:/workdirs
server:
image: yoi-server:latest
pull_policy: never
restart: unless-stopped
depends_on:
- runtime
expose:
- "8787"
volumes:
- server-data:/server-data
- ./docker/workspace:/workspace:ro
webui:
image: yoi-webui:latest
pull_policy: never
restart: unless-stopped
depends_on:
- server
ports:
- "${YOI_WEBUI_PORT:-8080}:80"
volumes:
runtime-data:
runtime-workdirs:
server-data:

View File

@ -123,6 +123,8 @@ pub struct BackendWorkingDirectorySummary {
pub cleanliness: Option<String>,
#[serde(default)]
pub primary_worker_id: Option<String>,
#[serde(default)]
pub management_kind: Option<String>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]

View File

@ -156,6 +156,10 @@ pub struct WorkingDirectorySummary {
pub cleanliness: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub primary_worker_id: Option<WorkerId>,
/// Backend projection metadata. Runtimes leave this absent; Workspace Browser
/// APIs fill it with `backend_managed` or `runtime_unmanaged`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub management_kind: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]

View File

@ -48,6 +48,7 @@ impl WorkingDirectory {
status: self.status.clone(),
cleanliness: None,
primary_worker_id: None,
management_kind: None,
}
}
}
@ -221,6 +222,7 @@ impl LocalGitWorktreeMaterializer {
status: WorkingDirectoryStatusKind::Corrupted,
cleanliness: Some("unknown".to_string()),
primary_worker_id: None,
management_kind: None,
},
}
}

View File

@ -34,15 +34,15 @@ impl WorkspaceHttpMemoryBackend {
}
}
pub async fn execute_operation(
pub fn execute_operation(
&self,
operation: MemoryBackendOperation,
) -> Result<MemoryBackendOperationResult, WorkspaceMemoryBackendError> {
execute_http_memory_backend(&self.workspace_id, &self.base_url, operation).await
execute_http_memory_backend(&self.workspace_id, &self.base_url, operation)
}
async fn execute(&self, operation: MemoryBackendOperation) -> Result<ToolOutput, ToolError> {
match self.execute_operation(operation).await {
fn execute(&self, operation: MemoryBackendOperation) -> Result<ToolOutput, ToolError> {
match self.execute_operation(operation) {
Ok(MemoryBackendOperationResult::ToolOutput(output)) => Ok(tool_output(output)),
Ok(result) => Err(ToolError::ExecutionFailed(format!(
"unexpected memory backend result for model-visible tool: {result:?}"
@ -70,7 +70,7 @@ pub enum WorkspaceMemoryBackendError {
}
impl WorkspaceClient {
pub async fn execute_memory_backend_operation(
pub fn execute_memory_backend_operation(
&self,
operation: MemoryBackendOperation,
) -> Result<MemoryBackendOperationResult, WorkspaceMemoryBackendError> {
@ -78,7 +78,7 @@ impl WorkspaceClient {
WorkspaceClient::Http {
workspace_id,
base_url,
} => execute_http_memory_backend(workspace_id, base_url, operation).await,
} => execute_http_memory_backend(workspace_id, base_url, operation),
WorkspaceClient::Available { kind } => Err(WorkspaceMemoryBackendError::Unavailable {
reason: format!(
"workspace client kind `{kind}` does not expose the Backend Workspace API"
@ -93,7 +93,7 @@ impl WorkspaceClient {
}
}
async fn execute_http_memory_backend(
fn execute_http_memory_backend(
workspace_id: &str,
base_url: &str,
operation: MemoryBackendOperation,
@ -103,13 +103,12 @@ async fn execute_http_memory_backend(
base_url.trim_end_matches('/'),
workspace_id
);
let response = reqwest::Client::new()
let response = reqwest::blocking::Client::new()
.post(url)
.json(&operation)
.send()
.await?;
.send()?;
let status = response.status();
let body = response.text().await?;
let body = response.text()?;
if !status.is_success() {
return Err(WorkspaceMemoryBackendError::Http { status, body });
}
@ -221,7 +220,7 @@ impl Tool for WorkspaceHttpMemoryTool {
_ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let operation = (self.build)(input_json)?;
self.backend.execute(operation).await
self.backend.execute(operation)
}
}

View File

@ -433,7 +433,6 @@ impl Tool for StageCandidateTool {
source_refs,
},
))
.await
.map_err(|e| ToolError::ExecutionFailed(format!("write staging failed: {e}")))?;
let ids = match result {
MemoryBackendOperationResult::StagingWritten(output) if output.staging_count == 1 => {
@ -714,7 +713,7 @@ mod tests {
#[tokio::test]
async fn stage_candidate_writes_staging_record_with_source_evidence() {
let (client, request_rx) = stub_memory_backend_response(
r#"{"status":"ok","result":{"kind":"staging_written","staging_count":1,"staging_ids":["00000000-0000-7000-8000-000000000001"]}}"#,
r#"{"Ok":{"result":{"StagingWritten":{"staging_count":1,"staging_ids":["00000000-0000-7000-8000-000000000001"]}}}}"#,
);
let state = SessionExploreState::new(
SessionReferenceView::new("segment-1", vec![Item::user_message("durable decision")]),
@ -746,7 +745,7 @@ mod tests {
vec!["00000000-0000-7000-8000-000000000001".to_string()]
);
let request = request_rx.recv().unwrap();
assert!(request.contains("\"operation\":\"stage_candidate\""));
assert!(request.contains("\"StageCandidate\""));
assert!(request.contains("\"kind\":\"decision\""));
assert!(request.contains("\"id\":\"M0000\""));
assert!(request.contains("\"evidence_id\":\"M0000\""));

View File

@ -897,17 +897,12 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
self.workspace_context.client()
}
async fn resident_summary_from_workspace_authority(
&self,
) -> Result<Option<String>, WorkerError> {
let result = self
.workspace_client()
.execute_memory_backend_operation(
memory::backend::MemoryBackendOperation::ResidentSummary(
memory::backend::MemoryResidentSummaryOperation::default(),
),
)
.await?;
fn resident_summary_from_workspace_authority(&self) -> Result<Option<String>, WorkerError> {
let result = self.workspace_client().execute_memory_backend_operation(
memory::backend::MemoryBackendOperation::ResidentSummary(
memory::backend::MemoryResidentSummaryOperation::default(),
),
)?;
match result {
memory::backend::MemoryBackendOperationResult::ToolOutput(output) => Ok(output.content),
other => Err(WorkerError::FeatureInstall(format!(
@ -1470,7 +1465,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
/// Subsequent invocations are no-ops: the template field is
/// consumed with `Option::take()`, so the materialised value
/// persists across all later turns and compaction.
async fn ensure_system_prompt_materialized(&mut self) -> Result<(), WorkerError> {
fn ensure_system_prompt_materialized(&mut self) -> Result<(), WorkerError> {
let Some(template) = self.system_prompt_template.take() else {
return Ok(());
};
@ -1503,7 +1498,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
.as_ref()
.is_some_and(|m| m.inject_summary.unwrap_or(true));
let resident_summary: Option<String> = if inject_summary {
match self.resident_summary_from_workspace_authority().await {
match self.resident_summary_from_workspace_authority() {
Ok(summary) => summary,
Err(error) => {
tracing::debug!(%error, "resident memory summary unavailable");
@ -1584,7 +1579,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
/// first so extract sees a stable history range).
async fn prepare_for_run(&mut self) -> Result<(), WorkerError> {
self.ensure_interceptor_installed();
self.ensure_system_prompt_materialized().await?;
self.ensure_system_prompt_materialized()?;
self.cleanup_finished_memory_task();
self.ensure_segment_head()?;
if self.should_pre_run_compact() {
@ -2902,8 +2897,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
None,
None,
None,
)
.await;
);
return Ok(());
};
@ -2932,8 +2926,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
None,
None,
None,
)
.await;
);
return Ok(());
}
let result = self.run_extract_once(&memory_cfg, threshold).await;
@ -3004,7 +2997,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
None,
None,
None,
).await;
);
return Ok(ExtractDecision::Skipped);
}
@ -3015,23 +3008,18 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
.history()
.len();
if current_history_len <= processed_history_len {
audit
.emit(
self.workspace_client(),
event_tx,
memory::audit::WorkerLifecycleStatus::Skipped,
"no_new_history_items",
None,
Some(memory::audit::ExtractAudit {
history_range: Some([
processed_history_len as u64,
current_history_len as u64,
]),
..Default::default()
}),
None,
)
.await;
audit.emit(
self.workspace_client(),
event_tx,
memory::audit::WorkerLifecycleStatus::Skipped,
"no_new_history_items",
None,
Some(memory::audit::ExtractAudit {
history_range: Some([processed_history_len as u64, current_history_len as u64]),
..Default::default()
}),
None,
);
return Ok(ExtractDecision::Skipped);
}
@ -3043,17 +3031,15 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
.read_all(self.session_id(), self.segment_id())?
.len();
if entries_now == 0 {
audit
.emit(
self.workspace_client(),
event_tx,
memory::audit::WorkerLifecycleStatus::Skipped,
"empty_segment_log",
None,
None,
None,
)
.await;
audit.emit(
self.workspace_client(),
event_tx,
memory::audit::WorkerLifecycleStatus::Skipped,
"empty_segment_log",
None,
None,
None,
);
return Ok(ExtractDecision::Skipped);
}
let end_entry = entries_now - 1;
@ -3062,26 +3048,21 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
.map(|p| p.processed_through_entry + 1)
.unwrap_or(0);
if start_entry > end_entry {
audit
.emit(
self.workspace_client(),
event_tx,
memory::audit::WorkerLifecycleStatus::Skipped,
"no_new_segment_entries",
None,
Some(memory::audit::ExtractAudit {
session_id: Some(self.session_id().to_string()),
segment_id: Some(self.segment_id().to_string()),
entry_range: Some([start_entry as u64, end_entry as u64]),
history_range: Some([
processed_history_len as u64,
current_history_len as u64,
]),
..Default::default()
}),
None,
)
.await;
audit.emit(
self.workspace_client(),
event_tx,
memory::audit::WorkerLifecycleStatus::Skipped,
"no_new_segment_entries",
None,
Some(memory::audit::ExtractAudit {
session_id: Some(self.session_id().to_string()),
segment_id: Some(self.segment_id().to_string()),
entry_range: Some([start_entry as u64, end_entry as u64]),
history_range: Some([processed_history_len as u64, current_history_len as u64]),
..Default::default()
}),
None,
);
return Ok(ExtractDecision::Skipped);
}
@ -3092,19 +3073,15 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
history_range: Some([processed_history_len as u64, current_history_len as u64]),
..Default::default()
};
audit
.emit(
self.workspace_client(),
event_tx,
memory::audit::WorkerLifecycleStatus::Started,
format!(
"token_threshold_reached tokens_since={tokens_since} threshold={threshold}"
),
None,
Some(extract_audit_base.clone()),
None,
)
.await;
audit.emit(
self.workspace_client(),
event_tx,
memory::audit::WorkerLifecycleStatus::Started,
format!("token_threshold_reached tokens_since={tokens_since} threshold={threshold}"),
None,
Some(extract_audit_base.clone()),
None,
);
let items_to_extract = self.engine.as_ref().expect("worker present").history()
[processed_history_len..current_history_len]
@ -3117,17 +3094,15 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
let client = match self.build_extractor_client(memory_cfg) {
Ok(client) => client,
Err(err) => {
audit
.emit(
self.workspace_client(),
event_tx,
memory::audit::WorkerLifecycleStatus::Failed,
format!("client_build_failed: {err}"),
None,
Some(extract_audit_base),
None,
)
.await;
audit.emit(
self.workspace_client(),
event_tx,
memory::audit::WorkerLifecycleStatus::Failed,
format!("client_build_failed: {err}"),
None,
Some(extract_audit_base),
None,
);
return Err(err);
}
};
@ -3135,17 +3110,15 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
let extract_system_prompt = match self.prompts.memory_extract_system(memory_language) {
Ok(prompt) => prompt,
Err(err) => {
audit
.emit(
self.workspace_client(),
event_tx,
memory::audit::WorkerLifecycleStatus::Failed,
format!("prompt_render_failed: {err}"),
None,
Some(extract_audit_base),
None,
)
.await;
audit.emit(
self.workspace_client(),
event_tx,
memory::audit::WorkerLifecycleStatus::Failed,
format!("prompt_render_failed: {err}"),
None,
Some(extract_audit_base),
None,
);
return Err(WorkerError::PromptCatalog(err));
}
};
@ -3178,17 +3151,15 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
.iter()
.any(|installed| installed == name)
}) {
audit
.emit(
self.workspace_client(),
event_tx,
memory::audit::WorkerLifecycleStatus::Failed,
"session_explore_feature_install_failed",
None,
Some(extract_audit_base),
None,
)
.await;
audit.emit(
self.workspace_client(),
event_tx,
memory::audit::WorkerLifecycleStatus::Failed,
"session_explore_feature_install_failed",
None,
Some(extract_audit_base),
None,
);
return Err(WorkerError::FeatureInstall(
"session-explore feature install failed".to_string(),
));
@ -3207,17 +3178,15 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
Ok(result) => result.usage.as_ref().map(usage_audit_from_event),
Err(err) => {
let usage = err.usage.as_ref().map(usage_audit_from_event);
audit
.emit(
self.workspace_client(),
event_tx,
lifecycle_status_for_worker_error(&err.source),
format!("worker_failed: {}", err.source),
usage,
Some(extract_audit_base),
None,
)
.await;
audit.emit(
self.workspace_client(),
event_tx,
lifecycle_status_for_worker_error(&err.source),
format!("worker_failed: {}", err.source),
usage,
Some(extract_audit_base),
None,
);
return Err(WorkerError::Engine(err.source));
}
};
@ -3259,17 +3228,15 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
} else {
"completed_staging_written"
};
audit
.emit(
self.workspace_client(),
event_tx,
memory::audit::WorkerLifecycleStatus::Completed,
reason,
usage,
Some(extract_audit),
None,
)
.await;
audit.emit(
self.workspace_client(),
event_tx,
memory::audit::WorkerLifecycleStatus::Completed,
reason,
usage,
Some(extract_audit),
None,
);
Ok(ExtractDecision::Completed)
}
@ -3307,8 +3274,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
None,
None,
None,
)
.await;
);
if reason == "consolidation_backend_operation_unavailable" {
tracing::debug!(
"workspace memory consolidation skipped: backend operation is unavailable"
@ -3391,7 +3357,7 @@ impl WorkerAuditBase {
}
}
async fn emit(
fn emit(
&self,
workspace_client: &WorkspaceClient,
event_tx: Option<&broadcast::Sender<Event>>,
@ -3413,15 +3379,15 @@ impl WorkerAuditBase {
extract,
consolidation,
};
let _ = workspace_client
.execute_memory_backend_operation(memory::backend::MemoryBackendOperation::AppendAudit(
let _ = workspace_client.execute_memory_backend_operation(
memory::backend::MemoryBackendOperation::AppendAudit(
memory::backend::MemoryAppendAuditOperation {
event: memory::audit::AuditEvent::new(
memory::audit::AuditPayload::WorkerLifecycle(payload),
),
},
))
.await;
),
);
if should_emit_memory_worker_event(self.worker, status, &reason) {
emit_memory_worker_event(
event_tx,
@ -5586,7 +5552,7 @@ mod build_summary_prompt_tests {
)
.unwrap();
worker.set_system_prompt_template(template);
worker.ensure_system_prompt_materialized().await.unwrap();
worker.ensure_system_prompt_materialized().unwrap();
worker.engine().get_system_prompt().unwrap().to_string()
}
@ -5617,15 +5583,17 @@ mod build_summary_prompt_tests {
let mut buffer = [0_u8; 1024];
let _ = stream.read(&mut buffer).unwrap();
let body = serde_json::json!({
"status": "ok",
"result": {
"kind": "tool_output",
"summary": if content.is_some() {
"resident memory summary collected"
} else {
"resident memory summary unavailable"
},
"content": content,
"Ok": {
"result": {
"ToolOutput": {
"summary": if content.is_some() {
"resident memory summary collected"
} else {
"resident memory summary unavailable"
},
"content": content,
}
}
}
})
.to_string();

View File

@ -1587,6 +1587,7 @@ fn create_working_directory_for_runtime(
resolved_commit: None,
materialization_status: "pending".to_string(),
cleanliness: "unknown".to_string(),
management_kind: "backend_managed".to_string(),
created_at: now_registry_timestamp(),
updated_at: now_registry_timestamp(),
};
@ -1609,7 +1610,12 @@ fn create_working_directory_for_runtime(
result.diagnostics,
));
};
let record = workdir_record_from_summary(&api, &runtime_id, &working_directory.summary);
let record = workdir_record_from_summary(
&api,
&runtime_id,
&working_directory.summary,
"backend_managed",
);
api.store.upsert_workdir_registry(&record)?;
Ok(Json(BrowserWorkingDirectoryDetailResponse {
workspace_id: api.config.workspace_id.clone(),
@ -1628,7 +1634,17 @@ fn working_directory_detail_for_runtime(
.working_directory(runtime_id, working_directory_id)
.map_err(|err| err.into_error())?;
if let Some(working_directory) = result.working_directory {
let record = workdir_record_from_summary(&api, runtime_id, &working_directory.summary);
let management_kind = api
.store
.get_workdir_registry(&api.config.workspace_id, working_directory_id)?
.map(|record| record.management_kind)
.unwrap_or_else(|| "runtime_unmanaged".to_string());
let record = workdir_record_from_summary(
&api,
runtime_id,
&working_directory.summary,
management_kind.as_str(),
);
api.store.upsert_workdir_registry(&record)?;
return Ok(Json(BrowserWorkingDirectoryDetailResponse {
workspace_id: api.config.workspace_id.clone(),
@ -1695,7 +1711,17 @@ fn cleanup_working_directory_for_runtime(
result.diagnostics,
));
};
let record = workdir_record_from_summary(&api, runtime_id, &working_directory.summary);
let management_kind = api
.store
.get_workdir_registry(&api.config.workspace_id, working_directory_id)?
.map(|record| record.management_kind)
.unwrap_or_else(|| "runtime_unmanaged".to_string());
let record = workdir_record_from_summary(
&api,
runtime_id,
&working_directory.summary,
management_kind.as_str(),
);
api.store.upsert_workdir_registry(&record)?;
Ok(Json(BrowserWorkingDirectoryDetailResponse {
workspace_id: api.config.workspace_id.clone(),
@ -2984,8 +3010,20 @@ async fn create_workspace_worker(
WorkerRegistryDisplayNamePolicy::UseProvided,
)?;
if let Some(working_directory) = worker.working_directory.as_ref() {
let workdir_record =
workdir_record_from_summary(&api, worker.runtime_id.as_str(), working_directory);
let management_kind = api
.store
.get_workdir_registry(
&api.config.workspace_id,
&working_directory.working_directory_id,
)?
.map(|existing| existing.management_kind)
.unwrap_or_else(|| "runtime_unmanaged".to_string());
let workdir_record = workdir_record_from_summary(
&api,
worker.runtime_id.as_str(),
working_directory,
management_kind.as_str(),
);
api.store.upsert_workdir_registry(&workdir_record)?;
link_worker_to_workdir(
&api,
@ -3009,6 +3047,7 @@ async fn create_workspace_worker(
&api,
worker.runtime_id.as_str(),
&status.summary,
"runtime_unmanaged",
);
api.store.upsert_workdir_registry(&record)?;
}
@ -4386,7 +4425,7 @@ fn working_directory_summaries(api: &WorkspaceApi) -> ApiResult<Vec<WorkingDirec
let _ = sync_all_runtime_workdir_observations(api);
let records = api
.store
.list_workdir_registry(&api.config.workspace_id, 200)?;
.list_managed_workdir_registry(&api.config.workspace_id, 200)?;
Ok(records
.iter()
.map(workdir_summary_from_record)
@ -4580,8 +4619,20 @@ fn sync_worker_observation(
WorkerRegistryDisplayNamePolicy::PreserveExisting,
)?;
if let Some(working_directory) = worker.working_directory.as_ref() {
let workdir_record =
workdir_record_from_summary(api, worker.runtime_id.as_str(), working_directory);
let management_kind = api
.store
.get_workdir_registry(
&api.config.workspace_id,
&working_directory.working_directory_id,
)?
.map(|existing| existing.management_kind)
.unwrap_or_else(|| "runtime_unmanaged".to_string());
let workdir_record = workdir_record_from_summary(
api,
worker.runtime_id.as_str(),
working_directory,
management_kind.as_str(),
);
api.store.upsert_workdir_registry(&workdir_record)?;
link_worker_to_workdir(api, &record, &working_directory.working_directory_id)?;
}
@ -4612,6 +4663,7 @@ fn upsert_pending_backend_workdir(
resolved_commit: None,
materialization_status: "pending".to_string(),
cleanliness: "unknown".to_string(),
management_kind: "backend_managed".to_string(),
created_at: timestamp.clone(),
updated_at: timestamp,
})?;
@ -4640,7 +4692,12 @@ fn sync_runtime_workdir_observations(
&api.config.workspace_id,
&status.summary.working_directory_id,
)?;
let mut record = workdir_record_from_summary(api, runtime_id, &status.summary);
let management_kind = existing
.as_ref()
.map(|existing| existing.management_kind.clone())
.unwrap_or_else(|| "runtime_unmanaged".to_string());
let mut record =
workdir_record_from_summary(api, runtime_id, &status.summary, management_kind.as_str());
preserve_workdir_identity_for_corrupted_summary(&mut record, existing.as_ref());
api.store.upsert_workdir_registry(&record)?;
}
@ -4662,8 +4719,13 @@ fn sync_runtime_workdir_observations(
record.workdir_id.as_str(),
)?;
} else {
let mut updated =
workdir_record_from_summary(api, runtime_id, &status.summary);
let management_kind = record.management_kind.clone();
let mut updated = workdir_record_from_summary(
api,
runtime_id,
&status.summary,
management_kind.as_str(),
);
preserve_workdir_identity_for_corrupted_summary(
&mut updated,
Some(&record),
@ -4730,7 +4792,17 @@ fn sync_linked_workdir_after_worker_stop(
.working_directory(runtime_id, link.workdir_id.as_str())
.map_err(|err| err.into_error())?;
if let Some(status) = result.working_directory {
let record = workdir_record_from_summary(api, runtime_id, &status.summary);
let management_kind = api
.store
.get_workdir_registry(&api.config.workspace_id, link.workdir_id.as_str())?
.map(|record| record.management_kind)
.unwrap_or_else(|| "runtime_unmanaged".to_string());
let record = workdir_record_from_summary(
api,
runtime_id,
&status.summary,
management_kind.as_str(),
);
api.store.upsert_workdir_registry(&record)?;
} else if let Some(mut record) = api
.store
@ -4750,6 +4822,7 @@ fn workdir_record_from_summary(
api: &WorkspaceApi,
runtime_id: &str,
summary: &WorkingDirectorySummary,
management_kind: &str,
) -> WorkdirRegistryRecord {
let timestamp = now_registry_timestamp();
WorkdirRegistryRecord {
@ -4771,6 +4844,7 @@ fn workdir_record_from_summary(
.cleanliness
.clone()
.unwrap_or_else(|| "unknown".to_string()),
management_kind: management_kind.to_string(),
created_at: timestamp.clone(),
updated_at: timestamp,
}
@ -4821,6 +4895,7 @@ fn workdir_summary_from_record(record: &WorkdirRegistryRecord) -> WorkingDirecto
status,
cleanliness: Some(record.cleanliness.clone()),
primary_worker_id: None,
management_kind: Some(record.management_kind.clone()),
}
}
@ -5442,6 +5517,7 @@ mod tests {
resolved_commit: Some("abcdef".to_string()),
materialization_status: "missing".to_string(),
cleanliness: "clean".to_string(),
management_kind: "backend_managed".to_string(),
created_at: "1".to_string(),
updated_at: "3".to_string(),
};
@ -5462,13 +5538,22 @@ mod tests {
projected.working_directory.as_ref().unwrap().status,
WorkingDirectoryStatusKind::NotFound
);
assert_eq!(
projected
.working_directory
.as_ref()
.unwrap()
.management_kind
.as_deref(),
Some("backend_managed")
);
let serialized = serde_json::to_string(&projected).unwrap();
assert!(!serialized.contains("/tmp/"));
assert!(!serialized.contains("materialized_path"));
}
#[tokio::test]
async fn workspace_workdir_summaries_include_runtime_observed_rows() {
async fn workspace_managed_workdir_summaries_exclude_runtime_unmanaged_rows() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
api.store
@ -5481,6 +5566,7 @@ mod tests {
resolved_commit: None,
materialization_status: "present".to_string(),
cleanliness: "clean".to_string(),
management_kind: "backend_managed".to_string(),
created_at: "1".to_string(),
updated_at: "1".to_string(),
})
@ -5495,29 +5581,29 @@ mod tests {
resolved_commit: None,
materialization_status: "present".to_string(),
cleanliness: "unknown".to_string(),
management_kind: "runtime_unmanaged".to_string(),
created_at: "1".to_string(),
updated_at: "2".to_string(),
})
.unwrap();
let summaries = working_directory_summaries(&api)
let managed = working_directory_summaries(&api)
.unwrap_or_else(|err| panic!("working_directory_summaries failed: {}", err.error));
let ids = summaries
.iter()
.map(|summary| summary.working_directory_id.as_str())
.collect::<Vec<_>>();
assert!(ids.contains(&"managed"));
assert!(ids.contains(&"runtime-direct"));
assert_eq!(managed.len(), 1);
assert_eq!(managed[0].working_directory_id, "managed");
assert_eq!(
managed[0].management_kind.as_deref(),
Some("backend_managed")
);
let (runtime_projection, _) =
runtime_working_directory_summaries(&api, EMBEDDED_WORKER_RUNTIME_ID).unwrap_or_else(
|err| panic!("runtime_working_directory_summaries failed: {}", err.error),
);
assert!(
runtime_projection
.iter()
.any(|summary| summary.working_directory_id == "runtime-direct")
);
assert!(runtime_projection.iter().any(|summary| {
summary.working_directory_id == "runtime-direct"
&& summary.management_kind.as_deref() == Some("runtime_unmanaged")
}));
}
#[test]
fn unmanaged_runtime_workdir_projection_is_typed_and_diagnostic_safe() {
@ -5530,6 +5616,7 @@ mod tests {
resolved_commit: None,
materialization_status: "present".to_string(),
cleanliness: "unknown".to_string(),
management_kind: "runtime_unmanaged".to_string(),
created_at: "1".to_string(),
updated_at: "2".to_string(),
};
@ -5537,6 +5624,10 @@ mod tests {
let projected = workdir_summary_from_record(&workdir);
assert_eq!(projected.status, WorkingDirectoryStatusKind::Active);
assert_eq!(
projected.management_kind.as_deref(),
Some("runtime_unmanaged")
);
let serialized = serde_json::to_string(&projected).unwrap();
assert!(!serialized.contains("/tmp/"));
assert!(!serialized.contains("materialized_path"));
@ -6136,6 +6227,7 @@ mod tests {
workdir_id: workdir_id.to_string(),
runtime_id: "runtime-test".to_string(),
repository_id: "repo-test".to_string(),
management_kind: "backend_managed".to_string(),
selector: Some("HEAD".to_string()),
resolved_commit: None,
materialization_status: status.to_string(),

View File

@ -47,11 +47,6 @@ const MIGRATIONS: &[Migration] = &[
name: "add workdir runtime observation states",
apply: add_workdir_runtime_observation_states,
},
Migration {
version: 7,
name: "remove workdir registry management kind",
apply: remove_workdir_registry_management_kind_column,
},
];
struct Migration {
@ -96,6 +91,8 @@ pub struct WorkdirRegistryRecord {
pub resolved_commit: Option<String>,
pub materialization_status: String,
pub cleanliness: String,
/// `backend_managed` rows are authored by this Backend; `runtime_unmanaged` is for diagnostics only.
pub management_kind: String,
pub created_at: String,
pub updated_at: String,
}
@ -155,6 +152,11 @@ pub trait ControlPlaneStore: Send + Sync {
workspace_id: &str,
limit: usize,
) -> Result<Vec<WorkdirRegistryRecord>>;
fn list_managed_workdir_registry(
&self,
workspace_id: &str,
limit: usize,
) -> Result<Vec<WorkdirRegistryRecord>>;
fn delete_workdir_registry(&self, workspace_id: &str, workdir_id: &str) -> Result<bool>;
fn upsert_worker_workdir_link(&self, record: &WorkerWorkdirLinkRecord) -> Result<()>;
@ -376,8 +378,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
conn.execute(
r#"INSERT INTO workdir_registry (
workspace_id, workdir_id, runtime_id, repository_id, selector, resolved_commit,
materialization_status, cleanliness, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
materialization_status, cleanliness, management_kind, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
ON CONFLICT(workspace_id, workdir_id) DO UPDATE SET
runtime_id = excluded.runtime_id,
repository_id = excluded.repository_id,
@ -385,6 +387,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
resolved_commit = excluded.resolved_commit,
materialization_status = excluded.materialization_status,
cleanliness = excluded.cleanliness,
management_kind = excluded.management_kind,
updated_at = excluded.updated_at"#,
params![
record.workspace_id,
@ -395,6 +398,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
record.resolved_commit,
record.materialization_status,
record.cleanliness,
record.management_kind,
record.created_at,
record.updated_at,
],
@ -438,6 +442,22 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
})
}
fn list_managed_workdir_registry(
&self,
workspace_id: &str,
limit: usize,
) -> Result<Vec<WorkdirRegistryRecord>> {
self.with_conn(|conn| {
let sql = workdir_registry_select_sql(
"WHERE workspace_id = ?1 AND management_kind = 'backend_managed' ORDER BY updated_at DESC LIMIT ?2",
);
let mut stmt = conn.prepare(sql.as_str())?;
let rows = stmt.query_map(params![workspace_id, limit as i64], read_workdir_registry_record)?;
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Error::from)
})
}
fn delete_workdir_registry(&self, workspace_id: &str, workdir_id: &str) -> Result<bool> {
self.with_conn(|conn| {
let changed = conn.execute(
@ -557,7 +577,7 @@ fn read_worker_registry_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<Work
fn workdir_registry_select_sql(where_clause: &str) -> String {
format!(
"SELECT workspace_id, workdir_id, runtime_id, repository_id, selector, resolved_commit, \
materialization_status, cleanliness, created_at, updated_at \
materialization_status, cleanliness, management_kind, created_at, updated_at \
FROM workdir_registry {where_clause}"
)
}
@ -574,8 +594,9 @@ fn read_workdir_registry_record(
resolved_commit: row.get(5)?,
materialization_status: row.get(6)?,
cleanliness: row.get(7)?,
created_at: row.get(8)?,
updated_at: row.get(9)?,
management_kind: row.get(8)?,
created_at: row.get(9)?,
updated_at: row.get(10)?,
})
}
@ -608,6 +629,7 @@ CREATE TABLE IF NOT EXISTS workdir_registry (
resolved_commit TEXT,
materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'not_found', 'corrupted', 'unknown', 'failed')),
cleanliness TEXT NOT NULL CHECK (cleanliness IN ('clean', 'dirty', 'unknown')),
management_kind TEXT NOT NULL CHECK (management_kind IN ('backend_managed', 'runtime_unmanaged')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, workdir_id),
@ -926,6 +948,7 @@ fn add_workdir_runtime_observation_states(conn: &Connection) -> Result<()> {
resolved_commit TEXT,
materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'not_found', 'corrupted', 'unknown', 'failed')),
cleanliness TEXT NOT NULL CHECK (cleanliness IN ('clean', 'dirty', 'unknown')),
management_kind TEXT NOT NULL CHECK (management_kind IN ('backend_managed', 'runtime_unmanaged')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, workdir_id),
@ -933,7 +956,7 @@ fn add_workdir_runtime_observation_states(conn: &Connection) -> Result<()> {
);
INSERT OR REPLACE INTO workdir_registry_v6 (
workspace_id, workdir_id, runtime_id, repository_id, selector, resolved_commit,
materialization_status, cleanliness, created_at, updated_at
materialization_status, cleanliness, management_kind, created_at, updated_at
)
SELECT
workspace_id, workdir_id, runtime_id, repository_id, selector, resolved_commit,
@ -942,7 +965,7 @@ fn add_workdir_runtime_observation_states(conn: &Connection) -> Result<()> {
WHEN 'removed' THEN 'not_found'
ELSE materialization_status
END,
cleanliness, created_at, updated_at
cleanliness, management_kind, created_at, updated_at
FROM workdir_registry;
DROP TABLE workdir_registry;
ALTER TABLE workdir_registry_v6 RENAME TO workdir_registry;
@ -953,48 +976,6 @@ fn add_workdir_runtime_observation_states(conn: &Connection) -> Result<()> {
Ok(())
}
fn remove_workdir_registry_management_kind_column(conn: &Connection) -> Result<()> {
if !table_exists(conn, "workdir_registry")?
|| !table_columns(conn, "workdir_registry")?
.iter()
.any(|column| column == "management_kind")
{
return Ok(());
}
conn.execute_batch(
r#"
CREATE TABLE workdir_registry_v7 (
workspace_id TEXT NOT NULL,
workdir_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
repository_id TEXT NOT NULL,
selector TEXT,
resolved_commit TEXT,
materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'not_found', 'corrupted', 'unknown', 'failed')),
cleanliness TEXT NOT NULL CHECK (cleanliness IN ('clean', 'dirty', 'unknown')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, workdir_id),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
INSERT OR REPLACE INTO workdir_registry_v7 (
workspace_id, workdir_id, runtime_id, repository_id, selector, resolved_commit,
materialization_status, cleanliness, created_at, updated_at
)
SELECT
workspace_id, workdir_id, runtime_id, repository_id, selector, resolved_commit,
materialization_status, cleanliness, created_at, updated_at
FROM workdir_registry;
DROP TABLE workdir_registry;
ALTER TABLE workdir_registry_v7 RENAME TO workdir_registry;
CREATE INDEX IF NOT EXISTS idx_workdir_registry_workspace_updated
ON workdir_registry(workspace_id, updated_at DESC);
"#,
)?;
Ok(())
}
fn create_schema_v0_tables(conn: &Connection) -> Result<()> {
conn.execute_batch(
r#"
@ -1218,7 +1199,7 @@ mod tests {
let db = dir.path().join("control-plane.sqlite");
let store = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 7);
assert_eq!(store.schema_version().await.unwrap(), 6);
let record = WorkspaceRecord {
workspace_id: "local-dev".to_string(),
@ -1230,7 +1211,7 @@ mod tests {
store.upsert_workspace(&record).await.unwrap();
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(reopened.schema_version().await.unwrap(), 7);
assert_eq!(reopened.schema_version().await.unwrap(), 6);
assert_eq!(
reopened.get_workspace("local-dev").await.unwrap(),
Some(record)
@ -1439,7 +1420,7 @@ mod tests {
.unwrap();
let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 7);
assert_eq!(store.schema_version().await.unwrap(), 6);
store
.with_conn(|conn| {
@ -1569,6 +1550,7 @@ mod tests {
resolved_commit: Some("abcdef".to_string()),
materialization_status: "not_found".to_string(),
cleanliness: "clean".to_string(),
management_kind: "backend_managed".to_string(),
created_at: "2".to_string(),
updated_at: "3".to_string(),
};
@ -1582,6 +1564,7 @@ mod tests {
resolved_commit: Some("123456".to_string()),
materialization_status: "present".to_string(),
cleanliness: "unknown".to_string(),
management_kind: "runtime_unmanaged".to_string(),
created_at: "3".to_string(),
updated_at: "4".to_string(),
};
@ -1614,6 +1597,12 @@ mod tests {
store.list_workdir_registry("local-dev", 10).unwrap(),
vec![unmanaged_workdir.clone(), workdir.clone()]
);
assert_eq!(
store
.list_managed_workdir_registry("local-dev", 10)
.unwrap(),
vec![workdir]
);
assert_eq!(
store
.list_worker_workdir_links("local-dev", "embedded", 1)

View File

@ -1,304 +0,0 @@
{
pkgs,
yoi ? pkgs.callPackage ./package.nix { },
}:
let
lib = pkgs.lib;
imageTag = "latest";
mkRoot =
name: paths: _extraPathsToLink:
pkgs.symlinkJoin {
inherit name paths;
};
runtimeDirs = pkgs.runCommand "yoi-runtime-dirs" { } ''
mkdir -p "$out/runtime-data" "$out/workdirs"
chmod 0777 "$out/runtime-data" "$out/workdirs"
'';
serverDirs = pkgs.runCommand "yoi-server-dirs" { } ''
mkdir -p "$out/server-data" "$out/workspace"
chmod 0777 "$out/server-data" "$out/workspace"
'';
runtimeRoot =
mkRoot "yoi-runtime-root"
[
yoi
pkgs.bashInteractive
pkgs.coreutils
pkgs.cacert
pkgs.git
pkgs.openssh
runtimeDirs
]
[
"/runtime-data"
"/workdirs"
];
serverRoot =
mkRoot "yoi-server-root"
[
yoi
pkgs.coreutils
pkgs.cacert
pkgs.git
serverDirs
]
[
"/server-data"
"/workspace"
];
webuiSrc = lib.cleanSourceWith {
src = ./web/workspace;
filter =
path: type:
let
baseName = baseNameOf path;
in
!(baseName == "node_modules" || baseName == ".svelte-kit" || baseName == "build");
};
webuiDeps = pkgs.stdenvNoCC.mkDerivation {
pname = "yoi-webui-deno-deps";
version = "0.1.0";
src = webuiSrc;
nativeBuildInputs = [ pkgs.deno ];
outputHashAlgo = "sha256";
outputHashMode = "recursive";
outputHash = "sha256-q+otr+ANR9gB8bRZKFZDfNpM6rnQ4B4wNd/s1QNeSA4=";
buildPhase = ''
runHook preBuild
export HOME="$TMPDIR/home"
export DENO_DIR="$TMPDIR/deno-cache"
mkdir -p "$HOME" "$DENO_DIR"
deno task build
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p "$out"
cp -R "$DENO_DIR" "$out/deno-cache"
if [ -d node_modules ]; then
cp -R node_modules "$out/node_modules"
fi
runHook postInstall
'';
};
webuiStatic = pkgs.stdenvNoCC.mkDerivation {
pname = "yoi-webui-static";
version = "0.1.0";
src = webuiSrc;
nativeBuildInputs = [ pkgs.deno ];
buildPhase = ''
runHook preBuild
export HOME="$TMPDIR/home"
export DENO_DIR="$TMPDIR/deno-cache"
mkdir -p "$HOME"
cp -R ${webuiDeps}/deno-cache "$DENO_DIR"
chmod -R u+w "$DENO_DIR"
if [ -d ${webuiDeps}/node_modules ]; then
cp -R ${webuiDeps}/node_modules node_modules
chmod -R u+w node_modules
fi
deno task build
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p "$out"
cp -R build/. "$out/"
runHook postInstall
'';
};
webuiRoot = pkgs.runCommand "yoi-webui-root" { } ''
mkdir -p "$out/usr/share/yoi-webui"
cp -R ${webuiStatic}/. "$out/usr/share/yoi-webui/"
'';
webuiDirs = pkgs.runCommand "yoi-webui-dirs" { } ''
mkdir -p "$out/etc" "$out/tmp" "$out/var/cache/nginx" "$out/var/log/nginx"
touch "$out/tmp/.keep" "$out/var/cache/nginx/.keep" "$out/var/log/nginx/.keep"
cat > "$out/etc/passwd" <<'EOF'
root:x:0:0:root:/root:/bin/sh
nobody:x:65534:65534:nobody:/var/empty:/sbin/nologin
EOF
cat > "$out/etc/group" <<'EOF'
root:x:0:
nobody:x:65534:
nogroup:x:65534:
EOF
'';
nginxConf = pkgs.writeText "yoi-webui-nginx.conf" ''
pid /tmp/nginx.pid;
error_log /dev/stderr info;
events {}
http {
include ${pkgs.nginx}/conf/mime.types;
access_log /dev/stdout;
map $http_upgrade $connection_upgrade {
default upgrade;
''' close;
}
server {
listen 80;
server_name _;
root /usr/share/yoi-webui;
index index.html;
resolver 127.0.0.11 valid=30s ipv6=off;
set $yoi_backend server:8787;
location = /api {
proxy_pass http://$yoi_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
location /api/ {
proxy_pass http://$yoi_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
location / {
try_files $uri $uri/ /index.html;
}
}
}
'';
webuiImageRoot =
mkRoot "yoi-webui-root-env"
[
pkgs.nginx
pkgs.cacert
webuiRoot
webuiDirs
]
[
"/usr/share/yoi-webui"
"/var"
"/tmp"
];
commonEnv = [
"SSL_CERT_FILE=/etc/ssl/certs/ca-bundle.crt"
"GIT_SSL_CAINFO=/etc/ssl/certs/ca-bundle.crt"
];
in
{
runtime = pkgs.dockerTools.buildImage {
name = "yoi-runtime";
tag = imageTag;
copyToRoot = runtimeRoot;
config = {
Entrypoint = [ "/bin/worker-runtime-rest-server" ];
Cmd = [
"--bind"
"0.0.0.0:38800"
"--display-name"
"Docker Runtime"
"--fs-root"
"/runtime-data"
"--workdir-target"
"/workdirs"
];
Env = [ "PATH=/bin" ] ++ commonEnv;
ExposedPorts = {
"38800/tcp" = { };
};
Volumes = {
"/runtime-data" = { };
"/workdirs" = { };
};
WorkingDir = "/runtime-data";
};
};
server = pkgs.dockerTools.buildImage {
name = "yoi-server";
tag = imageTag;
copyToRoot = serverRoot;
config = {
Entrypoint = [ "/bin/yoi-workspace-server" ];
Cmd = [
"serve"
"--workspace"
"/workspace"
"--db"
"/server-data/workspace.db"
"--listen"
"0.0.0.0:8787"
];
Env = [ "PATH=/bin" ] ++ commonEnv;
ExposedPorts = {
"8787/tcp" = { };
};
Volumes = {
"/server-data" = { };
"/workspace" = { };
};
WorkingDir = "/server-data";
};
};
webui = pkgs.dockerTools.buildImage {
name = "yoi-webui";
tag = imageTag;
copyToRoot = webuiImageRoot;
config = {
Entrypoint = [
"/bin/nginx"
"-c"
"${nginxConf}"
"-g"
"daemon off;"
];
Env = [ "PATH=/bin" ] ++ commonEnv;
ExposedPorts = {
"80/tcp" = { };
};
};
};
webui-static = webuiStatic;
}

41
docker/nginx.conf Normal file
View File

@ -0,0 +1,41 @@
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
resolver 127.0.0.11 valid=30s ipv6=off;
set $yoi_backend server:8787;
location = /api {
proxy_pass http://$yoi_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
location /api/ {
proxy_pass http://$yoi_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@ -1,16 +0,0 @@
[server]
listen = "0.0.0.0:8787"
frontend_url = "http://127.0.0.1:8080"
[data]
root = "/server-data"
workspace_database_path = "/server-data/workspace.db"
embedded_runtime_store_root = "/server-data/embedded-runtime"
[limits]
max_records = 200
[[runtimes.remote]]
id = "docker-runtime"
endpoint = "http://runtime:38800"
display_name = "Docker Runtime"

View File

@ -1,3 +0,0 @@
workspace_id = "019a1e4a-7c0d-7000-9000-000000000001"
created_at = "2026-07-21T00:00:00Z"
display_name = "Docker Workspace"

View File

@ -17,13 +17,6 @@
let
pkgs = nixpkgs.legacyPackages.${system};
yoi = pkgs.callPackage ./package.nix { };
dockerImages =
if pkgs.stdenv.isLinux then
import ./docker.nix {
inherit pkgs yoi;
}
else
{ };
mkApp = name: description: {
type = "app";
program = "${yoi}/bin/${name}";
@ -31,16 +24,8 @@
};
in
{
packages = {
default = yoi;
yoi = yoi;
}
// pkgs.lib.optionalAttrs pkgs.stdenv.isLinux {
docker-runtime = dockerImages.runtime;
docker-server = dockerImages.server;
docker-webui = dockerImages.webui;
webui-static = dockerImages.webui-static;
};
packages.default = yoi;
packages.yoi = yoi;
apps.default = mkApp "yoi" "Run the Yoi terminal UI";
apps.yoi = mkApp "yoi" "Run the Yoi terminal UI";

View File

@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec {
filter = sourceFilter;
};
cargoHash = "sha256-iZaTREhL/aLixn67A1+Gi9opqh2j/yyFuGMyBBvuATM=";
cargoHash = "sha256-MUEySmRu5Te8+GCwlt3qk7jxcumYIMvnuvj64o2XuDw=";
depsExtraArgs = {
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
@ -93,7 +93,6 @@ rustPlatform.buildRustPackage rec {
postBuild = ''
cargo build --offline --profile release -p yoi-workspace-server --bin yoi-workspace-server
cargo build --offline --profile release -p worker-runtime --bin worker-runtime-rest-server --features ws-server,fs-store
'';
# The package check is a credential-free install smoke check below. Running the
@ -106,15 +105,13 @@ rustPlatform.buildRustPackage rec {
yoi_bin=$(find . -type f -name yoi | head -n 1)
workspace_server_bin=$(find . -type f -name yoi-workspace-server | head -n 1)
worker_runtime_bin=$(find . -type f -name worker-runtime-rest-server | head -n 1)
if [ -z "$yoi_bin" ] || [ -z "$workspace_server_bin" ] || [ -z "$worker_runtime_bin" ]; then
if [ -z "$yoi_bin" ] || [ -z "$workspace_server_bin" ]; then
echo "built binaries not found" >&2
find . -maxdepth 6 -type f \( -name yoi -o -name yoi-workspace-server -o -name worker-runtime-rest-server \) -print >&2
find . -maxdepth 6 -type f \( -name yoi -o -name yoi-workspace-server \) -print >&2
exit 1
fi
install -Dm755 "$yoi_bin" "$out/bin/yoi"
install -Dm755 "$workspace_server_bin" "$out/bin/yoi-workspace-server"
install -Dm755 "$worker_runtime_bin" "$out/bin/worker-runtime-rest-server"
runHook postInstall
'';
@ -126,9 +123,7 @@ rustPlatform.buildRustPackage rec {
"$out/bin/yoi" worker --help >/dev/null
test -x "$out/bin/yoi"
test -x "$out/bin/yoi-workspace-server"
test -x "$out/bin/worker-runtime-rest-server"
"$out/bin/yoi-workspace-server" --help >/dev/null
"$out/bin/worker-runtime-rest-server" --help >/dev/null
test ! -e "$out/bin/yoi-pod"
test ! -e "$out/share/yoi/resources"
if "$out/bin/yoi" --session not-a-uuid 2>yoi.err; then

View File

@ -125,6 +125,7 @@ export type WorkingDirectorySummary = {
status: string;
cleanliness?: string | null;
primary_worker_id?: number | null;
management_kind?: "backend_managed" | "runtime_unmanaged" | string | null;
cleanup_target: {
kind: string;
working_directory_id: string;