feat: integrate typed worker failure presentation

This commit is contained in:
2026-11-06 12:28:50 +09:00
18 changed files with 2415 additions and 73 deletions
+3 -1
View File
@@ -6,12 +6,14 @@
"dev": "deno run -A npm:vite@8.3.2 dev", "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", "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", "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: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: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: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: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: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: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-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", "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",
+14 -3
View File
@@ -30,7 +30,8 @@ const SKILL_API_LOAD_POLICY: JsonLoadPolicy = {
maxResponseBytes: SKILL_API_LIMITS.maxResponseBytes, maxResponseBytes: SKILL_API_LIMITS.maxResponseBytes,
}; };
class ResponseByteLimitError extends Error {} export class ResponseByteLimitError extends Error {}
export class ResponseTextDecodeError extends Error {}
function normalizePath(path: string): string { function normalizePath(path: string): string {
if (!path || path === "/") return ""; if (!path || path === "/") return "";
@@ -155,6 +156,13 @@ export async function readBoundedJson(
response: Response, response: Response,
maxBytes: number, maxBytes: number,
): Promise<unknown> { ): 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"); const contentLength = response.headers.get("content-length");
if (contentLength !== null) { if (contentLength !== null) {
const parsedLength = Number(contentLength); const parsedLength = Number(contentLength);
@@ -191,8 +199,11 @@ export async function readBoundedJson(
bytes.set(chunk, offset); bytes.set(chunk, offset);
offset += chunk.byteLength; offset += chunk.byteLength;
} }
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); try {
return JSON.parse(text) as unknown; return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
throw new ResponseTextDecodeError();
}
} }
async function requireJson<T>( async function requireJson<T>(
@@ -10,7 +10,7 @@ import type {
WorkingDirectoryRemovalResponse, WorkingDirectoryRemovalResponse,
WorkingDirectorySource, WorkingDirectorySource,
WorkingDirectorySummary, WorkingDirectorySummary,
} from "../../generated/workdir-api"; } from "../../generated/workdir-api.ts";
const SUMMARY_KEYS = new Set([ const SUMMARY_KEYS = new Set([
"working_directory_id", "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, BrowserWorkerWorkingDirectorySelection,
CreateWorkspaceWorkerRequest, CreateWorkspaceWorkerRequest,
CreateWorkspaceWorkerTicketAssignmentRequest, CreateWorkspaceWorkerTicketAssignmentRequest,
RepositoryApiError,
WorkerLaunchProfileCandidate, WorkerLaunchProfileCandidate,
} from "#lib/generated/worker-launch-api.ts"; } from "#lib/generated/worker-launch-api.ts";
import { parseCreateWorkspaceWorkerRequest } from "#lib/workspace/api/workers.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.find((runtime) => runtime.worker_creation_available) ??
options?.runtimes[0]; options?.runtimes[0];
const runtime = const runtime =
options?.runtimes.find((candidate) => candidate.runtime_id === current.runtime_id) ?? options?.runtimes.find((candidate) =>
candidate.runtime_id === current.runtime_id
) ??
preferredRuntime; preferredRuntime;
const availableWorkdirIds = new Set( const availableWorkdirIds = new Set(
(options?.working_directories ?? []).filter((directory) => (options?.working_directories ?? []).filter((directory) =>
directory.status === "active" && directory.source.kind !== "workspace_config" && directory.status === "active" &&
(directory.source.kind === "external_grant" || directory.cleanliness === "clean") && directory.source.kind !== "workspace_config" &&
(directory.source.kind === "external_grant" ||
directory.cleanliness === "clean") &&
directory.occupied_by == null directory.occupied_by == null
).map((directory) => directory.working_directory_id), ).map((directory) => directory.working_directory_id),
); );
@@ -137,7 +140,9 @@ export function buildCreateWorkspaceWorkerRequest(
ticket_assignment: form.ticket_assignment ?? null, ticket_assignment: form.ticket_assignment ?? null,
initial_submit: [ initial_submit: [
...(initialMessage ? [{ kind: "text", content: form.initial_text }] : []), ...(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), workdir_attachments: validatedAttachments(form.workdir_attachments),
feature_connections: form.subjektiv_subject_id.trim() feature_connections: form.subjektiv_subject_id.trim()
@@ -155,12 +160,3 @@ export function genericLaunchProfiles(
candidate.id !== "builtin:reviewer" && candidate.id !== "reviewer" 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, buildCreateWorkspaceWorkerRequest,
defaultWorkerLaunchForm, defaultWorkerLaunchForm,
genericLaunchProfiles, genericLaunchProfiles,
workerLaunchOutcomeUnknown,
} from "../sidebar/worker-launch.ts"; } from "../sidebar/worker-launch.ts";
import type { WorkerLaunchFormState } from "../sidebar/worker-launch.ts"; import type { WorkerLaunchFormState } from "../sidebar/worker-launch.ts";
import { emptyLaunchOptions } from "#lib/workspace/sidebar/worker-launch.test-fixtures.ts"; import { 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); 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, parseWorkingDirectoryCreateResponse,
validateWorkingDirectoryCreateRequest, validateWorkingDirectoryCreateRequest,
} from '#lib/workspace/api/workdirs.ts'; } 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 { import {
parseBrowserCreateWorkerResponse,
parseWorkerApiError, parseWorkerApiError,
parseWorkerLaunchOptionsResponse, parseWorkerLaunchOptionsResponse,
} from '#lib/workspace/api/workers.ts'; } from '#lib/workspace/api/workers.ts';
@@ -19,7 +21,6 @@
defaultWorkerLaunchForm, defaultWorkerLaunchForm,
workerLaunchAttachmentError, workerLaunchAttachmentError,
genericLaunchProfiles, genericLaunchProfiles,
workerLaunchOutcomeUnknown,
} from '#lib/workspace/sidebar/worker-launch.ts'; } from '#lib/workspace/sidebar/worker-launch.ts';
import type { WorkerLaunchAttachmentFormState } from '#lib/workspace/sidebar/worker-launch.ts'; import type { WorkerLaunchAttachmentFormState } from '#lib/workspace/sidebar/worker-launch.ts';
import type { import type {
@@ -63,7 +64,8 @@
let optionsError = $state<string | null>(null); let optionsError = $state<string | null>(null);
let submitting = $state(false); let submitting = $state(false);
let submitError = $state<DisplayError | null>(null); 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 displayName = $state(ticketContext?.ticketTitle ?? 'Worker');
let runtimeId = $state(''); let runtimeId = $state('');
let profile = $state(''); let profile = $state('');
@@ -108,7 +110,7 @@
: workerLaunchAttachmentError(workdirAttachments), : workerLaunchAttachmentError(workdirAttachments),
); );
let canStartWorker = $derived(Boolean( let canStartWorker = $derived(Boolean(
runtimeId && selectedRuntime?.worker_creation_available && !outcomeUnknown && runtimeId && selectedRuntime?.worker_creation_available && !needsReconciliation &&
!attachmentValidationError && !attachmentValidationError &&
(!selectedRuntimeForbidsWorkdirAttachments || workdirAttachments.length === 0), (!selectedRuntimeForbidsWorkdirAttachments || workdirAttachments.length === 0),
)); ));
@@ -312,7 +314,7 @@
} }
async function createWorker() { async function createWorker() {
if (submitting || outcomeUnknown) return; if (submitting || needsReconciliation) return;
if (!workspaceId) { if (!workspaceId) {
submitError = { message: 'workspace id is unavailable', diagnostics: [] }; submitError = { message: 'workspace id is unavailable', diagnostics: [] };
return; return;
@@ -327,8 +329,8 @@
} }
submitError = null; submitError = null;
startupError = null;
submitting = true; submitting = true;
let dispatched = false;
try { try {
const request = buildCreateWorkspaceWorkerRequest({ const request = buildCreateWorkspaceWorkerRequest({
runtime_id: runtimeId, runtime_id: runtimeId,
@@ -344,24 +346,26 @@
working_directory_repository_key: workingDirectoryRepositoryKey, working_directory_repository_key: workingDirectoryRepositoryKey,
working_directory_selector: workingDirectorySelector, working_directory_selector: workingDirectorySelector,
}); });
dispatched = true; needsReconciliation = true;
const response = await fetch(workerApiPath('/workers'), { const result = await submitWorkerLaunch(fetch, workerApiPath('/workers'), request);
method: 'POST', if (result.kind === 'failed') {
headers: { 'content-type': 'application/json' }, startupError = workerStartupNotice(result.receipt);
body: JSON.stringify(request), needsReconciliation = workerLaunchNeedsReconciliation(result.receipt);
});
if (!response.ok) {
submitError = await responseDisplayError(response, 'worker create failed');
outcomeUnknown = workerLaunchOutcomeUnknown({ error: submitError.message, message: '', diagnostics: submitError.diagnostics });
return; return;
} }
const payload = parseBrowserCreateWorkerResponse( // 成功を観測しているので、navigationが失敗しても新しいWorkerを作らせない。
await readBoundedJson(response, 8 * 1024 * 1024), needsReconciliation = true;
); try {
await goto(payload.console_href); await goto(result.response.console_href);
} catch (err) { } catch {
outcomeUnknown = dispatched; startupError = { explanation: 'The Worker was created, but its console could not be opened. Check Workers before starting another Worker.', supplements: [], compensation: [], details: [] };
submitError = exceptionDisplayError(err, 'worker create failed'); }
} 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 { } finally {
submitting = false; submitting = false;
} }
@@ -610,13 +614,20 @@
</label> </label>
</section> </section>
{#if submitError} {#if startupError}
<div class="section-state error worker-submit-error"> <FailureNotice failure={startupError} />
<p>{submitError.message}</p> {#if needsReconciliation}
{#if outcomeUnknown} <div class="worker-reconciliation" role="status">
<p>The launch outcome is unknown. Check the Worker list or Ticket before starting another Worker.</p> <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> <a href={`/w/${workspaceId}/workers`}>Check Workers</a>
{#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}
{#if submitError}
<div class="section-state error worker-submit-error" role="alert">
<p>{submitError.message}</p>
{#if submitError.diagnostics.length > 0} {#if submitError.diagnostics.length > 0}
<ul class="worker-error-diagnostics"> <ul class="worker-error-diagnostics">
{#each submitError.diagnostics as diagnostic} {#each submitError.diagnostics as diagnostic}
@@ -638,3 +649,9 @@
</form> </form>
{/if} {/if}
</section> </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>
@@ -7,7 +7,9 @@ import {
waitFor, waitFor,
} from "@testing-library/svelte"; } from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest"; import { afterEach, expect, test, vi } from "vitest";
import { goto } from "$app/navigation";
import type { ComponentProps } from "svelte"; import type { ComponentProps } from "svelte";
import { startupFixture } from "#lib/workspace/workers/startup-failure.test-fixtures.ts";
import NewWorker from "./PageContent.svelte"; import NewWorker from "./PageContent.svelte";
function mount( function mount(
ticketContext: ticketContext:
@@ -120,8 +122,8 @@ test.each(["embedded", "remote"].flatMap((runtime) =>
expect(screen.queryByRole("option", { name: "Trusted Reviewer" })) expect(screen.queryByRole("option", { name: "Trusted Reviewer" }))
.toBeNull(); .toBeNull();
await fireEvent.click(start); await fireEvent.click(start);
await screen.findByText( expect((await screen.findByRole("alert")).textContent).toContain(
"permission_denied: Backend rejected this assignment", "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)); const request = JSON.parse(String(api.mock.calls[1][1]?.body));
expect(request.runtime_id).toBe(runtime); 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 fireEvent.change(select, { target: { value: "read-only-grant" } });
await waitFor(() => expect(start.disabled).toBe(false)); await waitFor(() => expect(start.disabled).toBe(false));
await fireEvent.click(start); 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)); const request = JSON.parse(String(api.mock.calls[1][1]?.body));
expect(request.workdir_attachments).toEqual([{ expect(request.workdir_attachments).toEqual([{
alias: "workdir", alias: "workdir",
@@ -214,7 +218,7 @@ test.each(["backend", "network"])(
}); });
await fireEvent.click(start); await fireEvent.click(start);
await screen.findByText( 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); expect((start as HTMLButtonElement).disabled).toBe(true);
const request = JSON.parse(String(api.mock.calls[1][1]?.body)); const request = JSON.parse(String(api.mock.calls[1][1]?.body));
@@ -226,3 +230,140 @@ test.each(["backend", "network"])(
expect(api).toHaveBeenCalledTimes(2); 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`,
);
}