diff --git a/.yoi/objectives/00001KWW44EXK/item.md b/.yoi/objectives/00001KWW44EXK/item.md index b46d151c..efe96f46 100644 --- a/.yoi/objectives/00001KWW44EXK/item.md +++ b/.yoi/objectives/00001KWW44EXK/item.md @@ -2,7 +2,7 @@ title: "Runtime working directory materialization and sandboxed agent environments" state: "active" created_at: "2026-07-06T16:28:12Z" -updated_at: "2026-07-07T12:40:00Z" +updated_at: "2026-07-10T00:00:00Z" linked_tickets: ["00001KWPC13WQ", "00001KWMBAA6V"] --- @@ -34,7 +34,8 @@ Yoi は Workspace / Repository / Runtime を分ける方針になっている。 - Runtime root: Runtime が自身の store、cache、Worker metadata、working directory allocation を管理する root。長期的には `~/.yoi/runtimes//` 配下など、Workspace backend store とは別に置く。 - Repository cache: Runtime-local の共有 source/cache。Git なら bare mirror / object cache / packfile cache など。重く、長寿命で、複数 Worker allocation から共有される。 -- working directory: Worker ごとの作業環境。短寿命で、Worker が読み書きする root / mounts / scratch / overlay を含む。Browser-facing UI/API では `Workspace` と混同しないよう、この呼称に寄せる。`Volume` は storage backing の候補名であり、この作業領域そのものの呼称にはしない。 +- working directory: Worker ごとの作業環境。短寿命で、Worker が読み書きする root / mounts / scratch / overlay を含む。Browser-facing UI では `workspace` と混同しないよう `workdir` と表示してよい。`Volume` は storage backing の候補名であり、この作業領域そのものの呼称にはしない。 +- Worker archive: 作業単位として保存する Worker record。profile、Runtime、RepositoryPoint / workdir evidence、session/transcript refs、status、diagnostics、summary、retention policy を束ねる。Session 単体ではなく Worker archive を保存単位にする。 - Materialization strategy: RepositoryPoint から working directory を作る実装戦略。Git detached worktree、sparse checkout、CoW snapshot、reflink copy、overlay、container filesystem など。 - WorkingDirectoryAllocation: Runtime が払い出した作業環境の record。allocation id、worker id、workspace id、repository point、materializer kind、root、mounts、cleanup policy、status を持つ。 - Sandbox policy: Worker が見られる filesystem、network、process、secret、tool authority の境界を表す policy。 @@ -286,6 +287,17 @@ Rift の filtered CoW creation のように、重い artifacts を除外しな - Runtime が複数 Workspace / Repository の Worker を同時に抱える場合の namespace、quota、cleanup、audit boundary を固める。 - remote/self-hosted/hosted Runtime fleet で repository cache と working directory storage をどう扱うかを設計する。 +### 7. Worker / Session / workdir retention policy + +- Worker を保存単位にする。Session / transcript は Worker archive に内包または参照される履歴として扱い、Session 単体を長期保存 authority にしない。 +- Worker archive には retention policy を持たせる。少なくとも `active` / `archived` / `pinned` / `prunable` 相当の状態を表現できること。`pinned` は必要であり、明示的に残す作業記録を cleanup / prune から守る。 +- Session / transcript は削除可能にする。Worker が retained/pinned なら full history を残せるが、Worker prune 時には session files を削除、または summary だけ残して full transcript を捨てられるようにする。 +- Workdir 実ファイルは durable history ではなく再現可能 cache として扱う。RepositoryPoint / resolved commit から再現でき、dirty/uncommitted state が無いなら、停止済み Worker に紐づく workdir files は cleanup eligible とする。 +- Workdir record は Runtime-owned materialization evidence として扱う。実ファイルが削除済みなら `removed`、実ファイルが外部要因で欠落しているなら stale/missing materialization として診断可能にする。 +- Workdir と Worker の関係は、Worker archive 側に workdir binding summary / RepositoryPoint / resolved commit を保存する。Workdir list で linked Workers を見せる場合は、まず Worker records から derived view を作る。 +- Prune は plan-first にする。削除前に対象、理由、削除される bytes、残る archive/summary、blocking conditions(running Worker、pinned Worker、dirty state、unreachable commit など)を提示する。 +- UI 表示は `workdir` に寄せる。内部型/API の互換名 `working_directory` は移行中に残ってよいが、Browser-facing navigation では Runtime 管理配下の `Workdirs` として扱う。 + ## Non-goals - v0 で完全な container sandbox を実装すること。 @@ -307,6 +319,8 @@ Rift の filtered CoW creation のように、重い artifacts を除外しな - Runtime process 起動時の `--workspace` は legacy bootstrap input として隔離され、Runtime identity や single workspace binding とみなされない。 - Worker ごとの scope allocation conflict が、同一 source root を直接渡す設計ではなく materialized workspace allocation によって解消される。 - Sandbox / mount / cache / secret boundary を後続実装で強化できる model になっている。 +- Worker archive が Session / transcript refs と retention policy を束ね、`pinned` Worker を prune から守れる。 +- Workdir 実ファイルは再現可能 cache として cleanup/prune でき、削除前に plan-first で linked Worker、session retention、commit reachability、dirty/missing 状態を確認できる。 ## References diff --git a/web/workspace/src/app.css b/web/workspace/src/app.css index de0d7490..94d5ca9a 100644 --- a/web/workspace/src/app.css +++ b/web/workspace/src/app.css @@ -463,6 +463,47 @@ padding: 0; } + .workspace-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); + } + + .workspace-action-card { + display: grid; + gap: var(--space-1); + padding: var(--space-4); + border: 1px solid var(--line); + border-radius: var(--radius-card); + background: var(--bg-raised); + color: inherit; + text-decoration: none; + } + + .workspace-action-card:hover, + .workspace-action-card:focus-visible { + border-color: var(--accent); + } + + .workspace-action-card span, + .workspace-action-card small { + color: var(--text-muted); + } + + .workspace-action-card span { + font-size: 0.78rem; + } + + .workspace-action-card strong { + color: var(--text-strong); + } + + @media (max-width: 720px) { + .workspace-actions { + grid-template-columns: 1fr; + } + } + .runtime-card { padding: 0; } @@ -646,11 +687,7 @@ text-transform: uppercase; } - .workers-page { - display: grid; - gap: var(--space-4); - } - + .page-header-row, .workers-page-header { display: flex; justify-content: space-between; @@ -658,22 +695,43 @@ gap: var(--space-4); } + .page-header-row h1, + .page-header-row p, .workers-page-header h1, .workers-page-header p { margin: 0; } + .page-header-row h1, .workers-page-header h1 { color: var(--text-strong); font-size: clamp(1.6rem, 4vw, 2.4rem); line-height: 1.05; } + .page-header-row p:not(.breadcrumb), .workers-page-header p { margin-top: var(--space-2); color: var(--text-muted); } + .breadcrumb { + margin-bottom: var(--space-2); + color: var(--text-muted); + font-size: 0.85rem; + } + + .breadcrumb a { + color: inherit; + } + + .runtimes-page, + .workdirs-page, + .workers-page { + display: grid; + gap: var(--space-4); + } + .workers-table-wrap { border-top: 1px solid var(--line); } diff --git a/web/workspace/src/lib/workspace-console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace-console/worker-console.ui.test.ts index 23bbf053..63117750 100644 --- a/web/workspace/src/lib/workspace-console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace-console/worker-console.ui.test.ts @@ -23,6 +23,11 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async () new URL("../workspace-sidebar/WorkspaceSidebar.svelte", import.meta.url), ); + assert( + workspacePage.includes('href={`/w/${workspaceId}/runtimes`}') && + workspacePage.includes('href={`/w/${workspaceId}/workers`}'), + "top workspace page should link to Runtimes and Workers pages", + ); assert( !workspacePage.includes("workerConsoleHref") && !workspacePage.includes("Open Console"), @@ -47,6 +52,43 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async () ); }); +Deno.test("workspace Runtime management pages expose Runtimes and Runtime-owned workdirs", async () => { + const sidebar = await Deno.readTextFile( + new URL("../workspace-sidebar/WorkspaceSidebar.svelte", import.meta.url), + ); + const runtimesNav = await Deno.readTextFile( + new URL("../workspace-sidebar/RuntimesNavSection.svelte", import.meta.url), + ); + const runtimesPage = await Deno.readTextFile( + new URL("./../../routes/w/[workspaceId]/runtimes/+page.svelte", import.meta.url), + ); + const workdirsPage = await Deno.readTextFile( + new URL("./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.svelte", import.meta.url), + ); + const workdirsLoad = await Deno.readTextFile( + new URL("./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.ts", import.meta.url), + ); + + assert( + sidebar.includes("RuntimesNavSection") && + runtimesNav.includes('href={runtimesHref}') && + runtimesNav.includes("/runtimes"), + "sidebar should expose Runtime management navigation", + ); + assert( + runtimesPage.includes("Open workdirs") && + runtimesPage.includes("runtimes-table") && + runtimesPage.includes("/workdirs"), + "Runtimes page should table Runtimes and link to each Runtime's workdirs", + ); + assert( + workdirsPage.includes("Workdirs") && + workdirsPage.includes("workdirs-table") && + workdirsLoad.includes("/working-directories"), + "Workdirs page should read Runtime-owned working-directory API while using workdir UI language", + ); +}); + Deno.test("workspace Worker sidebar links New to the dedicated create page", async () => { const workersNav = await Deno.readTextFile( new URL("../workspace-sidebar/WorkersNavSection.svelte", import.meta.url), diff --git a/web/workspace/src/lib/workspace-sidebar/RuntimesNavSection.svelte b/web/workspace/src/lib/workspace-sidebar/RuntimesNavSection.svelte new file mode 100644 index 00000000..4cace34f --- /dev/null +++ b/web/workspace/src/lib/workspace-sidebar/RuntimesNavSection.svelte @@ -0,0 +1,23 @@ + + + diff --git a/web/workspace/src/lib/workspace-sidebar/WorkspaceSidebar.svelte b/web/workspace/src/lib/workspace-sidebar/WorkspaceSidebar.svelte index 63a1ec03..36100250 100644 --- a/web/workspace/src/lib/workspace-sidebar/WorkspaceSidebar.svelte +++ b/web/workspace/src/lib/workspace-sidebar/WorkspaceSidebar.svelte @@ -2,6 +2,7 @@ import { workspaceRoute } from '$lib/workspace-api/http'; import ObjectivesNavSection from './ObjectivesNavSection.svelte'; import RepositoriesNavSection from './RepositoriesNavSection.svelte'; + import RuntimesNavSection from './RuntimesNavSection.svelte'; import WorkersNavSection from './WorkersNavSection.svelte'; import type { RepositoryListResponse, WorkspaceResponse } from './types'; @@ -106,6 +107,7 @@ {/if} diff --git a/web/workspace/src/routes/w/[workspaceId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/+page.svelte index 09192810..016f9a4d 100644 --- a/web/workspace/src/routes/w/[workspaceId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/+page.svelte @@ -2,6 +2,7 @@ import type { PageProps } from './$types'; let { data }: PageProps = $props(); + let workspaceId = $derived(data.workspace?.workspace_id ?? data.workspaceId); @@ -37,6 +38,19 @@ {/if} +
+ + Runtimes + Manage runtimes and workdirs + {data.hosts?.items.length ?? 0} host{(data.hosts?.items.length ?? 0) === 1 ? '' : 's'} visible + + + Workers + Open worker list + Inspect status and attach to consoles + +
+

