feat: add browser execution workspaces

This commit is contained in:
2026-07-07 23:21:20 +09:00
parent ecf10c72ab
commit 684b19e87c
12 changed files with 993 additions and 111 deletions
@@ -4,6 +4,7 @@
import { buildBrowserCreateWorkerRequest, defaultWorkerLaunchForm } from './worker-launch';
import type {
BrowserCreateWorkerResponse,
BrowserExecutionWorkspaceCreateResponse,
ListResponse,
Worker,
WorkerLaunchOptionsResponse,
@@ -35,6 +36,11 @@
let runtimeId = $state('');
let profile = $state('builtin:coder');
let initialText = $state('');
let executionWorkspaceAllocationId = $state('');
let executionWorkspaceRepositoryId = $state('');
let executionWorkspaceSelector = $state('HEAD');
let relativeCwd = $state('');
let creatingWorkspace = $state(false);
$effect(() => {
if (!workspaceId) {
@@ -96,10 +102,18 @@
display_name: displayName,
profile,
initial_text: initialText,
execution_workspace_allocation_id: executionWorkspaceAllocationId,
execution_workspace_repository_id: executionWorkspaceRepositoryId,
execution_workspace_selector: executionWorkspaceSelector,
relative_cwd: relativeCwd,
});
runtimeId = form.runtime_id;
displayName = form.display_name;
profile = form.profile;
executionWorkspaceAllocationId = form.execution_workspace_allocation_id;
executionWorkspaceRepositoryId = form.execution_workspace_repository_id;
executionWorkspaceSelector = form.execution_workspace_selector;
relativeCwd = form.relative_cwd;
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return;
@@ -108,6 +122,39 @@
}
}
async function createExecutionWorkspace() {
if (!executionWorkspaceRepositoryId) {
submitError = 'select a repository before creating an execution workspace';
return;
}
creatingWorkspace = true;
submitError = null;
try {
const response = await fetch(workerApiPath('/execution-workspaces'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
repository_id: executionWorkspaceRepositoryId,
selector: executionWorkspaceSelector || null,
policy: { dirty_state: 'clean_point_only', cleanup: 'manual_or_worker_stop' },
}),
});
if (!response.ok) {
throw new Error(await responseErrorMessage(response, 'execution workspace create failed'));
}
const payload = (await response.json()) as BrowserExecutionWorkspaceCreateResponse;
const items = options?.execution_workspaces ?? [];
options = options
? { ...options, execution_workspaces: [...items.filter((item) => item.allocation_id !== payload.item.allocation_id), payload.item] }
: options;
executionWorkspaceAllocationId = payload.item.allocation_id;
} catch (err) {
submitError = err instanceof Error ? err.message : 'execution workspace create failed';
} finally {
creatingWorkspace = false;
}
}
async function createWorker() {
if (!workspaceId) {
submitError = 'workspace id is unavailable';
@@ -125,6 +172,10 @@
display_name: displayName,
profile,
initial_text: initialText,
execution_workspace_allocation_id: executionWorkspaceAllocationId,
execution_workspace_repository_id: executionWorkspaceRepositoryId,
execution_workspace_selector: executionWorkspaceSelector,
relative_cwd: relativeCwd,
})),
});
if (!response.ok) {
@@ -200,6 +251,43 @@
{/if}
</select>
</label>
<fieldset class="worker-execution-workspace">
<legend>Execution workspace</legend>
<label>
<span>Allocation</span>
<select bind:value={executionWorkspaceAllocationId}>
<option value="">No allocation selected</option>
{#each options?.execution_workspaces ?? [] as workspace}
<option value={workspace.allocation_id} disabled={workspace.status !== 'active'}>
{workspace.repository_id} · {workspace.requested_selector ?? 'HEAD'} · {workspace.resolved_commit.slice(0, 12)} · {workspace.status}
</option>
{/each}
</select>
</label>
<label>
<span>Repository for new allocation</span>
<select bind:value={executionWorkspaceRepositoryId}>
{#if options?.repositories.length}
{#each options.repositories as repository}
<option value={repository.id}>{repository.display_name}</option>
{/each}
{:else}
<option value="" disabled>No configured repositories</option>
{/if}
</select>
</label>
<label>
<span>Selector</span>
<input bind:value={executionWorkspaceSelector} autocomplete="off" placeholder="HEAD" />
</label>
<button type="button" disabled={creatingWorkspace || !executionWorkspaceRepositoryId} onclick={() => void createExecutionWorkspace()}>
{creatingWorkspace ? 'Allocating…' : 'Create execution workspace'}
</button>
<label>
<span>Relative cwd</span>
<input bind:value={relativeCwd} autocomplete="off" placeholder="Optional path inside allocation" />
</label>
</fieldset>
<label>
<span>Initial text</span>
<textarea bind:value={initialText} rows="3" placeholder="Optional first instruction"></textarea>
@@ -210,7 +298,7 @@
{#if submitError}
<p class="section-state error">{submitError}</p>
{/if}
<button type="submit" disabled={submitting || !runtimeId || !profile}>
<button type="submit" disabled={submitting || !runtimeId || !profile || !executionWorkspaceAllocationId}>
{submitting ? 'Starting…' : 'Start Coding Worker'}
</button>
</form>
@@ -234,6 +322,7 @@
</span>
<span class="item-meta">
{worker.role ? `${worker.role} · ` : ''}{worker.state} · {worker.status} · 🖥 {worker.host_id}
{worker.execution_workspace ? ` · ws:${worker.execution_workspace.repository_id}@${worker.execution_workspace.resolved_commit.slice(0, 8)}` : ''}
</span>
</a>
</li>
@@ -88,6 +88,7 @@ export type Worker = {
last_seen_at?: string | null;
implementation: { kind: string; display_hint: string };
capabilities: WorkerCapabilities;
execution_workspace?: ExecutionWorkspaceSummary | null;
diagnostics: Diagnostic[];
};
@@ -108,10 +109,61 @@ export type WorkerLaunchProfileCandidate = {
description: string;
};
export type ExecutionWorkspaceRepositoryOption = {
id: string;
display_name: string;
default_selector?: string | null;
};
export type ExecutionWorkspaceSummary = {
allocation_id: string;
repository_id: string;
requested_selector?: string | null;
materializer_kind: string;
dirty_state_policy: string;
resolved_commit: string;
resolved_tree?: string | null;
status: string;
cleanup_policy: string;
cleanup_target: {
kind: string;
allocation_id: string;
repository_id: string;
};
};
export type BrowserExecutionWorkspaceCreateResponse = {
workspace_id: string;
item: ExecutionWorkspaceSummary;
diagnostics: Diagnostic[];
};
export type BrowserExecutionWorkspaceListResponse = {
workspace_id: string;
items: ExecutionWorkspaceSummary[];
diagnostics: Diagnostic[];
};
export type BrowserWorkerExecutionWorkspaceSelection = {
allocation_id: string;
relative_cwd?: string | null;
};
export type BrowserExecutionWorkspaceCreateRequest = {
repository_id: string;
selector?: string | null;
policy?: {
dirty_state?: 'clean_point_only';
cleanup?: 'manual_or_worker_stop';
};
};
export type WorkerLaunchOptionsResponse = {
workspace_id: string;
runtimes: WorkerLaunchRuntimeOption[];
profiles: WorkerLaunchProfileCandidate[];
repositories: ExecutionWorkspaceRepositoryOption[];
execution_workspaces: ExecutionWorkspaceSummary[];
diagnostics: Diagnostic[];
};
@@ -1,18 +1,15 @@
import {
buildBrowserCreateWorkerRequest,
defaultWorkerLaunchForm,
type WorkerLaunchFormState,
} from './worker-launch.ts';
import { buildBrowserCreateWorkerRequest, defaultWorkerLaunchForm } from './worker-launch.ts';
import type { WorkerLaunchOptionsResponse } from './types.ts';
declare const Deno: {
test(name: string, fn: () => void): void;
test(name: string, fn: () => Promise<void> | void): void;
};
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
function assertEquals<T>(actual: T, expected: T): void {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
if (actualJson !== expectedJson) {
throw new Error(`Expected ${expectedJson}, got ${actualJson}`);
}
}
@@ -20,64 +17,86 @@ const options: WorkerLaunchOptionsResponse = {
workspace_id: 'workspace',
runtimes: [
{
runtime_id: 'remote-runtime',
display_name: 'Remote Runtime',
built_in: false,
can_spawn_worker: false,
runtime_id: 'remote',
display_name: 'Remote',
status: 'active',
can_spawn_worker: true,
built_in: false,
diagnostics: [],
},
{
runtime_id: 'embedded-worker-runtime',
display_name: 'Embedded Runtime',
built_in: true,
can_spawn_worker: true,
runtime_id: 'embedded',
display_name: 'Embedded',
status: 'active',
can_spawn_worker: true,
built_in: false,
diagnostics: [],
},
],
profiles: [
{ id: 'builtin:companion', label: 'Companion', description: 'chat' },
{ id: 'builtin:coder', label: 'Coder', description: 'code' },
],
repositories: [
{ id: 'repo', display_name: 'Repo', default_selector: 'HEAD' },
],
execution_workspaces: [
{
id: 'runtime_default',
label: 'Runtime default',
description: 'Runtime default profile.',
},
{
id: 'builtin:coder',
label: 'Coding Worker',
description: 'Coding role.',
allocation_id: 'alloc-1-repo',
repository_id: 'repo',
requested_selector: 'HEAD',
materializer_kind: 'local_git_worktree',
dirty_state_policy: 'clean_point_only',
resolved_commit: '0123456789abcdef',
status: 'active',
cleanup_policy: 'manual_or_worker_stop',
cleanup_target: { kind: 'git_worktree', allocation_id: 'alloc-1-repo', repository_id: 'repo' },
},
],
diagnostics: [],
};
Deno.test('new worker form defaults to backend-published runtime and profile candidates', () => {
const current: WorkerLaunchFormState = {
Deno.test('defaultWorkerLaunchForm chooses active runtime, coder profile, repository, and workspace', () => {
const form = defaultWorkerLaunchForm(options, {
runtime_id: '',
display_name: '',
profile: 'free-text-profile',
initial_text: 'start here',
};
const form = defaultWorkerLaunchForm(options, current);
assert(form.runtime_id === 'embedded-worker-runtime', 'should choose spawn-capable runtime');
assert(form.profile === 'builtin:coder', 'should choose backend-published coder profile');
assert(form.display_name === 'Coding Worker', 'should derive default display name');
assert(form.initial_text === 'start here', 'should preserve initial text');
});
Deno.test('new worker submit payload exposes only browser contract fields', () => {
const request = buildBrowserCreateWorkerRequest({
runtime_id: 'embedded-worker-runtime',
display_name: 'Coding Worker',
profile: 'builtin:coder',
initial_text: 'implement ticket',
profile: '',
initial_text: 'hello',
execution_workspace_allocation_id: '',
execution_workspace_repository_id: '',
execution_workspace_selector: '',
relative_cwd: '',
});
assert(
JSON.stringify(Object.keys(request).sort()) ===
JSON.stringify(['display_name', 'initial_text', 'profile', 'runtime_id'].sort()),
'submit payload should contain only Browser-facing worker create fields',
);
assert(!('kind' in request), 'kind must not be exposed as a Browser request field');
assertEquals(form.runtime_id, 'remote');
assertEquals(form.display_name, 'Coding Worker');
assertEquals(form.profile, 'builtin:coder');
assertEquals(form.initial_text, 'hello');
assertEquals(form.execution_workspace_allocation_id, 'alloc-1-repo');
assertEquals(form.execution_workspace_repository_id, 'repo');
assertEquals(form.execution_workspace_selector, 'HEAD');
});
Deno.test('buildBrowserCreateWorkerRequest sends allocation id and relative cwd only', () => {
const request = buildBrowserCreateWorkerRequest({
runtime_id: 'embedded',
display_name: 'Worker',
profile: 'builtin:coder',
initial_text: 'go',
execution_workspace_allocation_id: 'alloc-1-repo',
execution_workspace_repository_id: 'repo',
execution_workspace_selector: 'main',
relative_cwd: 'crates/yoi',
});
assertEquals(request, {
runtime_id: 'embedded',
display_name: 'Worker',
profile: 'builtin:coder',
initial_text: 'go',
execution_workspace: {
allocation_id: 'alloc-1-repo',
relative_cwd: 'crates/yoi',
},
});
});
@@ -1,13 +1,23 @@
import type { WorkerLaunchOptionsResponse } from './types';
import type { BrowserWorkerExecutionWorkspaceSelection, WorkerLaunchOptionsResponse } from './types';
export type WorkerLaunchFormState = {
runtime_id: string;
display_name: string;
profile: string;
initial_text: string;
execution_workspace_allocation_id: string;
execution_workspace_repository_id: string;
execution_workspace_selector: string;
relative_cwd: string;
};
export type BrowserCreateWorkerRequest = WorkerLaunchFormState;
export type BrowserCreateWorkerRequest = {
runtime_id: string;
display_name: string;
profile: string;
initial_text: string;
execution_workspace?: BrowserWorkerExecutionWorkspaceSelection;
};
export function defaultWorkerLaunchForm(
options: WorkerLaunchOptionsResponse | null,
@@ -18,6 +28,10 @@ export function defaultWorkerLaunchForm(
?? options?.runtimes[0];
const preferredProfile = options?.profiles.find((candidate) => candidate.id === 'builtin:coder')
?? options?.profiles[0];
const preferredExecutionWorkspace = options?.execution_workspaces.find((workspace) => workspace.status === 'active')
?? options?.execution_workspaces[0];
const preferredRepository = options?.repositories.find((repository) => repository.id === current.execution_workspace_repository_id)
?? options?.repositories[0];
return {
runtime_id: current.runtime_id || preferredRuntime?.runtime_id || '',
@@ -26,16 +40,34 @@ export function defaultWorkerLaunchForm(
? current.profile
: preferredProfile?.id || '',
initial_text: current.initial_text,
execution_workspace_allocation_id: options?.execution_workspaces.some(
(workspace) => workspace.allocation_id === current.execution_workspace_allocation_id,
)
? current.execution_workspace_allocation_id
: preferredExecutionWorkspace?.allocation_id || '',
execution_workspace_repository_id: current.execution_workspace_repository_id || preferredRepository?.id || '',
execution_workspace_selector: current.execution_workspace_selector || preferredRepository?.default_selector || 'HEAD',
relative_cwd: current.relative_cwd,
};
}
export function buildBrowserCreateWorkerRequest(
form: WorkerLaunchFormState,
): BrowserCreateWorkerRequest {
return {
const request: BrowserCreateWorkerRequest = {
runtime_id: form.runtime_id,
display_name: form.display_name,
profile: form.profile,
initial_text: form.initial_text,
};
if (form.execution_workspace_allocation_id) {
request.execution_workspace = {
allocation_id: form.execution_workspace_allocation_id,
};
const relativeCwd = form.relative_cwd.trim();
if (relativeCwd) {
request.execution_workspace.relative_cwd = relativeCwd;
}
}
return request;
}