config: commit without preview

This commit is contained in:
2026-08-15 02:06:46 +09:00
parent 404809ab6e
commit 4cf34375a8
12 changed files with 109 additions and 288 deletions
@@ -4,7 +4,6 @@
import {
commitConfigTree,
fetchConfigTree,
previewConfigTree,
} from "./api.ts";
import { ConfigSourceToolchain } from "./toolchain.ts";
import type {
@@ -23,15 +22,15 @@
let diagnostics = $state<ConfigDiagnostic[]>([]);
let status = $state("Loading source tree…");
let busy = $state(false);
let draftChanges = $state<ConfigTreeChange[]>([]);
let workingChanges = $state<ConfigTreeChange[]>([]);
let baseRevision = $state(0);
let baseDigest = $state("");
let renamePath = $state("");
let baseSnapshot = $state.raw<WorkspaceConfigTreeResponse["snapshot"] | null>(null);
let preflightDigest = $state("");
let conflict = $state(false);
let candidateContract = $state<WorkspaceConfigTreeResponse["contract"] | null>(null);
let toolchain: ConfigSourceToolchain | null = null;
let toolchain = $state.raw<ConfigSourceToolchain | null>(null);
let analysisReady = $state(false);
let analysisGeneration = 0;
const paths = $derived(
treeState ? Object.keys(treeState.snapshot.entries).toSorted() : [],
@@ -40,8 +39,7 @@
treeState && selectedPath ? treeState.snapshot.entries[selectedPath] : undefined,
);
const mainSelected = $derived(selectedPath === MAIN_ENTRYPOINT);
const dirty = $derived(draftChanges.length > 0 || (selected ? source !== selected.content : source.length > 0));
const commitReady = $derived(dirty && preflightDigest === treeState?.snapshot.digest);
const dirty = $derived(workingChanges.length > 0 || (selected ? source !== selected.content : source.length > 0));
onMount(() => {
toolchain = new ConfigSourceToolchain();
@@ -49,7 +47,30 @@
return () => toolchain?.close();
});
$effect(() => {
const analyzer = toolchain;
const path = selectedPath;
const value = source;
const ready = analysisReady;
const generation = ++analysisGeneration;
diagnostics = [];
if (!analyzer || !path || !ready) return;
const timer = setTimeout(() => {
void analyzer.analyze(path, value).then((result) => {
if (generation === analysisGeneration) diagnostics = result;
}).catch((error) => {
if (generation === analysisGeneration) status = `Analyze failed: ${String(error)}`;
});
}, 250);
return () => {
clearTimeout(timer);
if (generation === analysisGeneration) analysisGeneration += 1;
};
});
async function reload() {
analysisReady = false;
try {
treeState = await fetchConfigTree(workspaceId);
if (!selectedPath || !treeState.snapshot.entries[selectedPath]) {
@@ -60,11 +81,11 @@
baseRevision = treeState.snapshot.revision;
baseDigest = treeState.snapshot.digest;
await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle);
draftChanges = [];
analysisReady = true;
workingChanges = [];
renamePath = selectedPath;
diagnostics = [];
conflict = false;
candidateContract = null;
status = `Revision ${treeState.snapshot.revision} · ${treeState.snapshot.digest.slice(0, 20)}…`;
} catch (error) {
status = String(error);
@@ -76,9 +97,7 @@
if (!change || !toolchain) return;
const candidate = await toolchain.applyChanges([change]);
if (treeState) treeState = { ...treeState, snapshot: candidate };
if (baseSnapshot) draftChanges = await toolchain.changesBetween(baseSnapshot, candidate);
preflightDigest = "";
candidateContract = null;
if (baseSnapshot) workingChanges = await toolchain.changesBetween(baseSnapshot, candidate);
conflict = false;
}
@@ -114,27 +133,21 @@
return [MAIN_ENTRYPOINT];
}
async function analyze() {
if (!toolchain || !treeState || !selectedPath) return;
diagnostics = await toolchain.analyze(selectedPath, source);
status = diagnostics.length === 0 ? "No diagnostics." : `${diagnostics.length} diagnostic(s).`;
}
async function format() {
if (!toolchain) return;
try {
source = await toolchain.format(source);
await analyze();
status = "Formatted source. Changes remain local until Commit succeeds.";
} catch (error) {
status = String(error);
}
}
async function formatDraftSources() {
async function formatWorkingSources() {
if (!toolchain || !treeState || !baseSnapshot) return;
await stageCurrent();
const paths = new Set<string>();
for (const change of draftChanges) {
for (const change of workingChanges) {
if (change.kind === "create" || change.kind === "update") paths.add(change.path);
if (change.kind === "rename") paths.add(change.to);
}
@@ -157,74 +170,33 @@
if (!formattedAny) return;
treeState = { ...treeState, snapshot: candidate };
draftChanges = await toolchain.changesBetween(baseSnapshot, candidate);
workingChanges = await toolchain.changesBetween(baseSnapshot, candidate);
source = candidate.entries[selectedPath]?.content ?? source;
preflightDigest = "";
candidateContract = null;
conflict = false;
}
async function requestCandidatePreview() {
if (!toolchain) throw new Error("config source toolchain is unavailable");
const candidate = await previewConfigTree(workspaceId, {
changes: draftChanges,
entrypoints: entrypoints(),
});
await toolchain.evaluate(candidate.contract);
diagnostics = [];
candidateContract = candidate.contract;
preflightDigest = candidate.snapshot.digest;
return candidate;
}
function recordCandidateError(error: unknown) {
function recordCommitError(error: unknown) {
const message = String(error);
conflict = message.includes("conflict") || message.includes("base revision/digest mismatch");
status = conflict ? `${message} Reload the authoritative tree before editing again.` : message;
}
async function preview() {
if (!treeState) return;
busy = true;
try {
await formatDraftSources();
if (draftChanges.length === 0) {
status = "No draft changes to preview.";
return;
}
const candidate = await requestCandidatePreview();
status = `Preview valid · projection ${candidate.evaluation.projection_digest.slice(0, 20)}…`;
} catch (error) {
recordCandidateError(error);
} finally {
busy = false;
}
}
async function commit() {
if (!treeState) return;
busy = true;
try {
await formatDraftSources();
if (draftChanges.length === 0) {
status = "No draft changes to commit.";
await formatWorkingSources();
if (workingChanges.length === 0) {
status = "No working changes to commit.";
return;
}
if (preflightDigest !== treeState.snapshot.digest || !candidateContract) {
await requestCandidatePreview();
}
const contract = candidateContract;
if (!contract) throw new Error("candidate preview did not return a toolchain contract");
treeState = await commitConfigTree(workspaceId, {
base_revision: baseRevision,
base_digest: baseDigest,
changes: draftChanges,
entrypoints: contract.entrypoints,
toolchain_fingerprint: contract.fingerprint,
changes: workingChanges,
entrypoints: entrypoints(),
});
draftChanges = [];
preflightDigest = "";
candidateContract = null;
workingChanges = [];
conflict = false;
baseSnapshot = $state.snapshot(treeState.snapshot);
baseRevision = treeState.snapshot.revision;
@@ -234,21 +206,21 @@
diagnostics = [];
status = `Committed formatted revision ${treeState.snapshot.revision}.`;
} catch (error) {
recordCandidateError(error);
recordCommitError(error);
} finally {
busy = false;
}
}
async function discardAndReload() {
draftChanges = [];
workingChanges = [];
source = "";
await reload();
}
async function reloadAndReapply() {
if (!toolchain || !treeState) return;
const localChanges = [...draftChanges];
const localChanges = [...workingChanges];
const remote = await fetchConfigTree(workspaceId);
baseSnapshot = structuredClone(remote.snapshot);
baseRevision = remote.snapshot.revision;
@@ -256,14 +228,12 @@
await toolchain.setSnapshot(remote.snapshot, remote.contract.schema_bundle);
try {
const candidate = await toolchain.applyChanges(localChanges);
draftChanges = localChanges;
workingChanges = localChanges;
treeState = { ...remote, snapshot: candidate };
selectedPath = candidate.entries[selectedPath] ? selectedPath : Object.keys(candidate.entries).toSorted()[0] ?? "";
source = selectedPath ? candidate.entries[selectedPath].content : "";
conflict = false;
preflightDigest = "";
candidateContract = null;
status = "Local changes reapplied to the latest revision. Preview again before Commit.";
status = "Local changes reapplied to the latest revision. Commit to persist them.";
} catch (error) {
conflict = true;
status = `Local changes conflict with the latest revision: ${String(error)}. Discard local changes or resolve against a fresh reload.`;
@@ -276,7 +246,7 @@
selectedPath = path;
source = "{}\n";
diagnostics = [];
status = `Drafting new source ${path}. It is not persisted until Commit succeeds.`;
status = `Creating local source ${path}. It is not persisted until Commit succeeds.`;
}
async function deleteEntry() {
@@ -288,14 +258,12 @@
};
const candidate = await toolchain.applyChanges([change]);
treeState = { ...treeState, snapshot: candidate };
if (baseSnapshot) draftChanges = await toolchain.changesBetween(baseSnapshot, candidate);
preflightDigest = "";
candidateContract = null;
if (baseSnapshot) workingChanges = await toolchain.changesBetween(baseSnapshot, candidate);
conflict = false;
selectedPath = Object.keys(candidate.entries).toSorted()[0] ?? "";
source = selectedPath ? candidate.entries[selectedPath].content : "";
renamePath = selectedPath;
status = "Delete staged. Preview and Commit to persist the candidate tree.";
status = "Delete staged locally. Commit to persist the working tree.";
}
async function renameEntry() {
@@ -311,13 +279,11 @@
};
const candidate = await toolchain.applyChanges([change]);
treeState = { ...treeState, snapshot: candidate };
if (baseSnapshot) draftChanges = await toolchain.changesBetween(baseSnapshot, candidate);
preflightDigest = "";
candidateContract = null;
if (baseSnapshot) workingChanges = await toolchain.changesBetween(baseSnapshot, candidate);
conflict = false;
selectedPath = to;
source = candidate.entries[to]?.content ?? "";
status = `Rename to ${to} staged. Preview and Commit to persist.`;
status = `Rename to ${to} staged locally. Commit to persist it.`;
}
</script>
@@ -342,7 +308,7 @@
<form class="config-source-create" onsubmit={(event) => { event.preventDefault(); createEntry(); }}>
<label for="new-config-path">New path</label>
<input id="new-config-path" bind:value={newPath} placeholder="module.dcdl" />
<button type="submit">Create draft</button>
<button type="submit">Create local source</button>
</form>
</aside>
@@ -356,9 +322,7 @@
<input aria-label="Rename path" bind:value={renamePath} disabled={!selected || mainSelected || busy} />
<button type="button" onclick={renameEntry} disabled={!selected || mainSelected || renamePath === selectedPath || busy}>Rename</button>
<button type="button" onclick={format} disabled={!selectedPath || busy}>Format</button>
<button type="button" onclick={analyze} disabled={!selectedPath || busy}>Analyze</button>
<button type="button" onclick={preview} disabled={!dirty || busy}>Preview</button>
<button class="primary" type="button" onclick={commit} disabled={!commitReady || busy}>Commit</button>
<button class="primary" type="button" onclick={commit} disabled={!dirty || busy}>Commit</button>
<button class="danger" type="button" onclick={deleteEntry} disabled={!selected || mainSelected || busy}>Delete</button>
</div>
</header>
@@ -1,9 +1,7 @@
import type {
ConfigCommitRequest,
ConfigEntry,
ConfigPreviewRequest,
ConfigTreeSnapshot,
EvaluatedConfigCandidate,
WorkspaceConfigTreeResponse,
} from "./types.ts";
@@ -55,20 +53,6 @@ export async function fetchConfigRevision(
);
}
export async function previewConfigTree(
workspaceId: string,
request: ConfigPreviewRequest,
fetcher: typeof fetch = fetch,
): Promise<EvaluatedConfigCandidate> {
return await readJson(
await fetcher(`${sourceTreeUrl(workspaceId)}/preview`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
}),
);
}
export async function commitConfigTree(
workspaceId: string,
request: ConfigCommitRequest,
@@ -2,4 +2,4 @@
import type { ConfigTreeChange } from "./ConfigTreeChange";
import type { VirtualPath } from "./VirtualPath";
export type ConfigCommitRequest = { base_revision: number, base_digest: string, changes: Array<ConfigTreeChange>, entrypoints: Array<VirtualPath>, toolchain_fingerprint: string, };
export type ConfigCommitRequest = { base_revision: number, base_digest: string, changes: Array<ConfigTreeChange>, entrypoints: Array<VirtualPath>, };
@@ -1,5 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ConfigTreeChange } from "./ConfigTreeChange";
import type { VirtualPath } from "./VirtualPath";
export type ConfigPreviewRequest = { changes: Array<ConfigTreeChange>, entrypoints: Array<VirtualPath>, };
@@ -1,6 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ConfigTreeSnapshot } from "./ConfigTreeSnapshot";
import type { EvaluationResult } from "./EvaluationResult";
import type { ToolchainContract } from "./ToolchainContract";
export type EvaluatedConfigCandidate = { base_revision: number, base_digest: string, snapshot: ConfigTreeSnapshot, contract: ToolchainContract, evaluation: EvaluationResult, };
@@ -2,7 +2,6 @@ import type {
ConfigDiagnostic,
ConfigTreeChange,
ConfigTreeSnapshot,
ToolchainContract,
WorkspaceConfigSchemaBundle,
} from "./types.ts";
import { jsonWorkerMessage } from "./toolchain-message.ts";
@@ -20,7 +19,6 @@ type Command =
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "apply_changes" }>, "id">
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "changes_between" }>, "id">
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "analyze" }>, "id">
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "evaluate" }>, "id">
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "complete" }>, "id">
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "format" }>, "id">;
@@ -68,9 +66,6 @@ export class ConfigSourceToolchain {
analyze(path: string, source?: string): Promise<ConfigDiagnostic[]> {
return this.#request({ kind: "analyze", path, source });
}
evaluate(contract: ToolchainContract) {
return this.#request({ kind: "evaluate", contract });
}
async complete(
path: string,
source: string,
@@ -3,7 +3,6 @@ import init, {
apply_changes,
changes_between,
complete_current,
evaluate_current,
format_source,
set_schema_bundle,
set_snapshot,
@@ -20,7 +19,6 @@ export type ConfigSourceWorkerRequest =
| { id: number; kind: "apply_changes"; changes: ConfigTreeChange[] }
| { id: number; kind: "changes_between"; base: unknown; candidate: unknown }
| { id: number; kind: "analyze"; path: string; source?: string }
| { id: number; kind: "evaluate"; contract: unknown }
| {
id: number;
kind: "complete";
@@ -65,9 +63,6 @@ self.onmessage = async (
}
result = analyze_snapshot(snapshot, request.path, request.source);
break;
case "evaluate":
result = evaluate_current(request.contract);
break;
case "complete":
result = complete_current(
request.path,
@@ -13,6 +13,4 @@ export type { EvaluationResult } from "./generated/types/EvaluationResult.ts";
export type { ToolchainContract } from "./generated/types/ToolchainContract.ts";
export type { VirtualPath } from "./generated/types/VirtualPath.ts";
export type { ConfigCommitRequest } from "./generated/types/ConfigCommitRequest.ts";
export type { ConfigPreviewRequest } from "./generated/types/ConfigPreviewRequest.ts";
export type { EvaluatedConfigCandidate } from "./generated/types/EvaluatedConfigCandidate.ts";
export type { WorkspaceConfigState as WorkspaceConfigTreeResponse } from "./generated/types/WorkspaceConfigState.ts";
+9 -9
View File
@@ -6,7 +6,6 @@ import {
fetchConfigEntry,
fetchConfigRevision,
fetchConfigTree,
previewConfigTree,
} from "../../src/lib/workspace/config-source/api.ts";
function response(body: unknown, status = 200): Response {
@@ -16,7 +15,7 @@ function response(body: unknown, status = 200): Response {
});
}
Deno.test("config source API stays workspace-scoped and separates preview from commit", async () => {
Deno.test("config source API commits directly through the workspace scope", async () => {
const calls: Array<{ url: string; init?: RequestInit }> = [];
const fetcher = ((input: string | URL | Request, init?: RequestInit) => {
calls.push({ url: String(input), init });
@@ -26,37 +25,38 @@ Deno.test("config source API stays workspace-scoped and separates preview from c
await fetchConfigTree("w/one", fetcher);
await fetchConfigRevision("w/one", 7, fetcher);
await fetchConfigEntry("w/one", "profiles/main.dcdl", fetcher);
await previewConfigTree("w/one", { changes: [], entrypoints: [] }, fetcher);
await commitConfigTree("w/one", {
base_revision: 4,
base_digest: "sha256:base",
changes: [],
entrypoints: [],
toolchain_fingerprint: "sha256:toolchain",
}, fetcher);
assertEquals(calls.map((call) => call.url), [
"/api/w/w%2Fone/config/source-tree",
"/api/w/w%2Fone/config/source-tree/revisions/7",
"/api/w/w%2Fone/config/source-tree/entries/profiles%2Fmain.dcdl",
"/api/w/w%2Fone/config/source-tree/preview",
"/api/w/w%2Fone/config/source-tree/commit",
]);
assertEquals(calls[3].init?.method, "POST");
assertEquals(calls[4].init?.method, "POST");
assert(
String(calls[4].init?.body).includes('"base_digest":"sha256:base"'),
String(calls[3].init?.body).includes('"base_digest":"sha256:base"'),
);
});
Deno.test("config source API surfaces failed evaluation instead of treating it as a draft write", async () => {
Deno.test("config source API surfaces failed evaluation instead of treating it as a successful commit", async () => {
const fetcher = (() =>
Promise.resolve(
new Response("structured diagnostics", { status: 422 }),
)) as typeof fetch;
let message = "";
try {
await previewConfigTree("w", { changes: [], entrypoints: [] }, fetcher);
await commitConfigTree("w", {
base_revision: 1,
base_digest: "sha256:base",
changes: [],
entrypoints: [],
}, fetcher);
} catch (error) {
message = String(error);
}
@@ -99,7 +99,7 @@ Deno.test("main entrypoint always enables the fixed schema wrapper", async () =>
);
});
Deno.test("preview and commit format every draft Decodal source", async () => {
Deno.test("commit formats every working Decodal source without a preview roundtrip", async () => {
const source = await Deno.readTextFile(
new URL(
"../../src/lib/workspace/config-source/ConfigSourceEditor.svelte",
@@ -108,7 +108,7 @@ Deno.test("preview and commit format every draft Decodal source", async () => {
);
assert(
source.includes("async function formatDraftSources()") &&
source.includes("async function formatWorkingSources()") &&
source.includes('change.kind === "create" || change.kind === "update"') &&
source.includes('change.kind === "rename"') &&
source.includes('entry.content_type !== "decodal"') &&
@@ -116,15 +116,39 @@ Deno.test("preview and commit format every draft Decodal source", async () => {
"all changed Decodal entries should be formatted rather than only the selected source",
);
assert(
source.includes("await formatDraftSources();") &&
source.includes("await requestCandidatePreview();") &&
source.includes("await formatWorkingSources();") &&
source.includes("entrypoints: entrypoints()") &&
source.includes("Committed formatted revision"),
"preview and commit should format first and refresh stale preflight before persistence",
"commit should format first and send the working changes directly to Backend authority",
);
assert(
!source.includes(
"Preview the complete candidate successfully before Commit.",
!source.includes("previewConfigTree") &&
!source.includes("requestCandidatePreview") &&
!source.includes("toolchain_fingerprint"),
"normal commit must not depend on a separate preview or client-echoed toolchain fingerprint",
);
});
Deno.test("config diagnostics analyze continuously with debounce and generation fencing", async () => {
const source = await Deno.readTextFile(
new URL(
"../../src/lib/workspace/config-source/ConfigSourceEditor.svelte",
import.meta.url,
),
"format-driven candidate changes should trigger automatic preflight instead of a dead-end error",
);
assert(
source.includes("setTimeout(() =>") &&
source.includes("}, 250)") &&
source.includes("analyzer.analyze(path, value)") &&
source.includes("generation === analysisGeneration") &&
source.includes("analysisReady"),
"source changes should trigger only the latest debounced analysis after snapshot initialization",
);
assert(
!source.includes("onclick={analyze}") &&
!source.includes(">Analyze</button>") &&
!source.includes(">Preview</button>"),
"manual Analyze and Preview controls should be removed",
);
});