feat: integrate typed worker failure presentation
This commit is contained in:
@@ -6,12 +6,14 @@
|
||||
"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 src/lib/workspace/workers/startup-failure.test.ts test/worker-startup-backend-contract.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:worker:backend-contract": "cd ../.. && mkdir -p target && cargo test -p yoi-workspace-server --lib session_registration_connected_tests::browser_http_ -- --nocapture > target/worker-browser-contract.log 2>&1 && cd web/workspace && deno run --allow-read=../../target/worker-browser-contract.log test/worker-startup-backend-contract.ts ../../target/worker-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",
|
||||
|
||||
@@ -30,7 +30,8 @@ const SKILL_API_LOAD_POLICY: JsonLoadPolicy = {
|
||||
maxResponseBytes: SKILL_API_LIMITS.maxResponseBytes,
|
||||
};
|
||||
|
||||
class ResponseByteLimitError extends Error {}
|
||||
export class ResponseByteLimitError extends Error {}
|
||||
export class ResponseTextDecodeError extends Error {}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
if (!path || path === "/") return "";
|
||||
@@ -155,6 +156,13 @@ export async function readBoundedJson(
|
||||
response: Response,
|
||||
maxBytes: number,
|
||||
): Promise<unknown> {
|
||||
return JSON.parse(await readBoundedText(response, maxBytes)) as unknown;
|
||||
}
|
||||
|
||||
export async function readBoundedText(
|
||||
response: Response,
|
||||
maxBytes: number,
|
||||
): Promise<string> {
|
||||
const contentLength = response.headers.get("content-length");
|
||||
if (contentLength !== null) {
|
||||
const parsedLength = Number(contentLength);
|
||||
@@ -191,8 +199,11 @@ 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;
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
throw new ResponseTextDecodeError();
|
||||
}
|
||||
}
|
||||
|
||||
async function requireJson<T>(
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
WorkingDirectoryRemovalResponse,
|
||||
WorkingDirectorySource,
|
||||
WorkingDirectorySummary,
|
||||
} from "../../generated/workdir-api";
|
||||
} from "../../generated/workdir-api.ts";
|
||||
|
||||
const SUMMARY_KEYS = new Set([
|
||||
"working_directory_id",
|
||||
|
||||
@@ -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. <img src='/secret' onerror='alert(1)'>";
|
||||
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");
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import type { FailureNoticeModel } from './model.ts';
|
||||
|
||||
let { failure }: { failure: FailureNoticeModel } = $props();
|
||||
</script>
|
||||
|
||||
<div class="failure-notice" role="alert" aria-atomic="true">
|
||||
<p class="failure-notice__cause"><strong>Operation failed</strong> — {failure.explanation}</p>
|
||||
{#if failure.compensation.length > 0}
|
||||
<div class="failure-notice__compensation">
|
||||
<strong>Cleanup needs attention</strong>
|
||||
<ul>{#each failure.compensation as explanation}<li>{explanation}</li>{/each}</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if failure.supplements.length > 0}
|
||||
<div class="failure-notice__supplements">
|
||||
<strong>Additional information</strong>
|
||||
<ul>{#each failure.supplements as explanation}<li>{explanation}</li>{/each}</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if failure.details.length > 0}
|
||||
<details>
|
||||
<summary>Failure details</summary>
|
||||
<dl>
|
||||
{#each failure.details as detail}
|
||||
<dt>{detail.label}</dt><dd>{detail.value}</dd>
|
||||
{/each}
|
||||
</dl>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.failure-notice {
|
||||
border-inline-start: 3px solid var(--danger);
|
||||
padding: 0.75rem 1rem;
|
||||
color: var(--text);
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.failure-notice__cause { margin: 0; }
|
||||
.failure-notice__cause strong { color: var(--danger); }
|
||||
.failure-notice__compensation, .failure-notice__supplements, details { margin-top: 0.75rem; }
|
||||
ul { padding-inline-start: 1.25rem; margin-block: 0.25rem 0; }
|
||||
summary { cursor: pointer; width: fit-content; }
|
||||
summary:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
|
||||
dl { margin-bottom: 0; }
|
||||
dt { font-weight: 600; }
|
||||
dd { margin-inline-start: 1rem; margin-bottom: 0.5rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
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<D, E = undefined>(
|
||||
receipt: FailureReceipt<D, E>,
|
||||
): 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)),
|
||||
...(failure.context.supplementary_omitted
|
||||
? ["Some additional information was omitted by the server."]
|
||||
: []),
|
||||
].filter((text) => text !== failure.explanation),
|
||||
compensation: [],
|
||||
details,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): 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<string, string>[]
|
||||
) {
|
||||
let cancelled = false;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
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("<html>secret</html>", { 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 [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",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,331 @@
|
||||
import { FAILURE_LIMITS } from "#lib/generated/error-contract.ts";
|
||||
import type {
|
||||
FailureContext,
|
||||
FailureEnvelope,
|
||||
ProblemDetails,
|
||||
} from "#lib/generated/error-contract.ts";
|
||||
import {
|
||||
readBoundedText,
|
||||
ResponseByteLimitError,
|
||||
ResponseTextDecodeError,
|
||||
} from "#lib/workspace/api/http.ts";
|
||||
|
||||
// domain固有detailsの検証は公開schemaを所有するadapterへ渡す。
|
||||
// この受信結果は操作結果でも、別audienceへの再公開の許可でもない。
|
||||
export type DetailsDecode<D> =
|
||||
| { kind: "known"; details: D }
|
||||
| { kind: "unsupported" }
|
||||
| { kind: "invalid" };
|
||||
export type DetailsDecoder<D> = (
|
||||
envelope: FailureEnvelope<unknown>,
|
||||
) => DetailsDecode<D>;
|
||||
|
||||
export type ProblemExtensionDecoder<E> = (
|
||||
problem: Readonly<Record<string, unknown>>,
|
||||
) => DetailsDecode<E>;
|
||||
|
||||
export type FailureReceipt<D, E = undefined> =
|
||||
| { kind: "problem"; problem: ProblemDetails<D>; extension?: E }
|
||||
| { 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<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return invalid();
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<T>(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<string> | 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;
|
||||
}
|
||||
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 keys = stack.at(-1);
|
||||
if (!keys || keys.has(token)) return invalid();
|
||||
keys.add(token);
|
||||
}
|
||||
} else if (char === "{" || char === "[") {
|
||||
stack.push(char === "{" ? new Set<string>() : 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<D>(text: string, decode: DetailsDecoder<D>):
|
||||
| { kind: "known"; failure: FailureEnvelope<D> }
|
||||
| { 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<D>(value: unknown, decode: DetailsDecoder<D>):
|
||||
| { kind: "known"; failure: FailureEnvelope<D> }
|
||||
| { 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<unknown> = {
|
||||
version,
|
||||
required_extensions,
|
||||
domain,
|
||||
reason,
|
||||
category: category as FailureEnvelope<unknown>["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 {
|
||||
// RFC 3986のcomponentを検査するだけで、base解決/取得/正規化はしない。
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
utf8.encode(value).length > FAILURE_LIMITS.explanationBytes ||
|
||||
/%(?![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;
|
||||
}
|
||||
|
||||
export function decodeProblem<D, E = undefined>(
|
||||
text: string,
|
||||
status: number,
|
||||
contentType: string,
|
||||
decode: DetailsDecoder<D>,
|
||||
decodeExtension?: ProblemExtensionDecoder<E>,
|
||||
): FailureReceipt<D, E> {
|
||||
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;
|
||||
const extension = decodeExtension?.(raw);
|
||||
if (extension && extension.kind !== "known") return extension;
|
||||
return {
|
||||
kind: "problem",
|
||||
...(extension?.kind === "known" ? { extension: extension.details } : {}),
|
||||
problem: {
|
||||
type,
|
||||
instance,
|
||||
status,
|
||||
title,
|
||||
detail,
|
||||
yoi_failure: failure.failure,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
kind: error instanceof ResponseByteLimitError ? "too_large" : "invalid",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function receiveProblem<D, E = undefined>(
|
||||
response: Response,
|
||||
decode: DetailsDecoder<D>,
|
||||
decodeExtension?: ProblemExtensionDecoder<E>,
|
||||
): Promise<FailureReceipt<D, E>> {
|
||||
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 ResponseTextDecodeError
|
||||
? "invalid"
|
||||
: "transport",
|
||||
};
|
||||
}
|
||||
return decodeProblem(
|
||||
text,
|
||||
response.status,
|
||||
contentType,
|
||||
decode,
|
||||
decodeExtension,
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>): 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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<StartupCause, StartupProblemFacts>;
|
||||
class UnsupportedContract extends Error {}
|
||||
const unsupported = (): never => {
|
||||
throw new UnsupportedContract();
|
||||
};
|
||||
const invalid = (): never => {
|
||||
throw new Error("Invalid startup contract");
|
||||
};
|
||||
function exact(value: Record<string, unknown>, keys: string[]) {
|
||||
if (Object.keys(value).some((key) => !keys.includes(key))) invalid();
|
||||
}
|
||||
function enumValue<T extends string>(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<T>(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<unknown>,
|
||||
): DetailsDecode<StartupCause> {
|
||||
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<Record<string, unknown>>,
|
||||
): DetailsDecode<StartupProblemFacts> {
|
||||
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<StartupReceipt> {
|
||||
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" } };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
let submitting = $state(false);
|
||||
let submitError = $state<DisplayError | null>(null);
|
||||
let outcomeUnknown = $state(false);
|
||||
let startupError = $state<FailureNoticeModel | null>(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 @@
|
||||
</label>
|
||||
</section>
|
||||
|
||||
{#if submitError}
|
||||
<div class="section-state error worker-submit-error">
|
||||
<p>{submitError.message}</p>
|
||||
{#if outcomeUnknown}
|
||||
<p>The launch outcome is unknown. Check the Worker list or Ticket before starting another Worker.</p>
|
||||
{#if startupError}
|
||||
<FailureNotice failure={startupError} />
|
||||
{#if needsReconciliation}
|
||||
<div class="worker-reconciliation" role="status">
|
||||
<p>The launch result needs reconciliation. Check the Worker list or Ticket before starting another Worker.</p>
|
||||
<a href={`/w/${workspaceId}/workers`}>Check Workers</a>
|
||||
{/if}
|
||||
{#if ticketContext}<a href={`/w/${workspaceId}/tickets/${encodeURIComponent(ticketContext.ticketId)}`}>Check Ticket</a>{/if}
|
||||
<p>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.</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if submitError}
|
||||
<div class="section-state error worker-submit-error" role="alert">
|
||||
<p>{submitError.message}</p>
|
||||
{#if submitError.diagnostics.length > 0}
|
||||
<ul class="worker-error-diagnostics">
|
||||
{#each submitError.diagnostics as diagnostic}
|
||||
@@ -638,3 +649,9 @@
|
||||
</form>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.worker-reconciliation { overflow-wrap: anywhere; margin-block: var(--space-3); }
|
||||
.worker-reconciliation p { margin-block: var(--space-2); }
|
||||
.worker-reconciliation a + a { margin-inline-start: var(--space-4); }
|
||||
</style>
|
||||
|
||||
+145
-4
@@ -7,7 +7,9 @@ import {
|
||||
waitFor,
|
||||
} from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { goto } from "$app/navigation";
|
||||
import type { ComponentProps } from "svelte";
|
||||
import { startupFixture } from "#lib/workspace/workers/startup-failure.test-fixtures.ts";
|
||||
import NewWorker from "./PageContent.svelte";
|
||||
function mount(
|
||||
ticketContext:
|
||||
@@ -120,8 +122,8 @@ test.each(["embedded", "remote"].flatMap((runtime) =>
|
||||
expect(screen.queryByRole("option", { name: "Trusted Reviewer" }))
|
||||
.toBeNull();
|
||||
await fireEvent.click(start);
|
||||
await screen.findByText(
|
||||
"permission_denied: Backend rejected this assignment",
|
||||
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||
"The request did not return a supported failure response. The operation result has not been confirmed.",
|
||||
);
|
||||
const request = JSON.parse(String(api.mock.calls[1][1]?.body));
|
||||
expect(request.runtime_id).toBe(runtime);
|
||||
@@ -175,7 +177,9 @@ test("read-only Workdir requires an explicit selection and keeps backend permiss
|
||||
await fireEvent.change(select, { target: { value: "read-only-grant" } });
|
||||
await waitFor(() => expect(start.disabled).toBe(false));
|
||||
await fireEvent.click(start);
|
||||
await screen.findByText("permission_denied: No write permission");
|
||||
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||
"The request did not return a supported failure response. The operation result has not been confirmed.",
|
||||
);
|
||||
const request = JSON.parse(String(api.mock.calls[1][1]?.body));
|
||||
expect(request.workdir_attachments).toEqual([{
|
||||
alias: "workdir",
|
||||
@@ -214,7 +218,7 @@ test.each(["backend", "network"])(
|
||||
});
|
||||
await fireEvent.click(start);
|
||||
await screen.findByText(
|
||||
"The launch outcome is unknown. Check the Worker list or Ticket before starting another Worker.",
|
||||
"The launch result needs reconciliation. Check the Worker list or Ticket before starting another Worker.",
|
||||
);
|
||||
expect((start as HTMLButtonElement).disabled).toBe(true);
|
||||
const request = JSON.parse(String(api.mock.calls[1][1]?.body));
|
||||
@@ -226,3 +230,140 @@ test.each(["backend", "network"])(
|
||||
expect(api).toHaveBeenCalledTimes(2);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["denial", "lost", "rejected", "compensation"] as const)(
|
||||
"startup contract %s shows cause before details, preserves draft and applies only typed operation gating",
|
||||
async (name) => {
|
||||
const body = startupFixture(name);
|
||||
const api = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) =>
|
||||
String(input).endsWith("/launch-options")
|
||||
? Response.json(emptyLaunchOptions("empty"))
|
||||
: new Response(JSON.stringify(body), {
|
||||
status: body.status,
|
||||
headers: { "content-type": "application/problem+json" },
|
||||
})
|
||||
);
|
||||
vi.stubGlobal("fetch", api);
|
||||
mount({
|
||||
ticketId: "T-720",
|
||||
ticketTitle: "Keep draft",
|
||||
initialInput: "Keep this request unchanged",
|
||||
});
|
||||
const start = await screen.findByRole("button", { name: "Start Worker" });
|
||||
await fireEvent.click(start);
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toContain(body.detail);
|
||||
expect(alert.querySelector("details")!.open).toBe(false);
|
||||
expect(alert.querySelector("details")!.textContent).not.toContain(
|
||||
body.detail,
|
||||
);
|
||||
expect(alert.textContent!.split(body.detail)).toHaveLength(2); // wrapper/title/detailの重複なし
|
||||
expect(
|
||||
(screen.getByLabelText("Initial request") as HTMLTextAreaElement).value,
|
||||
).toBe("Keep this request unchanged");
|
||||
expect((start as HTMLButtonElement).disabled).toBe(name !== "rejected");
|
||||
if (name !== "rejected") {
|
||||
expect(screen.getByRole("link", { name: "Check Workers" })).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "Check Ticket" })).toBeTruthy();
|
||||
await fireEvent.click(start);
|
||||
expect(api).toHaveBeenCalledTimes(2);
|
||||
}
|
||||
if (name === "compensation") {
|
||||
expect(screen.getByText("Backend cleanup: residual.")).toBeTruthy();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["invalid", "oversize", "unsupported", "unsafe-detail"])(
|
||||
"%s failure never renders raw bodies or silently allows a second launch",
|
||||
async (mode) => {
|
||||
const body = JSON.parse(JSON.stringify(startupFixture("rejected")));
|
||||
if (mode === "unsupported") body.yoi_startup.version = 99;
|
||||
if (mode === "unsafe-detail") {
|
||||
body.yoi_failure.details.raw_body = "/private/secret token=credential";
|
||||
}
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) =>
|
||||
String(input).endsWith("/launch-options")
|
||||
? Response.json(emptyLaunchOptions("empty"))
|
||||
: new Response(
|
||||
mode === "invalid"
|
||||
? "/private/secret raw body"
|
||||
: mode === "oversize"
|
||||
? "secret".repeat(6000)
|
||||
: JSON.stringify(body),
|
||||
{
|
||||
status: 409,
|
||||
headers: { "content-type": "application/problem+json" },
|
||||
},
|
||||
)
|
||||
),
|
||||
);
|
||||
mount();
|
||||
const start = await screen.findByRole("button", { name: "Start Worker" });
|
||||
await fireEvent.click(start);
|
||||
await screen.findByRole("alert");
|
||||
expect(document.body.textContent).not.toContain("/private/");
|
||||
expect(document.body.textContent).not.toContain("secret");
|
||||
expect(document.body.textContent).not.toContain("token=credential");
|
||||
expect((start as HTMLButtonElement).disabled).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([false, true])(
|
||||
"a created Worker never permits another POST even when navigation fails: %s",
|
||||
async (navigationFails) => {
|
||||
if (navigationFails) {
|
||||
vi.mocked(goto).mockRejectedValueOnce(new Error("navigation failed"));
|
||||
}
|
||||
const api = vi.fn(async (input: RequestInfo | URL) =>
|
||||
String(input).endsWith("/launch-options")
|
||||
? Response.json(emptyLaunchOptions("empty"))
|
||||
: Response.json({
|
||||
workspace_id: "empty",
|
||||
runtime_id: "embedded",
|
||||
worker_id: "worker-a",
|
||||
console_href: "/w/empty/workers/W-1/console",
|
||||
diagnostics: [],
|
||||
worker: {
|
||||
runtime_id: "embedded",
|
||||
worker_id: "worker-a",
|
||||
host_id: "host-a",
|
||||
display_name: "Worker",
|
||||
label: "worker-a",
|
||||
profile: null,
|
||||
singleton_key: null,
|
||||
tags: [],
|
||||
workspace: {
|
||||
visibility: "workspace",
|
||||
identity: "empty",
|
||||
workspace_id: "empty",
|
||||
},
|
||||
state: "idle",
|
||||
last_seen_at: null,
|
||||
pinned: false,
|
||||
retention_state: "active",
|
||||
implementation: { kind: "runtime", display_hint: "Runtime Worker" },
|
||||
diagnostics: [],
|
||||
},
|
||||
})
|
||||
);
|
||||
vi.stubGlobal("fetch", api);
|
||||
mount();
|
||||
const start = await screen.findByRole("button", { name: "Start Worker" });
|
||||
await fireEvent.click(start);
|
||||
await waitFor(() =>
|
||||
expect(goto).toHaveBeenCalledWith("/w/empty/workers/W-1/console")
|
||||
);
|
||||
expect((start as HTMLButtonElement).disabled).toBe(true);
|
||||
if (navigationFails) {
|
||||
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||
"The Worker was created, but its console could not be opened.",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Check Workers" })).toBeTruthy();
|
||||
}
|
||||
await fireEvent.click(start);
|
||||
expect(api).toHaveBeenCalledTimes(2);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
import { assert, assertEquals } from "jsr:@std/assert@1.0.19";
|
||||
import { chromium } from "npm:playwright@1.59.1";
|
||||
import { loadScenario } from "../../../../tools/web-ux/src/scenario.ts";
|
||||
import {
|
||||
startOwnedProcesses,
|
||||
stopOwnedProcesses,
|
||||
} from "../../../../tools/web-ux/src/processes.ts";
|
||||
import { startupFixture } from "../../src/lib/workspace/workers/startup-failure.test-fixtures.ts";
|
||||
import { verifyStartupHttpCaptures } from "../worker-startup-backend-contract.ts";
|
||||
|
||||
// production build + exact T812 HTTP fixtures。派生故障・操作結果はWeb試験用でありServer保証ではない。
|
||||
// beforeは旧production buildで同fixtureを撮影するためだけに明示選択し、afterの成功と混同しない。
|
||||
Deno.test("Worker failure cause, independent outcomes, keyboard details and safe reconciliation", async () => {
|
||||
const before = Deno.env.get("T813_PHASE") === "before";
|
||||
const captureInput = Deno.env.get("T813_HTTP_CAPTURE_LOG");
|
||||
const captureLog = captureInput
|
||||
? await Deno.readTextFile(captureInput)
|
||||
: null;
|
||||
const captureSha256 = captureLog
|
||||
? Array.from(
|
||||
new Uint8Array(
|
||||
await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(captureLog),
|
||||
),
|
||||
),
|
||||
).map((b) => b.toString(16).padStart(2, "0")).join("")
|
||||
: null;
|
||||
const captures = captureLog
|
||||
? (await verifyStartupHttpCaptures(captureLog)).captures
|
||||
: null;
|
||||
function fixture(name: Parameters<typeof startupFixture>[0]) {
|
||||
if (captures && (name === "denial" || name === "lost")) {
|
||||
const capture = captures.find((item) =>
|
||||
item.status === (name === "denial" ? 409 : 502)
|
||||
);
|
||||
assert(capture, "Expected both validated real HTTP captures");
|
||||
return JSON.parse(capture.body);
|
||||
}
|
||||
return startupFixture(name);
|
||||
}
|
||||
const phase = before
|
||||
? "before-real-contract"
|
||||
: captureInput
|
||||
? "after-connected"
|
||||
: "after";
|
||||
const scenarioPath =
|
||||
new URL("./optional-repository.json", import.meta.url).pathname;
|
||||
const scenario = await loadScenario(scenarioPath);
|
||||
function freePort() {
|
||||
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
||||
const port = (listener.addr as Deno.NetAddr).port;
|
||||
listener.close();
|
||||
return port;
|
||||
}
|
||||
const upstream = freePort();
|
||||
let proxy = freePort();
|
||||
while (proxy === upstream) proxy = freePort();
|
||||
scenario.baseUrl = `http://127.0.0.1:${proxy}`;
|
||||
scenario.processes![0].args![4] = String(upstream);
|
||||
scenario.processes![0].readyUrl = `http://127.0.0.1:${upstream}/health`;
|
||||
scenario.processes![1].args![3] = String(proxy);
|
||||
scenario.processes![1].args![4] = `http://127.0.0.1:${upstream}`;
|
||||
scenario.processes![1].readyUrl = `${scenario.baseUrl}/health`;
|
||||
const output =
|
||||
new URL(`../../../../target/web-ux/t813/${phase}/`, import.meta.url)
|
||||
.pathname;
|
||||
await Deno.mkdir(output, { recursive: true });
|
||||
const processes = await startOwnedProcesses(
|
||||
scenario.processes!,
|
||||
scenarioPath,
|
||||
`${output}/process-logs`,
|
||||
[],
|
||||
);
|
||||
const records: unknown[] = [];
|
||||
try {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: 900 },
|
||||
colorScheme,
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
let name = "denial";
|
||||
let submissions = 0;
|
||||
const errors: string[] = [];
|
||||
const failedRequests: {
|
||||
method: string;
|
||||
path: string;
|
||||
error: string | undefined;
|
||||
scenario: string | undefined;
|
||||
}[] = [];
|
||||
const requestScenarios = new WeakMap<object, string>();
|
||||
const unexpectedFetches: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(String(error)));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") errors.push(message.text());
|
||||
});
|
||||
page.on(
|
||||
"requestfailed",
|
||||
(request) =>
|
||||
failedRequests.push({
|
||||
method: request.method(),
|
||||
path: new URL(request.url()).pathname,
|
||||
error: request.failure()?.errorText,
|
||||
scenario: requestScenarios.get(request),
|
||||
}),
|
||||
);
|
||||
page.on("request", (request) => {
|
||||
requestScenarios.set(request, name);
|
||||
if (request.url().includes("should-not-fetch")) {
|
||||
unexpectedFetches.push(request.url());
|
||||
}
|
||||
});
|
||||
// Existing shared fixture predates these required fields; adapt only fixture data, not authorization.
|
||||
await page.route(
|
||||
"**/api/auth/whoami",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
session: {
|
||||
kind: "authenticated",
|
||||
expires_at: "2099-01-01T00:00:00Z",
|
||||
},
|
||||
authentication_reference: "fixture-authentication",
|
||||
actor: {
|
||||
user_id: "fixture-user",
|
||||
account_id: "fixture-owner",
|
||||
handle: "alice",
|
||||
display_name: "Alice",
|
||||
auth_method: "browser_session",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/w/home-empty/workspace", async (route) => {
|
||||
const response = await route.fetch();
|
||||
const value = await response.json();
|
||||
await route.fulfill({
|
||||
json: {
|
||||
...value,
|
||||
permissions: { ...value.permissions, delete_subjects: true },
|
||||
},
|
||||
});
|
||||
});
|
||||
let body = JSON.parse(JSON.stringify(fixture("denial")));
|
||||
await page.route("**/api/w/home-empty/workers", async (route) => {
|
||||
if (route.request().method() !== "POST") return route.continue();
|
||||
submissions++;
|
||||
if (name === "transport") return route.abort("failed");
|
||||
await route.fulfill({
|
||||
status: body.status,
|
||||
contentType: "application/problem+json",
|
||||
body: name === "invalid"
|
||||
? "raw /private/secret token=credential"
|
||||
: name === "oversize"
|
||||
? "secret".repeat(6000)
|
||||
: JSON.stringify(body),
|
||||
});
|
||||
});
|
||||
for (
|
||||
name of before ? ["denial", "compensation"] : [
|
||||
"denial",
|
||||
"lost",
|
||||
"rejected",
|
||||
"compensation",
|
||||
"long",
|
||||
"unsupported",
|
||||
"invalid",
|
||||
"oversize",
|
||||
"transport",
|
||||
]
|
||||
) {
|
||||
body = JSON.parse(
|
||||
JSON.stringify(fixture(
|
||||
name === "lost"
|
||||
? "lost"
|
||||
: name === "rejected"
|
||||
? "rejected"
|
||||
: name === "compensation"
|
||||
? "compensation"
|
||||
: "denial",
|
||||
)),
|
||||
);
|
||||
if (name === "unsupported") body.yoi_startup.version = 2;
|
||||
if (name === "long") {
|
||||
body.detail =
|
||||
body.yoi_failure.explanation =
|
||||
"Subject lacks session.record; Session registration and Worker creation could not complete. "
|
||||
.repeat(12) +
|
||||
"<img src='https://should-not-fetch.test/private' onerror='alert(1)'>";
|
||||
body.type = "https://should-not-fetch.test/type";
|
||||
body.instance = "https://should-not-fetch.test/instance";
|
||||
body.future_extension = {
|
||||
raw_source: "/private/secret token=credential",
|
||||
};
|
||||
}
|
||||
const documentResponse = await page.goto(
|
||||
`${scenario.baseUrl}/w/home-empty/workers/new`,
|
||||
);
|
||||
await page.locator(".worker-launch-form").waitFor({
|
||||
timeout: 10000,
|
||||
});
|
||||
await page.getByLabel("Initial request").fill(
|
||||
"Preserve this draft request",
|
||||
);
|
||||
const start = page.getByRole("button", {
|
||||
name: "Start Worker",
|
||||
exact: true,
|
||||
});
|
||||
const previousSubmissions = submissions;
|
||||
await start.click();
|
||||
const notice = page.locator(
|
||||
before ? ".worker-submit-error" : ".failure-notice",
|
||||
);
|
||||
await notice.waitFor({ timeout: 10000 });
|
||||
assertEquals(submissions, previousSubmissions + 1);
|
||||
if (!before) {
|
||||
assertEquals(
|
||||
await page.getByLabel("Initial request").inputValue(),
|
||||
"Preserve this draft request",
|
||||
);
|
||||
assertEquals(await start.isDisabled(), name !== "rejected");
|
||||
assertEquals(
|
||||
await page.getByRole("link", { name: "Cancel", exact: true })
|
||||
.count(),
|
||||
1,
|
||||
);
|
||||
if (
|
||||
["denial", "lost", "rejected", "compensation", "long"]
|
||||
.includes(name)
|
||||
) {
|
||||
const mainText = await notice.locator(
|
||||
".failure-notice__cause",
|
||||
).innerText();
|
||||
assert(mainText.includes(body.detail));
|
||||
assertEquals(
|
||||
(await notice.innerText()).split(body.detail).length,
|
||||
2,
|
||||
);
|
||||
assertEquals(
|
||||
await notice.locator("details").getAttribute("open"),
|
||||
null,
|
||||
);
|
||||
assertEquals(
|
||||
(await notice.locator("details").innerText()).includes(
|
||||
body.detail,
|
||||
),
|
||||
false,
|
||||
);
|
||||
const summary = notice.locator("summary");
|
||||
await summary.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
assert(
|
||||
await notice.locator("details").evaluate((node) =>
|
||||
(node as unknown as { open: boolean }).open
|
||||
),
|
||||
);
|
||||
assert(
|
||||
(await notice.locator("details").innerText()).includes(
|
||||
"Support correlation",
|
||||
),
|
||||
);
|
||||
await page.keyboard.press("Enter");
|
||||
}
|
||||
assertEquals(await notice.locator("img, a").count(), 0);
|
||||
const text = await page.locator("main").innerText();
|
||||
assert(!text.includes("/private/secret"));
|
||||
assert(!text.includes("token=credential"));
|
||||
if (name === "compensation") {
|
||||
assert(text.includes("Backend cleanup: residual."));
|
||||
}
|
||||
if (name !== "rejected") {
|
||||
assertEquals(
|
||||
await page.getByRole("link", { name: "Check Workers" })
|
||||
.count(),
|
||||
1,
|
||||
);
|
||||
}
|
||||
}
|
||||
await notice.scrollIntoViewIfNeeded();
|
||||
assertEquals(
|
||||
await page.evaluate(
|
||||
"document.documentElement.scrollWidth > innerWidth",
|
||||
),
|
||||
false,
|
||||
`${name} ${width}`,
|
||||
);
|
||||
const path = `${output}/${name}-${colorScheme}-${width}.png`;
|
||||
await page.screenshot({ path, fullPage: true });
|
||||
const extraCaptures: { path: string; sha256: string }[] = [];
|
||||
// Expanded contents and reconciliation controls live in the shell's scrolling main.
|
||||
// Scroll those capture points explicitly; fullPage alone only expands the document.
|
||||
if (!before && name === "denial") {
|
||||
await notice.locator("summary").focus();
|
||||
await page.keyboard.press("Space");
|
||||
await notice.locator("dl").scrollIntoViewIfNeeded();
|
||||
const detailsPath =
|
||||
`${output}/details-${colorScheme}-${width}.png`;
|
||||
await page.screenshot({ path: detailsPath, fullPage: true });
|
||||
await notice.locator("summary").focus();
|
||||
await page.keyboard.press("Enter");
|
||||
const actionsPath =
|
||||
`${output}/reconciliation-${colorScheme}-${width}.png`;
|
||||
await page.getByRole("link", { name: "Check Workers" })
|
||||
.scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: actionsPath, fullPage: true });
|
||||
for (const extraPath of [detailsPath, actionsPath]) {
|
||||
const bytes = await Deno.readFile(extraPath);
|
||||
extraCaptures.push({
|
||||
path: extraPath,
|
||||
sha256: Array.from(
|
||||
new Uint8Array(
|
||||
await crypto.subtle.digest("SHA-256", bytes),
|
||||
),
|
||||
).map((b) => b.toString(16).padStart(2, "0")).join(""),
|
||||
});
|
||||
}
|
||||
}
|
||||
const image = await Deno.readFile(path);
|
||||
records.push({
|
||||
route: "/w/home-empty/workers/new",
|
||||
persona: "synthetic owner",
|
||||
name,
|
||||
captureInput,
|
||||
captureSha256,
|
||||
fixture: ["denial", "lost"].includes(name)
|
||||
? captureInput
|
||||
? "forwarded exact T812 corrected-source HTTP capture"
|
||||
: "T812 exact real HTTP body"
|
||||
: "synthetic derivative/fault",
|
||||
width,
|
||||
theme: colorScheme,
|
||||
phase,
|
||||
documentStatus: documentResponse?.status(),
|
||||
expectedApiFailure: name === "transport"
|
||||
? "network abort"
|
||||
: body.status,
|
||||
visibleError: await notice.innerText(),
|
||||
errors: [...errors],
|
||||
failedRequests: [...failedRequests],
|
||||
accessibility: await page.locator("main").ariaSnapshot(),
|
||||
image: path,
|
||||
extraCaptures,
|
||||
sha256: Array.from(
|
||||
new Uint8Array(await crypto.subtle.digest("SHA-256", image)),
|
||||
).map((b) => b.toString(16).padStart(2, "0")).join(""),
|
||||
});
|
||||
}
|
||||
assertEquals(unexpectedFetches, []);
|
||||
// Browser logs expected injected HTTP and transport failures; page JS errors are not allowed.
|
||||
assertEquals(
|
||||
errors.filter((error) =>
|
||||
!error.includes("Failed to load resource")
|
||||
),
|
||||
[],
|
||||
);
|
||||
assert(
|
||||
failedRequests.every((request) =>
|
||||
request.path === "/api/w/home-empty/workers" &&
|
||||
((request.method === "POST" &&
|
||||
request.scenario === "transport" &&
|
||||
request.error === "net::ERR_FAILED") ||
|
||||
(request.method === "POST" &&
|
||||
request.scenario === "oversize" &&
|
||||
request.error === "net::ERR_ABORTED") ||
|
||||
(request.method === "GET" &&
|
||||
request.error === "net::ERR_ABORTED"))
|
||||
),
|
||||
JSON.stringify(failedRequests),
|
||||
);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
} finally {
|
||||
await Deno.writeTextFile(
|
||||
`${output}/review-context.json`,
|
||||
JSON.stringify(records, null, 2),
|
||||
);
|
||||
assertEquals(await stopOwnedProcesses(processes), []);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { verifyStartupHttpCaptures } from "./worker-startup-backend-contract.ts";
|
||||
import { startupFixture } from "../src/lib/workspace/workers/startup-failure.test-fixtures.ts";
|
||||
|
||||
// bridge自身の試験。合成markerを実handler実行の証拠とは扱わない。
|
||||
function capture(name: "denial" | "lost") {
|
||||
const body = startupFixture(name);
|
||||
return `T812_BROWSER_STARTUP_PROBLEM status=${body.status} ignored reason phrase content_type=application/problem+json body=${
|
||||
JSON.stringify(body)
|
||||
}`;
|
||||
}
|
||||
async function rejects(log: string) {
|
||||
try {
|
||||
await verifyStartupHttpCaptures(log);
|
||||
} catch (error) {
|
||||
if (String(error).includes("test-private-body")) {
|
||||
throw new Error("Capture body disclosed");
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
"Expected the capture bridge to reject incomplete/invalid evidence",
|
||||
);
|
||||
}
|
||||
|
||||
Deno.test("Startup HTTP bridge requires both captured cases and passes real response shape through the production Web caller", async () => {
|
||||
const result = await verifyStartupHttpCaptures(
|
||||
`unrelated test output\n${capture("denial")}\n${capture("lost")}\n`,
|
||||
);
|
||||
if (result.records !== 2 || result.cases !== 2) {
|
||||
throw new Error("Unexpected capture coverage");
|
||||
}
|
||||
});
|
||||
Deno.test("Startup HTTP bridge refuses missing, malformed, oversized, mismatched and legacy captures without exposing raw body", async () => {
|
||||
const denial = capture("denial");
|
||||
const lost = capture("lost");
|
||||
for (
|
||||
const log of [
|
||||
"",
|
||||
denial,
|
||||
`${denial}\n${denial}`,
|
||||
`${denial}\n${lost.replace("status=502", "status=500")}`,
|
||||
`${denial}\n${
|
||||
lost.replace(
|
||||
"content_type=application/problem+json",
|
||||
"content_type=application/json",
|
||||
)
|
||||
}`,
|
||||
`${denial}\nT812_BROWSER_STARTUP_PROBLEM status=502 content_type=application/problem+json body=test-private-body`,
|
||||
`${denial}\nT812_BROWSER_STARTUP_PROBLEM status=502 content_type=application/problem+json body=${
|
||||
"x".repeat(32769)
|
||||
}`,
|
||||
`${denial}\nT812_BROWSER_STARTUP_PROBLEM test-private-body`,
|
||||
]
|
||||
) await rejects(log);
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
// 実認可・Runtime・保存を通したRust connected試験の公開HTTP捕捉だけを受ける。
|
||||
// Web送信/受信/表示modelへの接続であり、このprocess自体は認可や保存を代替証明しない。
|
||||
import type { CreateWorkspaceWorkerRequest } from "../src/lib/generated/worker-launch-api.ts";
|
||||
import {
|
||||
submitWorkerLaunch,
|
||||
workerLaunchNeedsReconciliation,
|
||||
workerStartupNotice,
|
||||
} from "../src/lib/workspace/workers/startup-failure.ts";
|
||||
|
||||
const marker = "T812_BROWSER_STARTUP_PROBLEM ";
|
||||
const maxLogBytes = 16 * 1024 * 1024;
|
||||
|
||||
export async function verifyStartupHttpCaptures(log: string) {
|
||||
if (new TextEncoder().encode(log).length > maxLogBytes) {
|
||||
throw new Error("Startup capture log exceeds the test input limit");
|
||||
}
|
||||
const request: CreateWorkspaceWorkerRequest = {
|
||||
runtime_id: "embedded",
|
||||
display_name: "HTTP contract check",
|
||||
profile: null,
|
||||
ticket_assignment: null,
|
||||
initial_submit: [],
|
||||
workdir_attachments: [],
|
||||
feature_connections: {},
|
||||
control_operation_id: null,
|
||||
};
|
||||
const cases = new Set<string>();
|
||||
const captures: { status: number; contentType: string; body: string }[] = [];
|
||||
let records = 0;
|
||||
for (const line of log.split("\n")) {
|
||||
if (!line.includes(marker)) continue;
|
||||
const metadata = line.slice(line.indexOf(marker) + marker.length);
|
||||
const match =
|
||||
/^status=(\d{3})(?: [A-Za-z ]+)? content_type=([^ ]+) body=(.*)$/u.exec(
|
||||
metadata,
|
||||
);
|
||||
if (!match) throw new Error("Malformed Startup HTTP capture metadata");
|
||||
const [, status, contentType, body] = match;
|
||||
let calls = 0;
|
||||
const path = "/api/w/contract/workers";
|
||||
const result = await submitWorkerLaunch(
|
||||
((url: unknown, init?: { method?: string; body?: unknown }) => {
|
||||
calls++;
|
||||
if (
|
||||
url !== path || init?.method !== "POST" ||
|
||||
init.body !== JSON.stringify(request)
|
||||
) {
|
||||
throw new Error("Startup caller did not preserve the launch request");
|
||||
}
|
||||
return Promise.resolve(
|
||||
new Response(body, {
|
||||
status: Number(status),
|
||||
headers: { "content-type": contentType },
|
||||
}),
|
||||
);
|
||||
}) as typeof fetch,
|
||||
path,
|
||||
request,
|
||||
);
|
||||
if (
|
||||
calls !== 1 || result.kind !== "failed" ||
|
||||
result.receipt.kind !== "problem"
|
||||
) {
|
||||
// 受信bodyや他のRust log行を例外へ転載しない。
|
||||
throw new Error(
|
||||
"Actual Startup HTTP capture was not accepted by the Web contract",
|
||||
);
|
||||
}
|
||||
const receipt = result.receipt;
|
||||
const failure = receipt.problem.yoi_failure;
|
||||
const cause = failure.details;
|
||||
if (
|
||||
failure.domain !== "session_registration" ||
|
||||
!("required_operation" in cause) ||
|
||||
cause.required_operation !== "session.record"
|
||||
) {
|
||||
throw new Error("Expected connected Session registration HTTP captures");
|
||||
}
|
||||
if (
|
||||
cause.reason === "permission_denied" && cause.principal === "subject" &&
|
||||
Number(status) === 409
|
||||
) cases.add("denial");
|
||||
else if (cause.reason === "outcome_unknown" && Number(status) === 502) {
|
||||
cases.add("response_lost");
|
||||
} else throw new Error("Unexpected connected Session registration result");
|
||||
if (
|
||||
!receipt.extension ||
|
||||
receipt.extension.operation.execution !== "errored" ||
|
||||
receipt.extension.backend_result_persistence !== "persisted"
|
||||
) {
|
||||
throw new Error(
|
||||
"Connected owner execution and saved result observations were not retained",
|
||||
);
|
||||
}
|
||||
if (
|
||||
workerStartupNotice(receipt).explanation !== receipt.problem.detail ||
|
||||
!workerLaunchNeedsReconciliation(receipt)
|
||||
) {
|
||||
throw new Error(
|
||||
"Web cause presentation or uncertain-result gating differs from the connected HTTP result",
|
||||
);
|
||||
}
|
||||
captures.push({ status: Number(status), contentType, body });
|
||||
records++;
|
||||
}
|
||||
if (!cases.has("denial") || !cases.has("response_lost")) {
|
||||
throw new Error(
|
||||
"Missing real denial or response-loss Startup HTTP capture",
|
||||
);
|
||||
}
|
||||
return { records, cases: cases.size, captures };
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const path = Deno.args[0];
|
||||
if (!path) {
|
||||
throw new Error("Expected the connected browser_http test output path");
|
||||
}
|
||||
if ((await Deno.stat(path)).size > maxLogBytes) {
|
||||
throw new Error("Startup capture log exceeds the test input limit");
|
||||
}
|
||||
const result = await verifyStartupHttpCaptures(await Deno.readTextFile(path));
|
||||
console.log(
|
||||
`Actual Backend Startup responses accepted by production Web caller, decoder, notice and gating: ${result.records} records, ${result.cases} cases`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user