feat: add browser execution workspaces
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user