feat(ticket): align Web actions and instructions with intent-led work

This commit is contained in:
2026-10-09 02:51:22 +09:00
parent 60ec815237
commit 5244308c0d
59 changed files with 2077 additions and 745 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@8.3.2 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
"test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts",
"build": "deno run -A npm:vite@8.3.2 build",
@@ -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:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933)
// Input contract: 0.1.0 (sha256:3ae89b3b76b2c31f016f4f9e1bc339115fdfb0b7d2a9964c4c708488a3e5f6f1)
// 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:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933)
// Input contract: 0.1.0 (sha256:3ae89b3b76b2c31f016f4f9e1bc339115fdfb0b7d2a9964c4c708488a3e5f6f1)
// 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:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933)
// Input contract: 0.1.0 (sha256:3ae89b3b76b2c31f016f4f9e1bc339115fdfb0b7d2a9964c4c708488a3e5f6f1)
// 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:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933)
// Input contract: 0.1.0 (sha256:3ae89b3b76b2c31f016f4f9e1bc339115fdfb0b7d2a9964c4c708488a3e5f6f1)
// 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:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0)
// Input contract: 0.1.0 (sha256:3ae89b3b76b2c31f016f4f9e1bc339115fdfb0b7d2a9964c4c708488a3e5f6f1)
// 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:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933)
// Input contract: 0.1.0 (sha256:3ae89b3b76b2c31f016f4f9e1bc339115fdfb0b7d2a9964c4c708488a3e5f6f1)
// 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
@@ -63,7 +63,7 @@ export type WorkerLaunchOptionsResponse = { default_profile?: string | null; dia
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; };
export type WorkerLaunchRuntimeOption = { built_in: boolean; diagnostics: Array<Diagnostic>; display_name: string; runtime_id: string; status: string; supports_workdir_attachments: boolean; worker_creation_available: boolean; };
export type WorkerLaunchWorkerSummary = { diagnostics?: Array<Diagnostic>; display_name: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned: boolean; profile?: string | null; retention_state: string; runtime_id: string; singleton_key?: string | null; state: string; tags: Array<string>; workdir_attachments?: Array<RuntimeWorkerWorkdirAttachmentSummary>; worker_id: string; workspace: WorkerWorkspaceSummary; };
@@ -87,7 +87,7 @@ function ticketDetailFixture() {
blockers: [],
can_assign_orchestrator: false,
can_queue: false,
can_start_manual_coder: false,
can_start_manual_worker: false,
can_unassign_orchestrator: false,
queue_tickets: [],
},
@@ -97,7 +97,7 @@ function ticketDetailFixture() {
assignments: [],
body: "Body",
body_truncated: false,
current_coder: null,
current_worker: null,
event_count: 0,
event_page: { ...page, returned: 0 },
events: [],
@@ -397,7 +397,7 @@ function parseTicketRoleAssignmentRecord(
"workspace_id",
], label);
const role = string(item.role, `${label}.role`);
if (!["orchestrator", "coder", "owner", "contributor"].includes(role)) {
if (!["orchestrator", "worker", "owner", "contributor"].includes(role)) {
throw new Error(`${label}.role is invalid`);
}
return {
@@ -423,7 +423,7 @@ function parseActionEligibility(
"blockers",
"can_assign_orchestrator",
"can_queue",
"can_start_manual_coder",
"can_start_manual_worker",
"can_unassign_orchestrator",
"queue_tickets",
], label);
@@ -434,9 +434,9 @@ function parseActionEligibility(
`${label}.can_assign_orchestrator`,
),
can_queue: boolean(item.can_queue, `${label}.can_queue`),
can_start_manual_coder: boolean(
item.can_start_manual_coder,
`${label}.can_start_manual_coder`,
can_start_manual_worker: boolean(
item.can_start_manual_worker,
`${label}.can_start_manual_worker`,
),
can_unassign_orchestrator: boolean(
item.can_unassign_orchestrator,
@@ -730,7 +730,7 @@ export function parseTicketDetail(value: unknown): TicketDetail {
"body",
"body_truncated",
"created_at",
"current_coder",
"current_worker",
"event_count",
"event_page",
"events",
@@ -774,13 +774,13 @@ export function parseTicketDetail(value: unknown): TicketDetail {
body: string(item.body, `${label}.body`),
body_truncated: boolean(item.body_truncated, `${label}.body_truncated`),
created_at: optionalNullableString(item.created_at, `${label}.created_at`),
current_coder: item.current_coder === undefined
current_worker: item.current_worker === undefined
? undefined
: item.current_coder === null
: item.current_worker === null
? null
: parseTicketAssignmentSummary(
item.current_coder,
`${label}.current_coder`,
item.current_worker,
`${label}.current_worker`,
),
event_count: integer(item.event_count, `${label}.event_count`),
event_page: parseQueryPage(item.event_page, `${label}.event_page`),
@@ -248,7 +248,7 @@ Deno.test("Worker launch options parser accepts the generated wire shape", () =>
display_name: "Runtime A",
built_in: false,
worker_creation_available: true,
working_directory_required: true,
supports_workdir_attachments: true,
status: "connected",
diagnostics: [],
}],
@@ -130,7 +130,7 @@ function runtimeOption(
"display_name",
"built_in",
"worker_creation_available",
"working_directory_required",
"supports_workdir_attachments",
"status",
"diagnostics",
],
@@ -144,9 +144,9 @@ function runtimeOption(
item.worker_creation_available,
`${label}.worker_creation_available`,
),
working_directory_required: boolean(
item.working_directory_required,
`${label}.working_directory_required`,
supports_workdir_attachments: boolean(
item.supports_workdir_attachments,
`${label}.supports_workdir_attachments`,
),
status: string(item.status, `${label}.status`),
diagnostics: array(item.diagnostics, `${label}.diagnostics`, diagnostic),
@@ -434,11 +434,11 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
ticketDetailPage.includes("`${path}/queue`") &&
!ticketDetailPage.includes("/merge-request/merge") &&
ticketDetailPage.includes("mergeRequest.selector_from") &&
ticketDetailPage.includes("mergeRequest.review_status") &&
ticketDetailPage.includes("summarySourceReviewStatus(mergeRequest)") &&
ticketDetailPage.includes("mergeRequestPagePath") &&
ticketDetailPage.includes('mutate("close", "/close"') &&
ticketDetailPage.includes("mutateAssignment") &&
ticketDetailPage.includes("can_start_manual_coder") &&
ticketDetailPage.includes("can_start_manual_worker") &&
ticketDetailPage.includes("ticket.relations.outgoing"),
"Ticket detail should expose typed lifecycle actions, relations, target selection, assignments, and Merge Request navigation",
);
@@ -59,7 +59,7 @@ export function fixtureDetail(key = "T-101") {
complete_for_integration: false,
missing: [],
},
current_coder: key === "T-101"
current_worker: key === "T-101"
? {
assignment_id: "a-1",
runtime_id: "internal-runtime",
@@ -71,7 +71,7 @@ export function fixtureDetail(key = "T-101") {
blockers: [],
can_assign_orchestrator: false,
can_queue: false,
can_start_manual_coder: false,
can_start_manual_worker: false,
can_unassign_orchestrator: false,
queue_tickets: [],
},
@@ -196,10 +196,10 @@ export function loadDashboard(
? (blockedKeys.length === blockers.length
? `Blocked by ${blockedKeys.join(", ")}`
: `${blockers.length} unresolved dependencies`)
: current.current_coder
: current.current_worker
? undefined
: "No coder assigned",
workerKey: current.current_coder?.worker_resource_key ??
: "No Worker assigned",
workerKey: current.current_worker?.worker_resource_key ??
undefined,
},
};
@@ -1,4 +1,4 @@
import type { WorkerLaunchOptionsResponse } from "./types.ts";
import type { WorkerLaunchOptionsResponse } from "../../generated/worker-launch-api.ts";
export function emptyLaunchOptions(
workspaceId: string,
@@ -12,7 +12,7 @@ export function emptyLaunchOptions(
status: "active",
worker_creation_available: true,
built_in: true,
working_directory_required: false,
supports_workdir_attachments: false,
diagnostics: [],
},
{
@@ -21,7 +21,7 @@ export function emptyLaunchOptions(
status: "active",
worker_creation_available: true,
built_in: false,
working_directory_required: true,
supports_workdir_attachments: true,
diagnostics: [],
},
],
@@ -10,18 +10,37 @@ declare const Deno: {
};
Deno.test("logical config attachments are never offered as process working directories", () => {
const config = { working_directory_id: "config-1", display_name: "Workspace config",
source: { kind: "workspace_config" as const, access: "read_write" as const, content_path: "/workspace-config", purpose: "Configuration" },
materializer_kind: "logical_workspace_config" as const, status: "active" as const, cleanliness: "clean" };
const form = defaultWorkerLaunchForm({ ...options, working_directories: [config] }, emptyForm({
workdir_attachments: [{ alias: "config", working_directory_id: "config-1", relative_cwd: "" }],
}));
if (form.workdir_attachments.some((attachment) => attachment.working_directory_id === "config-1")) {
const config = {
working_directory_id: "config-1",
display_name: "Workspace config",
source: {
kind: "workspace_config" as const,
access: "read_write" as const,
content_path: "/workspace-config",
purpose: "Configuration",
},
materializer_kind: "logical_workspace_config" as const,
status: "active" as const,
cleanliness: "clean",
};
const form = defaultWorkerLaunchForm(
{ ...options, working_directories: [config] },
emptyForm({
workdir_attachments: [{
alias: "config",
working_directory_id: "config-1",
relative_cwd: "",
}],
}),
);
if (
form.workdir_attachments.some((attachment) => attachment.working_directory_id === "config-1")
) {
throw new Error("logical config was converted to a process cwd attachment");
}
});
Deno.test("defaultWorkerLaunchForm uses the Backend-published defaults and initial Workdir", () => {
Deno.test("defaultWorkerLaunchForm selects a Runtime without claiming a profile or Workdir", () => {
const form = defaultWorkerLaunchForm(
options,
emptyForm({ initial_text: "hello" }),
@@ -29,14 +48,10 @@ Deno.test("defaultWorkerLaunchForm uses the Backend-published defaults and initi
assertEquals(form.runtime_id, "remote");
assertEquals(form.display_name, "Worker");
assertEquals(form.profile, "builtin:coder");
assertEquals(form.profile, "");
assertEquals(form.initial_text, "hello");
assertEquals(form.workdir_attachments, [{
alias: "workdir",
working_directory_id: "wd-1-repo",
relative_cwd: "",
}]);
assertEquals(form.working_directory_repository_key, "repo");
assertEquals(form.workdir_attachments, []);
assertEquals(form.working_directory_repository_key, "");
assertEquals(form.working_directory_selector, "HEAD");
});
@@ -71,7 +86,7 @@ Deno.test("defaultWorkerLaunchForm preserves an available read-only External Wor
}]);
});
Deno.test("defaultWorkerLaunchForm preserves an available Ticket role profile", () => {
Deno.test("defaultWorkerLaunchForm does not turn a generic profile choice into trusted review", () => {
const reviewerOptions = {
...options,
profiles: [
@@ -95,10 +110,10 @@ Deno.test("defaultWorkerLaunchForm preserves an available Ticket role profile",
}),
);
assertEquals(form.profile, "builtin:reviewer");
assertEquals(form.profile, "");
});
Deno.test("defaultWorkerLaunchForm skips occupied Workdirs and leaves an editable attachment", () => {
Deno.test("defaultWorkerLaunchForm does not claim occupied Workdirs", () => {
const form = defaultWorkerLaunchForm(
{
...options,
@@ -115,11 +130,7 @@ Deno.test("defaultWorkerLaunchForm skips occupied Workdirs and leaves an editabl
emptyForm({ initial_text: "hello" }),
);
assertEquals(form.workdir_attachments, [{
alias: "workdir",
working_directory_id: "",
relative_cwd: "",
}]);
assertEquals(form.workdir_attachments, []);
});
Deno.test("defaultWorkerLaunchForm preserves a Ticket repository target", () => {
@@ -149,11 +160,7 @@ Deno.test("defaultWorkerLaunchForm preserves a Ticket repository target", () =>
}),
);
assertEquals(form.workdir_attachments, [{
alias: "workdir",
working_directory_id: "ticket-workdir",
relative_cwd: "",
}]);
assertEquals(form.workdir_attachments, []);
assertEquals(form.working_directory_repository_key, "ticket-repo");
assertEquals(form.working_directory_selector, "work/ticket");
});
@@ -308,8 +315,7 @@ Deno.test("buildCreateWorkspaceWorkerRequest emits an empty attachment list for
});
function emptyForm(
overrides: Partial<Parameters<typeof buildCreateWorkspaceWorkerRequest>[0]> =
{},
overrides: Partial<Parameters<typeof buildCreateWorkspaceWorkerRequest>[0]> = {},
): Parameters<typeof buildCreateWorkspaceWorkerRequest>[0] {
return {
runtime_id: "",
@@ -361,7 +367,7 @@ const options: WorkerLaunchOptionsResponse = {
status: "active",
worker_creation_available: true,
built_in: false,
working_directory_required: true,
supports_workdir_attachments: true,
diagnostics: [],
},
{
@@ -370,7 +376,7 @@ const options: WorkerLaunchOptionsResponse = {
status: "active",
worker_creation_available: true,
built_in: true,
working_directory_required: false,
supports_workdir_attachments: false,
diagnostics: [],
},
],
@@ -1,6 +1,9 @@
import type {
BrowserWorkerWorkingDirectorySelection,
CreateWorkspaceWorkerRequest,
CreateWorkspaceWorkerTicketAssignmentRequest,
RepositoryApiError,
WorkerLaunchProfileCandidate,
} from "#lib/generated/worker-launch-api.ts";
import { parseCreateWorkspaceWorkerRequest } from "#lib/workspace/api/workers.ts";
@@ -14,6 +17,8 @@ export type WorkerLaunchAttachmentFormState = {
export type WorkerLaunchFormState = {
runtime_id: string;
flow?: string;
ticket_assignment?: CreateWorkspaceWorkerTicketAssignmentRequest | null;
display_name: string;
profile: string;
subjektiv_subject_id: string;
@@ -35,84 +40,33 @@ export function defaultWorkerLaunchForm(
) ??
options?.runtimes.find((runtime) => runtime.worker_creation_available) ??
options?.runtimes[0];
const preferredProfile = options?.profiles.find((candidate) =>
candidate.id === options.default_profile
);
const availableWorkingDirectories =
options?.working_directories.filter((directory) =>
directory.status === "active" &&
directory.source.kind !== "workspace_config" &&
(directory.source.kind === "external_grant" ||
directory.cleanliness === "clean") &&
directory.occupied_by == null
) ?? [];
const selectedRuntime = current.runtime_id
? options?.runtimes.find((runtime) =>
runtime.runtime_id === current.runtime_id
)
: preferredRuntime;
const workdirlessRuntime =
selectedRuntime?.working_directory_required === false;
const preferredWorkingDirectory = workdirlessRuntime
? undefined
: availableWorkingDirectories.find((directory) =>
Boolean(current.working_directory_repository_key) &&
directory.source.kind === "repository" &&
directory.source.repository_key ===
current.working_directory_repository_key &&
(!current.working_directory_selector ||
(directory.current_selector ?? directory.creation_selector) ===
current.working_directory_selector)
) ?? availableWorkingDirectories.find((directory) =>
Boolean(current.working_directory_repository_key) &&
directory.source.kind === "repository" &&
directory.source.repository_key ===
current.working_directory_repository_key
) ?? (current.working_directory_repository_key
? undefined
: availableWorkingDirectories[0]);
const preferredRepository =
options?.repositories.find((repository) =>
repository.repository_key === current.working_directory_repository_key
) ??
options?.repositories[0];
const runtime =
options?.runtimes.find((candidate) => candidate.runtime_id === current.runtime_id) ??
preferredRuntime;
const availableWorkdirIds = new Set(
availableWorkingDirectories.map((directory) =>
directory.working_directory_id
),
(options?.working_directories ?? []).filter((directory) =>
directory.status === "active" && directory.source.kind !== "workspace_config" &&
(directory.source.kind === "external_grant" || directory.cleanliness === "clean") &&
directory.occupied_by == null
).map((directory) => directory.working_directory_id),
);
const currentAttachments = workdirlessRuntime
? []
: current.workdir_attachments.filter((attachment) =>
availableWorkdirIds.has(attachment.working_directory_id)
);
const workdirAttachments = currentAttachments.length > 0
? currentAttachments
: preferredWorkingDirectory
? [{
alias: "workdir",
working_directory_id: preferredWorkingDirectory.working_directory_id,
relative_cwd: "",
}]
: selectedRuntime?.working_directory_required === true
? [{ alias: "workdir", working_directory_id: "", relative_cwd: "" }]
: [];
// Defaults never claim resource attachments or infer an execution recipe from a Ticket.
return {
...current,
runtime_id: current.runtime_id || preferredRuntime?.runtime_id || "",
display_name: current.display_name || "Worker",
profile:
options?.profiles.some((candidate) => candidate.id === current.profile)
genericLaunchProfiles(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:
current.working_directory_repository_key ||
preferredRepository?.repository_key || "",
working_directory_selector: current.working_directory_selector ||
preferredRepository?.default_selector || "HEAD",
: "",
workdir_attachments: runtime?.supports_workdir_attachments === false
? []
: current.workdir_attachments.filter((attachment) =>
availableWorkdirIds.has(attachment.working_directory_id)
),
working_directory_selector: current.working_directory_selector || "HEAD",
};
}
@@ -172,15 +126,19 @@ function validatedAttachments(
export function buildCreateWorkspaceWorkerRequest(
form: WorkerLaunchFormState,
): CreateWorkspaceWorkerRequest {
if (["builtin:reviewer", "reviewer"].includes(form.profile.trim())) {
throw new Error("Trusted Reviewer launch requires a bound review request.");
}
const initialMessage = form.initial_text.trim();
return parseCreateWorkspaceWorkerRequest({
runtime_id: form.runtime_id.trim(),
display_name: form.display_name.trim(),
profile: form.profile.trim() || null,
ticket_assignment: null,
initial_submit: initialMessage
? [{ kind: "text", content: form.initial_text }]
: [],
ticket_assignment: form.ticket_assignment ?? null,
initial_submit: [
...(initialMessage ? [{ kind: "text", content: form.initial_text }] : []),
...(form.flow?.trim() ? [{ kind: "flow", selector: form.flow.trim() }] : []),
],
workdir_attachments: validatedAttachments(form.workdir_attachments),
feature_connections: form.subjektiv_subject_id.trim()
? { subjektiv: { subject_id: form.subjektiv_subject_id.trim() } }
@@ -188,3 +146,21 @@ export function buildCreateWorkspaceWorkerRequest(
control_operation_id: null,
});
}
// A trusted Reviewer is launched through the bound review path, not a Profile selection.
export function genericLaunchProfiles(
profiles: WorkerLaunchProfileCandidate[],
): WorkerLaunchProfileCandidate[] {
return profiles.filter((candidate) =>
candidate.id !== "builtin:reviewer" && candidate.id !== "reviewer"
);
}
export function workerLaunchOutcomeUnknown(error: RepositoryApiError): boolean {
return [
error.error,
error.message,
...(error.diagnostics ?? []).flatMap((item) => [item.code, item.message]),
]
.some((value) => /outcome[_\s-]?unknown|OutcomeUnknown/i.test(value));
}
@@ -73,13 +73,22 @@
justify-content: space-between;
gap: var(--space-4);
}
.ticket-panel-header {
flex-wrap: wrap;
}
.ticket-panel-header > div:first-child {
flex: 1 1 12rem;
}
.ticket-panel-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
max-width: 100%;
gap: var(--space-3);
}
.orchestrator-status {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.65rem;
border: 1px solid var(--line);
@@ -480,17 +489,11 @@
.ticket-detail-grid {
grid-template-columns: minmax(0, 1fr);
}
.ticket-control-rail {
grid-row: 1;
}
}
@media (max-width: 64rem) {
.ticket-detail-grid {
grid-template-columns: 1fr;
}
.ticket-control-rail {
grid-row: 1;
}
}
}
@@ -84,75 +84,45 @@ Deno.test("ticket lane visibility advances in bounded pages of 30", () => {
assertEquals(nextTicketLaneVisibleCount(95, 95), 95);
});
Deno.test("ticket worker launch uses the common Worker route and bounded Ticket context", () => {
const ticket = {
id: "00001KYRRDVH9",
title: "Ticket panel API",
targets: [
{
repository_key: "docs",
ref_selector: "main",
access: "read_only",
},
{
repository_key: "main repo",
ref_selector: "work/ticket",
access: "read_write",
},
],
} as TicketDetail;
assertEquals(
ticketWorkerMessage(ticket.id, "coder"),
"Work on Ticket 00001KYRRDVH9 as its coder.",
);
const href = ticketWorkerLaunchHref("workspace one", ticket, "reviewer");
const url = new URL(href, "https://example.test");
assertEquals(url.pathname, "/w/workspace%20one/workers/new");
assertEquals(url.searchParams.get("ticketId"), ticket.id);
assertEquals(url.searchParams.get("ticketRole"), "reviewer");
assertEquals(url.searchParams.get("repositoryKey"), "main repo");
assertEquals(url.searchParams.get("refSelector"), "work/ticket");
assertEquals(
url.searchParams.get("initialInput"),
"Work on Ticket 00001KYRRDVH9 as its reviewer.",
);
Deno.test("generic Ticket launch carries natural text without role or resource claims", () => {
const requests = [
"Review is unnecessary; complete the requested work.",
"Review the change, then merge it if approved.",
"Investigate and return without a conclusion.",
];
const targetSets: TicketDetail["targets"][] = [
[],
[{ repository_key: "docs", ref_selector: "main", access: "read_only" }],
[{ repository_key: "repo", ref_selector: "main", access: "read_write" }],
];
assertEquals(ticketWorkerMessage("T-720"), "Work on Ticket T-720.");
for (const targets of targetSets) {
const ticket = { id: "T-720", title: "Flexible launch", targets };
for (const request of requests) {
const url = new URL(
ticketWorkerLaunchHref("workspace one", ticket, request),
"https://example.test",
);
assertEquals(url.pathname, "/w/workspace%20one/workers/new");
assertEquals(url.searchParams.get("ticketId"), ticket.id);
assertEquals(url.searchParams.get("ticketTitle"), ticket.title);
assertEquals(url.searchParams.get("initialInput"), request);
for (
const field of [
"ticketRole",
"profile",
"repositoryKey",
"refSelector",
"flow",
]
) {
assertEquals(url.searchParams.has(field), false);
}
}
}
});
Deno.test("ticket worker launch never selects a read_only or ambiguous write target", () => {
const readOnly = {
id: "ticket-read-only",
title: "Read only",
targets: [{
repository_key: "docs",
ref_selector: "main",
access: "read_only",
}],
} as TicketDetail;
const readOnlyUrl = new URL(
ticketWorkerLaunchHref("workspace", readOnly, "coder"),
"https://example.test",
);
assertEquals(readOnlyUrl.searchParams.has("repositoryKey"), false);
assertEquals(readOnlyUrl.searchParams.has("refSelector"), false);
const ambiguous = {
...readOnly,
targets: [
{ repository_key: "main", ref_selector: "develop", access: "read_write" },
{ repository_key: "docs", ref_selector: "main", access: "read_write" },
],
} as TicketDetail;
const ambiguousUrl = new URL(
ticketWorkerLaunchHref("workspace", ambiguous, "coder"),
"https://example.test",
);
assertEquals(ambiguousUrl.searchParams.has("repositoryKey"), false);
assertEquals(ambiguousUrl.searchParams.has("refSelector"), false);
});
Deno.test("ticket detail uses server-derived role assignment actions", async () => {
Deno.test("ticket detail retains backend queue eligibility and generic Worker assignment actions", async () => {
const source = await Deno.readTextFile(
new URL(
"../../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
@@ -162,11 +132,6 @@ Deno.test("ticket detail uses server-derived role assignment actions", async ()
assertEquals(source.includes("ticket.action_eligibility.can_queue"), true);
assertEquals(source.includes("ticket.relations.blockers.length > 0"), true);
assertEquals(
source.includes("ticket.action_eligibility.queue_tickets"),
true,
);
assertEquals(source.includes("This operation queues:"), true);
assertEquals(source.includes("outcome.queued_tickets.join"), true);
assertEquals(
source.includes("resolve the listed blockers before Queue"),
@@ -176,7 +141,14 @@ Deno.test("ticket detail uses server-derived role assignment actions", async ()
source.includes("ticket.action_eligibility.can_assign_orchestrator"),
true,
);
assertEquals(source.includes("/assignments/${role}"), true);
assertEquals(
source.includes('mutateAssignment("start-manual", "worker"'),
true,
);
assertEquals(
source.includes('mutateAssignment("start-manual", "reviewer"'),
false,
);
assertEquals(source.includes('kind: "workspace_agent"'), true);
assertEquals(source.includes('kind: "worker"'), true);
assertEquals(source.includes("ticket.assignee"), false);
@@ -188,10 +160,6 @@ Deno.test("ticket detail uses server-derived role assignment actions", async ()
source.includes("(repository.diagnostics ?? []).length > 0"),
false,
);
assertEquals(
source.includes("unique registered repository targets"),
true,
);
});
Deno.test("Ticket authoring creates the target collection without singular authority", async () => {
@@ -14,7 +14,6 @@ export const TICKET_STATES = [
] as const;
export type TicketState = (typeof TICKET_STATES)[number];
export type TicketWorkerRole = "coder" | "reviewer";
export type WorkspaceOrchestratorStatus = BrowserWorkspaceOrchestratorResponse;
@@ -98,36 +97,20 @@ export function ticketLanes(tickets: TicketCardSummary[]): TicketLane[] {
}));
}
export function ticketWorkerMessage(
ticketId: string,
role: TicketWorkerRole,
): string {
return `Work on Ticket ${ticketId} as its ${role}.`;
export function ticketWorkerMessage(ticketId: string): string {
return `Work on Ticket ${ticketId}.`;
}
export function ticketWorkerLaunchHref(
workspaceId: string,
ticket: Pick<TicketDetail, "id" | "title" | "targets">,
role: TicketWorkerRole,
ticket: Pick<TicketDetail, "id" | "title">,
request = ticketWorkerMessage(ticket.id),
): string {
const implementationTargets = ticket.targets.filter((target) =>
target.access === "read_write"
);
const implementationTarget = implementationTargets.length === 1
? implementationTargets[0]
: null;
const params = new URLSearchParams({
ticketId: ticket.id,
ticketTitle: ticket.title,
ticketRole: role,
initialInput: ticketWorkerMessage(ticket.id, role),
initialInput: request,
});
if (implementationTarget) {
params.set("repositoryKey", implementationTarget.repository_key);
if (implementationTarget.ref_selector) {
params.set("refSelector", implementationTarget.ref_selector);
}
}
return `/w/${
encodeURIComponent(workspaceId)
}/workers/new?${params.toString()}`;
@@ -0,0 +1,186 @@
import {
buildCreateWorkspaceWorkerRequest,
defaultWorkerLaunchForm,
genericLaunchProfiles,
workerLaunchOutcomeUnknown,
} from "../sidebar/worker-launch.ts";
import type { WorkerLaunchFormState } from "../sidebar/worker-launch.ts";
import { emptyLaunchOptions } from "#lib/workspace/sidebar/worker-launch.test-fixtures.ts";
import { load } from "../../../routes/w/[workspaceId]/workers/new/+page.ts";
declare const Deno: { test(name: string, fn: () => void): void };
const form: WorkerLaunchFormState = {
runtime_id: "embedded",
display_name: "Worker",
profile: "",
subjektiv_subject_id: "",
initial_text: "",
workdir_attachments: [],
working_directory_repository_key: "",
working_directory_selector: "HEAD",
};
function equal(actual: unknown, expected: unknown) {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
);
}
}
Deno.test("generic Ticket request keeps conclusion and review instructions as natural text with no mandatory Flow or profile", () => {
for (
const text of [
"Review is unnecessary.",
"Review, then merge if approved.",
"Return without a conclusion.",
]
) {
const request = buildCreateWorkspaceWorkerRequest({
...form,
initial_text: text,
ticket_assignment: { ticket_id: "T-720", operation_id: "launch-720" },
});
equal(request.ticket_assignment, {
ticket_id: "T-720",
operation_id: "launch-720",
});
equal(request.profile, null);
equal(request.workdir_attachments, []);
equal(request.initial_submit, [{ kind: "text", content: text }]);
}
});
Deno.test("explicit Flow and resource selections remain separate from the Ticket request", () => {
const request = buildCreateWorkspaceWorkerRequest({
...form,
runtime_id: "remote",
profile: "project:analysis",
initial_text: " Review is unnecessary.\nReturn without conclusion. ",
flow: " project:investigate ",
subjektiv_subject_id: "subject-1",
ticket_assignment: { ticket_id: "T-720", operation_id: "launch-720" },
workdir_attachments: [{
alias: "sessions",
working_directory_id: "read-only-grant",
relative_cwd: "logs",
}],
});
equal(request.initial_submit, [{
kind: "text",
content: " Review is unnecessary.\nReturn without conclusion. ",
}, { kind: "flow", selector: "project:investigate" }]);
equal(request.workdir_attachments, [{
alias: "sessions",
working_directory_id: "read-only-grant",
relative_cwd: "logs",
}]);
equal(request.feature_connections, {
subjektiv: { subject_id: "subject-1" },
});
});
Deno.test("launch defaults never claim repository or Workdir resources", () => {
const options = emptyLaunchOptions("workspace");
options.repositories = [{ repository_key: "repo", default_selector: "main" }];
options.working_directories = [{
working_directory_id: "wd-1",
source: { kind: "repository", repository_key: "repo" },
materializer_kind: "runtime_git_clone",
status: "active",
cleanliness: "clean",
}];
const result = defaultWorkerLaunchForm(options, {
...form,
runtime_id: "remote",
});
equal(result.profile, "");
equal(result.workdir_attachments, []);
equal(result.working_directory_repository_key, "");
});
Deno.test("launch defaults preserve explicit attachments only on a supporting Runtime", () => {
const options = emptyLaunchOptions("workspace");
options.working_directories = [{
working_directory_id: "read-only-grant",
source: {
kind: "external_grant",
grant_id: "grant-1",
grant_permissions: { read: true, write: false, command: false },
},
materializer_kind: "client_hosted_external",
status: "active",
cleanliness: "unknown",
}];
const attachments = [{
alias: "sessions",
working_directory_id: "read-only-grant",
relative_cwd: "logs",
}];
for (const runtime of options.runtimes) {
const result = defaultWorkerLaunchForm(options, {
...form,
runtime_id: runtime.runtime_id,
workdir_attachments: attachments,
});
equal(
result.workdir_attachments,
runtime.supports_workdir_attachments ? attachments : [],
);
}
});
Deno.test("generic launch cannot select a trusted Reviewer profile or infer it from URL roles", () => {
const options = emptyLaunchOptions("workspace");
options.profiles.push({
...options.profiles[0],
id: "builtin:reviewer",
label: "Reviewer",
});
equal(genericLaunchProfiles(options.profiles).map((item) => item.id), [
"builtin:companion",
]);
equal(
defaultWorkerLaunchForm(options, { ...form, profile: "builtin:reviewer" })
.profile,
"",
);
const data = load({
params: { workspaceId: "workspace" },
url: new URL(
"https://example.test/workers/new?ticketId=T-720&ticketRole=reviewer&profile=builtin:reviewer&repositoryKey=repo",
),
});
equal(data.ticketContext, {
ticketId: "T-720",
ticketTitle: "T-720",
initialInput: "Work on Ticket T-720.",
});
let rejected = false;
try {
buildCreateWorkspaceWorkerRequest({ ...form, profile: "builtin:reviewer" });
} catch {
rejected = true;
}
equal(rejected, true);
});
Deno.test("OutcomeUnknown is distinguished from a definite permission rejection", () => {
for (const code of ["OutcomeUnknown", "worker_spawn_outcome_unknown"]) {
equal(
workerLaunchOutcomeUnknown({
error: "runtime_operation_failed",
message: "Uncertain launch",
diagnostics: [{ code, message: "Reconcile", severity: "error" }],
}),
true,
);
}
equal(
workerLaunchOutcomeUnknown({
error: "permission_denied",
message: "Read only",
diagnostics: [],
}),
false,
);
});
@@ -215,11 +215,7 @@
<div class="workspace-page ticket-panel-page">
<header class="workspace-page-header ticket-panel-header">
<div>
<p class="workspace-eyebrow">Delivery</p>
<h1>Tickets</h1>
<p class="workspace-page-lede">
Plan, route, review, and close work without leaving the workspace.
</p>
</div>
<div class="ticket-panel-controls">
<button
@@ -235,7 +231,7 @@
</div>
{#if !orchestrator.data?.online}
<button
class="workspace-primary-button"
class="workspace-secondary-button"
type="button"
disabled={orchestratorStarting}
onclick={startOrchestrator}
@@ -261,7 +257,7 @@
<label>Title<input bind:value={createTitle} required /></label>
<label>Body<textarea bind:value={createBody} rows="8"></textarea></label>
<div class="ticket-target-list">
<strong>Repository targets</strong>
<strong>Repository resources (optional)</strong>
{#each createTargets as target, index}
{@const selectedRepository = repositories.find((repository) => repository.repository_key === target.repository_key)}
<fieldset class="ticket-target-row">
@@ -287,7 +283,7 @@
<button class="workspace-secondary-button" type="button" onclick={addCreateTarget}>Add target</button>
</div>
{#if !createTargetsSavable}
<p class="workspace-empty-copy">Every target row must select a different repository. The exact one read-write target and selector requirements are enforced when the Ticket is marked ready.</p>
<p class="workspace-empty-copy">Each optional resource row must select a different registered repository. Empty or read-only resources are allowed.</p>
{/if}
<button class="workspace-primary-button" type="submit" disabled={createBusy || !createTitle.trim() || !createTargetsSavable}>
{createBusy ? "Creating…" : "Create Ticket"}
@@ -301,7 +297,7 @@
</p>
{:else if !orchestrator.data?.online}
<p class="workspace-callout">
Orchestration actions are unavailable until the embedded Orchestrator is online.
Orchestrator is offline. Requests can be queued; progress and results can still be recorded directly.
</p>
{/if}
@@ -17,6 +17,7 @@
import {
relationLabel,
TICKET_STATES,
ticketWorkerLaunchHref,
type WorkspaceOrchestratorStatus,
} from "#lib/workspace/tickets/ticket-panel.ts";
import type { ApiResult } from "#lib/workspace/api/http.ts";
@@ -36,9 +37,7 @@
access: TicketTargetAccess;
};
const MUTABLE_TICKET_STATES = TICKET_STATES.filter((state) =>
state !== "done" && state !== "ready" && state !== "queued"
);
const MUTABLE_TICKET_STATES = TICKET_STATES;
function editableTargets(targets: TicketTarget[]): EditableTicketTarget[] {
return targets.map((target) => ({
@@ -81,6 +80,7 @@
);
let nextState = $state(loadedTicket.state);
let transitionReason = $state("");
let progressBody = $state("");
let threadRole = $state("comment");
let threadBody = $state("");
let resolution = $state("");
@@ -90,11 +90,12 @@
let readyOperationKey = $state<string | null>(null);
let manualRuntimeId = $state("");
let manualWorkerId = $state("");
let manualBindings = $state<{ alias: string; working_directory_id: string; connection_id: string }[]>([]);
let cancellationReason = $state("");
let routeTicketSnapshot = `${initialData.ticketId}:${loadedTicket.item_revision}`;
let routeGeneration = 0;
const coderAssignment = $derived(
ticket.assignments.find((assignment) => assignment.role === "coder") ?? null,
const workerAssignment = $derived(
ticket.assignments.find((assignment) => assignment.role === "worker") ?? null,
);
function repositoryFor(repositoryKey: string): RepositorySummary | null {
return (loadedRepositories?.items ?? []).find((repository: RepositorySummary) =>
@@ -108,23 +109,22 @@
}
const targetCandidateValid = $derived.by(() => {
if (ticket.state !== "planning" || targetDrafts.length === 0) return false;
const repositoryKeys = new Set<string>();
let readWriteCount = 0;
for (const target of targetDrafts) {
const repository = repositoryFor(target.repository_key);
if (
!target.repository_key || repository === null ||
!effectiveRefSelector(target) ||
repositoryKeys.has(target.repository_key)
) return false;
repositoryKeys.add(target.repository_key);
if (target.access === "read_write") readWriteCount += 1;
}
return readWriteCount === 1;
return true;
});
const implementationStartEligible = $derived(
ticket.action_eligibility.can_start_manual_coder,
ticket.action_eligibility.can_start_manual_worker,
);
const ticketPath = $derived(
@@ -146,6 +146,7 @@
applyTicket(updatedTicket);
editing = false;
transitionReason = "";
progressBody = "";
threadRole = "comment";
threadBody = "";
resolution = "";
@@ -155,6 +156,7 @@
readyOperationKey = null;
manualRuntimeId = "";
manualWorkerId = "";
manualBindings = [];
cancellationReason = "";
}
@@ -172,6 +174,14 @@
});
});
function ticketMutationError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (/\(409\)/.test(message)) return `Update conflict. Refresh the Ticket and resolve the current revision or resource binding before retrying. ${message}`;
if (/\(401\)|\(403\)/.test(message)) return `Permission denied. This action did not grant access. ${message}`;
if (/\(404\)/.test(message)) return `Resource unavailable or not connected. Check the Ticket and authorized resource connection. ${message}`;
return `Outcome unknown or request rejected. Refresh before retrying; no success is inferred. ${message}`;
}
async function mutate(
action: string,
suffix: string,
@@ -193,7 +203,7 @@
return true;
} catch (error) {
if (generation === routeGeneration) {
errorMessage = error instanceof Error ? error.message : String(error);
errorMessage = ticketMutationError(error);
}
return false;
} finally {
@@ -201,6 +211,22 @@
}
}
async function refreshTicket(): Promise<void> {
if (busy) return;
const generation = routeGeneration;
busy = "refresh";
try {
const updated = await workspaceApiJson(ticketPath, parseTicketDetail, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (generation !== routeGeneration) return;
applyTicket(updated);
errorMessage = null;
} catch (error) {
if (generation === routeGeneration) errorMessage = ticketMutationError(error);
} finally {
if (generation === routeGeneration) busy = null;
}
}
async function queueTicket(): Promise<void> {
if (busy) return;
const generation = routeGeneration;
@@ -226,7 +252,7 @@
applyTicket(updatedTicket);
} catch (error) {
if (generation === routeGeneration) {
errorMessage = error instanceof Error ? error.message : String(error);
errorMessage = ticketMutationError(error);
}
} finally {
if (generation === routeGeneration) busy = null;
@@ -235,7 +261,7 @@
async function mutateAssignment(
action: string,
role: "orchestrator" | "coder",
role: "orchestrator" | "worker",
principal: Record<string, string>,
): Promise<void> {
if (busy) return;
@@ -252,6 +278,7 @@
operation_id: crypto.randomUUID(),
principal,
expected_assignment_id: null,
...(role === "worker" ? { workdir_bindings: manualBindings } : {}),
}),
},
parseTicketRoleAssignmentMutationResponse,
@@ -267,7 +294,7 @@
applyTicket(updatedTicket);
} catch (error) {
if (generation === routeGeneration) {
errorMessage = error instanceof Error ? error.message : String(error);
errorMessage = ticketMutationError(error);
}
} finally {
if (generation === routeGeneration) busy = null;
@@ -281,10 +308,10 @@
});
}
async function startManualCoder(event: SubmitEvent): Promise<void> {
async function assignExistingWorker(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (!manualRuntimeId.trim() || !manualWorkerId.trim()) return;
await mutateAssignment("start-manual", "coder", {
await mutateAssignment("start-manual", "worker", {
kind: "worker",
runtime_id: manualRuntimeId.trim(),
worker_id: manualWorkerId.trim(),
@@ -293,11 +320,11 @@
async function cancelImplementation(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (!coderAssignment || !cancellationReason.trim()) return;
if (!workerAssignment || !cancellationReason.trim()) return;
if (
await mutate("cancel-implementation", "/implementation-cancellations", {
operation_id: crypto.randomUUID(),
assignment_id: coderAssignment.assignment_id,
assignment_id: workerAssignment.assignment_id,
reason: cancellationReason.trim(),
})
) cancellationReason = "";
@@ -314,12 +341,10 @@
}
function addTarget(access: TicketTargetAccess = "read_only"): void {
if (ticket.state !== "planning") return;
targetDrafts.push({ repository_key: "", ref_selector: "", access });
}
function removeTarget(index: number): void {
if (ticket.state !== "planning") return;
targetDrafts.splice(index, 1);
}
@@ -338,12 +363,7 @@
}
async function markReady() {
if (!targetCandidateValid || busy) return;
const targets = normalizedTargets(targetDrafts);
if (JSON.stringify(ticket.targets) !== JSON.stringify(targets)) {
const saved = await mutate("target", "", targetEditBody(), "PATCH");
if (!saved) return;
}
if (ticket.state !== "planning" || busy) return;
readyOperationKey ??= crypto.randomUUID();
if (
await mutate("ready", "/ready", {
@@ -364,9 +384,10 @@
operation_key: crypto.randomUUID(),
expected_item_revision: ticket.item_revision,
expected_state: ticket.state,
reason: transitionReason.trim() || "Progress decision from the Ticket page",
reason: transitionReason.trim(),
body: progressBody.trim() || null,
})
) transitionReason = "";
) { transitionReason = ""; progressBody = ""; }
}
async function appendThread(event: SubmitEvent) {
@@ -421,7 +442,7 @@
</header>
{#if errorMessage}
<div class="workspace-callout is-error" role="alert">{errorMessage}</div>
<div class="workspace-callout is-error" role="alert"><p>{errorMessage}</p><p>No success or approval is inferred. An uncertain response may already have applied; refresh the current Ticket before retrying.</p><button type="button" class="workspace-secondary-button" disabled={busy !== null} onclick={refreshTicket}>Refresh Ticket status</button></div>
{/if}
{#if queueMessage}
@@ -432,7 +453,7 @@
<form class="ticket-editor" onsubmit={saveEdit}>
<label>Title<input bind:value={editTitle} required /></label>
<label>Body<textarea bind:value={editBody} rows="12"></textarea></label>
<button class="workspace-primary-button" type="submit" disabled={busy === "edit" || !editTitle.trim()}>
<button class="workspace-primary-button" type="submit" disabled={busy !== null || !editTitle.trim()}>
{busy === "edit" ? "Saving…" : "Save changes"}
</button>
</form>
@@ -515,8 +536,9 @@
</header>
{#if event.author}<p class="ticket-event-author">{event.author}</p>{/if}
{#if event.from || event.to}<p>{event.from ?? "—"} → {event.to ?? "—"}</p>{/if}
{#if event.reason}<p>{event.reason}</p>{/if}
{#if event.reason}<RichMarkdown text={event.reason} />{/if}
{#if event.body}<RichMarkdown text={event.body} />{/if}
{#each event.references as reference}<p>{reference}</p>{/each}
</div>
</article>
{:else}
@@ -527,6 +549,29 @@
</main>
<aside class="ticket-control-rail">
<section class="ticket-control-card">
<header><h2>Progress decision</h2></header>
<p class="workspace-empty-copy">State records progress only. It does not start or stop a Worker, grant access, approve review, or merge code. Reopening does not resume an old Worker.</p>
<form class="ticket-control-form" onsubmit={transition}>
<label>State
<select bind:value={nextState}>
{#each MUTABLE_TICKET_STATES as state}<option value={state}>{state}</option>{/each}
</select>
</label>
<label>Reason<input bind:value={transitionReason} placeholder="Completion, start, or reopening decision" required /></label>
<label>Result, references, and remaining work (optional)<textarea bind:value={progressBody} rows="3" placeholder="A comment can be the result. Add links and remaining questions here."></textarea></label>
<button class="workspace-secondary-button" type="submit" disabled={busy !== null || nextState === ticket.state || !transitionReason.trim()}>
{nextState === "done" ? "Complete Ticket" : ticket.state === "done" || ticket.state === "closed" ? "Reopen / apply state" : "Apply state"}
</button>
</form>
{#if ticket.state === "planning"}
<button class="workspace-secondary-button ticket-queue-button" type="button" disabled={busy !== null} onclick={markReady}>{busy === "ready" ? "Marking ready…" : "Mark ready"}</button>
{/if}
<button class="workspace-secondary-button ticket-queue-button" type="button" disabled={busy !== null || !ticket.action_eligibility.can_queue} onclick={() => void queueTicket()}>{busy === "queue" ? "Queueing…" : "Request Orchestrator (queue)"}</button>
<p class="workspace-empty-copy">Queue requests only this Ticket. Dependencies remain diagnostic information and are not automatically queued.</p>
{#each ticket.action_eligibility.blockers as blocker}<p class="workspace-callout">{blocker}</p>{/each}
</section>
<section class="ticket-control-card ticket-worker-card">
<header><h2>Role assignments</h2><span>Retained responsibility</span></header>
<p class="workspace-empty-copy">Responsibility is retained after work ends and Worker removal; it does not indicate a running Worker or active work authority.</p>
@@ -561,24 +606,37 @@
</button>
{/if}
{#if implementationStartEligible}
<form class="ticket-control-form" onsubmit={startManualCoder}>
<a class="workspace-secondary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket)}>Start a Ticket Worker</a>
<details><summary>Assign an existing Worker</summary>
<p class="workspace-empty-copy">Explicit assignment is separate from state. Backend validates the Worker and resource binding; a retained assignment is not execution authority.</p>
<form class="ticket-control-form" onsubmit={assignExistingWorker}>
<label>Runtime ID<input bind:value={manualRuntimeId} required /></label>
<label>Worker ID<input bind:value={manualWorkerId} required /></label>
<p class="workspace-empty-copy">Select only resources needed for this request. No selection binds no Workdirs; unrelated existing attachments are not inherited. Use authorized connection IDs for explicit rebind.</p>
{#each manualBindings as binding, index}
<fieldset class="ticket-target-row"><legend>Resource binding {index + 1}</legend>
<label>Alias<input bind:value={binding.alias} required /></label>
<label>Workdir ID<input bind:value={binding.working_directory_id} required /></label>
<label>Connection ID<input bind:value={binding.connection_id} required /></label>
<button type="button" class="workspace-secondary-button" disabled={busy !== null} onclick={() => manualBindings.splice(index, 1)}>Remove binding</button>
</fieldset>
{/each}
<button type="button" class="workspace-secondary-button" disabled={busy !== null} onclick={() => manualBindings.push({ alias: "", working_directory_id: "", connection_id: "" })}>Add resource binding</button>
<button
class="workspace-secondary-button"
type="submit"
disabled={busy !== null || !manualRuntimeId.trim() || !manualWorkerId.trim()}
>
{busy === "start-manual" ? "Starting…" : "Assign Coder and start"}
{busy === "start-manual" ? "Assigning…" : "Assign Worker"}
</button>
</form>
</form></details>
{/if}
{#if ticket.state === "inprogress" && coderAssignment}
{#if ticket.state === "inprogress" && workerAssignment}
<details class="ticket-cancel-implementation">
<summary>Cancel implementation</summary>
<form class="ticket-control-form" onsubmit={cancelImplementation}>
<p class="workspace-empty-copy">
Cancel the assigned Coder, remove its assignment, and return this Ticket to ready.
Cancel the assigned Worker’s unfinished work and return this Ticket to ready. Responsibility is retained. This does not delete the Worker or Workdir.
</p>
<label>Reason<textarea bind:value={cancellationReason} rows="3" required></textarea></label>
<button
@@ -599,7 +657,9 @@
</section>
<section class="ticket-control-card">
<header><h2>Repository targets</h2><span>{targetDrafts.length}</span></header>
<header><h2>Repository resources (optional)</h2><span>{targetDrafts.length}</span></header>
<p class="workspace-empty-copy">Targets describe permitted resources, not connected attachments. Editing targets or the body does not expand live permissions. Changed resources require explicit reconnection; stale or unavailable connections are rejected by Backend.</p>
{#if data.repositories.error}<p class="workspace-callout is-error">Repository catalog unavailable: {data.repositories.error}</p>{/if}
<form class="ticket-control-form" onsubmit={saveTarget}>
<div class="ticket-target-list">
{#each targetDrafts as target, index}
@@ -607,78 +667,41 @@
<fieldset class="ticket-target-row">
<legend>Target {index + 1}</legend>
<label>Repository
<select bind:value={target.repository_key} disabled={ticket.state !== "planning"} required>
<select bind:value={target.repository_key} disabled={busy !== null} required>
<option value="">Choose repository</option>
{#each loadedRepositories?.items ?? [] as repository}
<option value={repository.repository_key}>{repository.repository_key}</option>
{/each}
</select>
</label>
<label>Ref selector<input bind:value={target.ref_selector} placeholder={selectedRepository?.default_selector ?? "branch, tag, or revision"} disabled={ticket.state !== "planning"} /></label>
<label>Ref selector<input bind:value={target.ref_selector} placeholder={selectedRepository?.default_selector ?? "branch, tag, or revision"} disabled={busy !== null} /></label>
<label>Access
<select bind:value={target.access} disabled={ticket.state !== "planning"}>
<select bind:value={target.access} disabled={busy !== null}>
<option value="read_write">Read and write</option>
<option value="read_only">Read only</option>
</select>
</label>
{#if ticket.state === "planning"}
<button class="workspace-secondary-button" type="button" onclick={() => removeTarget(index)}>Remove target</button>
{/if}
</fieldset>
{:else}
<p class="workspace-empty-copy">No repository targets.</p>
{/each}
</div>
{#if ticket.state === "planning"}
<button
class="workspace-secondary-button"
type="button"
onclick={() => addTarget(targetDrafts.some((target) => target.access === "read_write") ? "read_only" : "read_write")}
onclick={() => addTarget()}
>Add target</button>
{/if}
<button class="workspace-secondary-button" type="submit" disabled={busy === "target" || ticket.state !== "planning"}>
<button class="workspace-secondary-button" type="submit" disabled={busy !== null || !targetCandidateValid}>
{busy === "target" ? "Saving…" : "Save targets"}
</button>
</form>
</section>
<section class="ticket-control-card">
<header><h2>Workflow</h2></header>
<form class="ticket-control-form" onsubmit={transition}>
<label>State
<select bind:value={nextState}>
{#each MUTABLE_TICKET_STATES as state}<option value={state}>{state}</option>{/each}
</select>
</label>
<label>Reason<input bind:value={transitionReason} placeholder="Optional decision context" /></label>
<button class="workspace-secondary-button" type="submit" disabled={busy === "state" || nextState === ticket.state}>
Apply state
</button>
</form>
{#if ticket.state === "planning"}
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy !== null || !targetCandidateValid} onclick={markReady}>
{busy === "ready" ? "Marking ready…" : "Mark ready"}
</button>
{#if !targetCandidateValid}
<p class="workspace-empty-copy">Add unique registered repository targets with effective ref selectors and exactly one read-write target before marking ready.</p>
{/if}
{:else if ticket.state === "ready"}
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !ticket.action_eligibility.can_queue} onclick={() => void queueTicket()}>
{busy === "queue" ? "Queueing…" : `Queue ${ticket.action_eligibility.queue_tickets.length} Ticket(s)`}
</button>
{#if !ticket.action_eligibility.can_queue}
<p class="workspace-empty-copy">Queue requires a valid target, an active Orchestrator assignment, no active Coder assignment, and no dependency still in planning.</p>
{:else if ticket.action_eligibility.queue_tickets.length > 0}
<p class="workspace-empty-copy">This operation queues: {ticket.action_eligibility.queue_tickets.join(", ")}.</p>
{#if ticket.relations.blockers.length > 0}
<p class="workspace-empty-copy">Ready dependencies are queued atomically. Queued or in-progress dependencies remain unchanged for the Orchestrator to schedule.</p>
{/if}
{/if}
{/if}
</section>
<details class="ticket-control-card">
<summary>Append timeline event</summary>
<summary>Record result or comment</summary>
<form class="ticket-control-form" onsubmit={appendThread}>
<label>Role<select bind:value={threadRole}>
<option value="comment">Comment</option>
@@ -687,12 +710,13 @@
<option value="implementation_report">Implementation report</option>
</select></label>
<label>Body<textarea bind:value={threadBody} rows="5" required></textarea></label>
<button class="workspace-secondary-button" type="submit" disabled={busy === "thread" || !threadBody.trim()}>Append event</button>
<button class="workspace-secondary-button" type="submit" disabled={busy !== null || !threadBody.trim()}>Append event</button>
</form>
</details>
{#if mergeRequests.length > 0}
<section class="ticket-control-card">
<header><h2>Current requirement evidence</h2></header>
<header><h2>MR requirement evidence</h2></header>
<p><strong>Current requirements:</strong> {ticket.evidence.complete_for_integration ? "satisfied for integration" : "not satisfied for integration"}.</p>
<p><strong>Current requirement approval:</strong> {currentRequirementApprovalStatus(ticket.evidence)}.</p>
{#if ticket.evidence.missing.length > 0}
@@ -700,14 +724,16 @@
{#each ticket.evidence.missing as missing}<li>{missing}</li>{/each}
</ul>
{/if}
<p class="workspace-empty-copy">This evidence evaluates current Ticket requirements, separately from persisted integration approval.</p>
<p class="workspace-empty-copy">This evaluates linked MR requirements, separately from Ticket progress and persisted integration approval.</p>
{#if ticket.state === "done"}
<p class="workspace-empty-copy">This Ticket remains done. Incomplete current requirement evidence does not cancel recorded completion.</p>
{/if}
</section>
{/if}
<section class="ticket-control-card">
<header><h2>Merge Requests</h2></header>
<p class="workspace-empty-copy">Ticket completion is not MR approval or integration.</p>
{#if mergeRequests.length > 0}
{#each mergeRequests as mergeRequest (mergeRequest.merge_request_id)}
<article class="ticket-control-card">
@@ -733,7 +759,7 @@
</article>
{/each}
{:else}
<p class="workspace-empty-copy">The assigned Coder has not opened a Merge Request.</p>
<p class="workspace-empty-copy">No Merge Requests linked. A Ticket can be completed with a comment or document result.</p>
{/if}
</section>
@@ -742,7 +768,7 @@
<summary>Close ticket</summary>
<form class="ticket-control-form" onsubmit={closeTicket}>
<label>Resolution<textarea bind:value={resolution} rows="5" required></textarea></label>
<button class="workspace-danger-button" type="submit" disabled={busy === "close" || !resolution.trim()}>Close ticket</button>
<button class="workspace-danger-button" type="submit" disabled={busy !== null || !resolution.trim()}>Close ticket</button>
</form>
</details>
{:else if ticket.resolution}
@@ -18,6 +18,8 @@
buildCreateWorkspaceWorkerRequest,
defaultWorkerLaunchForm,
workerLaunchAttachmentError,
genericLaunchProfiles,
workerLaunchOutcomeUnknown,
} from '#lib/workspace/sidebar/worker-launch.ts';
import type { WorkerLaunchAttachmentFormState } from '#lib/workspace/sidebar/worker-launch.ts';
import type {
@@ -58,19 +60,11 @@
let optionsError = $state<string | null>(null);
let submitting = $state(false);
let submitError = $state<DisplayError | null>(null);
let displayName = $state(
ticketContext
? `${ticketContext.ticketTitle} · ${ticketContext.ticketRole || 'Worker'}`
: 'Worker',
);
let outcomeUnknown = $state(false);
let displayName = $state(ticketContext?.ticketTitle ?? 'Worker');
let runtimeId = $state('');
let profile = $state(
ticketContext
? ticketContext.ticketRole === 'reviewer'
? 'builtin:reviewer'
: 'builtin:coder'
: '',
);
let profile = $state('');
let flow = $state('');
let subjektivSubjectId = $state('');
let subjects = $state<SubjektivSubjectResponse[]>([]);
let subjectsLoading = $state(false);
@@ -82,24 +76,25 @@
let initialText = $state(ticketContext?.initialInput ?? '');
let workdirAttachments = $state<WorkerLaunchAttachmentFormState[]>([]);
let workingDirectoryDisplayName = $state('');
let workingDirectoryRepositoryKey = $state(ticketContext?.repositoryKey ?? '');
let workingDirectorySelector = $state(ticketContext?.refSelector ?? 'HEAD');
let workingDirectoryRepositoryKey = $state('');
let workingDirectorySelector = $state('HEAD');
let creatingWorkingDirectory = $state(false);
let newWorkingDirectoryAttachmentIndex = $derived(
workdirAttachments.findIndex((attachment) => attachment.working_directory_id === NEW_WORKING_DIRECTORY_VALUE),
);
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 availableProfiles = $derived(genericLaunchProfiles(options?.profiles ?? []));
let selectedProfile = $derived(availableProfiles.find((candidate) => candidate.id === (profile || options?.default_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 selectedRuntimeForbidsWorkdirAttachments = $derived(selectedRuntime?.supports_workdir_attachments === false);
let availableWorkingDirectories = $derived(
selectedRuntimeAllowsNoWorkdir
selectedRuntimeForbidsWorkdirAttachments
? []
: (options?.working_directories ?? []).filter((directory) =>
directory.status === 'active' &&
directory.status === 'active' && directory.source.kind !== 'workspace_config' &&
(directory.source.kind === 'external_grant' || directory.cleanliness === 'clean') &&
directory.occupied_by == null
),
@@ -110,11 +105,9 @@
: workerLaunchAttachmentError(workdirAttachments),
);
let canStartWorker = $derived(Boolean(
runtimeId &&
profile &&
(selectedRuntimeAllowsNoWorkdir
? workdirAttachments.length === 0
: workdirAttachments.length > 0 && !attachmentValidationError),
runtimeId && selectedRuntime?.worker_creation_available && !outcomeUnknown &&
!attachmentValidationError &&
(!selectedRuntimeForbidsWorkdirAttachments || workdirAttachments.length === 0),
));
function workerApiPath(path: string): string {
@@ -133,14 +126,8 @@
});
$effect(() => {
if (selectedRuntimeAllowsNoWorkdir && workdirAttachments.length > 0) {
if (selectedRuntimeForbidsWorkdirAttachments && workdirAttachments.length > 0) {
workdirAttachments = [];
} else if (selectedRuntime?.working_directory_required === true && workdirAttachments.length === 0) {
workdirAttachments = [{
alias: 'workdir',
working_directory_id: availableWorkingDirectories[0]?.working_directory_id ?? '',
relative_cwd: '',
}];
}
});
@@ -163,15 +150,9 @@
}
function addAttachment(): void {
const selectedWorkdirs = new Set(
workdirAttachments.map((attachment) => attachment.working_directory_id),
);
const available = availableWorkingDirectories.find((directory) =>
!selectedWorkdirs.has(directory.working_directory_id)
);
workdirAttachments = [...workdirAttachments, {
alias: nextAttachmentAlias(),
working_directory_id: available?.working_directory_id ?? '',
working_directory_id: '',
relative_cwd: '',
}];
}
@@ -212,21 +193,7 @@
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;
workdirAttachments = form.workdir_attachments;
if (
runtimeRequiresWorkdir &&
ticketContext?.repositoryKey &&
workdirAttachments.every((attachment) => !attachment.working_directory_id)
) {
workdirAttachments = [{
alias: 'workdir',
working_directory_id: NEW_WORKING_DIRECTORY_VALUE,
relative_cwd: '',
}];
}
workingDirectoryRepositoryKey = form.working_directory_repository_key;
workingDirectorySelector = form.working_directory_selector;
} catch (err) {
@@ -290,8 +257,8 @@
submitError = { message: 'select a runtime before creating a workdir', diagnostics: [] };
return;
}
if (selectedRuntimeAllowsNoWorkdir) {
submitError = { message: 'embedded Runtime does not create workdirs', diagnostics: [] };
if (selectedRuntimeForbidsWorkdirAttachments) {
submitError = { message: 'the selected Runtime does not support Workdir attachments', diagnostics: [] };
return;
}
if (!workingDirectoryRepositoryKey) {
@@ -342,18 +309,15 @@
}
async function createWorker() {
if (submitting || outcomeUnknown) return;
if (!workspaceId) {
submitError = { message: 'workspace id is unavailable', diagnostics: [] };
return;
}
if (selectedRuntimeAllowsNoWorkdir && workdirAttachments.length > 0) {
if (selectedRuntimeForbidsWorkdirAttachments && workdirAttachments.length > 0) {
submitError = { message: 'the selected Runtime must start without Workdir attachments', diagnostics: [] };
return;
}
if (!selectedRuntimeAllowsNoWorkdir && workdirAttachments.length === 0) {
submitError = { message: 'add at least one Workdir attachment before starting a Worker; only embedded Runtime can start without one', diagnostics: [] };
return;
}
if (attachmentValidationError) {
submitError = { message: attachmentValidationError, diagnostics: [] };
return;
@@ -361,23 +325,31 @@
submitError = null;
submitting = true;
let dispatched = false;
try {
const request = buildCreateWorkspaceWorkerRequest({
runtime_id: runtimeId,
display_name: displayName,
profile,
flow,
ticket_assignment: ticketContext
? { ticket_id: ticketContext.ticketId, operation_id: crypto.randomUUID() }
: null,
subjektiv_subject_id: subjektivSubjectId,
initial_text: initialText,
workdir_attachments: workdirAttachments,
working_directory_repository_key: workingDirectoryRepositoryKey,
working_directory_selector: workingDirectorySelector,
});
dispatched = true;
const response = await fetch(workerApiPath('/workers'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(buildCreateWorkspaceWorkerRequest({
runtime_id: runtimeId,
display_name: displayName,
profile,
subjektiv_subject_id: subjektivSubjectId,
initial_text: initialText,
workdir_attachments: workdirAttachments,
working_directory_repository_key: workingDirectoryRepositoryKey,
working_directory_selector: workingDirectorySelector,
})),
body: JSON.stringify(request),
});
if (!response.ok) {
submitError = await responseDisplayError(response, 'worker create failed');
outcomeUnknown = workerLaunchOutcomeUnknown({ error: submitError.message, message: '', diagnostics: submitError.diagnostics });
return;
}
const payload = parseBrowserCreateWorkerResponse(
@@ -385,6 +357,7 @@
);
await goto(payload.console_href);
} catch (err) {
outcomeUnknown = dispatched;
submitError = exceptionDisplayError(err, 'worker create failed');
} finally {
submitting = false;
@@ -422,14 +395,14 @@
<header class="worker-new-page-header">
<div>
<h1 id="new-worker-heading">New Worker</h1>
<p>Create a Worker on a selected Runtime. Workdir-less conversation Workers are only available on embedded Runtime.</p>
<p>Choose a Runtime and any resources needed for this request.</p>
</div>
</header>
{#if ticketContext}
<aside class="worker-ticket-context">
<div>
<span>Ticket {ticketContext.ticketRole || 'Worker'}</span>
<span>Ticket assignment</span>
<strong>{ticketContext.ticketTitle}</strong>
<code>{ticketContext.ticketId}</code>
</div>
@@ -461,7 +434,7 @@
</select>
</div>
{#if selectedRuntimeAllowsNoWorkdir}
{#if selectedRuntimeForbidsWorkdirAttachments}
<p class="worker-workdir-note">No filesystem tools or Bash will be available without a Workdir.</p>
{:else}
<div class="worker-attachment-heading">
@@ -536,6 +509,7 @@
<label>
<span>Repository</span>
<select bind:value={workingDirectoryRepositoryKey}>
<option value="">Select repository</option>
{#if options?.repositories.length}
{#each options.repositories as repository}
<option value={repository.repository_key}>{repository.repository_key}</option>
@@ -565,15 +539,12 @@
<input bind:value={displayName} required maxlength="80" autocomplete="off" />
</label>
<label>
<span>Profile</span>
<select bind:value={profile} required>
{#if options?.profiles.length}
{#each options.profiles as candidate}
<option value={candidate.id}>{candidate.label}</option>
{/each}
{:else}
<option value="" disabled>No profile candidates</option>
{/if}
<span>Profile (optional)</span>
<select bind:value={profile} aria-label="Profile">
<option value="">Workspace default</option>
{#each availableProfiles as candidate}
<option value={candidate.id}>{candidate.label}</option>
{/each}
</select>
</label>
</div>
@@ -627,7 +598,11 @@
</div>
{/if}
<label>
<span>Initial text</span>
<span>Flow (optional)</span>
<input bind:value={flow} autocomplete="off" placeholder="Optional Flow selector" />
</label>
<label>
<span>Initial request</span>
<textarea bind:value={initialText} rows="7" placeholder="Optional first instruction"></textarea>
</label>
</section>
@@ -635,6 +610,10 @@
{#if submitError}
<div class="section-state error worker-submit-error">
<p>{submitError.message}</p>
{#if outcomeUnknown}
<p>The launch outcome is unknown. Check the Worker list or Ticket before starting another Worker.</p>
<a href={`/w/${workspaceId}/workers`}>Check Workers</a>
{/if}
{#if submitError.diagnostics.length > 0}
<ul class="worker-error-diagnostics">
{#each submitError.diagnostics as diagnostic}
@@ -2,17 +2,14 @@ export function load(
{ params, url }: { params: { workspaceId: string }; url: URL },
) {
const ticketId = url.searchParams.get("ticketId") ?? "";
const ticketRole = url.searchParams.get("ticketRole") ?? "";
return {
workspaceId: params.workspaceId,
ticketContext: ticketId
? {
ticketId,
ticketTitle: url.searchParams.get("ticketTitle") ?? ticketId,
ticketRole,
initialInput: url.searchParams.get("initialInput") ?? "",
repositoryKey: url.searchParams.get("repositoryKey") ?? "",
refSelector: url.searchParams.get("refSelector") ?? "HEAD",
initialInput: url.searchParams.get("initialInput") ??
`Work on Ticket ${ticketId}.`,
}
: null,
};
@@ -9,7 +9,11 @@ import {
import { afterEach, expect, test, vi } from "vitest";
import type { ComponentProps } from "svelte";
import NewWorker from "./+page.svelte";
function mount() {
function mount(
ticketContext:
| { ticketId: string; ticketTitle: string; initialInput: string }
| null = null,
) {
const data = {
workspaceId: "empty",
repositories: {
@@ -18,7 +22,7 @@ function mount() {
source: "fixture",
diagnostics: [],
},
ticketContext: null,
ticketContext,
} as unknown as ComponentProps<typeof NewWorker>["data"];
return render(NewWorker, {
props: { data, params: { workspaceId: "empty" } },
@@ -58,14 +62,14 @@ test("zero-repository Workspace launches a normal embedded Worker request withou
const body = JSON.parse(String(api.mock.calls[1][1]?.body));
expect(body).toMatchObject({
runtime_id: "embedded",
profile: "builtin:companion",
profile: null,
workdir_attachments: [],
ticket_assignment: null,
});
expect(body).not.toHaveProperty("repository_key");
});
test("zero-repository Workspace does not bypass a remote Runtime Workdir requirement", async () => {
test("zero-repository Workspace allows a remote Runtime launch without attachments", async () => {
const options = emptyLaunchOptions("empty");
vi.stubGlobal(
"fetch",
@@ -74,10 +78,151 @@ test("zero-repository Workspace does not bypass a remote Runtime Workdir require
),
);
mount();
await screen.findByRole("button", { name: "Start Worker" });
await screen.findByLabelText("Attachment 1 Workdir");
expect(
(screen.getByRole("button", { name: "Start Worker" }) as HTMLButtonElement)
.disabled,
).toBe(true);
const start = await screen.findByRole("button", { name: "Start Worker" });
expect(screen.queryByLabelText("Attachment 1 Workdir")).toBeNull();
expect(screen.getByRole("button", { name: "Add attachment" })).toBeTruthy();
expect((start as HTMLButtonElement).disabled).toBe(false);
});
test.each(["embedded", "remote"].flatMap((runtime) =>
[
"Review is unnecessary.",
"Review, then merge if approved.",
"Return without a conclusion.",
].map((text) => ({ runtime, text }))
))(
"$runtime Ticket launch retains natural request without attachments: $text",
async ({ runtime, text }) => {
const options = emptyLaunchOptions("empty");
if (runtime === "remote") options.runtimes.reverse();
options.profiles.push({
...options.profiles[0],
id: "builtin:reviewer",
label: "Trusted Reviewer",
});
const api = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) =>
String(input).endsWith("/launch-options")
? Response.json(options)
: Response.json({
error: "permission_denied",
message: "Backend rejected this assignment",
diagnostics: [],
}, { status: 403 })
);
vi.stubGlobal("fetch", api);
mount({
ticketId: "T-720",
ticketTitle: "Investigate launch",
initialInput: text,
});
const start = await screen.findByRole("button", { name: "Start Worker" });
expect((start as HTMLButtonElement).disabled).toBe(false);
expect(screen.queryByRole("option", { name: "Trusted Reviewer" }))
.toBeNull();
await fireEvent.click(start);
await screen.findByText(
"permission_denied: Backend rejected this assignment",
);
const request = JSON.parse(String(api.mock.calls[1][1]?.body));
expect(request.runtime_id).toBe(runtime);
expect(request.ticket_assignment.ticket_id).toBe("T-720");
expect(request.ticket_assignment.operation_id).toMatch(/^[0-9a-f-]{36}$/);
expect(request.initial_submit).toEqual([{ kind: "text", content: text }]);
expect(request.profile).toBeNull();
expect(request.workdir_attachments).toEqual([]);
expect(request.ticket_assignment).not.toHaveProperty("role");
},
);
test("read-only Workdir requires an explicit selection and keeps backend permissions authoritative", async () => {
const options = emptyLaunchOptions("empty");
options.runtimes.reverse();
options.working_directories = [{
working_directory_id: "read-only-grant",
display_name: "Read-only sessions",
source: {
kind: "external_grant",
grant_id: "grant-1",
grant_permissions: { read: true, write: false, command: false },
},
materializer_kind: "client_hosted_external",
status: "active",
cleanliness: "unknown",
}];
const api = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) =>
String(input).endsWith("/launch-options")
? Response.json(options)
: Response.json({
error: "permission_denied",
message: "No write permission",
diagnostics: [],
}, { status: 403 })
);
vi.stubGlobal("fetch", api);
mount({
ticketId: "T-720",
ticketTitle: "Read-only investigation",
initialInput: "Return without a conclusion.",
});
const start = await screen.findByRole("button", {
name: "Start Worker",
}) as HTMLButtonElement;
expect(start.disabled).toBe(false);
await fireEvent.click(screen.getByRole("button", { name: "Add attachment" }));
const select = await screen.findByLabelText("Attachment 1 Workdir");
expect((select as HTMLSelectElement).value).toBe("");
expect(start.disabled).toBe(true);
await fireEvent.change(select, { target: { value: "read-only-grant" } });
await waitFor(() => expect(start.disabled).toBe(false));
await fireEvent.click(start);
await screen.findByText("permission_denied: No write permission");
const request = JSON.parse(String(api.mock.calls[1][1]?.body));
expect(request.workdir_attachments).toEqual([{
alias: "workdir",
working_directory_id: "read-only-grant",
relative_cwd: null,
}]);
expect(request).not.toHaveProperty("targets");
expect(request.workdir_attachments[0]).not.toHaveProperty("permissions");
});
test.each(["backend", "network"])(
"explicit Flow stays optional and uncertain %s outcome prevents duplicate launch",
async (failure) => {
const api = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => {
if (String(input).endsWith("/launch-options")) {
return Response.json(emptyLaunchOptions("empty"));
}
if (failure === "network") {
throw new TypeError("Connection lost after dispatch");
}
return Response.json({
error: "OutcomeUnknown",
message: "Runtime result needs reconciliation",
diagnostics: [],
}, { status: 503 });
});
vi.stubGlobal("fetch", api);
mount({
ticketId: "T-720",
ticketTitle: "Investigate launch",
initialInput: "Return without a conclusion.",
});
const start = await screen.findByRole("button", { name: "Start Worker" });
await fireEvent.input(screen.getByLabelText("Flow (optional)"), {
target: { value: "project:investigate" },
});
await fireEvent.click(start);
await screen.findByText(
"The launch outcome is unknown. Check the Worker list or Ticket before starting another Worker.",
);
expect((start as HTMLButtonElement).disabled).toBe(true);
const request = JSON.parse(String(api.mock.calls[1][1]?.body));
expect(request.initial_submit).toEqual([{
kind: "text",
content: "Return without a conclusion.",
}, { kind: "flow", selector: "project:investigate" }]);
await fireEvent.click(start);
expect(api).toHaveBeenCalledTimes(2);
},
);
@@ -1,17 +1,13 @@
import { assert, assertEquals } from "jsr:@std/assert@1.0.19";
import { chromium } from "npm:playwright@1.59.1";
import { loadScenario } from "../../../../tools/web-ux/src/scenario.ts";
import {
startOwnedProcesses,
stopOwnedProcesses,
} from "../../../../tools/web-ux/src/processes.ts";
import { startOwnedProcesses, stopOwnedProcesses } from "../../../../tools/web-ux/src/processes.ts";
import { creationResponse } from "../../src/lib/workspace/api/workspace-catalog.test-fixtures.ts";
// Browser-only contract over the production static build and loopback API fixtures;
// no actual Backend, Runtime, Worker, account or repository is created.
Deno.test("production empty Workspace supports optional Git validation, reopening, and Workdir-less Worker submission at all review widths", async () => {
const scenarioPath =
new URL("./optional-repository.json", import.meta.url).pathname;
const scenarioPath = new URL("./optional-repository.json", import.meta.url).pathname;
const scenario = await loadScenario(scenarioPath);
async function freePort() {
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
@@ -28,8 +24,7 @@ Deno.test("production empty Workspace supports optional Git validation, reopenin
scenario.processes![1].args![3] = String(proxyPort);
scenario.processes![1].args![4] = `http://127.0.0.1:${upstreamPort}`;
scenario.processes![1].readyUrl = `${scenario.baseUrl}/health`;
const output =
new URL("../../.svelte-kit/t717/browser/", import.meta.url).pathname;
const output = new URL("../../.svelte-kit/t717/browser/", import.meta.url).pathname;
const processes = await startOwnedProcesses(
scenario.processes!,
scenarioPath,
@@ -138,8 +133,9 @@ Deno.test("production empty Workspace supports optional Git validation, reopenin
.selectOption("remote");
assert(
await page.getByRole("button", { name: "Start Worker" })
.isDisabled(),
.isEnabled(),
);
await page.getByRole("button", { name: "Add attachment", exact: true }).click();
await page.getByLabel("Attachment 1 Workdir").selectOption(
"__new_working_directory__",
);