Hosts

{#if data.hosts} diff --git a/web/workspace/src/routes/w/[workspaceId]/runtimes/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/runtimes/+page.svelte new file mode 100644 index 00000000..f4bd641f --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/runtimes/+page.svelte @@ -0,0 +1,72 @@ + + + + Runtimes · Yoi Workspace + + + +
+
+
+

Runtimes

+

Execution backends available to this workspace.

+
+
+ + {#if data.runtimesError} +

{data.runtimesError}

+ {:else if !data.runtimes} +

Loading Runtimes…

+ {:else if data.runtimes.items.length === 0} +

No Runtimes are visible.

+ {:else} +
+ + + + + + + + + + + + + + {#each data.runtimes.items as runtime} + + + + + + + + + + {/each} + +
RuntimeKindStatusScopePlatformCapacityWorkdirs
+ {runtime.label} + {runtime.runtime_id} + {runtime.kind}{runtime.status}{runtimeScope(runtime)}{runtimePlatform(runtime)}{runtime.capabilities.max_workers} workers + + Open workdirs + +
+
+ {/if} +
diff --git a/web/workspace/src/routes/w/[workspaceId]/runtimes/+page.ts b/web/workspace/src/routes/w/[workspaceId]/runtimes/+page.ts new file mode 100644 index 00000000..dd217b97 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/runtimes/+page.ts @@ -0,0 +1,16 @@ +import { loadJson, workspaceApiPath } from "$lib/workspace-api/http"; +import type { ListResponse, Runtime } from "$lib/workspace-sidebar/types"; +import type { PageLoad } from "./$types"; + +export const load: PageLoad = async ({ fetch, params }) => { + const runtimes = await loadJson>( + fetch, + workspaceApiPath(params.workspaceId, "/runtimes"), + ); + + return { + workspaceId: params.workspaceId, + runtimes: runtimes.data, + runtimesError: runtimes.error, + }; +}; diff --git a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.svelte new file mode 100644 index 00000000..a7692aab --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.svelte @@ -0,0 +1,70 @@ + + + + Workdirs · {runtimeLabel} · Yoi Workspace + + + +
+
+
+ +

Workdirs

+

Workdirs owned by {data.runtimeId}.

+
+
+ + {#if data.workdirsError} +

{data.workdirsError}

+ {:else if !data.workdirs} +

Loading workdirs…

+ {:else if data.workdirs.items.length === 0} +

No workdirs are visible for this Runtime.

+ {:else} +
+ + + + + + + + + + + + + {#each data.workdirs.items as workdir} + + + + + + + + + {/each} + +
WorkdirRepositorySelectorCommitStatusPolicy
{workdir.working_directory_id}{workdir.repository_id}{selectorLabel(workdir)}{commitLabel(workdir)}{workdir.status} + {workdir.dirty_state_policy} + {workdir.cleanup_policy} +
+
+ {/if} +
diff --git a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.ts b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.ts new file mode 100644 index 00000000..ab9122d0 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.ts @@ -0,0 +1,30 @@ +import { loadJson, workspaceApiPath } from "$lib/workspace-api/http"; +import type { + BrowserWorkingDirectoryListResponse, + ListResponse, + Runtime, +} from "$lib/workspace-sidebar/types"; +import type { PageLoad } from "./$types"; + +export const load: PageLoad = async ({ fetch, params }) => { + const runtimeId = params.runtimeId; + const [runtimes, workdirs] = await Promise.all([ + loadJson>(fetch, workspaceApiPath(params.workspaceId, "/runtimes")), + loadJson( + fetch, + workspaceApiPath( + params.workspaceId, + `/runtimes/${encodeURIComponent(runtimeId)}/working-directories`, + ), + ), + ]); + + return { + workspaceId: params.workspaceId, + runtimeId, + runtimes: runtimes.data, + runtimesError: runtimes.error, + workdirs: workdirs.data, + workdirsError: workdirs.error, + }; +}; diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/+page.svelte index 30435d19..377cbae2 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/+page.svelte @@ -51,7 +51,7 @@ Runtime Profile Status - Working directory + Workdir Action diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte index f7a0b7cb..506a2a0d 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte @@ -99,11 +99,11 @@ async function createWorkingDirectory() { if (!runtimeId) { - submitError = { message: 'select a runtime before creating a working directory', diagnostics: [] }; + submitError = { message: 'select a runtime before creating a workdir', diagnostics: [] }; return; } if (!workingDirectoryRepositoryId) { - submitError = { message: 'select a repository before creating a working directory', diagnostics: [] }; + submitError = { message: 'select a repository before creating a workdir', diagnostics: [] }; return; } creatingWorkingDirectory = true; @@ -122,7 +122,7 @@ }, ); if (!response.ok) { - submitError = await responseDisplayError(response, 'working directory create failed'); + submitError = await responseDisplayError(response, 'workdir create failed'); return; } const payload = (await response.json()) as BrowserWorkingDirectoryCreateResponse; @@ -138,7 +138,7 @@ : options; workingDirectoryId = payload.item.working_directory_id; } catch (err) { - submitError = exceptionDisplayError(err, 'working directory create failed'); + submitError = exceptionDisplayError(err, 'workdir create failed'); } finally { creatingWorkingDirectory = false; } @@ -150,7 +150,7 @@ return; } if (isNewWorkingDirectorySelected || !workingDirectoryId) { - submitError = { message: 'select or create a working directory before starting a Worker', diagnostics: [] }; + submitError = { message: 'select or create a workdir before starting a Worker', diagnostics: [] }; return; } @@ -220,7 +220,7 @@

New Worker

-

Create a Worker on a selected Runtime and working directory.

+

Create a Worker on a selected Runtime and workdir.

Back to workspace
@@ -236,14 +236,14 @@
Run at - + {#each options?.working_directories ?? [] as directory} {/each} - + in + Relative cwd inside workdir +