From bf796f8db2bcbe4b12142ad4d0c3a473a948583f Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 6 Nov 2026 11:06:10 +0900 Subject: [PATCH] feat(web): present typed startup causes and gate uncertain launches --- web/workspace/deno.json | 3 +- .../src/lib/workspace/failure/model.ts | 4 +- .../src/lib/workspace/failure/receive.ts | 29 +- .../lib/workspace/sidebar/worker-launch.ts | 24 +- .../workers/startup-failure.test-fixtures.ts | 59 ++ .../workspace/workers/startup-failure.test.ts | 203 +++++++ .../lib/workspace/workers/startup-failure.ts | 504 ++++++++++++++++++ .../workspace/workers/worker-launch.test.ts | 22 - .../workers/new/PageContent.svelte | 73 ++- .../new/empty-workspace.browser.spec.ts | 149 +++++- .../worker-failure.browser.test.ts | 355 ++++++++++++ 11 files changed, 1347 insertions(+), 78 deletions(-) create mode 100644 web/workspace/src/lib/workspace/workers/startup-failure.test-fixtures.ts create mode 100644 web/workspace/src/lib/workspace/workers/startup-failure.test.ts create mode 100644 web/workspace/src/lib/workspace/workers/startup-failure.ts create mode 100644 web/workspace/test/visual-review/worker-failure.browser.test.ts diff --git a/web/workspace/deno.json b/web/workspace/deno.json index d0d81930a..9f72fda46 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,12 +6,13 @@ "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,../../crates/error-contract/fixtures --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV src/lib/workspace/feature-permissions.test.ts 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/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/failure/receive.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/subjektiv/routes.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/alert-order.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 src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.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 src/lib/workspace/tickets/ticket-search.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 test/config-source/adoption-api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts src/lib/workspace/memory/subject-body.test.ts src/lib/workspace/memory/conversation-attempt.test.ts src/lib/workspace/memory/conversation-api.test.ts tests/subject-delete.test.ts tests/journal-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 src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts src/lib/workspace/api/ticket-parent.test.ts src/lib/workspace/tickets/ticket-parent-controller.test.ts", + "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema,../../crates/error-contract/fixtures --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV src/lib/workspace/feature-permissions.test.ts 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/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/failure/receive.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/subjektiv/routes.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/alert-order.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 src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.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 src/lib/workspace/workers/startup-failure.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 src/lib/workspace/tickets/ticket-search.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 test/config-source/adoption-api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts src/lib/workspace/memory/subject-body.test.ts src/lib/workspace/memory/conversation-attempt.test.ts src/lib/workspace/memory/conversation-api.test.ts tests/subject-delete.test.ts tests/journal-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 src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts src/lib/workspace/api/ticket-parent.test.ts src/lib/workspace/tickets/ticket-parent-controller.test.ts", "test:journal:backend-contract": "cd ../.. && mkdir -p target && cargo test -p yoi-workspace-server --lib journal_browser_web_contract -- --nocapture > target/journal-browser-contract.log && cd web/workspace && deno run --allow-read=../../target/journal-browser-contract.log test/journal-backend-contract.ts ../../target/journal-browser-contract.log", "test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts", "test:browser:activation": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/activation-browser/activation-shell.browser.test.ts", "test:browser:hosting": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/hosting-browser/hosting-shell.browser.test.ts test/hosting-browser/setup-shell.browser.test.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", + "test:browser:worker-failure": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/worker-failure.browser.test.ts", "test:browser:drive": "deno test --config test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/drive-browser/drive.browser.test.ts", "test:browser:subject-navigation": "cd ../.. && deno run --config tools/web-ux/deno.json -A tools/web-ux/browser-tests/subject_navigation_review.ts web/workspace/build navigation-task", "test:browser:subject-handling": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/subject-handling.browser.test.ts", diff --git a/web/workspace/src/lib/workspace/failure/model.ts b/web/workspace/src/lib/workspace/failure/model.ts index 9e6a17a52..adff9a8b8 100644 --- a/web/workspace/src/lib/workspace/failure/model.ts +++ b/web/workspace/src/lib/workspace/failure/model.ts @@ -22,8 +22,8 @@ const FALLBACKS = { // 原因の説明/秘匿はServerの公開projectionが所有する。UIでreasonを翻訳したり // details/sourceを走査しない。相関参照は問い合わせ用で、再送keyではない。 -export function failureNotice( - receipt: FailureReceipt, +export function failureNotice( + receipt: FailureReceipt, ): FailureNoticeModel { if (receipt.kind !== "problem") { return { diff --git a/web/workspace/src/lib/workspace/failure/receive.ts b/web/workspace/src/lib/workspace/failure/receive.ts index e921964ca..66dcaea3e 100644 --- a/web/workspace/src/lib/workspace/failure/receive.ts +++ b/web/workspace/src/lib/workspace/failure/receive.ts @@ -20,8 +20,12 @@ export type DetailsDecoder = ( envelope: FailureEnvelope, ) => DetailsDecode; -export type FailureReceipt = - | { kind: "problem"; problem: ProblemDetails } +export type ProblemExtensionDecoder = ( + problem: Readonly>, +) => DetailsDecode; + +export type FailureReceipt = + | { kind: "problem"; problem: ProblemDetails; extension?: E } | { kind: "unsupported" } | { kind: "invalid" } | { kind: "too_large" } @@ -247,12 +251,13 @@ function uriReference(value: unknown): string { return value; } -export function decodeProblem( +export function decodeProblem( text: string, status: number, contentType: string, decode: DetailsDecoder, -): FailureReceipt { + decodeExtension?: ProblemExtensionDecoder, +): FailureReceipt { try { if ( contentType.split(";")[0].trim().toLowerCase() !== @@ -270,8 +275,11 @@ export function decodeProblem( const failure = decodeEnvelope(raw.yoi_failure, decode); if (detail !== failureRecord(raw.yoi_failure).explanation) return invalid(); if (failure.kind !== "known") return failure; + const extension = decodeExtension?.(raw); + if (extension && extension.kind !== "known") return extension; return { kind: "problem", + ...(extension?.kind === "known" ? { extension: extension.details } : {}), problem: { type, instance, @@ -288,10 +296,11 @@ export function decodeProblem( } } -export async function receiveProblem( +export async function receiveProblem( response: Response, decode: DetailsDecoder, -): Promise> { + decodeExtension?: ProblemExtensionDecoder, +): Promise> { const contentType = response.headers.get("content-type") ?? ""; if ( contentType.split(";")[0].trim().toLowerCase() !== @@ -312,5 +321,11 @@ export async function receiveProblem( : "transport", }; } - return decodeProblem(text, response.status, contentType, decode); + return decodeProblem( + text, + response.status, + contentType, + decode, + decodeExtension, + ); } diff --git a/web/workspace/src/lib/workspace/sidebar/worker-launch.ts b/web/workspace/src/lib/workspace/sidebar/worker-launch.ts index b6555cac4..e997f29ee 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-launch.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-launch.ts @@ -2,7 +2,6 @@ import type { BrowserWorkerWorkingDirectorySelection, CreateWorkspaceWorkerRequest, CreateWorkspaceWorkerTicketAssignmentRequest, - RepositoryApiError, WorkerLaunchProfileCandidate, } from "#lib/generated/worker-launch-api.ts"; import { parseCreateWorkspaceWorkerRequest } from "#lib/workspace/api/workers.ts"; @@ -41,12 +40,16 @@ export function defaultWorkerLaunchForm( options?.runtimes.find((runtime) => runtime.worker_creation_available) ?? options?.runtimes[0]; const runtime = - options?.runtimes.find((candidate) => candidate.runtime_id === current.runtime_id) ?? + options?.runtimes.find((candidate) => + candidate.runtime_id === current.runtime_id + ) ?? preferredRuntime; const availableWorkdirIds = new Set( (options?.working_directories ?? []).filter((directory) => - directory.status === "active" && directory.source.kind !== "workspace_config" && - (directory.source.kind === "external_grant" || directory.cleanliness === "clean") && + 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), ); @@ -137,7 +140,9 @@ export function buildCreateWorkspaceWorkerRequest( ticket_assignment: form.ticket_assignment ?? null, initial_submit: [ ...(initialMessage ? [{ kind: "text", content: form.initial_text }] : []), - ...(form.flow?.trim() ? [{ kind: "flow", selector: form.flow.trim() }] : []), + ...(form.flow?.trim() + ? [{ kind: "flow", selector: form.flow.trim() }] + : []), ], workdir_attachments: validatedAttachments(form.workdir_attachments), feature_connections: form.subjektiv_subject_id.trim() @@ -155,12 +160,3 @@ export function genericLaunchProfiles( 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)); -} diff --git a/web/workspace/src/lib/workspace/workers/startup-failure.test-fixtures.ts b/web/workspace/src/lib/workspace/workers/startup-failure.test-fixtures.ts new file mode 100644 index 000000000..4da85257a --- /dev/null +++ b/web/workspace/src/lib/workspace/workers/startup-failure.test-fixtures.ts @@ -0,0 +1,59 @@ +import denial from "../../../../../../crates/runtime-api/tests/fixtures/startup-browser-session-record-denied.problem.json" with { + type: "json", +}; +import responseLost from "../../../../../../crates/runtime-api/tests/fixtures/startup-browser-registration-response-lost.problem.json" with { + type: "json", +}; + +// 最初の二件だけがT812の実HTTP捕捉。派生ケースはWeb受信/操作/描画試験用で、Serverの保証ではない。 +export function startupFixture( + name: "denial" | "lost" | "rejected" | "compensation", +) { + const body = structuredClone(name === "lost" ? responseLost : denial); + if (name === "rejected") { + body.yoi_startup.operation.acceptance = "rejected"; + body.yoi_startup.operation.execution = "rejected"; + body.yoi_startup.operation.backend_compensation = "completed"; + body.yoi_startup.secondary = []; + body.yoi_startup.resources = { + resources: "none", + cleanup: "not_needed", + durable_effects_possible: false, + }; + } + if (name === "compensation") { + return { + ...body, + status: 500, + type: "urn:yoi:problem:startup-failed", + detail: + "Worker creation finalization failed; acceptance, execution and compensation are reported separately.", + yoi_failure: { + ...body.yoi_failure, + domain: "startup_boundary", + reason: "finalize_failed", + category: "internal", + details: { reason: "finalize_failed" }, + explanation: + "Worker creation finalization failed; acceptance, execution and compensation are reported separately.", + }, + yoi_startup: { + ...body.yoi_startup, + operation: { + ...body.yoi_startup.operation, + acceptance: "accepted", + execution: "errored", + backend_compensation: "residual", + finalize: { stage: "publication", compensation: "residual" }, + }, + secondary: [{ kind: "cleanup", failure: "feature" }], + resources: { + resources: "worker_startup", + cleanup: { failed: "feature" }, + durable_effects_possible: true, + }, + }, + }; + } + return body; +} diff --git a/web/workspace/src/lib/workspace/workers/startup-failure.test.ts b/web/workspace/src/lib/workspace/workers/startup-failure.test.ts new file mode 100644 index 000000000..f0e0e96b6 --- /dev/null +++ b/web/workspace/src/lib/workspace/workers/startup-failure.test.ts @@ -0,0 +1,203 @@ +import { + decodeWorkerStartupProblem, + submitWorkerLaunch, + workerLaunchNeedsReconciliation, + workerStartupNotice, +} from "./startup-failure.ts"; +import { startupFixture } from "./startup-failure.test-fixtures.ts"; +import { buildCreateWorkspaceWorkerRequest } from "../sidebar/worker-launch.ts"; + +declare const Deno: { + test(name: string, run: () => void | Promise): void; +}; +function equal(actual: unknown, expected: unknown) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ); + } +} +const decode = (body: unknown, status?: number) => + decodeWorkerStartupProblem( + JSON.stringify(body), + status ?? (body as { status: number }).status, + ); + +Deno.test("exact T812 HTTP fixtures preserve cause and independent operation facts including backend persistence", () => { + for (const name of ["denial", "lost"] as const) { + const body = startupFixture(name); + const receipt = decode(body); + if (receipt.kind !== "problem") throw new Error(receipt.kind); + equal(receipt.problem.yoi_failure.details, body.yoi_failure.details); + equal(receipt.extension, body.yoi_startup); + equal(workerStartupNotice(receipt).explanation, body.detail); + // 実409でもServerがunknownと観測したacceptanceを拒否/未実行へ補完しない。 + equal(workerLaunchNeedsReconciliation(receipt), true); + } +}); +Deno.test("definite rejection, unknown and compensation remain independent of wording, status, severity and diagnostic order", () => { + for (const name of ["denial", "lost", "rejected", "compensation"] as const) { + for ( + const text of [ + "文面が変わりました。", + "OutcomeUnknown", + "No effects; retry immediately!", + ] + ) { + const body = JSON.parse(JSON.stringify(startupFixture(name))); + body.title = "変更されたtitle"; + body.detail = body.yoi_failure.explanation = text; + body.future_extension = { raw_body: "secret /internal/path token" }; + body.yoi_failure.context.diagnostics = [{ + code: "safe", + explanation: "Supplement", + }, { code: "other", explanation: "Other supplement" }]; + const received = decode(body); + equal(received.kind, "problem"); + equal(workerLaunchNeedsReconciliation(received), name !== "rejected"); + body.yoi_failure.context.diagnostics.reverse(); + equal(workerLaunchNeedsReconciliation(decode(body)), name !== "rejected"); + equal( + JSON.stringify(workerStartupNotice(received)).includes("secret"), + false, + ); + } + } +}); +Deno.test("unknown type permits validated public explanation but absent/malformed/unknown mandatory results never unlock launch", () => { + const body = JSON.parse(JSON.stringify(startupFixture("rejected"))); + body.type = "urn:future:problem"; + equal(workerLaunchNeedsReconciliation(decode(body)), false); + for ( + const mutate of [ + (v: typeof body) => delete v.yoi_startup, + (v: typeof body) => delete v.yoi_startup.operation.execution, + (v: typeof body) => { + v.yoi_startup.operation.acceptance = "future"; + }, + (v: typeof body) => { + v.yoi_startup.version = 2; + }, + (v: typeof body) => { + v.yoi_failure.required_extensions = ["future"]; + }, + (v: typeof body) => { + v.yoi_failure.details.principal = "future"; + }, + (v: typeof body) => { + v.yoi_startup.secondary = Array(9).fill({ + kind: "cleanup", + phase: "cleanup", + failure: "feature", + }); + }, + (v: typeof body) => { + v.yoi_startup.operation.correlation = "/internal/secret"; + }, + (v: typeof body) => { + v.yoi_failure.category = "internal"; + }, + (v: typeof body) => { + v.status = 502; + }, + (v: typeof body) => { + v.yoi_failure.details.raw_body = "secret"; + }, + ] + ) { + const changed = structuredClone(body); + mutate(changed); + const receipt = decode(changed, 409); + if (receipt.kind === "problem") { + throw new Error("invalid/unsupported contract accepted"); + } + equal(workerLaunchNeedsReconciliation(receipt), true); + equal( + JSON.stringify(workerStartupNotice(receipt)).includes("secret"), + false, + ); + } +}); +Deno.test("Runtime's envelope reason is failed, not its closed detail reason; optional backend persistence stays independent", () => { + const body = JSON.parse(JSON.stringify(startupFixture("rejected"))); + body.yoi_failure.domain = "worker_startup"; + body.yoi_failure.reason = "failed"; + body.yoi_failure.category = "internal"; + body.yoi_failure.details = { reason: "metadata_pending", stage: "metadata" }; + delete body.yoi_startup.backend_result_persistence; + const receipt = decode(body); + equal(receipt.kind, "problem"); + if (receipt.kind === "problem") { + equal(receipt.extension?.backend_result_persistence, null); + } + body.yoi_failure.reason = "metadata_pending"; + equal(decode(body).kind, "invalid"); +}); +Deno.test("actual launch caller handles transport/parse/oversize/legacy failure safely and sends one POST only", async () => { + const request = buildCreateWorkspaceWorkerRequest({ + runtime_id: "embedded", + display_name: "Kept draft", + profile: "", + subjektiv_subject_id: "", + initial_text: "Do not change", + workdir_attachments: [], + working_directory_repository_key: "", + working_directory_selector: "HEAD", + }); + for ( + const response of [ + () => + new Response("secret raw body", { + status: 500, + headers: { "content-type": "application/problem+json" }, + }), + () => + new Response("x".repeat(32769), { + status: 502, + headers: { "content-type": "application/problem+json" }, + }), + () => + Response.json({ error: "permission_denied", message: "raw secret" }, { + status: 403, + }), + () => Response.json({ invalid: "success body" }), + () => { + throw new TypeError("secret transport"); + }, + () => + new Response(JSON.stringify(startupFixture("denial")), { + status: 409, + headers: { "content-type": "application/problem+json" }, + }), + ] + ) { + let calls = 0; + const result = await submitWorkerLaunch( + ((_path: unknown, init: RequestInit) => { + calls++; + equal(init.method, "POST"); + equal(JSON.parse(String(init.body)), request); + return Promise.resolve(response()); + }) as typeof fetch, + "/api/w/test/workers", + request, + ); + equal(calls, 1); + if (result.kind !== "failed") throw new Error("unexpected creation"); + equal(workerLaunchNeedsReconciliation(result.receipt), true); + equal( + JSON.stringify(workerStartupNotice(result.receipt)).includes("secret"), + false, + ); + } +}); + +Deno.test("persistence uncertainty does not become retry permission even alongside a rejected execution", () => { + for (const field of ["persistence", "backend_result_persistence"]) { + for (const outcome of ["not_saved", "conflict", "outcome_unknown"]) { + const body = JSON.parse(JSON.stringify(startupFixture("rejected"))); + body.yoi_startup[field] = outcome; + equal(workerLaunchNeedsReconciliation(decode(body)), true); + } + } +}); diff --git a/web/workspace/src/lib/workspace/workers/startup-failure.ts b/web/workspace/src/lib/workspace/workers/startup-failure.ts new file mode 100644 index 000000000..99e0c1ea3 --- /dev/null +++ b/web/workspace/src/lib/workspace/workers/startup-failure.ts @@ -0,0 +1,504 @@ +import type { + Category, + CompensationOutcome, + CreationAcceptance, + ExecutionOutcome, + FinalizeStage, + RuntimeStartupReason, + SessionPrincipal, + SessionRegistrationReason, + StartupBoundaryReason, + StartupCleanupFailure, + StartupFailurePhase, + StartupIoKind, + StartupPersistenceOutcome, + StartupProblemDetails, + StartupProblemFacts, + StartupStage, +} from "#lib/generated/worker-launch-api.ts"; +import { readBoundedJson } from "#lib/workspace/api/http.ts"; +import { parseBrowserCreateWorkerResponse } from "#lib/workspace/api/workers.ts"; +import type { FailureEnvelope } from "#lib/generated/error-contract.ts"; +import { failureNotice } from "#lib/workspace/failure/model.ts"; +import type { FailureNoticeModel } from "#lib/workspace/failure/model.ts"; +import { + decodeProblem, + failureIdentifier, + failureRecord, + receiveProblem, +} from "#lib/workspace/failure/receive.ts"; +import type { + DetailsDecode, + FailureReceipt, +} from "#lib/workspace/failure/receive.ts"; + +type StartupCause = StartupProblemDetails["yoi_failure"]["details"]; +export type StartupReceipt = FailureReceipt; +class UnsupportedContract extends Error {} +const unsupported = (): never => { + throw new UnsupportedContract(); +}; +const invalid = (): never => { + throw new Error("Invalid startup contract"); +}; +function exact(value: Record, keys: string[]) { + if (Object.keys(value).some((key) => !keys.includes(key))) invalid(); +} +function enumValue(value: unknown, values: readonly T[]): T { + if (typeof value !== "string") return invalid(); + if (!values.includes(value as T)) return unsupported(); + return value as T; +} +function optional(value: unknown, decode: (v: unknown) => T): T | null { + return value == null ? null : decode(value); +} +const acceptance = [ + "rejected", + "accepted", + "unknown", +] as const satisfies readonly CreationAcceptance[]; +const execution = [ + "accepted", + "busy", + "rejected", + "errored", + "unsupported", + "unknown", +] as const satisfies readonly ExecutionOutcome[]; +const compensation = [ + "completed", + "residual", + "unknown", +] as const satisfies readonly CompensationOutcome[]; +const persistence = [ + "persisted", + "not_saved", + "conflict", + "outcome_unknown", +] as const satisfies readonly StartupPersistenceOutcome[]; +const cleanupFailure = [ + "ownership", + "workdir", + "artifacts", + "allocation", + "feature", + "unsupported", +] as const satisfies readonly StartupCleanupFailure[]; +const phase = [ + "cleanup", + "runtime_record_delete", + "runtime_snapshot_save", + "runtime_removal_publish", + "metadata_save", + "runtime_reconciliation_record_save", + "runtime_reconciliation_publish", +] as const satisfies readonly StartupFailurePhase[]; +const finalizeStage = [ + "worker_identity", + "workspace_api_binding", + "create_reservation", + "worker_registry", + "workdir_attachment", + "workdir_registry", + "ticket_assignment_bind", + "ticket_assignment_current", + "ticket_state_accept", + "session_registration", + "initial_input", + "publication", +] as const satisfies readonly FinalizeStage[]; +const stage = [ + "preflight", + "metadata", + "reconstruction", + "recovery", + "binding", + "feature", + "activation_effects", + "controller", +] as const satisfies readonly StartupStage[]; +const ioKind = [ + "not_found", + "permission_denied", + "already_exists", + "invalid_input", + "invalid_data", + "interrupted", + "timed_out", + "would_block", + "unexpected_eof", + "other", +] as const satisfies readonly StartupIoKind[]; + +// 公開schemaのclosed discriminator/fieldを検証するだけで、reasonの説明・秘匿・HTTP分類は生成しない。 +export function decodeStartupCause( + envelope: FailureEnvelope, +): DetailsDecode { + try { + if (envelope.details_version !== 1) return { kind: "unsupported" }; + const value = failureRecord(envelope.details); + let category: Category; + let expectedReason = value.reason; + if (envelope.domain === "session_registration") { + exact(value, ["reason", "principal", "required_operation"]); + enumValue( + value.reason, + [ + "permission_denied", + "attribution_missing", + "binding_mismatch", + "transport", + "outcome_unknown", + "invalid_response", + ] as const satisfies readonly SessionRegistrationReason[], + ); + enumValue( + value.principal, + [ + "operator", + "subject", + "unknown", + ] as const satisfies readonly SessionPrincipal[], + ); + if (value.required_operation !== "session.record") { + return { kind: "unsupported" }; + } + category = value.reason === "transport" + ? "dependency" + : value.reason === "outcome_unknown" + ? "unknown" + : value.reason === "invalid_response" + ? "internal" + : "authorization"; + } else if (envelope.domain === "worker_startup") { + exact(value, ["reason", "stage"]); + enumValue( + value.reason, + [ + "startup_failed", + "segment_empty", + "system_prompt_missing", + "metadata_pending", + "metadata_missing", + "metadata_inactive", + "manifest_missing", + "allocation_failed", + "session_read_failed", + "metadata_read_failed", + "provider_failed", + "attribution_failed", + "feature_failed", + ] as const satisfies readonly RuntimeStartupReason[], + ); + enumValue(value.stage, stage); + expectedReason = "failed"; + category = "internal"; + } else if (envelope.domain === "startup_boundary") { + exact(value, ["reason"]); + enumValue( + value.reason, + [ + "timeout", + "transport", + "decode", + "invalid_response", + "too_large", + "unsupported_version", + "unsupported_reason", + "finalize_failed", + ] as const satisfies readonly StartupBoundaryReason[], + ); + category = value.reason === "finalize_failed" ? "internal" : "dependency"; + } else return { kind: "unsupported" }; + if (expectedReason !== envelope.reason || category !== envelope.category) { + return { kind: "invalid" }; + } + return { kind: "known", details: value as StartupCause }; + } catch (error) { + return { + kind: error instanceof UnsupportedContract ? "unsupported" : "invalid", + }; + } +} + +export function decodeStartupFacts( + root: Readonly>, +): DetailsDecode { + try { + const facts = failureRecord(root.yoi_startup); + exact(facts, [ + "version", + "operation", + "secondary", + "resources", + "persistence", + "backend_result_persistence", + "io_kind", + ]); + if ( + typeof facts.version !== "number" || !Number.isInteger(facts.version) || + facts.version < 1 || facts.version > 65535 + ) return invalid(); + if (facts.version !== 1) return { kind: "unsupported" }; + const operation = failureRecord(facts.operation); + exact(operation, [ + "acceptance", + "execution", + "finalize", + "backend_compensation", + "correlation", + ]); + const parsedOperation = { + acceptance: enumValue(operation.acceptance, acceptance), + execution: enumValue(operation.execution, execution), + finalize: optional(operation.finalize, (v) => { + const finalize = failureRecord(v); + exact(finalize, ["stage", "compensation"]); + return { + stage: enumValue(finalize.stage, finalizeStage), + compensation: enumValue(finalize.compensation, compensation), + }; + }), + backend_compensation: optional( + operation.backend_compensation, + (v) => enumValue(v, compensation), + ), + correlation: optional(operation.correlation, (v) => { + const reference = failureIdentifier(v); + if (!/^[A-Za-z0-9._-]+$/u.test(reference)) return invalid(); + return reference; + }), + }; + if (!Array.isArray(facts.secondary) || facts.secondary.length > 8) { + return invalid(); + } + const secondary = facts.secondary.map((v) => { + const item = failureRecord(v); + if (item.kind !== "cleanup") enumValue(item.phase, phase); + if (item.kind === "projection_unavailable") { + exact(item, ["kind", "phase"]); + } else if (item.kind === "cleanup") { + exact(item, ["kind", "failure"]); + enumValue(item.failure, cleanupFailure); + } else if (item.kind === "persistence") { + exact(item, ["kind", "phase", "outcome"]); + enumValue(item.outcome, persistence); + } else return unsupported(); + return item; + }); + const resources = optional(facts.resources, (v) => { + const item = failureRecord(v); + exact(item, ["resources", "cleanup", "durable_effects_possible"]); + const resources = enumValue(item.resources, [ + "none", + "worker_startup", + "unproven", + ]); + if (typeof item.durable_effects_possible !== "boolean") return invalid(); + if (typeof item.cleanup === "string") { + enumValue(item.cleanup, ["not_needed", "required", "confirmed"]); + } else { + const failed = failureRecord(item.cleanup); + exact(failed, ["failed"]); + enumValue(failed.failed, cleanupFailure); + } + return { ...item, resources }; + }); + // castsは正本生成型のnominal bounds markerを満たす検証済み値に限定する。 + return { + kind: "known", + details: { + version: 1, + operation: parsedOperation, + secondary, + resources, + persistence: optional( + facts.persistence, + (v) => enumValue(v, persistence), + ), + backend_result_persistence: optional( + facts.backend_result_persistence, + (v) => enumValue(v, persistence), + ), + io_kind: optional(facts.io_kind, (v) => enumValue(v, ioKind)), + } as StartupProblemFacts, + }; + } catch (error) { + return { + kind: error instanceof UnsupportedContract ? "unsupported" : "invalid", + }; + } +} + +export function decodeWorkerStartupProblem( + text: string, + status: number, + contentType = "application/problem+json", +): StartupReceipt { + return decodeProblem( + text, + status, + contentType, + decodeStartupCause, + decodeStartupFacts, + ); +} +export function receiveWorkerStartupProblem( + response: Response, +): Promise { + return receiveProblem(response, decodeStartupCause, decodeStartupFacts); +} + +// UIの新規送信抑止。原因/HTTP/診断/cleanup完了から未実行を推測しない。 +// accepted/unknown/受理後errorや未確認の補償・副作用は既存Workers/Ticketの正本で照合する。 +export function workerLaunchNeedsReconciliation( + receipt: StartupReceipt, +): boolean { + if (receipt.kind !== "problem" || !receipt.extension) return true; + const { + operation, + resources, + secondary, + persistence, + backend_result_persistence, + } = receipt.extension; + return (persistence != null && persistence !== "persisted") || + (backend_result_persistence != null && + backend_result_persistence !== "persisted") || + operation.acceptance !== "rejected" || + !["rejected", "unsupported"].includes(operation.execution) || + (operation.backend_compensation != null && + operation.backend_compensation !== "completed") || + (operation.finalize != null && + operation.finalize.compensation !== "completed") || + secondary.some((item) => + item.kind !== "persistence" || item.outcome !== "persisted" + ) || + (resources != null && + (resources.durable_effects_possible || resources.resources !== "none" || + !["not_needed", "confirmed"].includes(String(resources.cleanup)))); +} + +export function workerStartupNotice( + receipt: StartupReceipt, +): FailureNoticeModel { + const notice = failureNotice(receipt); + if (receipt.kind !== "problem" || !receipt.extension) return notice; + const facts = receipt.extension; + notice.supplements.push( + `Creation acceptance: ${facts.operation.acceptance}. Execution outcome: ${facts.operation.execution}.`, + ); + if (facts.operation.correlation) { + notice.details.push({ + label: "Support correlation", + value: facts.operation.correlation, + }); + } + if (facts.operation.backend_compensation) { + const text = `Backend cleanup: ${facts.operation.backend_compensation}.`; + if (facts.operation.backend_compensation === "completed") { + notice.details.push({ label: "Backend cleanup", value: "completed" }); + } else notice.compensation.push(text); + } + if (facts.operation.finalize) { + if (facts.operation.finalize.compensation !== "completed") { + notice.compensation.push( + `Finalization cleanup: ${facts.operation.finalize.compensation}.`, + ); + } else {notice.details.push({ + label: "Finalization cleanup", + value: "completed", + });} + notice.details.push({ + label: "Finalization stage", + value: facts.operation.finalize.stage, + }); + } + if (facts.resources) { + const cleanup = facts.resources.cleanup; + if (typeof cleanup !== "string" || cleanup === "required") { + notice.compensation.push( + `Runtime cleanup: ${ + typeof cleanup === "string" ? cleanup : `failed (${cleanup.failed})` + }.`, + ); + } + notice.details.push({ + label: "Runtime resource observation", + value: + `${facts.resources.resources}; durable effects possible: ${facts.resources.durable_effects_possible}; cleanup: ${ + typeof cleanup === "string" ? cleanup : cleanup.failed + }`, + }); + } + for (const item of facts.secondary) { + if (item.kind !== "persistence" || item.outcome !== "persisted") { + notice.supplements.push(`Secondary ${item.kind} result needs attention.`); + } + notice.details.push({ + label: "Secondary result", + value: `${item.kind === "cleanup" ? "cleanup" : item.phase}: ${ + item.kind === "cleanup" + ? item.failure + : item.kind === "persistence" + ? item.outcome + : item.kind + }`, + }); + } + for ( + const [label, value] of [ + ["Session metadata persistence", facts.persistence], + ["Backend result persistence", facts.backend_result_persistence], + ["I/O kind", facts.io_kind], + ] + ) { + if (value) notice.details.push({ label: label!, value }); + if (value && value !== "persisted" && label !== "I/O kind") { + notice.supplements.push(`${label}: ${value}.`); + } + } + return notice; +} + +// 送信/受信の失敗はremote側の確定拒否ではない。新しいPOSTへの自動再送は行わない。 +export async function submitWorkerLaunch( + fetchFn: typeof fetch, + path: string, + request: + import("#lib/generated/worker-launch-api.ts").CreateWorkspaceWorkerRequest, +): Promise< + | { + kind: "created"; + response: + import("#lib/generated/worker-launch-api.ts").BrowserCreateWorkerResponse; + } + | { kind: "failed"; receipt: StartupReceipt } +> { + let response: Response; + try { + response = await fetchFn(path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(request), + }); + } catch { + return { kind: "failed", receipt: { kind: "transport" } }; + } + if (!response.ok) { + return { + kind: "failed", + receipt: await receiveWorkerStartupProblem(response), + }; + } + try { + return { + kind: "created", + response: parseBrowserCreateWorkerResponse( + await readBoundedJson(response, 8 * 1024 * 1024), + ), + }; + } catch { + return { kind: "failed", receipt: { kind: "invalid" } }; + } +} diff --git a/web/workspace/src/lib/workspace/workers/worker-launch.test.ts b/web/workspace/src/lib/workspace/workers/worker-launch.test.ts index ffa75bbf2..6c9585829 100644 --- a/web/workspace/src/lib/workspace/workers/worker-launch.test.ts +++ b/web/workspace/src/lib/workspace/workers/worker-launch.test.ts @@ -2,7 +2,6 @@ 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"; @@ -163,24 +162,3 @@ Deno.test("generic launch cannot select a trusted Reviewer profile or infer it f } 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, - ); -}); diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/new/PageContent.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/new/PageContent.svelte index 4608c5c14..81e1adc74 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/new/PageContent.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/new/PageContent.svelte @@ -6,8 +6,10 @@ parseWorkingDirectoryCreateResponse, validateWorkingDirectoryCreateRequest, } from '#lib/workspace/api/workdirs.ts'; + import FailureNotice from '#lib/workspace/failure/FailureNotice.svelte'; + import type { FailureNoticeModel } from '#lib/workspace/failure/model.ts'; + import { submitWorkerLaunch, workerLaunchNeedsReconciliation, workerStartupNotice } from '#lib/workspace/workers/startup-failure.ts'; import { - parseBrowserCreateWorkerResponse, parseWorkerApiError, parseWorkerLaunchOptionsResponse, } from '#lib/workspace/api/workers.ts'; @@ -19,7 +21,6 @@ defaultWorkerLaunchForm, workerLaunchAttachmentError, genericLaunchProfiles, - workerLaunchOutcomeUnknown, } from '#lib/workspace/sidebar/worker-launch.ts'; import type { WorkerLaunchAttachmentFormState } from '#lib/workspace/sidebar/worker-launch.ts'; import type { @@ -63,7 +64,8 @@ let optionsError = $state(null); let submitting = $state(false); let submitError = $state(null); - let outcomeUnknown = $state(false); + let startupError = $state(null); + let needsReconciliation = $state(false); let displayName = $state(ticketContext?.ticketTitle ?? 'Worker'); let runtimeId = $state(''); let profile = $state(''); @@ -108,7 +110,7 @@ : workerLaunchAttachmentError(workdirAttachments), ); let canStartWorker = $derived(Boolean( - runtimeId && selectedRuntime?.worker_creation_available && !outcomeUnknown && + runtimeId && selectedRuntime?.worker_creation_available && !needsReconciliation && !attachmentValidationError && (!selectedRuntimeForbidsWorkdirAttachments || workdirAttachments.length === 0), )); @@ -312,7 +314,7 @@ } async function createWorker() { - if (submitting || outcomeUnknown) return; + if (submitting || needsReconciliation) return; if (!workspaceId) { submitError = { message: 'workspace id is unavailable', diagnostics: [] }; return; @@ -327,8 +329,8 @@ } submitError = null; + startupError = null; submitting = true; - let dispatched = false; try { const request = buildCreateWorkspaceWorkerRequest({ runtime_id: runtimeId, @@ -344,24 +346,26 @@ 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(request), - }); - if (!response.ok) { - submitError = await responseDisplayError(response, 'worker create failed'); - outcomeUnknown = workerLaunchOutcomeUnknown({ error: submitError.message, message: '', diagnostics: submitError.diagnostics }); + needsReconciliation = true; + const result = await submitWorkerLaunch(fetch, workerApiPath('/workers'), request); + if (result.kind === 'failed') { + startupError = workerStartupNotice(result.receipt); + needsReconciliation = workerLaunchNeedsReconciliation(result.receipt); return; } - const payload = parseBrowserCreateWorkerResponse( - await readBoundedJson(response, 8 * 1024 * 1024), - ); - await goto(payload.console_href); - } catch (err) { - outcomeUnknown = dispatched; - submitError = exceptionDisplayError(err, 'worker create failed'); + // 成功を観測しているので、navigationが失敗しても新しいWorkerを作らせない。 + needsReconciliation = true; + try { + await goto(result.response.console_href); + } catch { + startupError = { explanation: 'The Worker was created, but its console could not be opened. Check Workers before starting another Worker.', supplements: [], compensation: [], details: [] }; + } + } catch { + if (needsReconciliation) { + startupError = { explanation: 'The launch result could not be interpreted safely. Check Workers before starting another Worker.', supplements: [], compensation: [], details: [] }; + } else { + submitError = { message: 'Check the Worker launch settings before starting.', diagnostics: [] }; + } } finally { submitting = false; } @@ -610,13 +614,20 @@ - {#if submitError} -
-

{submitError.message}

- {#if outcomeUnknown} -

The launch outcome is unknown. Check the Worker list or Ticket before starting another Worker.

+ {#if startupError} + + {#if needsReconciliation} +
+

The launch result needs reconciliation. Check the Worker list or Ticket before starting another Worker.

Check Workers - {/if} + {#if ticketContext}Check Ticket{/if} +

Share only the support correlation from Failure details with an authorized operator. It is not a retry key; do not share credentials, raw responses, or internal logs.

+
+ {/if} + {/if} + {#if submitError} +