Compare commits

..

4 Commits

Author SHA1 Message Date
44deacb3f4
fix: remove workdir management kind 2026-07-22 20:52:09 +09:00
fe2a39c12d
fix: async workspace memory backend 2026-07-22 19:44:52 +09:00
aa542b38d9
feat: add local image compose setup 2026-07-22 19:09:17 +09:00
c1ee1ff9e9
feat: build docker images with nix 2026-07-22 18:43:35 +09:00
18 changed files with 635 additions and 451 deletions

View File

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

View File

@ -1,88 +0,0 @@
# 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

38
compose.yaml Normal file
View File

@ -0,0 +1,38 @@
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,8 +123,6 @@ 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,10 +156,6 @@ 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,7 +48,6 @@ impl WorkingDirectory {
status: self.status.clone(),
cleanliness: None,
primary_worker_id: None,
management_kind: None,
}
}
}
@ -222,7 +221,6 @@ 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 fn execute_operation(
pub async fn execute_operation(
&self,
operation: MemoryBackendOperation,
) -> Result<MemoryBackendOperationResult, WorkspaceMemoryBackendError> {
execute_http_memory_backend(&self.workspace_id, &self.base_url, operation)
execute_http_memory_backend(&self.workspace_id, &self.base_url, operation).await
}
fn execute(&self, operation: MemoryBackendOperation) -> Result<ToolOutput, ToolError> {
match self.execute_operation(operation) {
async fn execute(&self, operation: MemoryBackendOperation) -> Result<ToolOutput, ToolError> {
match self.execute_operation(operation).await {
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 fn execute_memory_backend_operation(
pub async 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),
} => execute_http_memory_backend(workspace_id, base_url, operation).await,
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 {
}
}
fn execute_http_memory_backend(
async fn execute_http_memory_backend(
workspace_id: &str,
base_url: &str,
operation: MemoryBackendOperation,
@ -103,12 +103,13 @@ fn execute_http_memory_backend(
base_url.trim_end_matches('/'),
workspace_id
);
let response = reqwest::blocking::Client::new()
let response = reqwest::Client::new()
.post(url)
.json(&operation)
.send()?;
.send()
.await?;
let status = response.status();
let body = response.text()?;
let body = response.text().await?;
if !status.is_success() {
return Err(WorkspaceMemoryBackendError::Http { status, body });
}
@ -220,7 +221,7 @@ impl Tool for WorkspaceHttpMemoryTool {
_ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let operation = (self.build)(input_json)?;
self.backend.execute(operation)
self.backend.execute(operation).await
}
}

View File

@ -433,6 +433,7 @@ 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 => {
@ -713,7 +714,7 @@ mod tests {
#[tokio::test]
async fn stage_candidate_writes_staging_record_with_source_evidence() {
let (client, request_rx) = stub_memory_backend_response(
r#"{"Ok":{"result":{"StagingWritten":{"staging_count":1,"staging_ids":["00000000-0000-7000-8000-000000000001"]}}}}"#,
r#"{"status":"ok","result":{"kind":"staging_written","staging_count":1,"staging_ids":["00000000-0000-7000-8000-000000000001"]}}"#,
);
let state = SessionExploreState::new(
SessionReferenceView::new("segment-1", vec![Item::user_message("durable decision")]),
@ -745,7 +746,7 @@ mod tests {
vec!["00000000-0000-7000-8000-000000000001".to_string()]
);
let request = request_rx.recv().unwrap();
assert!(request.contains("\"StageCandidate\""));
assert!(request.contains("\"operation\":\"stage_candidate\""));
assert!(request.contains("\"kind\":\"decision\""));
assert!(request.contains("\"id\":\"M0000\""));
assert!(request.contains("\"evidence_id\":\"M0000\""));

View File

@ -897,12 +897,17 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
self.workspace_context.client()
}
fn resident_summary_from_workspace_authority(&self) -> Result<Option<String>, WorkerError> {
let result = self.workspace_client().execute_memory_backend_operation(
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?;
match result {
memory::backend::MemoryBackendOperationResult::ToolOutput(output) => Ok(output.content),
other => Err(WorkerError::FeatureInstall(format!(
@ -1465,7 +1470,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.
fn ensure_system_prompt_materialized(&mut self) -> Result<(), WorkerError> {
async fn ensure_system_prompt_materialized(&mut self) -> Result<(), WorkerError> {
let Some(template) = self.system_prompt_template.take() else {
return Ok(());
};
@ -1498,7 +1503,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() {
match self.resident_summary_from_workspace_authority().await {
Ok(summary) => summary,
Err(error) => {
tracing::debug!(%error, "resident memory summary unavailable");
@ -1579,7 +1584,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()?;
self.ensure_system_prompt_materialized().await?;
self.cleanup_finished_memory_task();
self.ensure_segment_head()?;
if self.should_pre_run_compact() {
@ -2897,7 +2902,8 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
None,
None,
None,
);
)
.await;
return Ok(());
};
@ -2926,7 +2932,8 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
None,
None,
None,
);
)
.await;
return Ok(());
}
let result = self.run_extract_once(&memory_cfg, threshold).await;
@ -2997,7 +3004,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
None,
None,
None,
);
).await;
return Ok(ExtractDecision::Skipped);
}
@ -3008,18 +3015,23 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
.history()
.len();
if current_history_len <= processed_history_len {
audit.emit(
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]),
history_range: Some([
processed_history_len as u64,
current_history_len as u64,
]),
..Default::default()
}),
None,
);
)
.await;
return Ok(ExtractDecision::Skipped);
}
@ -3031,7 +3043,8 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
.read_all(self.session_id(), self.segment_id())?
.len();
if entries_now == 0 {
audit.emit(
audit
.emit(
self.workspace_client(),
event_tx,
memory::audit::WorkerLifecycleStatus::Skipped,
@ -3039,7 +3052,8 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
None,
None,
None,
);
)
.await;
return Ok(ExtractDecision::Skipped);
}
let end_entry = entries_now - 1;
@ -3048,7 +3062,8 @@ 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(
audit
.emit(
self.workspace_client(),
event_tx,
memory::audit::WorkerLifecycleStatus::Skipped,
@ -3058,11 +3073,15 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
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]),
history_range: Some([
processed_history_len as u64,
current_history_len as u64,
]),
..Default::default()
}),
None,
);
)
.await;
return Ok(ExtractDecision::Skipped);
}
@ -3073,15 +3092,19 @@ 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(
audit
.emit(
self.workspace_client(),
event_tx,
memory::audit::WorkerLifecycleStatus::Started,
format!("token_threshold_reached tokens_since={tokens_since} threshold={threshold}"),
format!(
"token_threshold_reached tokens_since={tokens_since} threshold={threshold}"
),
None,
Some(extract_audit_base.clone()),
None,
);
)
.await;
let items_to_extract = self.engine.as_ref().expect("worker present").history()
[processed_history_len..current_history_len]
@ -3094,7 +3117,8 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
let client = match self.build_extractor_client(memory_cfg) {
Ok(client) => client,
Err(err) => {
audit.emit(
audit
.emit(
self.workspace_client(),
event_tx,
memory::audit::WorkerLifecycleStatus::Failed,
@ -3102,7 +3126,8 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
None,
Some(extract_audit_base),
None,
);
)
.await;
return Err(err);
}
};
@ -3110,7 +3135,8 @@ 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(
audit
.emit(
self.workspace_client(),
event_tx,
memory::audit::WorkerLifecycleStatus::Failed,
@ -3118,7 +3144,8 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
None,
Some(extract_audit_base),
None,
);
)
.await;
return Err(WorkerError::PromptCatalog(err));
}
};
@ -3151,7 +3178,8 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
.iter()
.any(|installed| installed == name)
}) {
audit.emit(
audit
.emit(
self.workspace_client(),
event_tx,
memory::audit::WorkerLifecycleStatus::Failed,
@ -3159,7 +3187,8 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
None,
Some(extract_audit_base),
None,
);
)
.await;
return Err(WorkerError::FeatureInstall(
"session-explore feature install failed".to_string(),
));
@ -3178,7 +3207,8 @@ 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(
audit
.emit(
self.workspace_client(),
event_tx,
lifecycle_status_for_worker_error(&err.source),
@ -3186,7 +3216,8 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
usage,
Some(extract_audit_base),
None,
);
)
.await;
return Err(WorkerError::Engine(err.source));
}
};
@ -3228,7 +3259,8 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
} else {
"completed_staging_written"
};
audit.emit(
audit
.emit(
self.workspace_client(),
event_tx,
memory::audit::WorkerLifecycleStatus::Completed,
@ -3236,7 +3268,8 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
usage,
Some(extract_audit),
None,
);
)
.await;
Ok(ExtractDecision::Completed)
}
@ -3274,7 +3307,8 @@ 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"
@ -3357,7 +3391,7 @@ impl WorkerAuditBase {
}
}
fn emit(
async fn emit(
&self,
workspace_client: &WorkspaceClient,
event_tx: Option<&broadcast::Sender<Event>>,
@ -3379,15 +3413,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,
@ -5552,7 +5586,7 @@ mod build_summary_prompt_tests {
)
.unwrap();
worker.set_system_prompt_template(template);
worker.ensure_system_prompt_materialized().unwrap();
worker.ensure_system_prompt_materialized().await.unwrap();
worker.engine().get_system_prompt().unwrap().to_string()
}
@ -5583,9 +5617,9 @@ mod build_summary_prompt_tests {
let mut buffer = [0_u8; 1024];
let _ = stream.read(&mut buffer).unwrap();
let body = serde_json::json!({
"Ok": {
"status": "ok",
"result": {
"ToolOutput": {
"kind": "tool_output",
"summary": if content.is_some() {
"resident memory summary collected"
} else {
@ -5593,8 +5627,6 @@ mod build_summary_prompt_tests {
},
"content": content,
}
}
}
})
.to_string();
let response = format!(

View File

@ -1587,7 +1587,6 @@ 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(),
};
@ -1610,12 +1609,7 @@ fn create_working_directory_for_runtime(
result.diagnostics,
));
};
let record = workdir_record_from_summary(
&api,
&runtime_id,
&working_directory.summary,
"backend_managed",
);
let record = workdir_record_from_summary(&api, &runtime_id, &working_directory.summary);
api.store.upsert_workdir_registry(&record)?;
Ok(Json(BrowserWorkingDirectoryDetailResponse {
workspace_id: api.config.workspace_id.clone(),
@ -1634,17 +1628,7 @@ 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 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(),
);
let record = workdir_record_from_summary(&api, runtime_id, &working_directory.summary);
api.store.upsert_workdir_registry(&record)?;
return Ok(Json(BrowserWorkingDirectoryDetailResponse {
workspace_id: api.config.workspace_id.clone(),
@ -1711,17 +1695,7 @@ fn cleanup_working_directory_for_runtime(
result.diagnostics,
));
};
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(),
);
let record = workdir_record_from_summary(&api, runtime_id, &working_directory.summary);
api.store.upsert_workdir_registry(&record)?;
Ok(Json(BrowserWorkingDirectoryDetailResponse {
workspace_id: api.config.workspace_id.clone(),
@ -3010,20 +2984,8 @@ async fn create_workspace_worker(
WorkerRegistryDisplayNamePolicy::UseProvided,
)?;
if let Some(working_directory) = worker.working_directory.as_ref() {
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(),
);
let workdir_record =
workdir_record_from_summary(&api, worker.runtime_id.as_str(), working_directory);
api.store.upsert_workdir_registry(&workdir_record)?;
link_worker_to_workdir(
&api,
@ -3047,7 +3009,6 @@ async fn create_workspace_worker(
&api,
worker.runtime_id.as_str(),
&status.summary,
"runtime_unmanaged",
);
api.store.upsert_workdir_registry(&record)?;
}
@ -4425,7 +4386,7 @@ fn working_directory_summaries(api: &WorkspaceApi) -> ApiResult<Vec<WorkingDirec
let _ = sync_all_runtime_workdir_observations(api);
let records = api
.store
.list_managed_workdir_registry(&api.config.workspace_id, 200)?;
.list_workdir_registry(&api.config.workspace_id, 200)?;
Ok(records
.iter()
.map(workdir_summary_from_record)
@ -4619,20 +4580,8 @@ fn sync_worker_observation(
WorkerRegistryDisplayNamePolicy::PreserveExisting,
)?;
if let Some(working_directory) = worker.working_directory.as_ref() {
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(),
);
let workdir_record =
workdir_record_from_summary(api, worker.runtime_id.as_str(), working_directory);
api.store.upsert_workdir_registry(&workdir_record)?;
link_worker_to_workdir(api, &record, &working_directory.working_directory_id)?;
}
@ -4663,7 +4612,6 @@ 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,
})?;
@ -4692,12 +4640,7 @@ fn sync_runtime_workdir_observations(
&api.config.workspace_id,
&status.summary.working_directory_id,
)?;
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());
let mut record = workdir_record_from_summary(api, runtime_id, &status.summary);
preserve_workdir_identity_for_corrupted_summary(&mut record, existing.as_ref());
api.store.upsert_workdir_registry(&record)?;
}
@ -4719,13 +4662,8 @@ fn sync_runtime_workdir_observations(
record.workdir_id.as_str(),
)?;
} else {
let management_kind = record.management_kind.clone();
let mut updated = workdir_record_from_summary(
api,
runtime_id,
&status.summary,
management_kind.as_str(),
);
let mut updated =
workdir_record_from_summary(api, runtime_id, &status.summary);
preserve_workdir_identity_for_corrupted_summary(
&mut updated,
Some(&record),
@ -4792,17 +4730,7 @@ 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 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(),
);
let record = workdir_record_from_summary(api, runtime_id, &status.summary);
api.store.upsert_workdir_registry(&record)?;
} else if let Some(mut record) = api
.store
@ -4822,7 +4750,6 @@ fn workdir_record_from_summary(
api: &WorkspaceApi,
runtime_id: &str,
summary: &WorkingDirectorySummary,
management_kind: &str,
) -> WorkdirRegistryRecord {
let timestamp = now_registry_timestamp();
WorkdirRegistryRecord {
@ -4844,7 +4771,6 @@ 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,
}
@ -4895,7 +4821,6 @@ 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()),
}
}
@ -5517,7 +5442,6 @@ 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(),
};
@ -5538,22 +5462,13 @@ 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_managed_workdir_summaries_exclude_runtime_unmanaged_rows() {
async fn workspace_workdir_summaries_include_runtime_observed_rows() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
api.store
@ -5566,7 +5481,6 @@ 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(),
})
@ -5581,29 +5495,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 managed = working_directory_summaries(&api)
let summaries = working_directory_summaries(&api)
.unwrap_or_else(|err| panic!("working_directory_summaries failed: {}", err.error));
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 ids = summaries
.iter()
.map(|summary| summary.working_directory_id.as_str())
.collect::<Vec<_>>();
assert!(ids.contains(&"managed"));
assert!(ids.contains(&"runtime-direct"));
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"
&& summary.management_kind.as_deref() == Some("runtime_unmanaged")
}));
assert!(
runtime_projection
.iter()
.any(|summary| summary.working_directory_id == "runtime-direct")
);
}
#[test]
fn unmanaged_runtime_workdir_projection_is_typed_and_diagnostic_safe() {
@ -5616,7 +5530,6 @@ 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(),
};
@ -5624,10 +5537,6 @@ 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"));
@ -6227,7 +6136,6 @@ 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,6 +47,11 @@ 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 {
@ -91,8 +96,6 @@ 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,
}
@ -152,11 +155,6 @@ 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<()>;
@ -378,8 +376,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, management_kind, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
materialization_status, cleanliness, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
ON CONFLICT(workspace_id, workdir_id) DO UPDATE SET
runtime_id = excluded.runtime_id,
repository_id = excluded.repository_id,
@ -387,7 +385,6 @@ 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,
@ -398,7 +395,6 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
record.resolved_commit,
record.materialization_status,
record.cleanliness,
record.management_kind,
record.created_at,
record.updated_at,
],
@ -442,22 +438,6 @@ 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(
@ -577,7 +557,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, management_kind, created_at, updated_at \
materialization_status, cleanliness, created_at, updated_at \
FROM workdir_registry {where_clause}"
)
}
@ -594,9 +574,8 @@ fn read_workdir_registry_record(
resolved_commit: row.get(5)?,
materialization_status: row.get(6)?,
cleanliness: row.get(7)?,
management_kind: row.get(8)?,
created_at: row.get(9)?,
updated_at: row.get(10)?,
created_at: row.get(8)?,
updated_at: row.get(9)?,
})
}
@ -629,7 +608,6 @@ 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),
@ -948,7 +926,6 @@ 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),
@ -956,7 +933,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, management_kind, created_at, updated_at
materialization_status, cleanliness, created_at, updated_at
)
SELECT
workspace_id, workdir_id, runtime_id, repository_id, selector, resolved_commit,
@ -965,7 +942,7 @@ fn add_workdir_runtime_observation_states(conn: &Connection) -> Result<()> {
WHEN 'removed' THEN 'not_found'
ELSE materialization_status
END,
cleanliness, management_kind, created_at, updated_at
cleanliness, created_at, updated_at
FROM workdir_registry;
DROP TABLE workdir_registry;
ALTER TABLE workdir_registry_v6 RENAME TO workdir_registry;
@ -976,6 +953,48 @@ 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#"
@ -1199,7 +1218,7 @@ mod tests {
let db = dir.path().join("control-plane.sqlite");
let store = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 6);
assert_eq!(store.schema_version().await.unwrap(), 7);
let record = WorkspaceRecord {
workspace_id: "local-dev".to_string(),
@ -1211,7 +1230,7 @@ mod tests {
store.upsert_workspace(&record).await.unwrap();
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(reopened.schema_version().await.unwrap(), 6);
assert_eq!(reopened.schema_version().await.unwrap(), 7);
assert_eq!(
reopened.get_workspace("local-dev").await.unwrap(),
Some(record)
@ -1420,7 +1439,7 @@ mod tests {
.unwrap();
let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 6);
assert_eq!(store.schema_version().await.unwrap(), 7);
store
.with_conn(|conn| {
@ -1550,7 +1569,6 @@ 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(),
};
@ -1564,7 +1582,6 @@ 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(),
};
@ -1597,12 +1614,6 @@ 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)

304
docker.nix Normal file
View File

@ -0,0 +1,304 @@
{
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;
}

View File

@ -1,41 +0,0 @@
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

@ -0,0 +1,16 @@
[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

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

View File

@ -17,6 +17,13 @@
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}";
@ -24,8 +31,16 @@
};
in
{
packages.default = yoi;
packages.yoi = yoi;
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;
};
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-MUEySmRu5Te8+GCwlt3qk7jxcumYIMvnuvj64o2XuDw=";
cargoHash = "sha256-iZaTREhL/aLixn67A1+Gi9opqh2j/yyFuGMyBBvuATM=";
depsExtraArgs = {
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
@ -93,6 +93,7 @@ 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
@ -105,13 +106,15 @@ 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)
if [ -z "$yoi_bin" ] || [ -z "$workspace_server_bin" ]; then
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
echo "built binaries not found" >&2
find . -maxdepth 6 -type f \( -name yoi -o -name yoi-workspace-server \) -print >&2
find . -maxdepth 6 -type f \( -name yoi -o -name yoi-workspace-server -o -name worker-runtime-rest-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
'';
@ -123,7 +126,9 @@ 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,7 +125,6 @@ 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;