feat(ticket): align Web actions and instructions with intent-led work
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
+155
-10
@@ -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__",
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user