feat: replace Subject retirement with durable explicit deletion
This commit is contained in:
@@ -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",
|
||||
|
||||
Generated
+7
@@ -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;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user