refactor: make browser workspace writes bootstrap-only

This commit is contained in:
2026-08-11 15:36:09 +02:00
parent d9fd902d08
commit 1790d2449f
14 changed files with 844 additions and 1509 deletions
+72 -59
View File
@@ -4,21 +4,15 @@ import type {
} from "../api/workspaces";
export { workspacePreferences, type WorkspacePreference } from "./preferences";
export interface WorkspaceDraft {
export interface WorkspaceBootstrapDraft {
workspaceId: string;
baseCommit: string;
baseBlob?: string;
workspace: CanonicalWorkspace;
updatedAt: string;
}
/** A publishable deletion intent; it deliberately carries no workspace body. */
export interface WorkspaceDeletionDraft {
id: string;
baseCommit: string;
baseBlob: string;
updatedAt: string;
}
/** @deprecated Use WorkspaceBootstrapDraft. */
export type WorkspaceDraft = WorkspaceBootstrapDraft;
export const WORKSPACE_SUMMARY_ERROR = "Could not load workspace registry. Please retry.";
export const WORKSPACE_POLICY_ERROR = "Could not load selected workspace policy. Please retry.";
@@ -123,9 +117,9 @@ export const workspacePolicyGate = {
},
};
const PREFIX = "thothii.workspace-registry.v1";
const DRAFT_PREFIX = `${PREFIX}.draft.`;
const DELETE_DRAFT_PREFIX = `${PREFIX}.delete.`;
const LEGACY_PREFIX = "thothii.workspace-registry.v1";
const PREFIX = "thothii.workspace-registry.v2";
const DRAFT_PREFIX = `${PREFIX}.bootstrap.`;
function storage(): Storage | undefined {
try { return window.localStorage; } catch { return undefined; }
@@ -234,7 +228,7 @@ function copyFilesystemEvidence(value: unknown, id: string): EvidenceSource | un
const maxBytes = positiveInteger(source?.max_bytes);
if (
source?.type !== "filesystem"
|| uri !== `workspace-content/${id}/evidence`
|| uri !== `${id}/evidence`
|| !Array.isArray(patterns)
|| patterns.length === 0
|| !patterns.every((pattern) => typeof pattern === "string" && isSafeEvidencePattern(pattern))
@@ -480,20 +474,47 @@ export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace |
};
}
function normalize(value: unknown): WorkspaceDraft | undefined {
const source = exactRecord(value, ["workspaceId", "baseCommit", "baseBlob", "workspace", "updatedAt"]);
const workspace = sanitizeCanonicalWorkspace(source?.workspace);
function repairBootstrapWorkspace(value: unknown, id: string): CanonicalWorkspace | undefined {
const direct = sanitizeCanonicalWorkspace(value);
if (direct) return direct;
const source = exactRecord(value, [
"workspace", "dwh", "semantic_index", "llm_policy", "diagnostics", "evidence",
]);
const evidence = exactRecord(source?.evidence, ["source", "policy"]);
const evidenceSource = exactRecord(evidence?.source, ["type", "uri", "patterns", "max_bytes"]);
if (
source
&& evidence
&& evidenceSource?.type === "filesystem"
&& typeof evidenceSource.uri === "string"
&& /^[a-z][a-z0-9-]{2,62}\/evidence$/.test(evidenceSource.uri)
) {
return sanitizeCanonicalWorkspace({
...source,
evidence: {
...evidence,
source: {
...evidenceSource,
uri: `${id}/evidence`,
},
},
});
}
return undefined;
}
function normalize(value: unknown): WorkspaceBootstrapDraft | undefined {
const source = exactRecord(value, ["workspaceId", "baseCommit", "workspace", "updatedAt"]);
const id = workspaceId(source?.workspaceId);
const workspace = id ? repairBootstrapWorkspace(source?.workspace, id) : undefined;
const baseCommit = typeof source?.baseCommit === "string" && /^[0-9a-f]{40}$/.test(source.baseCommit) ? source.baseCommit : undefined;
const baseBlob = source?.baseBlob === undefined ? undefined : typeof source.baseBlob === "string" && /^[0-9a-f]{40}$/.test(source.baseBlob) ? source.baseBlob : undefined;
const updatedAt = typeof source?.updatedAt === "string" && Number.isFinite(Date.parse(source.updatedAt)) ? source.updatedAt : undefined;
if (!source || !workspace || !id || id !== workspace.workspace.id || !baseCommit || (source.baseBlob !== undefined && !baseBlob) || !updatedAt) {
if (!source || !workspace || !id || id !== workspace.workspace.id || !baseCommit || !updatedAt) {
return undefined;
}
return {
workspaceId: id,
baseCommit,
...(baseBlob ? { baseBlob } : {}),
workspace,
updatedAt,
};
@@ -503,28 +524,21 @@ function key(id: string): string {
return `${DRAFT_PREFIX}${encodeURIComponent(id)}`;
}
function deletionKey(id: string): string {
return `${DELETE_DRAFT_PREFIX}${encodeURIComponent(id)}`;
function purgeLegacyRegistryDrafts(): void {
const store = storage();
if (!store) return;
const keys = Array.from({ length: store.length }, (_, index) => store.key(index)).filter((value): value is string => value !== null);
for (const entry of keys) {
if (entry.startsWith(`${LEGACY_PREFIX}.draft.`) || entry.startsWith(`${LEGACY_PREFIX}.delete.`)) {
store.removeItem(entry);
}
}
}
function normalizeDeletion(value: unknown): WorkspaceDeletionDraft | undefined {
const source = exactRecord(value, ["id", "baseCommit", "baseBlob", "updatedAt"]);
const id = workspaceId(source?.id);
const baseCommit = typeof source?.baseCommit === "string" && /^[0-9a-f]{40}$/.test(source.baseCommit)
? source.baseCommit
: undefined;
const baseBlob = typeof source?.baseBlob === "string" && /^[0-9a-f]{40}$/.test(source.baseBlob)
? source.baseBlob
: undefined;
const updatedAt = typeof source?.updatedAt === "string" && Number.isFinite(Date.parse(source.updatedAt))
? source.updatedAt
: undefined;
return id && baseCommit && baseBlob && updatedAt ? { id, baseCommit, baseBlob, updatedAt } : undefined;
}
/** Browser-only workspace drafts. Saving or editing one never calls the server. */
export const workspaceDrafts = {
load(id: string): WorkspaceDraft | undefined {
/** Browser-only bootstrap drafts. Saving or editing one never calls the server. */
export const workspaceBootstrapDrafts = {
load(id: string): WorkspaceBootstrapDraft | undefined {
purgeLegacyRegistryDrafts();
try {
const raw = storage()?.getItem(key(id));
return raw ? normalize(JSON.parse(raw)) : undefined;
@@ -533,35 +547,34 @@ export const workspaceDrafts = {
}
},
save(draft: WorkspaceDraft): void {
save(draft: WorkspaceBootstrapDraft): void {
purgeLegacyRegistryDrafts();
const safe = normalize(draft);
if (!safe) return;
try { storage()?.setItem(key(safe.workspaceId), JSON.stringify(safe)); } catch { /* storage is optional */ }
},
discard(id: string): void {
purgeLegacyRegistryDrafts();
try { storage()?.removeItem(key(id)); } catch { /* storage is optional */ }
},
};
/** Browser-only deletion drafts. Task 10 alone may publish one. */
/** @deprecated Use workspaceBootstrapDrafts. */
export const workspaceDrafts = workspaceBootstrapDrafts;
/** @deprecated Removed in P1.1; existing workspaces are curator-owned and deletions are not drafted in-browser. */
export interface WorkspaceDeletionDraft {
id: string;
baseCommit: string;
baseBlob: string;
updatedAt: string;
}
/** @deprecated Removed in P1.1; kept temporarily so legacy imports compile during the UI transition. */
export const workspaceDeletionDrafts = {
load(id: string): WorkspaceDeletionDraft | undefined {
try {
const raw = storage()?.getItem(deletionKey(id));
return raw ? normalizeDeletion(JSON.parse(raw)) : undefined;
} catch {
return undefined;
}
},
save(draft: WorkspaceDeletionDraft): void {
const safe = normalizeDeletion(draft);
if (!safe) return;
try { storage()?.setItem(deletionKey(safe.id), JSON.stringify(safe)); } catch { /* storage is optional */ }
},
discard(id: string): void {
try { storage()?.removeItem(deletionKey(id)); } catch { /* storage is optional */ }
},
load(_id: string): WorkspaceDeletionDraft | undefined { return undefined; },
save(_draft: WorkspaceDeletionDraft): void {},
discard(_id: string): void {},
};