feat(web): present typed startup causes and gate uncertain launches

This commit is contained in:
2026-11-06 11:06:10 +09:00
parent 590a270757
commit bf796f8db2
11 changed files with 1347 additions and 78 deletions
+2 -1
View File
@@ -6,12 +6,13 @@
"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,../../crates/error-contract/fixtures --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV src/lib/workspace/feature-permissions.test.ts tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/failure/receive.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/subjektiv/routes.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts test/config-source/adoption-api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts src/lib/workspace/memory/subject-body.test.ts src/lib/workspace/memory/conversation-attempt.test.ts src/lib/workspace/memory/conversation-api.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts src/lib/workspace/api/ticket-parent.test.ts src/lib/workspace/tickets/ticket-parent-controller.test.ts", "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema,../../crates/error-contract/fixtures --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV src/lib/workspace/feature-permissions.test.ts tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/failure/receive.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/subjektiv/routes.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts src/lib/workspace/workers/startup-failure.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts test/config-source/adoption-api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts src/lib/workspace/memory/subject-body.test.ts src/lib/workspace/memory/conversation-attempt.test.ts src/lib/workspace/memory/conversation-api.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts src/lib/workspace/api/ticket-parent.test.ts src/lib/workspace/tickets/ticket-parent-controller.test.ts",
"test:journal:backend-contract": "cd ../.. && mkdir -p target && cargo test -p yoi-workspace-server --lib journal_browser_web_contract -- --nocapture > target/journal-browser-contract.log && cd web/workspace && deno run --allow-read=../../target/journal-browser-contract.log test/journal-backend-contract.ts ../../target/journal-browser-contract.log", "test:journal:backend-contract": "cd ../.. && mkdir -p target && cargo test -p yoi-workspace-server --lib journal_browser_web_contract -- --nocapture > target/journal-browser-contract.log && cd web/workspace && deno run --allow-read=../../target/journal-browser-contract.log test/journal-backend-contract.ts ../../target/journal-browser-contract.log",
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts", "test: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",
@@ -22,8 +22,8 @@ const FALLBACKS = {
// 原因の説明/秘匿はServerの公開projectionが所有する。UIでreasonを翻訳したり // 原因の説明/秘匿はServerの公開projectionが所有する。UIでreasonを翻訳したり
// details/sourceを走査しない。相関参照は問い合わせ用で、再送keyではない。 // details/sourceを走査しない。相関参照は問い合わせ用で、再送keyではない。
export function failureNotice<D>( export function failureNotice<D, E = undefined>(
receipt: FailureReceipt<D>, receipt: FailureReceipt<D, E>,
): FailureNoticeModel { ): FailureNoticeModel {
if (receipt.kind !== "problem") { if (receipt.kind !== "problem") {
return { return {
@@ -20,8 +20,12 @@ export type DetailsDecoder<D> = (
envelope: FailureEnvelope<unknown>, envelope: FailureEnvelope<unknown>,
) => DetailsDecode<D>; ) => DetailsDecode<D>;
export type FailureReceipt<D> = export type ProblemExtensionDecoder<E> = (
| { kind: "problem"; problem: ProblemDetails<D> } problem: Readonly<Record<string, unknown>>,
) => DetailsDecode<E>;
export type FailureReceipt<D, E = undefined> =
| { kind: "problem"; problem: ProblemDetails<D>; extension?: E }
| { kind: "unsupported" } | { kind: "unsupported" }
| { kind: "invalid" } | { kind: "invalid" }
| { kind: "too_large" } | { kind: "too_large" }
@@ -247,12 +251,13 @@ function uriReference(value: unknown): string {
return value; return value;
} }
export function decodeProblem<D>( export function decodeProblem<D, E = undefined>(
text: string, text: string,
status: number, status: number,
contentType: string, contentType: string,
decode: DetailsDecoder<D>, decode: DetailsDecoder<D>,
): FailureReceipt<D> { decodeExtension?: ProblemExtensionDecoder<E>,
): FailureReceipt<D, E> {
try { try {
if ( if (
contentType.split(";")[0].trim().toLowerCase() !== contentType.split(";")[0].trim().toLowerCase() !==
@@ -270,8 +275,11 @@ export function decodeProblem<D>(
const failure = decodeEnvelope(raw.yoi_failure, decode); const failure = decodeEnvelope(raw.yoi_failure, decode);
if (detail !== failureRecord(raw.yoi_failure).explanation) return invalid(); if (detail !== failureRecord(raw.yoi_failure).explanation) return invalid();
if (failure.kind !== "known") return failure; if (failure.kind !== "known") return failure;
const extension = decodeExtension?.(raw);
if (extension && extension.kind !== "known") return extension;
return { return {
kind: "problem", kind: "problem",
...(extension?.kind === "known" ? { extension: extension.details } : {}),
problem: { problem: {
type, type,
instance, instance,
@@ -288,10 +296,11 @@ export function decodeProblem<D>(
} }
} }
export async function receiveProblem<D>( export async function receiveProblem<D, E = undefined>(
response: Response, response: Response,
decode: DetailsDecoder<D>, decode: DetailsDecoder<D>,
): Promise<FailureReceipt<D>> { decodeExtension?: ProblemExtensionDecoder<E>,
): Promise<FailureReceipt<D, E>> {
const contentType = response.headers.get("content-type") ?? ""; const contentType = response.headers.get("content-type") ?? "";
if ( if (
contentType.split(";")[0].trim().toLowerCase() !== contentType.split(";")[0].trim().toLowerCase() !==
@@ -312,5 +321,11 @@ export async function receiveProblem<D>(
: "transport", : "transport",
}; };
} }
return decodeProblem(text, response.status, contentType, decode); 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} {#if ticketContext}<a href={`/w/${workspaceId}/tickets/${encodeURIComponent(ticketContext.ticketId)}`}>Check Ticket</a>{/if}
<p>Share only the support correlation from Failure details with an authorized operator. It is not a retry key; do not share credentials, raw responses, or internal logs.</p>
</div>
{/if}
{/if}
{#if submitError}
<div class="section-state error worker-submit-error" role="alert">
<p>{submitError.message}</p>
{#if submitError.diagnostics.length > 0} {#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,355 @@
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";
// 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 phase = before ? "before-real-contract" : "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(startupFixture("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(startupFixture(
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,
fixture: ["denial", "lost"].includes(name)
? "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), []);
}
});