From 8c71ec719a7b906dd2d8742c64cf7b802c71ec02 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 6 Nov 2026 07:27:21 +0900 Subject: [PATCH 1/6] feat(web): add bounded shared failure reception and presentation --- web/workspace/deno.json | 2 +- web/workspace/src/lib/workspace/api/http.ts | 12 +- .../failure/FailureNotice.browser.spec.ts | 28 ++ .../workspace/failure/FailureNotice.svelte | 50 ++++ .../src/lib/workspace/failure/model.ts | 56 ++++ .../src/lib/workspace/failure/receive.test.ts | 256 ++++++++++++++++ .../src/lib/workspace/failure/receive.ts | 275 ++++++++++++++++++ 7 files changed, 675 insertions(+), 4 deletions(-) create mode 100644 web/workspace/src/lib/workspace/failure/FailureNotice.browser.spec.ts create mode 100644 web/workspace/src/lib/workspace/failure/FailureNotice.svelte create mode 100644 web/workspace/src/lib/workspace/failure/model.ts create mode 100644 web/workspace/src/lib/workspace/failure/receive.test.ts create mode 100644 web/workspace/src/lib/workspace/failure/receive.ts diff --git a/web/workspace/deno.json b/web/workspace/deno.json index caade0d29..d0d81930a 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -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 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/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 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", diff --git a/web/workspace/src/lib/workspace/api/http.ts b/web/workspace/src/lib/workspace/api/http.ts index 27773b1bc..c97221022 100644 --- a/web/workspace/src/lib/workspace/api/http.ts +++ b/web/workspace/src/lib/workspace/api/http.ts @@ -30,7 +30,7 @@ const SKILL_API_LOAD_POLICY: JsonLoadPolicy = { maxResponseBytes: SKILL_API_LIMITS.maxResponseBytes, }; -class ResponseByteLimitError extends Error {} +export class ResponseByteLimitError extends Error {} function normalizePath(path: string): string { if (!path || path === "/") return ""; @@ -155,6 +155,13 @@ export async function readBoundedJson( response: Response, maxBytes: number, ): Promise { + return JSON.parse(await readBoundedText(response, maxBytes)) as unknown; +} + +export async function readBoundedText( + response: Response, + maxBytes: number, +): Promise { const contentLength = response.headers.get("content-length"); if (contentLength !== null) { const parsedLength = Number(contentLength); @@ -191,8 +198,7 @@ export async function readBoundedJson( bytes.set(chunk, offset); offset += chunk.byteLength; } - const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); - return JSON.parse(text) as unknown; + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); } async function requireJson( diff --git a/web/workspace/src/lib/workspace/failure/FailureNotice.browser.spec.ts b/web/workspace/src/lib/workspace/failure/FailureNotice.browser.spec.ts new file mode 100644 index 000000000..a279cebd4 --- /dev/null +++ b/web/workspace/src/lib/workspace/failure/FailureNotice.browser.spec.ts @@ -0,0 +1,28 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen } from "@testing-library/svelte"; +import { afterEach, expect, test } from "vitest"; +import FailureNotice from "./FailureNotice.svelte"; + +afterEach(cleanup); +test("failure cause is an alert before optional details; server text cannot create markup or network links", () => { + const explanation = + "Subject session.record is denied; Session registration and Worker creation could not proceed. "; + render(FailureNotice, { + failure: { + explanation, + supplements: ["Other operations remain available."], + compensation: ["Cleanup could not be confirmed."], + details: [{ label: "Support correlation", value: "correlation-1" }], + }, + }); + const alert = screen.getByRole("alert"); + expect(alert.textContent).toContain(explanation); + expect(alert.querySelector("img")).toBeNull(); + expect(alert.querySelector("a")).toBeNull(); + const details = alert.querySelector("details")!; + expect(details.open).toBe(false); + expect(details.textContent).not.toContain("session.record"); + expect(screen.getByText("Cleanup needs attention")).toBeTruthy(); + expect(screen.getByText("Additional information")).toBeTruthy(); + expect(alert.getAttribute("aria-atomic")).toBe("true"); +}); diff --git a/web/workspace/src/lib/workspace/failure/FailureNotice.svelte b/web/workspace/src/lib/workspace/failure/FailureNotice.svelte new file mode 100644 index 000000000..548f74e69 --- /dev/null +++ b/web/workspace/src/lib/workspace/failure/FailureNotice.svelte @@ -0,0 +1,50 @@ + + + + + diff --git a/web/workspace/src/lib/workspace/failure/model.ts b/web/workspace/src/lib/workspace/failure/model.ts new file mode 100644 index 000000000..6165e4ff4 --- /dev/null +++ b/web/workspace/src/lib/workspace/failure/model.ts @@ -0,0 +1,56 @@ +import type { FailureReceipt } from "./receive.ts"; + +export type FailureNoticeModel = { + explanation: string; + supplements: string[]; + compensation: string[]; + details: { label: string; value: string }[]; +}; + +const FALLBACKS = { + unsupported: + "The server returned an unsupported failure contract. The operation result has not been confirmed.", + invalid: + "The server response could not be interpreted safely. The operation result has not been confirmed.", + too_large: + "The server response exceeded the safe size limit. The operation result has not been confirmed.", + transport: + "The response could not be received. The operation result has not been confirmed.", + http: + "The request did not return a supported failure response. The operation result has not been confirmed.", +} as const; + +// 原因の説明/秘匿はServerの公開projectionが所有する。UIでreasonを翻訳したり +// details/sourceを走査しない。相関参照は問い合わせ用で、再送keyではない。 +export function failureNotice( + receipt: FailureReceipt, +): FailureNoticeModel { + if (receipt.kind !== "problem") { + return { + explanation: FALLBACKS[receipt.kind], + supplements: [], + compensation: [], + details: [], + }; + } + const failure = receipt.problem.yoi_failure; + const details = [ + { label: "Cause", value: `${failure.domain} / ${failure.reason}` }, + ...failure.context.operations.map((operation) => ({ + label: "Operation / stage", + value: `${operation.operation} / ${operation.stage}`, + })), + ...(failure.context.correlation + ? [{ label: "Support correlation", value: failure.context.correlation }] + : []), + ]; + return { + explanation: failure.explanation, + // 同義wrapperをtitle/detail/explanationの三重表示にしない。 + supplements: [ + ...new Set(failure.context.diagnostics.map((item) => item.explanation)), + ].filter((text) => text !== failure.explanation), + compensation: [], + details, + }; +} diff --git a/web/workspace/src/lib/workspace/failure/receive.test.ts b/web/workspace/src/lib/workspace/failure/receive.test.ts new file mode 100644 index 000000000..36f3204d6 --- /dev/null +++ b/web/workspace/src/lib/workspace/failure/receive.test.ts @@ -0,0 +1,256 @@ +declare const Deno: { + test(name: string, fn: () => void | Promise): void; + readTextFileSync(path: URL): string; +}; + +import { + decodeFailure, + decodeProblem, + failureIdentifier, + failureRecord, + receiveProblem, +} from "./receive.ts"; +import type { DetailsDecoder } from "./receive.ts"; +import { failureNotice } from "./model.ts"; +import { FAILURE_LIMITS } from "#lib/generated/error-contract.ts"; + +function equal(actual: unknown, expected: unknown) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ); + } +} +// T810の二domain例はcoreの意味検証用。実Server認可/HTTP保証の代替ではない。 +const fixtureDecoder: DetailsDecoder<{ operation: string }> = (envelope) => { + if (envelope.details_version !== 1) return { kind: "unsupported" }; + const details = failureRecord(envelope.details); + if (envelope.domain === "subject" && envelope.reason === "operation_denied") { + if (envelope.category !== "authorization") return { kind: "invalid" }; + failureIdentifier(details.subject_ref); + return { + kind: "known", + details: { operation: failureIdentifier(details.required_operation) }, + }; + } + if (envelope.domain === "workdir" && envelope.reason === "in_use") { + if (envelope.category !== "conflict") return { kind: "invalid" }; + failureIdentifier(details.workdir_ref); + return { + kind: "known", + details: { operation: failureIdentifier(details.operation) }, + }; + } + return { kind: "unsupported" }; +}; +const subject = JSON.parse( + Deno.readTextFileSync( + new URL( + "../../../../../../crates/error-contract/fixtures/subject-denied.json", + import.meta.url, + ), + ), +); +const workdir = JSON.parse( + Deno.readTextFileSync( + new URL( + "../../../../../../crates/error-contract/fixtures/workdir-in-use.json", + import.meta.url, + ), + ), +); +function problem(failure: unknown = subject) { + return { + type: "urn:yoi:problem:example", + title: "Example", + status: 409, + detail: (failure as typeof subject).explanation, + instance: "", + yoi_failure: failure, + }; +} +function decode(value: unknown) { + return decodeProblem( + JSON.stringify(value), + 409, + "application/problem+json; charset=utf-8", + fixtureDecoder, + ); +} + +Deno.test("T810 Rust/TS shared fixtures preserve cause, operation context and correlation", () => { + for (const fixture of [subject, workdir]) { + const received = decodeFailure(JSON.stringify(fixture), fixtureDecoder); + if (received.kind !== "known") throw new Error(received.kind); + equal(received.failure.domain, fixture.domain); + equal(received.failure.reason, fixture.reason); + equal(received.failure.context, fixture.context); + equal( + received.failure.details.operation, + fixture.details.required_operation ?? fixture.details.operation, + ); + } +}); +Deno.test("explanatory wording, language, RFC type and unknown extensions do not select operation results", () => { + for ( + const explanation of [ + "拒否されたため登録できません。", + "OutcomeUnknown is only text", + "A new explanation", + ] + ) { + const value = problem({ ...subject, explanation }); + value.title = "Unbekannter Fehler"; + value.type = "urn:future:problem"; + const received = decode({ + ...value, + future_extension: { source: "/internal/secret", token: "secret" }, + }); + if (received.kind !== "problem") throw new Error(received.kind); + equal(received.problem.yoi_failure.details.operation, "session.record"); + const displayed = failureNotice(received); + equal(displayed.explanation, explanation); + equal(JSON.stringify(displayed).includes("secret"), false); + equal("retryable" in displayed, false); + } +}); +Deno.test("unsupported required contracts never become known outcomes or display opaque payloads", () => { + for ( + const failure of [ + { ...subject, version: 2 }, + { ...subject, required_extensions: ["future"] }, + { ...subject, details_version: 2 }, + { ...subject, reason: "future" }, + ] + ) { + const received = decode(problem(failure)); + equal(received.kind, "unsupported"); + equal( + JSON.stringify(failureNotice(received)).includes("session.record"), + false, + ); + } +}); +Deno.test("invalid identity, status mismatch, missing/malformed fields, duplicate fields and depth reject safely", () => { + for ( + const value of [ + { ...problem(), status: 403 }, + { ...problem(), detail: "other" }, + { ...problem(), yoi_failure: null }, + { ...problem(), title: null }, + problem({ ...subject, category: "internal" }), + problem({ + ...subject, + context: { + ...subject.context, + operations: Array(9).fill(subject.context.operations[0]), + }, + }), + problem({ ...subject, reason: "bad\nreason" }), + problem({ ...subject, explanation: "あ".repeat(700) }), + ] + ) equal(decode(value).kind, "invalid"); + equal( + decodeProblem( + '{"status":403,' + JSON.stringify(problem()).slice(1), + 409, + "application/problem+json", + fixtureDecoder, + ).kind, + "invalid", + ); + equal( + decode({ + ...problem(), + unknown: JSON.parse("[".repeat(17) + "0" + "]".repeat(17)), + }).kind, + "invalid", + ); + equal( + decodeProblem( + "raw /private/path token=secret", + 500, + "application/problem+json", + fixtureDecoder, + ).kind, + "invalid", + ); +}); +Deno.test("display uses server cause once, supplementary diagnostics separately, never raw details or source chain", () => { + const received = decode( + problem({ + ...subject, + details: { ...subject.details, source: "secret" }, + context: { + ...subject.context, + diagnostics: [ + { code: "wrapper", explanation: subject.explanation }, + { + code: "additional", + explanation: "Saved results remain available.", + }, + ], + }, + }), + ); + const display = failureNotice(received); + equal(display.supplements, ["Saved results remain available."]); + equal(display.explanation, subject.explanation); + equal(JSON.stringify(display).includes("secret"), false); +}); +Deno.test("stream byte budgets cancel before JSON parse with and without content-length", async () => { + for ( + const headers of [ + { "content-length": String(FAILURE_LIMITS.bytes + 1) }, + {}, + ] as Record[] + ) { + let cancelled = false; + const stream = new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array(1024)); + }, + cancel() { + cancelled = true; + }, + }); + const response = new Response(stream, { + status: 500, + headers: { ...headers, "content-type": "application/problem+json" }, + }); + equal((await receiveProblem(response, fixtureDecoder)).kind, "too_large"); + equal(cancelled, true); + } +}); +Deno.test("bounded response text, unknown MIME, broken transport and UTF8 never expose raw bodies", async () => { + equal( + (await receiveProblem( + new Response(JSON.stringify(problem()), { + status: 409, + headers: { "content-type": "application/problem+json" }, + }), + fixtureDecoder, + )).kind, + "problem", + ); + const cases = [ + new Response("secret", { status: 502 }), + new Response(new Uint8Array([255]), { + status: 502, + headers: { "content-type": "application/problem+json" }, + }), + new Response( + new ReadableStream({ + start(controller) { + controller.error(new Error("secret transport path")); + }, + }), + { status: 502, headers: { "content-type": "application/problem+json" } }, + ), + ]; + for (const response of cases) { + const received = await receiveProblem(response, fixtureDecoder); + if (received.kind === "problem") throw new Error("unexpected problem"); + equal(JSON.stringify(failureNotice(received)).includes("secret"), false); + } +}); diff --git a/web/workspace/src/lib/workspace/failure/receive.ts b/web/workspace/src/lib/workspace/failure/receive.ts new file mode 100644 index 000000000..58c4d4e30 --- /dev/null +++ b/web/workspace/src/lib/workspace/failure/receive.ts @@ -0,0 +1,275 @@ +import { FAILURE_LIMITS } from "#lib/generated/error-contract.ts"; +import type { + FailureContext, + FailureEnvelope, + ProblemDetails, +} from "#lib/generated/error-contract.ts"; +import { + readBoundedText, + ResponseByteLimitError, +} from "#lib/workspace/api/http.ts"; + +// domain固有detailsの検証は公開schemaを所有するadapterへ渡す。 +// この受信結果は操作結果でも、別audienceへの再公開の許可でもない。 +export type DetailsDecode = + | { kind: "known"; details: D } + | { kind: "unsupported" } + | { kind: "invalid" }; +export type DetailsDecoder = ( + envelope: FailureEnvelope, +) => DetailsDecode; + +export type FailureReceipt = + | { kind: "problem"; problem: ProblemDetails } + | { kind: "unsupported" } + | { kind: "invalid" } + | { kind: "too_large" } + | { kind: "transport" } + | { kind: "http"; status: number }; + +class InvalidFailure extends Error {} +const invalid = (): never => { + throw new InvalidFailure(); +}; +const utf8 = new TextEncoder(); + +export function failureRecord(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return invalid(); + } + return value as Record; +} + +export function failureString( + value: unknown, + max: number = FAILURE_LIMITS.explanationBytes, +): string { + if ( + typeof value !== "string" || value.length === 0 || value.includes("\0") || + utf8.encode(value).length > max + ) { + return invalid(); + } + return value; +} +export function failureIdentifier(value: unknown): string { + const text = failureString(value, FAILURE_LIMITS.identifierBytes); + if (/[\x00-\x1f\x7f-\x9f]/u.test(text)) return invalid(); + return text; +} +function integer(value: unknown): number { + if ( + typeof value !== "number" || !Number.isInteger(value) || value < 1 || + value > 65535 + ) return invalid(); + return value; +} +function list(value: unknown, max: number, parse: (v: unknown) => T): T[] { + if (!Array.isArray(value) || value.length > max) return invalid(); + return value.map(parse); +} +function optionalIdentifier(value: unknown): string | null { + return value == null ? null : failureIdentifier(value); +} +function context(value: unknown): FailureContext { + const raw = failureRecord(value); + if (typeof raw.supplementary_omitted !== "boolean") return invalid(); + return { + operations: list(raw.operations, FAILURE_LIMITS.contexts, (v) => { + const op = failureRecord(v); + return { + operation: failureIdentifier(op.operation), + stage: failureIdentifier(op.stage), + operation_ref: optionalIdentifier(op.operation_ref), + }; + }), + causes: list(raw.causes, FAILURE_LIMITS.causes, (v) => { + const cause = failureRecord(v); + return { + domain: failureIdentifier(cause.domain), + reason: failureIdentifier(cause.reason), + correlation: failureIdentifier(cause.correlation), + }; + }), + diagnostics: list(raw.diagnostics, FAILURE_LIMITS.diagnostics, (v) => { + const diagnostic = failureRecord(v); + return { + code: failureIdentifier(diagnostic.code), + explanation: failureString(diagnostic.explanation), + }; + }), + correlation: optionalIdentifier(raw.correlation), + supplementary_omitted: raw.supplementary_omitted, + }; +} + +// JSON.parse前に深さを制限する。重複keyはlast-winsで必須契約を上書きさせない。 +function parseBounded(text: string): unknown { + if (utf8.encode(text).length > FAILURE_LIMITS.bytes) { + throw new ResponseByteLimitError(); + } + const stack: (Set | null)[] = []; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + if (char === '"') { + const start = i; + for (i++; i < text.length; i++) { + if (text[i] === "\\") i++; + else if (text[i] === '"') break; + } + let next = i + 1; + while (/\s/u.test(text[next] ?? "") && next < text.length) next++; + if (text[next] === ":") { + const key: unknown = JSON.parse(text.slice(start, i + 1)); + const keys = stack.at(-1); + if (!keys || typeof key !== "string" || keys.has(key)) return invalid(); + keys.add(key); + } + } else if (char === "{" || char === "[") { + stack.push(char === "{" ? new Set() : null); + if (stack.length > FAILURE_LIMITS.depth) return invalid(); + } else if (char === "}" || char === "]") { + if (!stack.length) return invalid(); + stack.pop(); + } + } + return JSON.parse(text) as unknown; +} + +export function decodeFailure(text: string, decode: DetailsDecoder): + | { kind: "known"; failure: FailureEnvelope } + | { kind: "unsupported" } + | { kind: "invalid" } + | { kind: "too_large" } { + try { + return decodeEnvelope(parseBounded(text), decode); + } catch (error) { + return { + kind: error instanceof ResponseByteLimitError ? "too_large" : "invalid", + }; + } +} +function decodeEnvelope(value: unknown, decode: DetailsDecoder): + | { kind: "known"; failure: FailureEnvelope } + | { kind: "unsupported" } + | { kind: "invalid" } { + const raw = failureRecord(value); + const version = integer(raw.version); + const details_version = integer(raw.details_version); + const required_extensions = list( + raw.required_extensions, + FAILURE_LIMITS.diagnostics, + failureIdentifier, + ); + const category = failureIdentifier(raw.category); + const domain = failureIdentifier(raw.domain); + const reason = failureIdentifier(raw.reason); + const explanation = failureString(raw.explanation); + const metadata = context(raw.context); + if (version !== 1 || required_extensions.length !== 0) { + return { kind: "unsupported" }; + } + if ( + ![ + "authorization", + "invalid_input", + "conflict", + "dependency", + "internal", + "unknown", + ].includes(category) + ) return { kind: "unsupported" }; + const envelope: FailureEnvelope = { + version, + required_extensions, + domain, + reason, + category: category as FailureEnvelope["category"], + details_version, + details: raw.details, + explanation, + context: metadata, + }; + const result = decode(envelope); + return result.kind === "known" + ? { kind: "known", failure: { ...envelope, details: result.details } } + : result; +} + +function uriReference(value: unknown): string { + // URIは表示/取得先として使わない。WHATWG URLの補正も行わない。 + if ( + typeof value !== "string" || + utf8.encode(value).length > FAILURE_LIMITS.explanationBytes || + /[^\x21-\x7e]/u.test(value) || /%(?![0-9a-f]{2})/iu.test(value) + ) return invalid(); + return value; +} + +export function decodeProblem( + text: string, + status: number, + contentType: string, + decode: DetailsDecoder, +): FailureReceipt { + try { + if ( + contentType.split(";")[0].trim().toLowerCase() !== + "application/problem+json" + ) return { kind: "http", status }; + const raw = failureRecord(parseBounded(text)); + if ( + !Number.isInteger(status) || status < 400 || status > 599 || + raw.status !== status + ) return invalid(); + const type = uriReference(raw.type); + const instance = uriReference(raw.instance); + const title = failureString(raw.title); + const detail = failureString(raw.detail); + const failure = decodeEnvelope(raw.yoi_failure, decode); + if (detail !== failureRecord(raw.yoi_failure).explanation) return invalid(); + if (failure.kind !== "known") return failure; + return { + kind: "problem", + problem: { + type, + instance, + status, + title, + detail, + yoi_failure: failure.failure, + }, + }; + } catch (error) { + return { + kind: error instanceof ResponseByteLimitError ? "too_large" : "invalid", + }; + } +} + +export async function receiveProblem( + response: Response, + decode: DetailsDecoder, +): Promise> { + const contentType = response.headers.get("content-type") ?? ""; + if ( + contentType.split(";")[0].trim().toLowerCase() !== + "application/problem+json" + ) { + await response.body?.cancel().catch(() => {}); + return { kind: "http", status: response.status }; + } + let text: string; + try { + text = await readBoundedText(response, FAILURE_LIMITS.bytes); + } catch (error) { + return { + kind: error instanceof ResponseByteLimitError + ? "too_large" + : error instanceof TypeError + ? "invalid" + : "transport", + }; + } + return decodeProblem(text, response.status, contentType, decode); +} From 838b6cd3c3dbf45fe555958ef9c017f1bc09bc6b Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 6 Nov 2026 07:41:47 +0900 Subject: [PATCH 2/6] fix(web): validate problem references and distinguish read failures --- web/workspace/src/lib/workspace/api/http.ts | 7 +- .../src/lib/workspace/failure/model.ts | 3 + .../src/lib/workspace/failure/receive.test.ts | 137 +++++++++++++++++- .../src/lib/workspace/failure/receive.ts | 53 ++++++- 4 files changed, 192 insertions(+), 8 deletions(-) diff --git a/web/workspace/src/lib/workspace/api/http.ts b/web/workspace/src/lib/workspace/api/http.ts index c97221022..996d7059f 100644 --- a/web/workspace/src/lib/workspace/api/http.ts +++ b/web/workspace/src/lib/workspace/api/http.ts @@ -31,6 +31,7 @@ const SKILL_API_LOAD_POLICY: JsonLoadPolicy = { }; export class ResponseByteLimitError extends Error {} +export class ResponseTextDecodeError extends Error {} function normalizePath(path: string): string { if (!path || path === "/") return ""; @@ -198,7 +199,11 @@ export async function readBoundedText( bytes.set(chunk, offset); offset += chunk.byteLength; } - return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw new ResponseTextDecodeError(); + } } async function requireJson( diff --git a/web/workspace/src/lib/workspace/failure/model.ts b/web/workspace/src/lib/workspace/failure/model.ts index 6165e4ff4..9e6a17a52 100644 --- a/web/workspace/src/lib/workspace/failure/model.ts +++ b/web/workspace/src/lib/workspace/failure/model.ts @@ -49,6 +49,9 @@ export function failureNotice( // 同義wrapperをtitle/detail/explanationの三重表示にしない。 supplements: [ ...new Set(failure.context.diagnostics.map((item) => item.explanation)), + ...(failure.context.supplementary_omitted + ? ["Some additional information was omitted by the server."] + : []), ].filter((text) => text !== failure.explanation), compensation: [], details, diff --git a/web/workspace/src/lib/workspace/failure/receive.test.ts b/web/workspace/src/lib/workspace/failure/receive.test.ts index 36f3204d6..b48f44bf0 100644 --- a/web/workspace/src/lib/workspace/failure/receive.test.ts +++ b/web/workspace/src/lib/workspace/failure/receive.test.ts @@ -248,9 +248,144 @@ Deno.test("bounded response text, unknown MIME, broken transport and UTF8 never { status: 502, headers: { "content-type": "application/problem+json" } }, ), ]; - for (const response of cases) { + for (const [index, response] of cases.entries()) { const received = await receiveProblem(response, fixtureDecoder); + equal(received.kind, ["http", "invalid", "transport"][index]); if (received.kind === "problem") throw new Error("unexpected problem"); equal(JSON.stringify(failureNotice(received)).includes("secret"), false); } }); + +Deno.test("Problem URI references are syntax checked without normalization, navigation or fetching", () => { + for ( + const reference of [ + "", + "../requests/1", + "?occurrence=1", + "#s", + "g:h", + "a/b:c", + "///path", + "https://user:pass@example.test:443/a?b#c", + "//[2001:db8::1]:443/a", + "//[::ffff:192.0.2.128]/", + "//[v1.a:b]/x", + "a%20b/%5B?x=%25&y=?/#%2f", + ] + ) { + const received = decode({ + ...problem(), + type: reference, + instance: reference, + }); + if (received.kind !== "problem") { + throw new Error(`${reference}: ${received.kind}`); + } + equal(received.problem.instance, reference); + } + for ( + const reference of [ + "/requests/%GG", + "/requests/[invalid", + "foo%", + "//user@@host/x", + "//host:wrongport/x", + "//[::not-ipv6]/", + "//[v.a]/", + "//[vG.foo]/", + "//[v1.]/", + "//[::1]tail/", + "//[::1", + "//host]/", + "bad space", + "1invalid:rest", + ":rest", + "a{b", + "a#b#c", + "a\\b", + "日本語", + "/a\n", + "?x=\0", + ] + ) { + equal( + decode({ ...problem(), type: reference, instance: reference }).kind, + "invalid", + ); + } +}); + +Deno.test("all shared collection budgets and UTF8 identifiers are validated before presentation", () => { + for ( + const [key, max, item] of [ + ["operations", 8, subject.context.operations[0]], + ["causes", 4, { + domain: "subject", + reason: "operation_denied", + correlation: "safe", + }], + ["diagnostics", 8, { + code: "safe", + explanation: "Additional information", + }], + ] as const + ) { + equal( + decode( + problem({ + ...subject, + context: { ...subject.context, [key]: Array(max).fill(item) }, + }), + ).kind, + "problem", + ); + equal( + decode( + problem({ + ...subject, + context: { ...subject.context, [key]: Array(max + 1).fill(item) }, + }), + ).kind, + "invalid", + ); + } + const received = decode( + problem({ + ...subject, + context: { ...subject.context, supplementary_omitted: true }, + }), + ); + equal(failureNotice(received).supplements, [ + "Some additional information was omitted by the server.", + ]); + equal( + decode( + problem({ + ...subject, + context: { ...subject.context, correlation: "あ".repeat(43) }, + }), + ).kind, + "invalid", + ); + equal( + decodeProblem( + " ".repeat(FAILURE_LIMITS.bytes) + JSON.stringify(problem()), + 409, + "application/problem+json", + fixtureDecoder, + ).kind, + "too_large", + ); +}); + +Deno.test("JSON Unicode scalars agree with the Rust wire reader rather than replacing lone surrogates", () => { + for (const explanation of ["bad\ud800", "bad\udfff"]) { + equal(decode(problem({ ...subject, explanation })).kind, "invalid"); + } + equal( + decode( + problem({ ...subject, explanation: "Valid supplementary scalar: 🚀" }), + ).kind, + "problem", + ); +}); diff --git a/web/workspace/src/lib/workspace/failure/receive.ts b/web/workspace/src/lib/workspace/failure/receive.ts index 58c4d4e30..e921964ca 100644 --- a/web/workspace/src/lib/workspace/failure/receive.ts +++ b/web/workspace/src/lib/workspace/failure/receive.ts @@ -7,6 +7,7 @@ import type { import { readBoundedText, ResponseByteLimitError, + ResponseTextDecodeError, } from "#lib/workspace/api/http.ts"; // domain固有detailsの検証は公開schemaを所有するadapterへ渡す。 @@ -117,13 +118,16 @@ function parseBounded(text: string): unknown { if (text[i] === "\\") i++; else if (text[i] === '"') break; } + const token: unknown = JSON.parse(text.slice(start, i + 1)); + if (typeof token !== "string" || /[\uD800-\uDFFF]/u.test(token)) { + return invalid(); + } let next = i + 1; while (/\s/u.test(text[next] ?? "") && next < text.length) next++; if (text[next] === ":") { - const key: unknown = JSON.parse(text.slice(start, i + 1)); const keys = stack.at(-1); - if (!keys || typeof key !== "string" || keys.has(key)) return invalid(); - keys.add(key); + if (!keys || keys.has(token)) return invalid(); + keys.add(token); } } else if (char === "{" || char === "[") { stack.push(char === "{" ? new Set() : null); @@ -197,12 +201,49 @@ function decodeEnvelope(value: unknown, decode: DetailsDecoder): } function uriReference(value: unknown): string { - // URIは表示/取得先として使わない。WHATWG URLの補正も行わない。 + // RFC 3986のcomponentを検査するだけで、base解決/取得/正規化はしない。 if ( typeof value !== "string" || utf8.encode(value).length > FAILURE_LIMITS.explanationBytes || - /[^\x21-\x7e]/u.test(value) || /%(?![0-9a-f]{2})/iu.test(value) + /%(?![0-9a-f]{2})/iu.test(value) ) return invalid(); + const match = + /^(?:([a-z][a-z0-9+.-]*):)?(?:\/\/([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#([^#]*))?$/iu + .exec(value); + if (!match) return invalid(); + const [, scheme, authority, path, query, fragment] = match; + const pchar = /^[a-z0-9._~!$&'()*+,;=:@%/-]*$/iu; + const queryChars = /^[a-z0-9._~!$&'()*+,;=:@%/?-]*$/iu; + if ( + !pchar.test(path) || (query !== undefined && !queryChars.test(query)) || + (fragment !== undefined && !queryChars.test(fragment)) + ) return invalid(); + if ( + scheme === undefined && authority === undefined && + path.split("/")[0].includes(":") + ) return invalid(); + if (authority !== undefined) { + const parts = authority.split("@"); + if ( + parts.length > 2 || + (parts.length === 2 && !/^[a-z0-9._~!$&'()*+,;=:%-]*$/iu.test(parts[0])) + ) return invalid(); + const hostPort = parts.at(-1)!; + if (hostPort.startsWith("[")) { + const literal = /^\[([^\]]+)\](?::[0-9]*)?$/u.exec(hostPort); + if (!literal) return invalid(); + if (literal[1].startsWith("v") || literal[1].startsWith("V")) { + if (!/^v[0-9a-f]+\.[a-z0-9._~!$&'()*+,;=:-]+$/iu.test(literal[1])) { + return invalid(); + } + } else { + // URLのhost validatorだけをIPv6構文に利用し、値は一切置換しない。 + new URL(`http://[${literal[1]}]/`); + } + } else if (!/^[a-z0-9._~!$&'()*+,;=%-]*(?::[0-9]*)?$/iu.test(hostPort)) { + return invalid(); + } + } return value; } @@ -266,7 +307,7 @@ export async function receiveProblem( return { kind: error instanceof ResponseByteLimitError ? "too_large" - : error instanceof TypeError + : error instanceof ResponseTextDecodeError ? "invalid" : "transport", }; From 590a27075791c27e95ef9af55e9527a34fd13b04 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 6 Nov 2026 11:06:10 +0900 Subject: [PATCH 3/6] build(web): consume T812 public startup contracts and HTTP fixtures --- ...er-registration-response-lost.problem.json | 1 + ...browser-session-record-denied.problem.json | 1 + .../src/repository_openapi_typescript.rs | 214 +++- openapi/server-api.json | 948 +++++++++++++++++- .../src/lib/generated/companion-api.ts | 2 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 4 +- web/workspace/src/lib/generated/ticket-api.ts | 2 +- .../src/lib/generated/workdir-api.ts | 2 +- .../src/lib/generated/worker-launch-api.ts | 66 +- 10 files changed, 1225 insertions(+), 17 deletions(-) create mode 100644 crates/runtime-api/tests/fixtures/startup-browser-registration-response-lost.problem.json create mode 100644 crates/runtime-api/tests/fixtures/startup-browser-session-record-denied.problem.json diff --git a/crates/runtime-api/tests/fixtures/startup-browser-registration-response-lost.problem.json b/crates/runtime-api/tests/fixtures/startup-browser-registration-response-lost.problem.json new file mode 100644 index 000000000..109543b79 --- /dev/null +++ b/crates/runtime-api/tests/fixtures/startup-browser-registration-response-lost.problem.json @@ -0,0 +1 @@ +{"type":"urn:yoi:problem:dependency","title":"Dependency failure","status":502,"detail":"Worker creation could not complete Session registration: Unknown principal has an unknown session.record outcome. Creation acceptance and execution outcome are reported separately.","instance":"urn:yoi:worker-startup:worker-af7808a90c24ed19bb134795f4460b01f7d40be31567155dff4431898e871343","yoi_failure":{"version":1,"required_extensions":[],"domain":"session_registration","reason":"outcome_unknown","category":"unknown","details_version":1,"details":{"reason":"outcome_unknown","principal":"unknown","required_operation":"session.record"},"explanation":"Worker creation could not complete Session registration: Unknown principal has an unknown session.record outcome. Creation acceptance and execution outcome are reported separately.","context":{"operations":[{"operation":"session.record","stage":"registration","operation_ref":null},{"operation":"worker_startup","stage":"binding","operation_ref":null}],"causes":[],"diagnostics":[],"correlation":null,"supplementary_omitted":false}},"yoi_startup":{"version":1,"operation":{"acceptance":"unknown","execution":"errored","finalize":null,"backend_compensation":"unknown","correlation":"worker-af7808a90c24ed19bb134795f4460b01f7d40be31567155dff4431898e871343"},"secondary":[{"kind":"persistence","phase":"runtime_record_delete","outcome":"outcome_unknown"}],"resources":{"resources":"worker_startup","cleanup":"confirmed","durable_effects_possible":false},"persistence":"persisted","backend_result_persistence":"persisted","io_kind":null}} \ No newline at end of file diff --git a/crates/runtime-api/tests/fixtures/startup-browser-session-record-denied.problem.json b/crates/runtime-api/tests/fixtures/startup-browser-session-record-denied.problem.json new file mode 100644 index 000000000..10de5012e --- /dev/null +++ b/crates/runtime-api/tests/fixtures/startup-browser-session-record-denied.problem.json @@ -0,0 +1 @@ +{"type":"urn:yoi:problem:conflict","title":"Operation conflict","status":409,"detail":"Worker creation could not complete Session registration: Subject is not authorized for required operation session.record. Creation acceptance and execution outcome are reported separately.","instance":"urn:yoi:worker-startup:worker-8d4e95ba9450471443009bb32726792468b06281cc2664258dfb2eae7ffd7bf5","yoi_failure":{"version":1,"required_extensions":[],"domain":"session_registration","reason":"permission_denied","category":"authorization","details_version":1,"details":{"reason":"permission_denied","principal":"subject","required_operation":"session.record"},"explanation":"Worker creation could not complete Session registration: Subject is not authorized for required operation session.record. Creation acceptance and execution outcome are reported separately.","context":{"operations":[{"operation":"session.record","stage":"registration","operation_ref":null},{"operation":"worker_startup","stage":"binding","operation_ref":null}],"causes":[],"diagnostics":[],"correlation":null,"supplementary_omitted":false}},"yoi_startup":{"version":1,"operation":{"acceptance":"unknown","execution":"errored","finalize":null,"backend_compensation":"unknown","correlation":"worker-8d4e95ba9450471443009bb32726792468b06281cc2664258dfb2eae7ffd7bf5"},"secondary":[{"kind":"persistence","phase":"runtime_record_delete","outcome":"outcome_unknown"}],"resources":{"resources":"worker_startup","cleanup":"confirmed","durable_effects_possible":false},"persistence":"persisted","backend_result_persistence":"persisted","io_kind":null}} \ No newline at end of file diff --git a/crates/server-api/src/repository_openapi_typescript.rs b/crates/server-api/src/repository_openapi_typescript.rs index 269e5b1c7..71ec4605d 100644 --- a/crates/server-api/src/repository_openapi_typescript.rs +++ b/crates/server-api/src/repository_openapi_typescript.rs @@ -304,6 +304,7 @@ fn validate_worker_operations( ) -> Result, GenerationError> { let operations = [ OperationContract { + error: "RepositoryApiError", path: "/api/workers", method: "get", operation_id: "workspace_worker_list_alias", @@ -311,6 +312,7 @@ fn validate_worker_operations( successes: &[("200", "WorkerListResponse")], }, OperationContract { + error: "WorkerCreateApiError", path: "/api/workers", method: "post", operation_id: "workspace_worker_create_alias", @@ -318,6 +320,7 @@ fn validate_worker_operations( successes: &[("200", "BrowserCreateWorkerResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/orchestrator", method: "get", operation_id: "workspace_orchestrator_status", @@ -325,6 +328,7 @@ fn validate_worker_operations( successes: &[("200", "BrowserWorkspaceOrchestratorResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/orchestrator", method: "post", operation_id: "workspace_orchestrator_start", @@ -332,6 +336,7 @@ fn validate_worker_operations( successes: &[("200", "BrowserWorkspaceOrchestratorResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/workers", method: "get", operation_id: "workspace_worker_list", @@ -339,6 +344,7 @@ fn validate_worker_operations( successes: &[("200", "WorkerListResponse")], }, OperationContract { + error: "WorkerCreateApiError", path: "/api/w/{workspace_id}/workers", method: "post", operation_id: "workspace_worker_create", @@ -346,6 +352,7 @@ fn validate_worker_operations( successes: &[("200", "BrowserCreateWorkerResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/workers/{worker_ref}", method: "get", operation_id: "workspace_worker_detail", @@ -353,6 +360,7 @@ fn validate_worker_operations( successes: &[("200", "WorkerSummary")], }, OperationContract { + error: "RepositoryApiError", path: "/api/workers/launch-options", method: "get", operation_id: "workspace_worker_launch_options_alias", @@ -360,6 +368,7 @@ fn validate_worker_operations( successes: &[("200", "WorkerLaunchOptionsResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/workers/launch-options", method: "get", operation_id: "workspace_worker_launch_options", @@ -375,6 +384,7 @@ fn validate_companion_operations( ) -> Result, GenerationError> { let operations = [ OperationContract { + error: "RepositoryApiError", path: "/api/companion/status", method: "get", operation_id: "companion_status_alias", @@ -382,6 +392,7 @@ fn validate_companion_operations( successes: &[("200", "CompanionStatusResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/companion/status", method: "get", operation_id: "companion_status", @@ -389,6 +400,7 @@ fn validate_companion_operations( successes: &[("200", "CompanionStatusResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/companion/transcript", method: "get", operation_id: "companion_transcript_alias", @@ -396,6 +408,7 @@ fn validate_companion_operations( successes: &[("200", "CompanionTranscriptProjection")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/companion/transcript", method: "get", operation_id: "companion_transcript", @@ -403,6 +416,7 @@ fn validate_companion_operations( successes: &[("200", "CompanionTranscriptProjection")], }, OperationContract { + error: "RepositoryApiError", path: "/api/companion/messages", method: "post", operation_id: "companion_message_alias", @@ -410,6 +424,7 @@ fn validate_companion_operations( successes: &[("200", "CompanionMessageResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/companion/messages", method: "post", operation_id: "companion_message", @@ -417,6 +432,7 @@ fn validate_companion_operations( successes: &[("200", "CompanionMessageResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/companion/cancel", method: "post", operation_id: "companion_cancel_alias", @@ -424,6 +440,7 @@ fn validate_companion_operations( successes: &[("200", "CompanionMessageResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/companion/cancel", method: "post", operation_id: "companion_cancel", @@ -439,6 +456,7 @@ fn validate_ticket_browser_operations( ) -> Result, GenerationError> { let operations = [ OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/tickets/{id}/link-summary", method: "get", operation_id: "ticket_link_summary_get", @@ -446,6 +464,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketLinkSummary")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/tickets/{id}/parent", method: "get", operation_id: "ticket_parent_get", @@ -453,6 +472,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketParentLink")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/tickets/{id}/parent", method: "put", operation_id: "ticket_parent_set", @@ -460,6 +480,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketParentLink")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/tickets/{id}/children", method: "get", operation_id: "ticket_children_query", @@ -467,6 +488,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketChildrenPage")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/ticket-tags", method: "get", operation_id: "workspace_ticket_tag_list", @@ -474,6 +496,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "Array_of_WorkspaceTag")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/ticket-tags", method: "post", operation_id: "workspace_ticket_tag_mutate", @@ -481,6 +504,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "WorkspaceTag")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/tickets/{id}/tags", method: "put", operation_id: "ticket_tags_set", @@ -488,6 +512,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "Array_of_WorkspaceTag")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/tickets/browse", method: "post", operation_id: "ticket_browse", @@ -495,6 +520,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketBrowseQueryResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/tickets/query", method: "post", operation_id: "ticket_query", @@ -502,6 +528,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketQueryResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/tickets", method: "get", operation_id: "ticket_list", @@ -509,6 +536,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketListResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/tickets", method: "post", operation_id: "ticket_create_record", @@ -516,6 +544,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketRef")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/tickets/{id}", method: "get", operation_id: "ticket_get", @@ -523,6 +552,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketDetail")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/tickets/{id}", method: "patch", operation_id: "ticket_edit", @@ -530,6 +560,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketDetail")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/tickets/{id}/posts/{event_ref}", method: "get", operation_id: "ticket_post_get", @@ -537,6 +568,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketEventDetail")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/tickets/{id}/posts/{event_ref}", method: "patch", operation_id: "ticket_post_edit", @@ -544,6 +576,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketDetail")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/tickets/{id}/assignments/{role}", method: "put", operation_id: "ticket_assignment_set", @@ -551,6 +584,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketRoleAssignmentMutationResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/tickets/{id}/implementation-cancellations", method: "post", operation_id: "ticket_implementation_cancel", @@ -558,6 +592,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketDetail")], }, OperationContract { + error: "RepositoryApiError", path: "/workspaces/{workspace_id}/tickets/{ticket_id}/case", method: "post", operation_id: "ticket_case_update", @@ -565,6 +600,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketSummary")], }, OperationContract { + error: "RepositoryApiError", path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments", method: "get", operation_id: "ticket_assignments_page", @@ -572,6 +608,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketAssignmentsPage")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/notification-diagnostics", method: "get", operation_id: "notification_diagnostics", @@ -579,6 +616,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "NotificationDiagnosticsPage")], }, OperationContract { + error: "RepositoryApiError", path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-diagnostics", method: "get", operation_id: "ticket_assignment_diagnostics", @@ -586,6 +624,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketAssignmentDiagnosticsPage")], }, OperationContract { + error: "RepositoryApiError", path: "/workspaces/{workspace_id}/tickets/{ticket_id}/implementation-results", method: "post", operation_id: "ticket_implementation_result", @@ -593,6 +632,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketImplementationResultReceipt")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/tickets/{ticket_id}/implementation-results", method: "post", operation_id: "ticket_implementation_result_browser", @@ -600,6 +640,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketImplementationResultReceipt")], }, OperationContract { + error: "RepositoryApiError", path: "/workspaces/{workspace_id}/tickets/{ticket_id}/implementation-results/{result_id}", method: "get", operation_id: "ticket_implementation_result_get", @@ -607,6 +648,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketImplementationResultReceipt")], }, OperationContract { + error: "RepositoryApiError", path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-answer", method: "post", operation_id: "ticket_assignment_answer", @@ -614,6 +656,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketAssignmentAnswerReceipt")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments", method: "get", operation_id: "ticket_assignments_page_browser", @@ -621,6 +664,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketAssignmentsPage")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-diagnostics", method: "get", operation_id: "ticket_assignment_diagnostics_browser", @@ -628,6 +672,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketAssignmentDiagnosticsPage")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-answer", method: "post", operation_id: "ticket_assignment_answer_browser", @@ -635,6 +680,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketAssignmentAnswerReceipt")], }, OperationContract { + error: "RepositoryApiError", path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking", method: "get", operation_id: "ticket_assignment_tracking_get", @@ -642,6 +688,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "Array_of_TicketAssignmentTrackingProjection")], }, OperationContract { + error: "RepositoryApiError", path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking", method: "post", operation_id: "ticket_assignment_tracking_update", @@ -649,6 +696,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketAssignmentTrackingProjection")], }, OperationContract { + error: "RepositoryApiError", path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/history", method: "get", operation_id: "ticket_assignment_tracking_history", @@ -656,6 +704,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketAssignmentTrackingHistoryPage")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/tickets/{id}/ready", method: "post", operation_id: "ticket_ready", @@ -663,6 +712,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketDetail")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/tickets/{id}/events", method: "post", operation_id: "ticket_event_append", @@ -670,6 +720,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketDetail")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/tickets/{id}/queue", method: "post", operation_id: "ticket_queue", @@ -677,6 +728,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketQueueOutcome")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/objectives", method: "get", operation_id: "objective_list", @@ -684,6 +736,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "ObjectiveListResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/objectives/{objective_id}", method: "get", operation_id: "objective_get", @@ -691,6 +744,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "ObjectiveDetail")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/merge-requests", method: "get", operation_id: "merge_request_list", @@ -698,6 +752,7 @@ fn validate_ticket_browser_operations( successes: &[("200", "MergeRequestListResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/merge-requests/{merge_request_id}", method: "get", operation_id: "merge_request_show", @@ -713,6 +768,7 @@ fn validate_repository_operations( ) -> Result, GenerationError> { let operations = [ OperationContract { + error: "RepositoryApiError", path: "/api/repositories", method: "get", operation_id: "repository_list_alias", @@ -720,6 +776,7 @@ fn validate_repository_operations( successes: &[("200", "RepositoryListResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/repositories/{repository_key}", method: "get", operation_id: "repository_detail_alias", @@ -727,6 +784,7 @@ fn validate_repository_operations( successes: &[("200", "RepositoryDetailResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/repositories", method: "get", operation_id: "repository_list", @@ -734,6 +792,7 @@ fn validate_repository_operations( successes: &[("200", "RepositoryListResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/repositories", method: "post", operation_id: "repository_create", @@ -744,6 +803,7 @@ fn validate_repository_operations( ], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/repositories/{repository_key}", method: "get", operation_id: "repository_detail", @@ -751,6 +811,7 @@ fn validate_repository_operations( successes: &[("200", "RepositoryDetailResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/repositories/{repository_key}/ssh-connection-test", method: "post", operation_id: "repository_ssh_connection_probe", @@ -758,6 +819,7 @@ fn validate_repository_operations( successes: &[("200", "RepositorySshConnectionProbeResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/repositories/{repository_key}/ssh-connection-test", method: "put", operation_id: "repository_ssh_host_trust_confirm", @@ -765,6 +827,7 @@ fn validate_repository_operations( successes: &[("200", "RepositorySshHostTrust")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/repositories/{repository_key}/log", method: "get", operation_id: "repository_log", @@ -772,6 +835,7 @@ fn validate_repository_operations( successes: &[("200", "RepositoryLogResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/repositories/{repository_key}/log", method: "get", operation_id: "repository_log_alias", @@ -779,6 +843,7 @@ fn validate_repository_operations( successes: &[("200", "RepositoryLogResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/hosts", method: "get", operation_id: "host_list", @@ -786,6 +851,7 @@ fn validate_repository_operations( successes: &[("200", "HostListResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/hosts", method: "get", operation_id: "host_list_alias", @@ -793,6 +859,7 @@ fn validate_repository_operations( successes: &[("200", "HostListResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/hosts/{host_id}/workers", method: "get", operation_id: "host_worker_list", @@ -800,6 +867,7 @@ fn validate_repository_operations( successes: &[("200", "HostWorkerListResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/hosts/{host_id}/workers", method: "get", operation_id: "host_worker_list_alias", @@ -815,6 +883,7 @@ fn validate_workdir_operations( ) -> Result, GenerationError> { let operations = [ OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories", method: "get", operation_id: "runtime_workdir_list", @@ -822,6 +891,7 @@ fn validate_workdir_operations( successes: &[("200", "WorkingDirectoryListResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories", method: "post", operation_id: "runtime_workdir_create", @@ -832,6 +902,7 @@ fn validate_workdir_operations( ], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories/{working_directory_id}", method: "get", operation_id: "runtime_workdir_detail", @@ -839,6 +910,7 @@ fn validate_workdir_operations( successes: &[("200", "WorkingDirectoryDetailResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories/{working_directory_id}", method: "delete", operation_id: "runtime_workdir_cleanup", @@ -846,6 +918,7 @@ fn validate_workdir_operations( successes: &[("200", "WorkingDirectoryRemovalResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/external-workdir-grants", method: "post", operation_id: "external_workdir_grant_create", @@ -853,6 +926,7 @@ fn validate_workdir_operations( successes: &[("201", "ExternalWorkdirGrantResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/external-workdir-grants/{grant_id}", method: "get", operation_id: "external_workdir_grant_get", @@ -860,6 +934,7 @@ fn validate_workdir_operations( successes: &[("200", "ExternalWorkdirGrantResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/external-workdir-grants/{grant_id}", method: "delete", operation_id: "external_workdir_grant_revoke", @@ -867,6 +942,7 @@ fn validate_workdir_operations( successes: &[("200", "ExternalWorkdirGrantResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/working-directories", method: "get", operation_id: "workspace_workdir_list", @@ -874,6 +950,7 @@ fn validate_workdir_operations( successes: &[("200", "WorkingDirectoryListResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/working-directories", method: "post", operation_id: "workspace_workdir_create", @@ -884,6 +961,7 @@ fn validate_workdir_operations( ], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/working-directories/{working_directory_id}", method: "get", operation_id: "workspace_workdir_detail", @@ -891,6 +969,7 @@ fn validate_workdir_operations( successes: &[("200", "WorkingDirectoryDetailResponse")], }, OperationContract { + error: "RepositoryApiError", path: "/api/w/{workspace_id}/working-directories/{working_directory_id}", method: "delete", operation_id: "workspace_workdir_cleanup", @@ -1332,12 +1411,28 @@ fn validate_operation_contracts( &format!("{} response {status}", operation.operation_id), )?; let schema = json_content_schema(response, operation.operation_id)?; + let startup_error = operation.error == "WorkerCreateApiError" + && !successes.contains_key(status.as_str()); let expected = successes .get(status.as_str()) .copied() - .unwrap_or("RepositoryApiError"); + .unwrap_or(if startup_error { + "RepositoryApiError" + } else { + operation.error + }); require_schema_ref(schema, expected, operation.operation_id)?; roots.insert(expected.to_owned()); + if startup_error { + let content = object_field(response, "content", operation.operation_id)?; + let problem = + object_field(content, "application/problem+json", operation.operation_id)?; + let schema = problem.get("schema").ok_or_else(|| { + GenerationError::invalid("missing startup Problem Details schema") + })?; + require_schema_ref(schema, "StartupProblemDetails", operation.operation_id)?; + roots.insert("StartupProblemDetails".to_owned()); + } } for (status, expected) in successes { if !responses.contains_key(status) { @@ -1352,6 +1447,7 @@ fn validate_operation_contracts( } struct OperationContract { + error: &'static str, path: &'static str, method: &'static str, operation_id: &'static str, @@ -1789,8 +1885,31 @@ fn render_typed_schema( ) -> Result { match kind { "string" => { - ensure_schema_keywords(schema, &["type"], context)?; - Ok("string".to_owned()) + ensure_schema_keywords( + schema, + &["type", "minLength", "maxLength", "pattern"], + context, + )?; + let mut constraints = Vec::new(); + if schema.contains_key("minLength") || schema.contains_key("maxLength") { + let range = + render_length_range(schema.get("minLength"), schema.get("maxLength"), context)?; + constraints.push(format!("readonly \"__yoiStringLength\": \"{range}\";")); + } + if let Some(pattern) = schema.get("pattern") { + let pattern = pattern.as_str().ok_or_else(|| { + GenerationError::invalid(format!("{context} string pattern must be a string")) + })?; + constraints.push(format!( + "readonly \"__yoiStringPattern\": {};", + serde_json::to_string(pattern).expect("string encoding") + )); + } + if constraints.is_empty() { + Ok("string".to_owned()) + } else { + Ok(format!("string & {{ {} }}", constraints.join(" "))) + } } "boolean" => { ensure_schema_keywords(schema, &["type"], context)?; @@ -1880,9 +1999,12 @@ fn render_typed_schema( .join(", ") )) } - _ => Err(GenerationError::invalid(format!( - "{context} contains unsupported variable array bounds" - ))), + (minimum, maximum) => { + let range = render_length_range(minimum, maximum, context)?; + Ok(format!( + "Array<{item}> & {{ readonly \"__yoiArrayLength\": \"{range}\"; }}" + )) + } } } "object" => { @@ -1899,6 +2021,36 @@ fn render_typed_schema( } } +// Nominal markers preserve wire bounds in generated declarations; they are not +// runtime validators. Only finite safe non-negative integer constraints are admitted. +fn render_length_range( + minimum: Option<&Value>, + maximum: Option<&Value>, + context: &str, +) -> Result { + let bound = |value: &Value| { + value + .as_u64() + .filter(|value| *value <= MAX_SAFE_INTEGER as u64) + .ok_or_else(|| { + GenerationError::invalid(format!( + "{context} length bound must be a safe non-negative integer" + )) + }) + }; + let minimum = minimum.map(bound).transpose()?.unwrap_or(0); + let maximum = maximum.map(bound).transpose()?; + if maximum.is_some_and(|maximum| minimum > maximum) { + return Err(GenerationError::invalid(format!( + "{context} length range is inverted" + ))); + } + Ok(format!( + "{minimum}..{}", + maximum.map_or_else(|| "unbounded".into(), |maximum| maximum.to_string()) + )) +} + fn render_object(schema: &Map, context: &str) -> Result { let empty_properties = Map::new(); let properties = match schema.get("properties") { @@ -2282,6 +2434,50 @@ mod tests { )); } + #[test] + fn bounded_strings_and_variable_arrays_preserve_constraints_without_becoming_validators() { + assert_eq!( + render_schema( + &serde_json::json!({"type":"string", "minLength":1, "maxLength":128}), + "bounded" + ) + .unwrap(), + "string & { readonly \"__yoiStringLength\": \"1..128\"; }" + ); + assert_eq!( + render_schema( + &serde_json::json!({"type":"array", "items":{"type":"string"}, "maxItems":8}), + "bounded" + ) + .unwrap(), + "Array & { readonly \"__yoiArrayLength\": \"0..8\"; }" + ); + assert_eq!( + render_schema( + &serde_json::json!({"type":"string", "pattern":"^[a-z]+$"}), + "pattern" + ) + .unwrap(), + "string & { readonly \"__yoiStringPattern\": \"^[a-z]+$\"; }" + ); + assert!( + render_schema( + &serde_json::json!({"type":"string", "pattern":42}), + "pattern" + ) + .is_err() + ); + for schema in [ + serde_json::json!({"type":"string", "maxLength":-1}), + serde_json::json!({"type":"string", "minLength":2, "maxLength":1}), + serde_json::json!({"type":"string", "maxLength":1.5}), + serde_json::json!({"type":"array", "items":{"type":"string"}, "maxItems":9007199254740992u64}), + serde_json::json!({"type":"array", "items":{"type":"string"}, "minItems":9, "maxItems":8}), + ] { + assert!(render_schema(&schema, "bounded").is_err(), "{schema}"); + } + } + #[test] fn generator_rejects_lossy_reference_siblings() { let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); @@ -2303,13 +2499,13 @@ mod tests { #[test] fn generator_rejects_unimplemented_ordinary_schema_keywords() { let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); - document["components"]["schemas"]["RepositorySource"]["properties"]["uri"]["maxLength"] = - serde_json::json!(1); + document["components"]["schemas"]["RepositorySource"]["properties"]["uri"]["format"] = + serde_json::json!("email"); let error = generate_repository_typescript(&document.to_string()).unwrap_err(); assert!( error .to_string() - .contains("unsupported schema keyword `maxLength`") + .contains("unsupported schema keyword `format`") ); let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); diff --git a/openapi/server-api.json b/openapi/server-api.json index 864bc1037..894587dcf 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -644,6 +644,41 @@ ], "type": "string" }, + "Category": { + "enum": [ + "authorization", + "invalid_input", + "conflict", + "dependency", + "internal", + "unknown" + ], + "type": "string" + }, + "CausalLink": { + "description": "Only explicit causal references belong here. Cleanup/audit failures stay in domain results.", + "properties": { + "correlation": { + "$ref": "#/components/schemas/CorrelationRef" + }, + "domain": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "reason": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "domain", + "reason", + "correlation" + ], + "type": "object" + }, "ChatConversation": { "additionalProperties": false, "properties": { @@ -1388,6 +1423,14 @@ ], "type": "object" }, + "CompensationOutcome": { + "enum": [ + "completed", + "residual", + "unknown" + ], + "type": "string" + }, "CompiledFlowDefinition": { "additionalProperties": false, "properties": { @@ -1860,6 +1903,12 @@ }, "type": "object" }, + "CorrelationRef": { + "description": "Correlation is deliberately a distinct field/type from operation identity.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, "CreateRemoteRuntimeRequest": { "additionalProperties": false, "properties": { @@ -2066,6 +2115,14 @@ ], "type": "object" }, + "CreationAcceptance": { + "enum": [ + "rejected", + "accepted", + "unknown" + ], + "type": "string" + }, "DefaultIntakeReadyBodyRequest": { "additionalProperties": false, "properties": { @@ -3218,6 +3275,17 @@ ], "type": "object" }, + "ExecutionOutcome": { + "enum": [ + "accepted", + "busy", + "rejected", + "errored", + "unsupported", + "unknown" + ], + "type": "string" + }, "ExtensibleTicketStatus": { "oneOf": [ { @@ -3451,6 +3519,72 @@ }, "type": "object" }, + "FailureContext": { + "description": "Common metadata is untrusted after deserialization. No audience label confers trust.", + "properties": { + "causes": { + "description": "Ordered immediate-to-root chain, at most four references; never recursive payloads.", + "items": { + "$ref": "#/components/schemas/CausalLink" + }, + "maxItems": 4, + "type": "array" + }, + "correlation": { + "anyOf": [ + { + "$ref": "#/components/schemas/CorrelationRef" + }, + { + "type": "null" + } + ] + }, + "diagnostics": { + "items": { + "$ref": "#/components/schemas/FailureDiagnostic" + }, + "maxItems": 8, + "type": "array" + }, + "operations": { + "items": { + "$ref": "#/components/schemas/OperationContext" + }, + "maxItems": 8, + "type": "array" + }, + "supplementary_omitted": { + "type": "boolean" + } + }, + "required": [ + "operations", + "causes", + "diagnostics", + "supplementary_omitted" + ], + "type": "object" + }, + "FailureDiagnostic": { + "properties": { + "code": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "explanation": { + "maxLength": 2048, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "code", + "explanation" + ], + "type": "object" + }, "FeatureInvocation": { "properties": { "arguments": { @@ -3543,6 +3677,39 @@ } ] }, + "FinalizeFacts": { + "additionalProperties": false, + "properties": { + "compensation": { + "$ref": "#/components/schemas/CompensationOutcome" + }, + "stage": { + "$ref": "#/components/schemas/FinalizeStage" + } + }, + "required": [ + "stage", + "compensation" + ], + "type": "object" + }, + "FinalizeStage": { + "enum": [ + "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" + ], + "type": "string" + }, "FindingSeverity": { "enum": [ "blocker", @@ -6934,6 +7101,34 @@ ], "type": "object" }, + "OperationContext": { + "properties": { + "operation": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "operation_ref": { + "description": "A reference, not an idempotency/retry grant. Disclosure remains domain-owned.", + "maxLength": 128, + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "stage": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "operation", + "stage" + ], + "type": "object" + }, "OperationCounts": { "properties": { "delete": { @@ -13099,6 +13294,42 @@ ], "type": "object" }, + "RuntimeStartupCause": { + "additionalProperties": false, + "description": "Safe local Runtime cause when no more specific domain attachment was supplied.\nThe primary cause is selected where the failure occurs, not parsed from its diagnostic.", + "properties": { + "reason": { + "$ref": "#/components/schemas/RuntimeStartupReason" + }, + "stage": { + "$ref": "#/components/schemas/StartupStage" + } + }, + "required": [ + "stage", + "reason" + ], + "type": "object" + }, + "RuntimeStartupReason": { + "description": "Coarse local causes do not claim a domain authorization verdict.", + "enum": [ + "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" + ], + "type": "string" + }, "RuntimeSummary": { "properties": { "arch": { @@ -14325,6 +14556,52 @@ "minimum": 0, "type": "integer" }, + "SessionOperation": { + "enum": [ + "session.record" + ], + "type": "string" + }, + "SessionPrincipal": { + "enum": [ + "operator", + "subject", + "unknown" + ], + "type": "string" + }, + "SessionRegistrationCause": { + "additionalProperties": false, + "description": "No account/session identifiers, paths, or free-form diagnostics are retained.", + "properties": { + "principal": { + "$ref": "#/components/schemas/SessionPrincipal" + }, + "reason": { + "$ref": "#/components/schemas/SessionRegistrationReason" + }, + "required_operation": { + "$ref": "#/components/schemas/SessionOperation" + } + }, + "required": [ + "reason", + "principal", + "required_operation" + ], + "type": "object" + }, + "SessionRegistrationReason": { + "enum": [ + "permission_denied", + "attribution_missing", + "binding_mismatch", + "transport", + "outcome_unknown", + "invalid_response" + ], + "type": "string" + }, "SetCookieHeader": { "description": "Sensitive `Set-Cookie` response header value.\n\nFormatting is available only for transport encoding; `Debug` always redacts the value.", "type": "string" @@ -14810,10 +15087,568 @@ ], "type": "object" }, + "StartupBoundaryCause": { + "additionalProperties": false, + "description": "An observation at the receiving/finalizing boundary, not a reported Runtime\nexecution result. It deliberately cannot hold raw transport or error text.", + "properties": { + "reason": { + "$ref": "#/components/schemas/StartupBoundaryReason" + } + }, + "required": [ + "reason" + ], + "type": "object" + }, + "StartupBoundaryReason": { + "enum": [ + "timeout", + "transport", + "decode", + "invalid_response", + "too_large", + "unsupported_version", + "unsupported_reason", + "finalize_failed" + ], + "type": "string" + }, + "StartupCleanup": { + "oneOf": [ + { + "enum": [ + "not_needed", + "required", + "confirmed" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "failed": { + "$ref": "#/components/schemas/StartupCleanupFailure" + } + }, + "required": [ + "failed" + ], + "type": "object" + } + ] + }, + "StartupCleanupFailure": { + "enum": [ + "ownership", + "workdir", + "artifacts", + "allocation", + "feature", + "unsupported" + ], + "type": "string" + }, + "StartupCorrelation": { + "description": "Opaque, caller-selected lookup token, not an operation identity or capability.\nNo whitespace, URI/path/query syntax or control characters are accepted.", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9._-]+$", + "type": "string" + }, "StartupFailureAttachment": { "description": "A bounded, projected envelope at the Worker/Runtime or metadata boundary.\nNo constructor accepts Display text, arbitrary JSON fields, or an unprojected error.", "type": "string" }, + "StartupFailurePhase": { + "oneOf": [ + { + "enum": [ + "cleanup", + "runtime_record_delete", + "runtime_snapshot_save", + "runtime_removal_publish", + "metadata_save" + ], + "type": "string" + }, + { + "const": "runtime_reconciliation_record_save", + "description": "Retaining create reconciliation observations in the canonical Worker record.", + "type": "string" + }, + { + "const": "runtime_reconciliation_publish", + "description": "Publishing a retained record to Runtime subscribers; not a durable write.", + "type": "string" + } + ] + }, + "StartupIoKind": { + "description": "Path-free IO classification. Nonportable OS details remain at the resource owner.", + "enum": [ + "not_found", + "permission_denied", + "already_exists", + "invalid_input", + "invalid_data", + "interrupted", + "timed_out", + "would_block", + "unexpected_eof", + "other" + ], + "type": "string" + }, + "StartupOperationFacts": { + "additionalProperties": false, + "description": "Explicit owner observations. There is deliberately no Default and no conversion\nfrom HTTP status, retry hints, resource facts or failure reason into these fields.", + "properties": { + "acceptance": { + "$ref": "#/components/schemas/CreationAcceptance" + }, + "backend_compensation": { + "anyOf": [ + { + "$ref": "#/components/schemas/CompensationOutcome" + }, + { + "type": "null" + } + ], + "description": "Backend compensation can occur before/without finalization; never infer\nthis observation from Runtime cleanup, resources or finalize compensation." + }, + "correlation": { + "anyOf": [ + { + "$ref": "#/components/schemas/StartupCorrelation" + }, + { + "type": "null" + } + ] + }, + "execution": { + "$ref": "#/components/schemas/ExecutionOutcome" + }, + "finalize": { + "anyOf": [ + { + "$ref": "#/components/schemas/FinalizeFacts" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "acceptance", + "execution" + ], + "type": "object" + }, + "StartupPersistenceOutcome": { + "description": "Persisted is evidence only for this attachment/record write, not execution completion.", + "enum": [ + "persisted", + "not_saved", + "conflict", + "outcome_unknown" + ], + "type": "string" + }, + "StartupProblemDetails": { + "description": "Public generator/reader DTO. Deserialization does not grant disclosure authority.", + "properties": { + "detail": { + "maxLength": 2048, + "minLength": 1, + "type": "string" + }, + "instance": { + "maxLength": 2048, + "type": "string" + }, + "status": { + "format": "uint16", + "maximum": 599, + "minimum": 400, + "type": "integer" + }, + "title": { + "maxLength": 2048, + "minLength": 1, + "type": "string" + }, + "type": { + "maxLength": 2048, + "type": "string" + }, + "yoi_failure": { + "oneOf": [ + { + "description": "DTO/schema source only. Deserialization does not produce a ProjectedFailure.", + "properties": { + "category": { + "$ref": "#/components/schemas/Category" + }, + "context": { + "$ref": "#/components/schemas/FailureContext" + }, + "details": { + "$ref": "#/components/schemas/SessionRegistrationCause" + }, + "details_version": { + "format": "uint16", + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "domain": { + "const": "session_registration", + "type": "string" + }, + "explanation": { + "maxLength": 2048, + "minLength": 1, + "type": "string" + }, + "reason": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "required_extensions": { + "items": { + "type": "string" + }, + "maxItems": 8, + "type": "array" + }, + "version": { + "format": "uint16", + "maximum": 65535, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "version", + "required_extensions", + "domain", + "reason", + "category", + "details_version", + "details", + "explanation", + "context" + ], + "type": "object" + }, + { + "description": "DTO/schema source only. Deserialization does not produce a ProjectedFailure.", + "properties": { + "category": { + "$ref": "#/components/schemas/Category" + }, + "context": { + "$ref": "#/components/schemas/FailureContext" + }, + "details": { + "$ref": "#/components/schemas/RuntimeStartupCause" + }, + "details_version": { + "format": "uint16", + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "domain": { + "const": "worker_startup", + "type": "string" + }, + "explanation": { + "maxLength": 2048, + "minLength": 1, + "type": "string" + }, + "reason": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "required_extensions": { + "items": { + "type": "string" + }, + "maxItems": 8, + "type": "array" + }, + "version": { + "format": "uint16", + "maximum": 65535, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "version", + "required_extensions", + "domain", + "reason", + "category", + "details_version", + "details", + "explanation", + "context" + ], + "type": "object" + }, + { + "description": "DTO/schema source only. Deserialization does not produce a ProjectedFailure.", + "properties": { + "category": { + "$ref": "#/components/schemas/Category" + }, + "context": { + "$ref": "#/components/schemas/FailureContext" + }, + "details": { + "$ref": "#/components/schemas/StartupBoundaryCause" + }, + "details_version": { + "format": "uint16", + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "domain": { + "const": "startup_boundary", + "type": "string" + }, + "explanation": { + "maxLength": 2048, + "minLength": 1, + "type": "string" + }, + "reason": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "required_extensions": { + "items": { + "type": "string" + }, + "maxItems": 8, + "type": "array" + }, + "version": { + "format": "uint16", + "maximum": 65535, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "version", + "required_extensions", + "domain", + "reason", + "category", + "details_version", + "details", + "explanation", + "context" + ], + "type": "object" + } + ] + }, + "yoi_startup": { + "$ref": "#/components/schemas/StartupProblemFacts" + } + }, + "required": [ + "type", + "title", + "status", + "detail", + "instance", + "yoi_failure", + "yoi_startup" + ], + "type": "object" + }, + "StartupProblemFacts": { + "additionalProperties": false, + "description": "The extension contains only typed allowlisted observations; never the attachment.", + "properties": { + "backend_result_persistence": { + "anyOf": [ + { + "$ref": "#/components/schemas/StartupPersistenceOutcome" + }, + { + "type": "null" + } + ], + "description": "Backend-owned result write only; never evidence of Session metadata persistence." + }, + "io_kind": { + "anyOf": [ + { + "$ref": "#/components/schemas/StartupIoKind" + }, + { + "type": "null" + } + ] + }, + "operation": { + "$ref": "#/components/schemas/StartupOperationFacts" + }, + "persistence": { + "anyOf": [ + { + "$ref": "#/components/schemas/StartupPersistenceOutcome" + }, + { + "type": "null" + } + ], + "description": "Session metadata persistence, independent of the Backend reservation result." + }, + "resources": { + "anyOf": [ + { + "$ref": "#/components/schemas/StartupResourceFacts" + }, + { + "type": "null" + } + ] + }, + "secondary": { + "items": { + "$ref": "#/components/schemas/StartupSecondaryFailure" + }, + "maxItems": 8, + "type": "array" + }, + "version": { + "format": "uint16", + "maximum": 1, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "version", + "operation", + "secondary" + ], + "type": "object" + }, + "StartupResourceFacts": { + "properties": { + "cleanup": { + "$ref": "#/components/schemas/StartupCleanup" + }, + "durable_effects_possible": { + "type": "boolean" + }, + "resources": { + "$ref": "#/components/schemas/StartupResources" + } + }, + "required": [ + "resources", + "cleanup", + "durable_effects_possible" + ], + "type": "object" + }, + "StartupResources": { + "description": "Independent resource observations from the existing execution owner. These diagnostics\nnever promote a result to RolledBack or authorize replay; the backend disposition does.", + "enum": [ + "none", + "worker_startup", + "unproven" + ], + "type": "string" + }, + "StartupSecondaryFailure": { + "description": "A separate secondary fact, not a replacement primary cause or a causal-chain link.", + "oneOf": [ + { + "properties": { + "kind": { + "const": "projection_unavailable", + "type": "string" + }, + "phase": { + "$ref": "#/components/schemas/StartupFailurePhase" + } + }, + "required": [ + "kind", + "phase" + ], + "type": "object" + }, + { + "properties": { + "failure": { + "$ref": "#/components/schemas/StartupCleanupFailure" + }, + "kind": { + "const": "cleanup", + "type": "string" + } + }, + "required": [ + "kind", + "failure" + ], + "type": "object" + }, + { + "properties": { + "kind": { + "const": "persistence", + "type": "string" + }, + "outcome": { + "$ref": "#/components/schemas/StartupPersistenceOutcome" + }, + "phase": { + "$ref": "#/components/schemas/StartupFailurePhase" + } + }, + "required": [ + "kind", + "phase", + "outcome" + ], + "type": "object" + } + ] + }, + "StartupStage": { + "description": "Owner-selected startup stage; independent of resource cleanup disposition.", + "enum": [ + "preflight", + "metadata", + "reconstruction", + "recovery", + "binding", + "feature", + "activation_effects", + "controller" + ], + "type": "string" + }, "SubjectBodyApiError": { "additionalProperties": false, "description": "Only action handlers with a causally bound request may provide resolution evidence.", @@ -20793,6 +21628,7 @@ }, "WorkerOperationState": { "enum": [ + "unknown", "accepted", "unsupported", "rejected" @@ -23363,7 +24199,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:894ccff36a463bec91b635956924114aa0db87d8b3f815230d6e8395511d2f6e" + "x-yoi-source-digest": "sha256:2dad54b31293643566259ea555e11c46effce33aab9f30c8a861a46303024bad" }, "openapi": "3.1.0", "paths": { @@ -48730,6 +49566,11 @@ "schema": { "$ref": "#/components/schemas/RepositoryApiError" } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/StartupProblemDetails" + } } }, "description": "Error response" @@ -48740,6 +49581,11 @@ "schema": { "$ref": "#/components/schemas/RepositoryApiError" } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/StartupProblemDetails" + } } }, "description": "Error response" @@ -48750,6 +49596,11 @@ "schema": { "$ref": "#/components/schemas/RepositoryApiError" } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/StartupProblemDetails" + } } }, "description": "Error response" @@ -48760,6 +49611,11 @@ "schema": { "$ref": "#/components/schemas/RepositoryApiError" } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/StartupProblemDetails" + } } }, "description": "Error response" @@ -48770,6 +49626,11 @@ "schema": { "$ref": "#/components/schemas/RepositoryApiError" } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/StartupProblemDetails" + } } }, "description": "Error response" @@ -48780,6 +49641,11 @@ "schema": { "$ref": "#/components/schemas/RepositoryApiError" } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/StartupProblemDetails" + } } }, "description": "Error response" @@ -48790,6 +49656,11 @@ "schema": { "$ref": "#/components/schemas/RepositoryApiError" } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/StartupProblemDetails" + } } }, "description": "Error response" @@ -48800,6 +49671,26 @@ "schema": { "$ref": "#/components/schemas/RepositoryApiError" } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/StartupProblemDetails" + } + } + }, + "description": "Error response" + }, + "504": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/StartupProblemDetails" + } } }, "description": "Error response" @@ -49947,6 +50838,11 @@ "schema": { "$ref": "#/components/schemas/RepositoryApiError" } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/StartupProblemDetails" + } } }, "description": "Error response" @@ -49957,6 +50853,11 @@ "schema": { "$ref": "#/components/schemas/RepositoryApiError" } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/StartupProblemDetails" + } } }, "description": "Error response" @@ -49967,6 +50868,11 @@ "schema": { "$ref": "#/components/schemas/RepositoryApiError" } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/StartupProblemDetails" + } } }, "description": "Error response" @@ -49977,6 +50883,11 @@ "schema": { "$ref": "#/components/schemas/RepositoryApiError" } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/StartupProblemDetails" + } } }, "description": "Error response" @@ -49987,6 +50898,11 @@ "schema": { "$ref": "#/components/schemas/RepositoryApiError" } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/StartupProblemDetails" + } } }, "description": "Error response" @@ -49997,6 +50913,11 @@ "schema": { "$ref": "#/components/schemas/RepositoryApiError" } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/StartupProblemDetails" + } } }, "description": "Error response" @@ -50007,6 +50928,11 @@ "schema": { "$ref": "#/components/schemas/RepositoryApiError" } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/StartupProblemDetails" + } } }, "description": "Error response" @@ -50017,6 +50943,26 @@ "schema": { "$ref": "#/components/schemas/RepositoryApiError" } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/StartupProblemDetails" + } + } + }, + "description": "Error response" + }, + "504": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/StartupProblemDetails" + } } }, "description": "Error response" diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index ed272d568..5864393a5 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -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:894ccff36a463bec91b635956924114aa0db87d8b3f815230d6e8395511d2f6e) +// Input contract: 0.1.0 (sha256:2dad54b31293643566259ea555e11c46effce33aab9f30c8a861a46303024bad) // 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 diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 1984f7db8..877fab10e 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -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:894ccff36a463bec91b635956924114aa0db87d8b3f815230d6e8395511d2f6e) +// Input contract: 0.1.0 (sha256:2dad54b31293643566259ea555e11c46effce33aab9f30c8a861a46303024bad) // 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 diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index b2570df8c..79a2a31cf 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -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:894ccff36a463bec91b635956924114aa0db87d8b3f815230d6e8395511d2f6e) +// Input contract: 0.1.0 (sha256:2dad54b31293643566259ea555e11c46effce33aab9f30c8a861a46303024bad) // 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 @@ -175,7 +175,7 @@ export type WorkerImplementationSummary = { display_hint: string; kind: string; export type WorkerMaintenanceState = "compacting"; -export type WorkerOperationState = "accepted" | "unsupported" | "rejected"; +export type WorkerOperationState = "unknown" | "accepted" | "unsupported" | "rejected"; export type WorkerRestoreRequest = { expected_observation_token: string; request_id: string; }; diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index b23760724..232e27bbf 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -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:894ccff36a463bec91b635956924114aa0db87d8b3f815230d6e8395511d2f6e) +// Input contract: 0.1.0 (sha256:2dad54b31293643566259ea555e11c46effce33aab9f30c8a861a46303024bad) // 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 diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index 986c6fe70..cc5043dd9 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -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:894ccff36a463bec91b635956924114aa0db87d8b3f815230d6e8395511d2f6e) +// Input contract: 0.1.0 (sha256:2dad54b31293643566259ea555e11c46effce33aab9f30c8a861a46303024bad) // 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 diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 9571bddb5..967af2325 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -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:894ccff36a463bec91b635956924114aa0db87d8b3f815230d6e8395511d2f6e) +// Input contract: 0.1.0 (sha256:2dad54b31293643566259ea555e11c46effce33aab9f30c8a861a46303024bad) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check @@ -11,24 +11,46 @@ export type BrowserWorkerWorkingDirectorySelection = { alias: string; relative_c export type BrowserWorkspaceOrchestratorResponse = { diagnostics: Array; disposition: string; online: boolean; worker?: WorkerLaunchWorkerSummary | null; workspace_id: string; }; +export type Category = "authorization" | "invalid_input" | "conflict" | "dependency" | "internal" | "unknown"; + +export type CausalLink = { correlation: CorrelationRef; domain: string & { readonly "__yoiStringLength": "1..128"; }; reason: string & { readonly "__yoiStringLength": "1..128"; }; }; + +export type CompensationOutcome = "completed" | "residual" | "unknown"; + +export type CorrelationRef = string & { readonly "__yoiStringLength": "1..128"; }; + export type CreateWorkspaceWorkerRequest = { control_operation_id?: string | null; display_name: string; execution_grant?: WorkerDelegationRequest | null; feature_connections: WorkspaceWorkerFeatureConnectionsRequest; initial_submit?: Array; profile?: string | null; runtime_id: string; singleton_key?: string | null; ticket_assignment?: CreateWorkspaceWorkerTicketAssignmentRequest | null; workdir_attachments?: Array; }; export type CreateWorkspaceWorkerTicketAssignmentRequest = { operation_id: string; orchestrator_assignment_id?: string | null; ticket_id: string; }; +export type CreationAcceptance = "rejected" | "accepted" | "unknown"; + export type Diagnostic = { code: string; message: string; severity: DiagnosticSeverity; }; export type DiagnosticSeverity = "info" | "warning" | "error"; +export type ExecutionOutcome = "accepted" | "busy" | "rejected" | "errored" | "unsupported" | "unknown"; + export type ExternalWorkdirPermissions = { command: boolean; read: boolean; write: boolean; }; +export type FailureContext = { causes: Array & { readonly "__yoiArrayLength": "0..4"; }; correlation?: CorrelationRef | null; diagnostics: Array & { readonly "__yoiArrayLength": "0..8"; }; operations: Array & { readonly "__yoiArrayLength": "0..8"; }; supplementary_omitted: boolean; }; + +export type FailureDiagnostic = { code: string & { readonly "__yoiStringLength": "1..128"; }; explanation: string & { readonly "__yoiStringLength": "1..2048"; }; }; + export type FeatureInvocation = { arguments: Array; identity: FeatureInvocationIdentity; invocation_id: string; name: string; }; export type FeatureInvocationIdentity = string; +export type FinalizeFacts = { compensation: CompensationOutcome; stage: FinalizeStage; }; + +export type 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"; + export type InvocationArgumentValue = { name: string; value: InvocationValue; }; export type InvocationValue = { kind: "string"; value: string; } | { kind: "integer"; value: number; } | { kind: "boolean"; value: boolean; }; +export type OperationContext = { operation: string & { readonly "__yoiStringLength": "1..128"; }; operation_ref?: string & { readonly "__yoiStringLength": "1..128"; } | null; stage: string & { readonly "__yoiStringLength": "1..128"; }; }; + export type PasteArtifactAvailability = "available" | "unavailable" | "integrity_failed"; export type PasteArtifactMediaType = "text_plain_utf8"; @@ -39,6 +61,10 @@ export type PermissionResource = { installation_id: string; kind: "plugin_instal export type RepositoryApiError = { diagnostics?: Array; error: string; failure?: StartupFailureAttachment | null; message: string; }; +export type RuntimeStartupCause = { reason: RuntimeStartupReason; stage: StartupStage; }; + +export type RuntimeStartupReason = "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"; + export type RuntimeWorkerWorkdirAttachmentSummary = { alias: string; working_directory: RuntimeWorkingDirectorySummary; }; export type RuntimeWorkingDirectoryCleanupTarget = { kind: string; repository_id: string; working_directory_id: string; }; @@ -49,8 +75,46 @@ export type ScopedPermission = { operation: string; resource: PermissionResource export type Segment = { content: string; kind: "text"; } | { chars: number; content: string; id: number; kind: "paste"; lines: number; } | { artifact: PasteArtifactRef; kind: "paste_artifact"; } | { file: UploadedFileRef; kind: "uploaded_file"; } | { kind: "file_ref"; path: string; } | { invocation: FeatureInvocation; kind: "feature_invoke"; } | { kind: "flow"; selector: string; } | { kind: "unknown"; }; +export type SessionOperation = "session.record"; + +export type SessionPrincipal = "operator" | "subject" | "unknown"; + +export type SessionRegistrationCause = { principal: SessionPrincipal; reason: SessionRegistrationReason; required_operation: SessionOperation; }; + +export type SessionRegistrationReason = "permission_denied" | "attribution_missing" | "binding_mismatch" | "transport" | "outcome_unknown" | "invalid_response"; + +export type StartupBoundaryCause = { reason: StartupBoundaryReason; }; + +export type StartupBoundaryReason = "timeout" | "transport" | "decode" | "invalid_response" | "too_large" | "unsupported_version" | "unsupported_reason" | "finalize_failed"; + +export type StartupCleanup = "not_needed" | "required" | "confirmed" | { failed: StartupCleanupFailure; }; + +export type StartupCleanupFailure = "ownership" | "workdir" | "artifacts" | "allocation" | "feature" | "unsupported"; + +export type StartupCorrelation = string & { readonly "__yoiStringLength": "1..128"; readonly "__yoiStringPattern": "^[A-Za-z0-9._-]+$"; }; + export type StartupFailureAttachment = string; +export type StartupFailurePhase = "cleanup" | "runtime_record_delete" | "runtime_snapshot_save" | "runtime_removal_publish" | "metadata_save" | "runtime_reconciliation_record_save" | "runtime_reconciliation_publish"; + +export type StartupIoKind = "not_found" | "permission_denied" | "already_exists" | "invalid_input" | "invalid_data" | "interrupted" | "timed_out" | "would_block" | "unexpected_eof" | "other"; + +export type StartupOperationFacts = { acceptance: CreationAcceptance; backend_compensation?: CompensationOutcome | null; correlation?: StartupCorrelation | null; execution: ExecutionOutcome; finalize?: FinalizeFacts | null; }; + +export type StartupPersistenceOutcome = "persisted" | "not_saved" | "conflict" | "outcome_unknown"; + +export type StartupProblemDetails = { detail: string & { readonly "__yoiStringLength": "1..2048"; }; instance: string & { readonly "__yoiStringLength": "0..2048"; }; status: number & { readonly "__yoiIntegerRange": "400..599"; }; title: string & { readonly "__yoiStringLength": "1..2048"; }; type: string & { readonly "__yoiStringLength": "0..2048"; }; yoi_failure: { category: Category; context: FailureContext; details: SessionRegistrationCause; details_version: number & { readonly "__yoiIntegerRange": "1..65535"; }; domain: "session_registration"; explanation: string & { readonly "__yoiStringLength": "1..2048"; }; reason: string & { readonly "__yoiStringLength": "1..128"; }; required_extensions: Array & { readonly "__yoiArrayLength": "0..8"; }; version: number & { readonly "__yoiIntegerRange": "1..65535"; }; } | { category: Category; context: FailureContext; details: RuntimeStartupCause; details_version: number & { readonly "__yoiIntegerRange": "1..65535"; }; domain: "worker_startup"; explanation: string & { readonly "__yoiStringLength": "1..2048"; }; reason: string & { readonly "__yoiStringLength": "1..128"; }; required_extensions: Array & { readonly "__yoiArrayLength": "0..8"; }; version: number & { readonly "__yoiIntegerRange": "1..65535"; }; } | { category: Category; context: FailureContext; details: StartupBoundaryCause; details_version: number & { readonly "__yoiIntegerRange": "1..65535"; }; domain: "startup_boundary"; explanation: string & { readonly "__yoiStringLength": "1..2048"; }; reason: string & { readonly "__yoiStringLength": "1..128"; }; required_extensions: Array & { readonly "__yoiArrayLength": "0..8"; }; version: number & { readonly "__yoiIntegerRange": "1..65535"; }; }; yoi_startup: StartupProblemFacts; }; + +export type StartupProblemFacts = { backend_result_persistence?: StartupPersistenceOutcome | null; io_kind?: StartupIoKind | null; operation: StartupOperationFacts; persistence?: StartupPersistenceOutcome | null; resources?: StartupResourceFacts | null; secondary: Array & { readonly "__yoiArrayLength": "0..8"; }; version: number & { readonly "__yoiIntegerRange": "1..1"; }; }; + +export type StartupResourceFacts = { cleanup: StartupCleanup; durable_effects_possible: boolean; resources: StartupResources; }; + +export type StartupResources = "none" | "worker_startup" | "unproven"; + +export type StartupSecondaryFailure = { kind: "projection_unavailable"; phase: StartupFailurePhase; } | { failure: StartupCleanupFailure; kind: "cleanup"; } | { kind: "persistence"; outcome: StartupPersistenceOutcome; phase: StartupFailurePhase; }; + +export type StartupStage = "preflight" | "metadata" | "reconstruction" | "recovery" | "binding" | "feature" | "activation_effects" | "controller"; + export type SubjektivWorkerConnectionRequest = { subject_id: string; }; export type SubscriptionWorkerAvailability = "observed" | "unavailable"; From bf796f8db2bcbe4b12142ad4d0c3a473a948583f Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 6 Nov 2026 11:06:10 +0900 Subject: [PATCH 4/6] 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} +