feat: replace Subject retirement with durable explicit deletion

This commit is contained in:
2026-10-28 11:18:43 +09:00
parent 618880d38d
commit a06f6bfb3d
71 changed files with 7448 additions and 315 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@8.3.2 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV 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/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 tests/memory-api.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",
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV 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/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 tests/memory-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",
"test:journal:backend-contract": "cd ../.. && mkdir -p target && cargo test -p yoi-workspace-server --lib journal_browser_web_contract -- --nocapture > target/journal-browser-contract.log && cd web/workspace && deno run --allow-read=../../target/journal-browser-contract.log test/journal-backend-contract.ts ../../target/journal-browser-contract.log",
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
"test:browser:activation": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/activation-browser/activation-shell.browser.test.ts",
+7
View File
@@ -3,6 +3,7 @@
"specifiers": {
"jsr:@std/assert@*": "1.0.19",
"jsr:@std/internal@^1.0.12": "1.0.14",
"jsr:@std/path@1.1.4": "1.1.4",
"npm:@codemirror/autocomplete@6.20.3": "6.20.3",
"npm:@codemirror/commands@6.11.1": "6.11.1",
"npm:@codemirror/language@6.12.4": "6.12.4",
@@ -42,6 +43,12 @@
},
"@std/internal@1.0.14": {
"integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7"
},
"@std/path@1.1.4": {
"integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5",
"dependencies": [
"jsr:@std/internal"
]
}
},
"npm": {
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:7e640cff6bdcf6dc298cfca695c5cb71a520288b20f4f29ec8df8138724b97c4)
// Input contract: 0.1.0 (sha256:fc3064f2d7b0d2c2b11e166007943b0cac0cad0918e34d071e20079cf38d655e)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -52,6 +52,10 @@ export type WorkspacePermissionSummary = {
manage_secrets: boolean;
manage_runtimes: boolean;
delete_workspace: boolean;
/**
* Explicit Subject deletion authority for the current authenticated actor.
*/
delete_subjects: boolean;
};
export type WorkspaceDeletionState =
@@ -118,6 +118,14 @@ export type SubjektivSubjectCreateRequest = {
behavior_md?: string;
};
export type SubjektivSubjectDeleteRequest = { operation_id: string };
export type SubjektivSubjectDeleteResponse = {
subject_id: string;
operation_id: string;
completed: boolean;
};
export type SubjektivSubjectBehaviorUpdateRequest = {
expected_behavior_md: string;
behavior_md: string;
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:7e640cff6bdcf6dc298cfca695c5cb71a520288b20f4f29ec8df8138724b97c4)
// Input contract: 0.1.0 (sha256:fc3064f2d7b0d2c2b11e166007943b0cac0cad0918e34d071e20079cf38d655e)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:7e640cff6bdcf6dc298cfca695c5cb71a520288b20f4f29ec8df8138724b97c4)
// Input contract: 0.1.0 (sha256:fc3064f2d7b0d2c2b11e166007943b0cac0cad0918e34d071e20079cf38d655e)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:7e640cff6bdcf6dc298cfca695c5cb71a520288b20f4f29ec8df8138724b97c4)
// Input contract: 0.1.0 (sha256:fc3064f2d7b0d2c2b11e166007943b0cac0cad0918e34d071e20079cf38d655e)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:7e640cff6bdcf6dc298cfca695c5cb71a520288b20f4f29ec8df8138724b97c4)
// Input contract: 0.1.0 (sha256:fc3064f2d7b0d2c2b11e166007943b0cac0cad0918e34d071e20079cf38d655e)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -482,6 +482,7 @@ function permissions(value: unknown, path: string): WorkspacePermissionSummary {
"manage_secrets",
"manage_runtimes",
"delete_workspace",
"delete_subjects",
],
path,
);
@@ -492,6 +493,7 @@ function permissions(value: unknown, path: string): WorkspacePermissionSummary {
),
manage_secrets: boolean(item.manage_secrets, `${path}.manage_secrets`),
manage_runtimes: boolean(item.manage_runtimes, `${path}.manage_runtimes`),
delete_subjects: boolean(item.delete_subjects, `${path}.delete_subjects`),
delete_workspace: boolean(
item.delete_workspace,
`${path}.delete_workspace`,
@@ -0,0 +1,134 @@
<script lang="ts">
import { untrack } from 'svelte';
import { deleteSubjektivSubject, SubjektivSubjectDeleteError } from './api.ts';
import { clearSubjectDeleteAttempt, readSubjectDeleteAttempt, saveSubjectDeleteAttempt, type SubjectDeleteAttempt } from './subject-delete.ts';
import type { SubjektivSubjectResponse } from '#lib/generated/memory-api.ts';
import { workspaceRoute } from '#lib/workspace/api/http.ts';
let { workspaceId, subjectId, subject, onstarted }: {
workspaceId: string;
subjectId: string;
subject: SubjektivSubjectResponse | null;
onstarted: () => void;
} = $props();
let attempt = $state<SubjectDeleteAttempt | null>(null);
let phase = $state<'idle' | 'confirming' | 'running' | 'pending' | 'unknown' | 'failed' | 'completed'>('idle');
let confirmed = $state(false);
let error = $state<string | null>(null);
let storageError = $state(false);
let identity = '';
let generation = 0;
$effect(() => {
const next = JSON.stringify([workspaceId, subjectId]);
untrack(() => {
if (identity === next) return;
identity = next;
generation += 1;
attempt = null;
phase = 'idle';
confirmed = false;
error = null;
storageError = false;
try {
attempt = readSubjectDeleteAttempt(sessionStorage, workspaceId, subjectId);
if (attempt) {
phase = 'unknown';
onstarted();
}
} catch (cause) {
storageError = true;
error = cause instanceof Error ? cause.message : 'Cannot read the saved deletion request. Do not start a new request.';
}
});
});
async function submit(): Promise<void> {
if (phase === 'running' || phase === 'completed' || storageError || (!attempt && (!subject || !confirmed))) return;
const currentGeneration = generation;
const targetWorkspace = workspaceId;
const targetSubject = subjectId;
const request = attempt ?? { subjectId: targetSubject, operationId: crypto.randomUUID() };
const current = () => currentGeneration === generation && targetWorkspace === workspaceId && targetSubject === subjectId;
// 保存に失敗したら送らない。応答喪失後に新しいkeyを作る経路を残さない。
try { saveSubjectDeleteAttempt(sessionStorage, targetWorkspace, request); }
catch {
error = 'The retry identity could not be saved in this tab. No deletion request was sent. Allow session storage and try again.';
return;
}
attempt = request;
phase = 'running';
error = null;
onstarted();
try {
const result = await deleteSubjektivSubject(fetch, targetWorkspace, targetSubject, { operation_id: request.operationId });
// 対象変更後の遅延応答は新対象を更新せず、成功した旧対象の再送入力だけ消去する。
if (result.completed) {
try { clearSubjectDeleteAttempt(sessionStorage, targetWorkspace, targetSubject); }
catch { /* 消去できなくても残るのは同一keyだけ。本文や成功状態は保存しない。 */ }
}
if (!current()) return;
phase = result.completed ? 'completed' : 'pending';
} catch (cause) {
if (!current()) return;
phase = cause instanceof SubjektivSubjectDeleteError && cause.kind === 'rejected' ? 'failed' : 'unknown';
error = cause instanceof Error ? cause.message : 'The deletion outcome is unknown.';
}
}
</script>
{#if subject || attempt || storageError}
<section class="subject-deletion" aria-labelledby="subject-delete-heading" data-delete-state={phase}>
<header>
<h2 id="subject-delete-heading">Delete Subject</h2>
{#if phase === 'idle' && !storageError}
<button type="button" class="workspace-secondary-button subject-delete-open" onclick={() => { phase = 'confirming'; }}>Delete Subject…</button>
{/if}
</header>
{#if phase === 'confirming' || attempt}
<p class="delete-target">Target: <strong>{subject?.role ?? subjectId}</strong> · <code>{subjectId}</code></p>
{/if}
{#if phase === 'confirming'}
<form onsubmit={(event) => { event.preventDefault(); void submit(); }}>
<p><strong>This is irreversible.</strong> This Subject, its behavior, Memory, change history, staging, Surface, Journal, permissions, delegations, Writer/Session associations, and Subject Job inputs/results will be deleted. Connected and delegated Workers and Jobs must stop before completion can be confirmed.</p>
<p>Original Worker/Session text, existing archives and Workdirs, Tickets, and other separately retained resources are <strong>not deleted</strong>. Minimal deletion retry/audit metadata is retained. Other Subjects and Workspaces are not affected.</p>
<label class="delete-confirm"><input type="checkbox" bind:checked={confirmed} /> I have checked the target, deletion scope, retained data, and irreversible effect.</label>
<div class="delete-actions">
<button type="submit" class="workspace-danger-button" disabled={!confirmed}>Permanently delete Subject</button>
<button type="button" class="workspace-secondary-button" onclick={() => { phase = 'idle'; confirmed = false; }}>Cancel</button>
</div>
</form>
{:else if phase === 'running'}
<p role="status">Deleting… Waiting for stop and erasure confirmation. Do not treat this as completed.</p>
{:else if phase === 'pending'}
<p role="status"><strong>Deletion started; completion unconfirmed.</strong> The deletion fence is saved and the Subject is hidden from reads/lists. External stopping or erasure is not yet confirmed. Retry the same request to continue.</p>
{:else if phase === 'unknown'}
<p role="status"><strong>Deletion outcome unknown.</strong> The request may already have taken effect. The saved target and operation ID are retained; retry the same request, not a new deletion.</p>
{:else if phase === 'failed'}
<p role="alert"><strong>Deletion request rejected.</strong> Completion is not confirmed. Resolve the reported condition, then retry the same request.</p>
{:else if phase === 'completed'}
<p role="status"><strong>Subject deletion completed.</strong> Backend confirmed stopping and deletion of Subject-owned data. Separately retained Worker/Session text, archives, Workdirs, and Tickets remain.</p>
<a href={workspaceRoute(workspaceId, '/memory')}>Back to subjects →</a>
{/if}
{#if error}<p class="workspace-callout is-error" role="alert">{error}</p>{/if}
{#if attempt && phase !== 'completed'}
<details><summary>Deletion request identity</summary><code>{attempt.operationId}</code><p>This tab retains the target and same request ID across reloads, even when GET can no longer find the Subject.</p></details>
{#if phase === 'pending' || phase === 'unknown' || phase === 'failed'}
<button type="button" class="workspace-secondary-button subject-delete-retry" onclick={() => { void submit(); }}>Retry same deletion request</button>
{/if}
{/if}
</section>
{/if}
<style>
.subject-deletion { min-width: 0; }
header { display: flex; align-items: center; justify-content: space-between; gap: .75rem; flex-wrap: wrap; }
h2 { font-size: var(--font-size-body); margin: 0; }
p { line-height: 1.6; }
code, .delete-target { overflow-wrap: anywhere; }
.delete-confirm { display: flex; align-items: flex-start; gap: .6rem; line-height: 1.5; }
input { margin-top: .25rem; flex: 0 0 auto; }
.delete-actions { display: flex; gap: .75rem; flex-wrap: wrap; margin-top: 1rem; }
details { margin-block: 1rem; }
summary { cursor: pointer; }
</style>
@@ -29,6 +29,8 @@ import type {
SubjektivResidentSurfaceAvailability,
SubjektivResidentSurfaceResponse,
SubjektivResidentSurfaceSnapshot,
SubjektivSubjectDeleteRequest,
SubjektivSubjectDeleteResponse,
SubjektivSubjectBehaviorUpdateRequest,
SubjektivSubjectCreateRequest,
SubjektivSubjectListResponse,
@@ -158,6 +160,70 @@ function validateSubjectBehavior(behaviorMd: string): void {
}
}
export class SubjektivSubjectDeleteError extends Error {
constructor(
readonly kind: "rejected" | "unknown_outcome",
message: string,
readonly status: number | null = null,
) {
super(message);
this.name = "SubjektivSubjectDeleteError";
}
}
export function parseSubjektivSubjectDeleteResponse(
value: unknown,
subjectId: string,
operationId: string,
): SubjektivSubjectDeleteResponse {
const record = strictRecord(value, ["subject_id", "operation_id", "completed"], "Subject deletion");
const response = {
subject_id: requiredIdentifier(record, "subject_id"),
operation_id: requiredIdentifier(record, "operation_id"),
completed: requiredBoolean(record, "completed"),
};
if (response.subject_id !== subjectId || response.operation_id !== operationId) {
invalid("Subject deletion response does not match the request");
}
return response;
}
/** HTTP success can confirm only the deletion fence. Every retry retains this request. */
export async function deleteSubjektivSubject(
fetchFn: typeof fetch,
workspaceId: string,
subjectId: string,
request: SubjektivSubjectDeleteRequest,
): Promise<SubjektivSubjectDeleteResponse> {
identifier(request.operation_id, "operation_id");
let response: Response;
try {
response = await fetchFn(workspaceApiPath(workspaceId, `/subjektiv/subjects/${encodeURIComponent(subjectId)}`), {
method: "DELETE",
headers: { "content-type": "application/json" },
body: JSON.stringify({ operation_id: request.operation_id }),
});
} catch {
throw new SubjektivSubjectDeleteError("unknown_outcome", "The deletion outcome is unknown. Retry the same request.");
}
if (!response.ok) {
throw new SubjektivSubjectDeleteError(
response.status >= 500 ? "unknown_outcome" : "rejected",
await subjektivSubjectCreateErrorMessage(response),
response.status,
);
}
try {
return parseSubjektivSubjectDeleteResponse(
await readBoundedJson(response, MEMORY_API_LIMITS.maxResponseBytes),
subjectId,
request.operation_id,
);
} catch {
throw new SubjektivSubjectDeleteError("unknown_outcome", "The deletion response could not be confirmed. Retry the same request.");
}
}
export async function createSubjektivSubject(
fetchFn: typeof fetch,
workspaceId: string,
@@ -0,0 +1,51 @@
// 削除開始後はGETから対象が消えるため、同じtabでの再読込みにも依頼IDを保持する。
// Subject本文は保存しない。これは削除結果の正本ではなく、安全な再送用の最小入力。
export type SubjectDeleteAttempt = {
subjectId: string;
operationId: string;
};
export function subjectDeleteStorageKey(
workspaceId: string,
subjectId: string,
): string {
return `yoi.subject-delete:${JSON.stringify([workspaceId, subjectId])}`;
}
export function readSubjectDeleteAttempt(
storage: Pick<Storage, "getItem">,
workspaceId: string,
subjectId: string,
): SubjectDeleteAttempt | null {
const raw = storage.getItem(subjectDeleteStorageKey(workspaceId, subjectId));
if (raw === null) return null;
const value = JSON.parse(raw);
if (
!value || value.subjectId !== subjectId ||
typeof value.operationId !== "string" ||
value.operationId.length === 0 ||
Object.keys(value).some((key) =>
key !== "subjectId" && key !== "operationId"
)
) {
throw new Error(
"The saved deletion request cannot be read. Do not start another deletion request.",
);
}
return value;
}
export function saveSubjectDeleteAttempt(
storage: Pick<Storage, "setItem">,
workspaceId: string,
attempt: SubjectDeleteAttempt,
): void {
storage.setItem(
subjectDeleteStorageKey(workspaceId, attempt.subjectId),
JSON.stringify(attempt),
);
}
export function clearSubjectDeleteAttempt(
storage: Pick<Storage, "removeItem">,
workspaceId: string,
subjectId: string,
): void {
storage.removeItem(subjectDeleteStorageKey(workspaceId, subjectId));
}
@@ -38,7 +38,7 @@
}
function subjectStateLabel(state: SubjektivSubjectResponse['state']): string {
return state === 'active' ? 'Available' : 'Retired';
return state === 'active' ? 'Available' : 'Legacy ended Subject';
}
async function openCreateForm(): Promise<void> {
@@ -6,6 +6,7 @@
// 実行中Workerの履歴へ反映済みであることの証明には使わない。診断値は詳細へ分離する。
import { untrack } from 'svelte';
import DocumentMarkdown from '#lib/workspace/markdown/DocumentMarkdown.svelte';
import SubjectDelete from '#lib/workspace/memory/SubjectDelete.svelte';
import { formatDate, workspaceRoute } from '#lib/workspace/api/http.ts';
import {
SubjektivSubjectCreateError,
@@ -26,6 +27,7 @@
let behaviorSaving = $state(false);
let behaviorError = $state<string | null>(null);
let behaviorSaveConfirmed = $state(false);
let deletionStarted = $state(false);
let routeGeneration = 0;
let subjectWorkspaceId = untrack(() => data.workspaceId);
@@ -49,6 +51,7 @@
behaviorSaving = false;
behaviorError = null;
behaviorSaveConfirmed = false;
deletionStarted = false;
});
});
@@ -149,7 +152,7 @@
}
function subjectStateLabel(state: 'active' | 'retired'): string {
return state === 'active' ? 'Available' : 'Retired';
return state === 'active' ? 'Available' : 'Legacy ended Subject';
}
</script>
@@ -167,9 +170,16 @@
<h1 id="subject-heading">{subject?.role ?? data.subjectId}</h1>
<code class="subject-id" title={data.subjectId}>{data.subjectId}</code>
</div>
<span class="read-only-label">Behavior editable</span>
{#if !deletionStarted}<span class="read-only-label">Behavior editable</span>{/if}
</header>
{#if data.workspace.permissions.delete_subjects}
{#key JSON.stringify([data.workspaceId, data.subjectId])}
<SubjectDelete workspaceId={data.workspaceId} subjectId={data.subjectId} {subject} onstarted={() => { deletionStarted = true; }} />
{/key}
{/if}
{#if !deletionStarted}
<nav aria-label="Subjectの記録"><a href={workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}/journal`)}>Journal · 最近の出来事とSessionを探す →</a></nav>
{#if subject}
@@ -388,6 +398,7 @@
<div class="memory-state" role="status"><p>Current Memory data is unavailable.</p></div>
{/if}
</section>
{/if}
</section>
<style>
@@ -0,0 +1,142 @@
// 本番Web buildと合成HTTP境界を接続する。実認証・削除の永続性はBackend suiteの責務。
import { join, resolve } from "jsr:@std/path@1.1.4";
const [portText, build, mode] = Deno.args;
const port = Number(portText);
const root = resolve(import.meta.dirname!, "../../../..");
const upstreamPort = port + 1;
const upstream = new Deno.Command(Deno.execPath(), {
args: [
"run",
"-A",
join(root, "tools/web-ux/browser-tests/memory_document_fixture_server.ts"),
String(upstreamPort),
resolve(build),
"3",
],
stdout: "null",
stderr: "inherit",
}).spawn();
const stop = () => {
try {
upstream.kill();
} catch { /* already stopped */ }
};
Deno.addSignalListener("SIGTERM", () => {
stop();
Deno.exit();
});
Deno.addSignalListener("SIGINT", () => {
stop();
Deno.exit();
});
const attempts = new Map<string, string>();
const hidden = new Set<string>();
const user = {
user_id: "fixture-user",
account_id: "owner",
handle: "owner",
display_name: "Memory owner",
};
const apiRoot = "/api/w/memory-review";
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
const url = new URL(request.url);
if (url.pathname === "/health") {
try {
return await fetch(`http://127.0.0.1:${upstreamPort}/health`);
} catch {
return new Response(null, { status: 503 });
}
}
if (url.pathname === "/api/auth/whoami") {
return Response.json({
authentication_reference: "subject-delete-fixture",
actor: { ...user, auth_method: "browser_session" },
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
});
}
const sessionId =
request.headers.get("cookie")?.match(/delete-review-session=([^;]+)/)
?.[1] ?? crypto.randomUUID();
const reader =
request.headers.get("cookie")?.includes("subject-delete-persona=reader") ??
false;
const match = url.pathname.match(/\/subjektiv\/subjects\/([^/]+)(?:\/|$)/);
const id = match ? decodeURIComponent(match[1]) : null;
const key = JSON.stringify([sessionId, id]);
if (request.method === "DELETE" && id) {
if (reader) {
return Response.json({ error: "forbidden", message: "Owner required" }, {
status: 403,
});
}
const body = await request.json();
const prior = attempts.get(key);
if (prior && prior !== body.operation_id) {
return Response.json(
{ error: "conflict", message: "Changed operation" },
{ status: 409 },
);
}
attempts.set(key, body.operation_id);
hidden.add(key);
if (!prior && id === "unknown-subject") {
return Response.json({
error: "internal",
message: "Response lost after fence",
}, { status: 500 });
}
return Response.json({
subject_id: id,
operation_id: body.operation_id,
completed: !!prior,
});
}
if (id && request.method === "GET" && hidden.has(key)) {
return Response.json(
{ error: "not_found", message: "Subject unavailable" },
{ status: 404 },
);
}
const mapped = id &&
["pending-subject", "unknown-subject", "legacy-subject"].includes(id);
const target = new URL(request.url);
target.port = String(upstreamPort);
if (mapped) {
target.pathname = target.pathname.replace(
encodeURIComponent(id!),
"empty-subject",
);
}
let response = await fetch(new Request(target, request));
if (url.pathname === `${apiRoot}/workspace` || mapped) {
const data = await response.json();
if (url.pathname === `${apiRoot}/workspace` && mode !== "before") {
data.permissions.delete_subjects = !reader;
}
if (mapped) {
if ("id" in data) {
data.id = id;
data.role = `Deletion review · ${id}`;
if (id === "legacy-subject") data.state = "retired";
}
if ("subject_id" in data) data.subject_id = id;
}
response = Response.json(data, { status: response.status });
}
response = new Response(response.body, {
status: response.status,
headers: response.headers,
});
const persona = url.searchParams.get("persona");
if (persona) {
response.headers.set(
"set-cookie",
`subject-delete-persona=${persona}; Path=/; HttpOnly; SameSite=Lax`,
);
}
response.headers.append(
"set-cookie",
`delete-review-session=${sessionId}; Path=/; HttpOnly; SameSite=Lax`,
);
return response;
});
@@ -0,0 +1,257 @@
import { assert, assertEquals } from "@std/assert";
import { chromium, type Page } from "playwright";
import { join, resolve } from "@std/path";
import {
assertNoPageWideOverflow,
freePort,
waitForServer,
} from "../../../../tools/web-ux/browser-tests/memory_document_harness.ts";
const root = resolve(import.meta.dirname!, "../../../..");
const artifact = join(root, "target/web-ux/t786-browser");
async function confirm(page: Page) {
await page.locator(".subject-delete-open").click();
const button = page.getByRole("button", {
name: "Permanently delete Subject",
exact: true,
});
assert(await button.isDisabled());
await page.getByRole("checkbox").check();
await button.click();
}
Deno.test({
name:
"Subject deletion shell: owner confirmation, pending/reload/retry, unknown/rejected and route fencing",
sanitizeOps: false,
sanitizeResources: false,
fn: async () => {
const build = await new Deno.Command(Deno.execPath(), {
args: ["task", "build"],
cwd: join(root, "web/workspace"),
stdout: "null",
stderr: "piped",
}).output();
assert(build.success, new TextDecoder().decode(build.stderr));
const port = await freePort();
const server = new Deno.Command(Deno.execPath(), {
args: [
"run",
"-A",
join(import.meta.dirname!, "fixture-server.ts"),
String(port),
join(root, "web/workspace/build"),
],
stdout: "null",
stderr: "inherit",
}).spawn();
const origin = `http://127.0.0.1:${port}`;
await Deno.mkdir(artifact, { recursive: true });
const browser = await chromium.launch();
try {
await waitForServer(`${origin}/health`);
for (const theme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const context = await browser.newContext({
viewport: { width, height: 900 },
colorScheme: theme,
});
const page = await context.newPage();
const pageErrors: string[] = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
const requests: unknown[] = [];
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
await page.route(
"**/subjektiv/subjects/pending-subject",
async (route) => {
if (route.request().method() !== "DELETE") {
return route.continue();
}
requests.push(route.request().postDataJSON());
if (requests.length === 1) await held;
await route.continue();
},
);
await page.goto(`${origin}/w/memory-review/memory/pending-subject`);
await page.locator(".subject-delete-open").waitFor();
await page.screenshot({
path: join(artifact, `${theme}-${width}-initial.png`),
});
await page.locator(".subject-delete-open").click();
assert(
await page.getByText("This is irreversible.", { exact: true })
.isVisible(),
);
await assertNoPageWideOverflow(page);
await page.screenshot({
path: join(artifact, `${theme}-${width}-confirmation.png`),
});
await page.getByRole("button", { name: "Cancel", exact: true })
.click();
assertEquals(requests.length, 0);
await confirm(page);
await page.locator('[data-delete-state="running"]').waitFor();
assertEquals(await page.locator(".subject-overview").count(), 0);
await page.screenshot({
path: join(artifact, `${theme}-${width}-running.png`),
});
release();
await page.locator('[data-delete-state="pending"]').waitFor();
assertEquals(requests.length, 1);
const saved = await page.evaluate(() =>
Object.keys(sessionStorage).filter((key) =>
key.startsWith("yoi.subject-delete:")
).map((key) => JSON.parse(sessionStorage.getItem(key)!))
);
assertEquals(saved, [{
subjectId: "pending-subject",
operationId: (requests[0] as { operation_id: string }).operation_id,
}]);
await page.screenshot({
path: join(artifact, `${theme}-${width}-pending.png`),
});
await page.reload();
await page.locator('[data-delete-state="unknown"]').waitFor();
assertEquals(await page.locator(".subject-overview").count(), 0);
await page.locator(".subject-delete-retry").click();
await page.locator('[data-delete-state="completed"]').waitFor();
assertEquals(requests.length, 2);
assertEquals(requests[0], requests[1]);
assertEquals(
await page.evaluate(() =>
Object.keys(sessionStorage).filter((key) =>
key.startsWith("yoi.subject-delete:")
)
),
[],
);
await page.screenshot({
path: join(artifact, `${theme}-${width}-completed.png`),
});
await assertNoPageWideOverflow(page);
assertEquals(pageErrors, []);
await context.close();
}
}
for (const subject of ["unknown-subject", "legacy-subject"]) {
const page = await browser.newPage();
const requests: unknown[] = [];
page.on("request", (request) => {
if (request.method() === "DELETE") {
requests.push(request.postDataJSON());
}
});
await page.goto(`${origin}/w/memory-review/memory/${subject}`);
await page.locator(".subject-delete-open").waitFor();
if (subject === "legacy-subject") {
assert(
await page.getByText("Legacy ended Subject", { exact: true })
.isVisible(),
);
}
await confirm(page);
await page.locator(
`[data-delete-state="${
subject === "unknown-subject" ? "unknown" : "pending"
}"]`,
).waitFor();
await page.locator(".subject-delete-retry").click();
await page.locator('[data-delete-state="completed"]').waitFor();
assertEquals(requests.length, 2);
assertEquals(requests[0], requests[1]);
await page.close();
}
// 4xxでもkeyを捨てない。権限の変化を新しい依頼へ置換しない。
{
const page = await browser.newPage();
const requests: unknown[] = [];
await page.route(
"**/subjektiv/subjects/pending-subject",
async (route) => {
if (route.request().method() !== "DELETE") return route.continue();
requests.push(route.request().postDataJSON());
if (requests.length === 1) {
await route.fulfill({
status: 403,
json: { error: "forbidden", message: "Permission changed" },
});
} else {await route.fulfill({
json: {
subject_id: "pending-subject",
operation_id: (requests[0] as any).operation_id,
completed: true,
},
});}
},
);
await page.goto(`${origin}/w/memory-review/memory/pending-subject`);
await confirm(page);
await page.locator('[data-delete-state="failed"]').waitFor();
await page.screenshot({ path: join(artifact, "rejected.png") });
await page.locator(".subject-delete-retry").click();
await page.locator('[data-delete-state="completed"]').waitFor();
assertEquals(requests[0], requests[1]);
await page.close();
}
// 同一SPA内で他対象へ移動してから旧応答が届いても、新対象を消さない。
{
const page = await browser.newPage();
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
await page.route(
"**/subjektiv/subjects/pending-subject",
async (route) => {
if (route.request().method() !== "DELETE") return route.continue();
const body = route.request().postDataJSON();
await held;
await route.fulfill({
json: {
subject_id: "pending-subject",
operation_id: body.operation_id,
completed: true,
},
});
},
);
await page.goto(`${origin}/w/memory-review/memory/pending-subject`);
await confirm(page);
await page.locator('[data-delete-state="running"]').waitFor();
await page.getByRole("link", { name: "← All subjects", exact: true })
.click();
await page.locator(".subject-list a").first().click();
await page.locator(".subject-overview").waitFor();
const target = page.url();
const response = page.waitForResponse((r) =>
r.request().method() === "DELETE"
);
release();
await response;
assertEquals(page.url(), target);
assertEquals(
await page.locator('[data-delete-state="completed"]').count(),
0,
);
assertEquals(await page.locator(".subject-overview").count(), 1);
await page.close();
}
{
const page = await browser.newPage();
await page.goto(
`${origin}/w/memory-review/memory/legacy-subject?persona=reader`,
);
await page.locator(".subject-overview").waitFor();
assertEquals(await page.locator(".subject-deletion").count(), 0);
await page.screenshot({ path: join(artifact, "non-owner.png") });
await page.close();
}
} finally {
await browser.close();
server.kill("SIGTERM");
await server.status;
}
},
});
+152
View File
@@ -0,0 +1,152 @@
import {
deleteSubjektivSubject,
parseSubjektivSubjectDeleteResponse,
SubjektivSubjectDeleteError,
} from "../src/lib/workspace/memory/api.ts";
import {
clearSubjectDeleteAttempt,
readSubjectDeleteAttempt,
saveSubjectDeleteAttempt,
subjectDeleteStorageKey,
} from "../src/lib/workspace/memory/subject-delete.ts";
declare const Deno: {
test(name: string, fn: () => 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)}`,
);
}
}
Deno.test("Subject deletion uses DELETE with the same request after fence-only success", async () => {
const calls: unknown[] = [];
const fetcher = ((input, init) => {
calls.push([input, init]);
return Promise.resolve(
Response.json({
subject_id: "s/1",
operation_id: "same-operation",
completed: calls.length > 1,
}),
);
}) as typeof fetch;
for (const completed of [false, true]) {
equal(
await deleteSubjektivSubject(fetcher, "w/1", "s/1", {
operation_id: "same-operation",
}),
{ subject_id: "s/1", operation_id: "same-operation", completed },
);
}
equal(calls[0], calls[1]);
equal(calls[0], ["/api/w/w%2F1/subjektiv/subjects/s%2F1", {
method: "DELETE",
headers: { "content-type": "application/json" },
body: '{"operation_id":"same-operation"}',
}]);
});
Deno.test("Subject deletion never confirms a malformed or wrong-target response", () => {
for (
const payload of [
{ subject_id: "other", operation_id: "op", completed: true },
{ subject_id: "s", operation_id: "other", completed: true },
{ subject_id: "s", operation_id: "op", completed: "true" },
{ subject_id: "s", operation_id: "op" },
{ subject_id: "s", operation_id: "op", completed: true, extra: 1 },
]
) {
try {
parseSubjektivSubjectDeleteResponse(payload, "s", "op");
} catch {
continue;
}
throw new Error("Accepted an unconfirmed response");
}
});
Deno.test("Subject deletion classifies rejection separately from unknown transport/server/response outcomes", async () => {
for (
const item of [
{
fetcher: () => Promise.reject(new Error("lost")),
kind: "unknown_outcome",
},
{
fetcher: () =>
Promise.resolve(
Response.json({ error: "forbidden" }, { status: 403 }),
),
kind: "rejected",
},
{
fetcher: () =>
Promise.resolve(
Response.json({ error: "conflict" }, { status: 409 }),
),
kind: "rejected",
},
{
fetcher: () =>
Promise.resolve(
Response.json({ error: "uncertain" }, { status: 500 }),
),
kind: "unknown_outcome",
},
{
fetcher: () =>
Promise.resolve(
Response.json({
subject_id: "wrong",
operation_id: "op",
completed: true,
}),
),
kind: "unknown_outcome",
},
]
) {
try {
await deleteSubjektivSubject(item.fetcher as typeof fetch, "w", "s", {
operation_id: "op",
});
} catch (error) {
if (!(error instanceof SubjektivSubjectDeleteError)) throw error;
equal(error.kind, item.kind);
continue;
}
throw new Error("Unexpected success");
}
});
Deno.test("Retry input survives reload independently of Subject GET and is isolated by Workspace/Subject", () => {
const values = new Map<string, string>();
const storage = {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => {
values.set(key, value);
},
removeItem: (key: string) => {
values.delete(key);
},
};
const attempt = {
subjectId: "s",
operationId: "same-op",
};
saveSubjectDeleteAttempt(storage, "w", attempt);
equal(readSubjectDeleteAttempt(storage, "w", "s"), attempt);
equal(readSubjectDeleteAttempt(storage, "other", "s"), null);
equal(readSubjectDeleteAttempt(storage, "w", "other"), null);
saveSubjectDeleteAttempt(storage, "other", attempt);
clearSubjectDeleteAttempt(storage, "w", "s");
equal(readSubjectDeleteAttempt(storage, "w", "s"), null);
equal(readSubjectDeleteAttempt(storage, "other", "s"), attempt);
values.set(subjectDeleteStorageKey("w", "s"), '{"subjectId":"other"}');
try {
readSubjectDeleteAttempt(storage, "w", "s");
} catch {
return;
}
throw new Error("Corrupt retry input allowed new deletion");
});