feat: configure subject attachment on worker launch

This commit is contained in:
2026-10-04 08:40:30 +09:00
parent 1bbc48dad0
commit f7b722246d
25 changed files with 831 additions and 303 deletions
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
// Input contract: 0.1.0 (sha256:cba261b44424ef69fde6339246ed891300b456ac3f8cc3b99103cb90e5fa32d6)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
// Input contract: 0.1.0 (sha256:cba261b44424ef69fde6339246ed891300b456ac3f8cc3b99103cb90e5fa32d6)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
// Input contract: 0.1.0 (sha256:cba261b44424ef69fde6339246ed891300b456ac3f8cc3b99103cb90e5fa32d6)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
// Input contract: 0.1.0 (sha256:cba261b44424ef69fde6339246ed891300b456ac3f8cc3b99103cb90e5fa32d6)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
// Input contract: 0.1.0 (sha256:cba261b44424ef69fde6339246ed891300b456ac3f8cc3b99103cb90e5fa32d6)
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/workdir-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
// Input contract: 0.1.0 (sha256:cba261b44424ef69fde6339246ed891300b456ac3f8cc3b99103cb90e5fa32d6)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -11,7 +11,7 @@ export type BrowserWorkerWorkingDirectorySelection = { alias: string; relative_c
export type BrowserWorkspaceOrchestratorResponse = { diagnostics: Array<Diagnostic>; disposition: string; online: boolean; worker?: WorkerLaunchWorkerSummary | null; workspace_id: string; };
export type CreateWorkspaceWorkerRequest = { control_operation_id?: string | null; display_name: string; initial_submit?: Array<Segment>; profile?: string | null; runtime_id: string; singleton_key?: string | null; ticket_assignment?: CreateWorkspaceWorkerTicketAssignmentRequest | null; workdir_attachments?: Array<BrowserWorkerWorkingDirectorySelection>; };
export type CreateWorkspaceWorkerRequest = { control_operation_id?: string | null; display_name: string; feature_connections: WorkspaceWorkerFeatureConnectionsRequest; initial_submit?: Array<Segment>; profile?: string | null; runtime_id: string; singleton_key?: string | null; ticket_assignment?: CreateWorkspaceWorkerTicketAssignmentRequest | null; workdir_attachments?: Array<BrowserWorkerWorkingDirectorySelection>; };
export type CreateWorkspaceWorkerTicketAssignmentRequest = { operation_id: string; ticket_id: string; };
@@ -37,6 +37,8 @@ export type RuntimeWorkingDirectorySummary = { cleanliness?: string | null; clea
export type Segment = { content: string; kind: "text"; } | { chars: number; content: string; id: number; kind: "paste"; lines: number; } | { artifact: PasteArtifactRef; kind: "paste_artifact"; } | { file: UploadedFileRef; kind: "uploaded_file"; } | { kind: "file_ref"; path: string; } | { kind: "flow"; selector: string; } | { kind: "unknown"; };
export type SubjektivWorkerConnectionRequest = { subject_id: string; };
export type SubscriptionWorkerAvailability = "observed" | "unavailable";
export type UploadedFileAvailability = "available" | "unavailable" | "integrity_failed";
@@ -47,9 +49,11 @@ export type WorkerBusyState = { kind: "run"; state: WorkerRunState; } | { kind:
export type WorkerImplementationSummary = { display_hint: string; kind: string; };
export type WorkerLaunchFeatureConnectionOptions = { subjektiv: boolean; };
export type WorkerLaunchOptionsResponse = { default_profile?: string | null; diagnostics: Array<Diagnostic>; profiles: Array<WorkerLaunchProfileCandidate>; repositories: Array<WorkingDirectoryRepositoryOption>; runtimes: Array<WorkerLaunchRuntimeOption>; working_directories: Array<WorkingDirectorySummary>; workspace_id: string; };
export type WorkerLaunchProfileCandidate = { description: string; id: string; label: string; };
export type WorkerLaunchProfileCandidate = { description: string; feature_connections: WorkerLaunchFeatureConnectionOptions; id: string; label: string; };
export type WorkerLaunchRuntimeOption = { built_in: boolean; diagnostics: Array<Diagnostic>; display_name: string; runtime_id: string; status: string; worker_creation_available: boolean; working_directory_required: boolean; };
@@ -84,3 +88,5 @@ export type WorkingDirectorySource = { kind: "repository"; repository_key: strin
export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown";
export type WorkingDirectorySummary = { cleanliness?: string | null; cleanup_target?: WorkingDirectoryCleanupTarget | null; creation_ref?: string | null; creation_selector?: string | null; creation_tree?: string | null; current_ref?: string | null; current_selector?: string | null; current_tree?: string | null; display_name?: string | null; materializer_kind: WorkingDirectoryMaterializerKind; observed_at_epoch_seconds?: number | null; occupied_by?: WorkingDirectoryOccupancy | null; source: WorkingDirectorySource; status: WorkingDirectoryStatusKind; working_directory_id: string; };
export type WorkspaceWorkerFeatureConnectionsRequest = { subjektiv?: SubjektivWorkerConnectionRequest | null; };
@@ -166,7 +166,12 @@ Deno.test("Worker launch options parser accepts the generated wire shape", () =>
diagnostics: [],
}],
default_profile: null,
profiles: [{ id: "builtin:coder", label: "Coder", description: "Code" }],
profiles: [{
id: "builtin:coder",
label: "Coder",
description: "Code",
feature_connections: { subjektiv: true },
}],
repositories: [{ repository_key: "main" }],
working_directories: [],
diagnostics: [],
@@ -235,10 +240,27 @@ Deno.test("Worker create request parser requires the complete shared request", (
working_directory_id: "workdir-a",
relative_cwd: null,
}],
feature_connections: {},
control_operation_id: null,
};
assertEquals(parseCreateWorkspaceWorkerRequest(request), request);
assertEquals(
parseCreateWorkspaceWorkerRequest({
...request,
feature_connections: { subjektiv: { subject_id: "subject-42" } },
}).feature_connections,
{ subjektiv: { subject_id: "subject-42" } },
);
assertThrows(
() =>
parseCreateWorkspaceWorkerRequest({
...request,
resolved_subjektiv_attached: true,
}),
Error,
"unknown field resolved_subjektiv_attached",
);
assertThrows(
() =>
parseCreateWorkspaceWorkerRequest({
+31 -1
View File
@@ -21,6 +21,7 @@ import type {
WorkerWorkdirAttachmentSummary,
WorkerWorkspaceSummary,
WorkingDirectoryRepositoryOption,
WorkspaceWorkerFeatureConnectionsRequest,
} from "#lib/generated/worker-launch-api.ts";
import type { Segment } from "#lib/generated/protocol.ts";
import {
@@ -148,16 +149,26 @@ function runtimeOption(
};
}
function featureConnectionOptions(value: unknown, label: string) {
const item = record(value, label);
exact(item, ["subjektiv"], label);
return { subjektiv: boolean(item.subjektiv, `${label}.subjektiv`) };
}
function profileCandidate(
value: unknown,
label: string,
): WorkerLaunchProfileCandidate {
const item = record(value, label);
exact(item, ["id", "label", "description"], label);
exact(item, ["id", "label", "description", "feature_connections"], label);
return {
id: string(item.id, `${label}.id`),
label: string(item.label, `${label}.label`),
description: string(item.description, `${label}.description`),
feature_connections: featureConnectionOptions(
item.feature_connections,
`${label}.feature_connections`,
),
};
}
@@ -700,6 +711,20 @@ function ticketAssignment(
};
}
function featureConnectionsRequest(
value: unknown,
label: string,
): WorkspaceWorkerFeatureConnectionsRequest {
const item = record(value, label);
exact(item, ["subjektiv"], label);
const subjektiv = optional(item.subjektiv, `${label}.subjektiv`, (value, itemLabel) => {
const connection = record(value, itemLabel);
exact(connection, ["subject_id"], itemLabel);
return { subject_id: string(connection.subject_id, `${itemLabel}.subject_id`) };
});
return subjektiv === undefined ? {} : { subjektiv };
}
function unsignedInteger(value: unknown, label: string): number {
const parsed = number(value, label);
if (!Number.isSafeInteger(parsed) || parsed < 0) {
@@ -853,6 +878,7 @@ export function parseCreateWorkspaceWorkerRequest(
"ticket_assignment",
"initial_submit",
"workdir_attachments",
"feature_connections",
"control_operation_id",
],
"Worker create request",
@@ -870,6 +896,10 @@ export function parseCreateWorkspaceWorkerRequest(
"workdir_attachments",
workingDirectorySelection,
),
feature_connections: featureConnectionsRequest(
item.feature_connections,
"feature_connections",
),
control_operation_id: nullableString(
item.control_operation_id,
"control_operation_id",
@@ -64,7 +64,12 @@ Deno.test("defaultWorkerLaunchForm preserves an available Ticket role profile",
...options,
profiles: [
...options.profiles,
{ id: "builtin:reviewer", label: "Reviewer", description: "review" },
{
id: "builtin:reviewer",
label: "Reviewer",
description: "review",
feature_connections: { subjektiv: true },
},
],
};
const form = defaultWorkerLaunchForm(
@@ -195,10 +200,27 @@ Deno.test("buildCreateWorkspaceWorkerRequest emits multiple aliased attachments"
relative_cwd: null,
},
],
feature_connections: {},
control_operation_id: null,
});
});
Deno.test("buildCreateWorkspaceWorkerRequest keeps subject attachment explicit and separate from Profile", () => {
const unattached = buildCreateWorkspaceWorkerRequest(emptyForm({
profile: "builtin:companion",
}));
assertEquals(unattached.feature_connections, {});
const attached = buildCreateWorkspaceWorkerRequest(emptyForm({
profile: "builtin:companion",
subjektiv_subject_id: " subject-42 ",
}));
assertEquals(attached.feature_connections, {
subjektiv: { subject_id: "subject-42" },
});
assertEquals(attached.profile, "builtin:companion");
});
Deno.test("buildCreateWorkspaceWorkerRequest validates attachment aliases and selections", () => {
assertEquals(
workerLaunchAttachmentError([attachment("invalid/alias", "wd-1-repo")]),
@@ -268,6 +290,7 @@ Deno.test("buildCreateWorkspaceWorkerRequest emits an empty attachment list for
ticket_assignment: null,
initial_submit: [{ kind: "text", content: "chat" }],
workdir_attachments: [],
feature_connections: {},
control_operation_id: null,
});
});
@@ -280,6 +303,7 @@ function emptyForm(
runtime_id: "",
display_name: "",
profile: "",
subjektiv_subject_id: "",
initial_text: "",
workdir_attachments: [],
working_directory_repository_key: "",
@@ -340,8 +364,18 @@ const options: WorkerLaunchOptionsResponse = {
],
default_profile: "builtin:coder",
profiles: [
{ id: "builtin:companion", label: "Companion", description: "chat" },
{ id: "builtin:coder", label: "Coder", description: "code" },
{
id: "builtin:companion",
label: "Companion",
description: "chat",
feature_connections: { subjektiv: true },
},
{
id: "builtin:coder",
label: "Coder",
description: "code",
feature_connections: { subjektiv: true },
},
],
repositories: [
{ repository_key: "repo", default_selector: "HEAD" },
@@ -16,6 +16,7 @@ export type WorkerLaunchFormState = {
runtime_id: string;
display_name: string;
profile: string;
subjektiv_subject_id: string;
initial_text: string;
workdir_attachments: WorkerLaunchAttachmentFormState[];
working_directory_repository_key: string;
@@ -103,6 +104,7 @@ export function defaultWorkerLaunchForm(
options?.profiles.some((candidate) => candidate.id === current.profile)
? current.profile
: preferredProfile?.id || "",
subjektiv_subject_id: current.subjektiv_subject_id,
initial_text: current.initial_text,
workdir_attachments: workdirAttachments,
working_directory_repository_key:
@@ -179,6 +181,9 @@ export function buildCreateWorkspaceWorkerRequest(
? [{ kind: "text", content: form.initial_text }]
: [],
workdir_attachments: validatedAttachments(form.workdir_attachments),
feature_connections: form.subjektiv_subject_id.trim()
? { subjektiv: { subject_id: form.subjektiv_subject_id.trim() } }
: {},
control_operation_id: null,
});
}
@@ -184,6 +184,68 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.worker-feature-connection {
display: grid;
gap: var(--space-3);
min-width: 0;
border-left: 2px solid var(--accent);
padding-left: var(--space-3);
}
.worker-feature-connection-heading {
display: flex;
align-items: start;
justify-content: space-between;
gap: var(--space-3);
}
.worker-feature-connection-heading > div {
display: grid;
gap: var(--space-1);
}
.worker-feature-connection-heading h3,
.worker-feature-connection-heading p,
.worker-feature-connection-selection,
.worker-feature-connection-state,
.worker-feature-connection-note {
margin: 0;
}
.worker-feature-connection-heading h3 {
color: var(--text-strong);
font-size: var(--font-size-body);
}
.worker-feature-connection-heading p,
.worker-feature-connection-note,
.worker-feature-connection-state {
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.worker-feature-connection-heading > span {
flex: 0 0 auto;
color: var(--accent);
font-size: var(--font-size-compact);
font-weight: 700;
}
.worker-feature-connection-selection {
display: grid;
gap: var(--space-1);
min-width: 0;
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.worker-feature-connection-selection code {
overflow-wrap: anywhere;
}
.worker-feature-connection-state {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.worker-feature-connection-state.is-error {
color: var(--danger);
}
.worker-subject-load-more {
justify-self: start;
}
.worker-new-actions {
display: flex;
align-items: center;
@@ -213,6 +275,14 @@
.worker-attachment-row {
grid-template-columns: 1fr;
}
.worker-feature-connection-heading {
align-items: stretch;
flex-direction: column;
gap: var(--space-2);
}
.worker-feature-connection-heading > span {
align-self: start;
}
.worker-attachment-remove {
justify-self: start;
margin-bottom: 0;
@@ -11,6 +11,7 @@
parseWorkerApiError,
parseWorkerLaunchOptionsResponse,
} from '#lib/workspace/api/workers.ts';
import { parseSubjektivSubjectListResponse } from '#lib/workspace/memory/api.ts';
import { formatCurrentWorkdirRevision } from '#lib/workspace/settings/workdir-revision.ts';
import { formatExternalGrantPermissionLevel } from '#lib/workspace/settings/workdir-permissions.ts';
import {
@@ -24,6 +25,7 @@
WorkerLaunchOptionsResponse,
WorkingDirectorySummary,
} from '#lib/workspace/sidebar/types.ts';
import type { SubjektivSubjectResponse } from '#lib/generated/memory-api.ts';
import type { PageProps } from './$types';
type DisplayError = {
@@ -69,6 +71,14 @@
: 'builtin:coder'
: '',
);
let subjektivSubjectId = $state('');
let subjects = $state<SubjektivSubjectResponse[]>([]);
let subjectsLoading = $state(false);
let subjectsLoaded = $state(false);
let subjectsError = $state<string | null>(null);
let subjectsNextCursor = $state<string | null>(null);
let subjectsHaveMore = $state(false);
let previousProfile = $state('');
let initialText = $state(ticketContext?.initialInput ?? '');
let workdirAttachments = $state<WorkerLaunchAttachmentFormState[]>([]);
let workingDirectoryDisplayName = $state('');
@@ -80,6 +90,10 @@
);
let isNewWorkingDirectorySelected = $derived(newWorkingDirectoryAttachmentIndex >= 0);
let selectedRuntime = $derived(options?.runtimes.find((runtime) => runtime.runtime_id === runtimeId));
let selectedProfile = $derived(options?.profiles.find((candidate) => candidate.id === profile));
let subjektivConnectionAvailable = $derived(selectedProfile?.feature_connections.subjektiv === true);
let activeSubjects = $derived(subjects.filter((subject) => subject.state === 'active'));
let selectedSubject = $derived(activeSubjects.find((subject) => subject.id === subjektivSubjectId));
let selectedRuntimeAllowsNoWorkdir = $derived(selectedRuntime?.working_directory_required === false);
let availableWorkingDirectories = $derived(
selectedRuntimeAllowsNoWorkdir
@@ -130,6 +144,16 @@
}
});
$effect(() => {
if (profile !== previousProfile) {
subjektivSubjectId = '';
previousProfile = profile;
}
if (subjektivConnectionAvailable && !subjectsLoaded && !subjectsLoading) {
void loadSubjects(false);
}
});
function nextAttachmentAlias(): string {
const aliases = new Set(workdirAttachments.map((attachment) => attachment.alias.trim()));
if (!aliases.has('workdir')) return 'workdir';
@@ -178,6 +202,7 @@
runtime_id: runtimeId,
display_name: displayName,
profile,
subjektiv_subject_id: subjektivSubjectId,
initial_text: initialText,
workdir_attachments: workdirAttachments,
working_directory_repository_key: workingDirectoryRepositoryKey,
@@ -186,6 +211,7 @@
runtimeId = form.runtime_id;
displayName = form.display_name;
profile = form.profile;
subjektivSubjectId = form.subjektiv_subject_id;
const runtimeRequiresWorkdir = payload.runtimes.find((runtime) =>
runtime.runtime_id === form.runtime_id
)?.working_directory_required !== false;
@@ -215,6 +241,45 @@
}
}
async function loadSubjects(append: boolean) {
if (!workspaceId || subjectsLoading) return;
const cursor = append ? subjectsNextCursor : null;
if (append && !cursor) return;
subjectsLoading = true;
subjectsLoaded = true;
subjectsError = null;
try {
const query = new URLSearchParams({ limit: '100' });
if (cursor) query.set('cursor', cursor);
const response = await fetch(workerApiPath(`/subjektiv/subjects?${query}`));
if (!response.ok) {
throw new Error(`subject list failed (${response.status})`);
}
const payload = parseSubjektivSubjectListResponse(
await readBoundedJson(response, 8 * 1024 * 1024),
);
if (append) {
const byId = new Map(subjects.map((subject) => [subject.id, subject]));
for (const subject of payload.items) byId.set(subject.id, subject);
subjects = [...byId.values()];
} else {
subjects = payload.items;
}
subjectsNextCursor = payload.next_cursor ?? null;
subjectsHaveMore = payload.has_more;
if (
subjektivSubjectId &&
!subjects.some((subject) => subject.id === subjektivSubjectId && subject.state === 'active')
) {
subjektivSubjectId = '';
}
} catch (err) {
subjectsError = err instanceof Error ? err.message : 'subject list failed';
} finally {
subjectsLoading = false;
}
}
async function createWorkingDirectory() {
const attachmentIndex = newWorkingDirectoryAttachmentIndex;
if (attachmentIndex < 0) {
@@ -304,6 +369,7 @@
runtime_id: runtimeId,
display_name: displayName,
profile,
subjektiv_subject_id: subjektivSubjectId,
initial_text: initialText,
workdir_attachments: workdirAttachments,
working_directory_repository_key: workingDirectoryRepositoryKey,
@@ -511,6 +577,55 @@
</select>
</label>
</div>
{#if subjektivConnectionAvailable}
<div class="worker-feature-connection" data-worker-feature-connection="subjektiv">
<div class="worker-feature-connection-heading">
<div>
<h3>Subject Memory</h3>
<p>Optionally connect this Worker to one existing active subject.</p>
</div>
<span>Optional</span>
</div>
<label>
<span>Subject</span>
<select bind:value={subjektivSubjectId} aria-label="Subject Memory connection">
<option value="">Do not connect a subject</option>
{#each activeSubjects as subject (subject.id)}
<option value={subject.id}>{subject.role} · {subject.id}</option>
{/each}
</select>
</label>
{#if selectedSubject}
<p class="worker-feature-connection-selection">
<strong>{selectedSubject.role}</strong>
<code title={selectedSubject.id}>{selectedSubject.id}</code>
{#if selectedSubject.current_worker}
<span>Current Worker: {selectedSubject.current_worker.display_name}</span>
{/if}
</p>
{/if}
{#if subjectsLoading && subjects.length === 0}
<p class="worker-feature-connection-state" role="status">Loading authorized subjects…</p>
{:else if subjectsError}
<div class="worker-feature-connection-state is-error" role="alert">
<span>{subjectsError}</span>
<button type="button" class="secondary-button" onclick={() => void loadSubjects(false)}>Retry</button>
</div>
{:else if subjectsLoaded && activeSubjects.length === 0}
<p class="worker-feature-connection-state" role="status">No active subjects are available.</p>
{/if}
{#if subjectsHaveMore}
<button
type="button"
class="secondary-button worker-subject-load-more"
disabled={subjectsLoading}
onclick={() => void loadSubjects(true)}
>{subjectsLoading ? 'Loading…' : 'Load more subjects'}</button>
{/if}
<p class="worker-feature-connection-note">Profile enablement does not connect a subject by itself.</p>
</div>
{/if}
<label>
<span>Initial text</span>
<textarea bind:value={initialText} rows="7" placeholder="Optional first instruction"></textarea>