config: add revision reads and conflict recovery
This commit is contained in:
@@ -27,6 +27,8 @@
|
||||
let renamePath = $state("");
|
||||
let baseSnapshot = $state<WorkspaceConfigTreeResponse["snapshot"] | null>(null);
|
||||
let preflightDigest = $state("");
|
||||
let conflict = $state(false);
|
||||
let candidateContract = $state<WorkspaceConfigTreeResponse["contract"] | null>(null);
|
||||
let toolchain: ConfigSourceToolchain | null = null;
|
||||
|
||||
const paths = $derived(
|
||||
@@ -58,6 +60,8 @@
|
||||
draftChanges = [];
|
||||
renamePath = selectedPath;
|
||||
diagnostics = [];
|
||||
conflict = false;
|
||||
candidateContract = null;
|
||||
status = treeState.snapshot.revision === 0
|
||||
? "No committed sources yet. Create workspace.dcdl to begin."
|
||||
: `Revision ${treeState.snapshot.revision} · ${treeState.snapshot.digest.slice(0, 20)}…`;
|
||||
@@ -73,6 +77,8 @@
|
||||
if (treeState) treeState = { ...treeState, snapshot: candidate };
|
||||
if (baseSnapshot) draftChanges = await toolchain.changesBetween(baseSnapshot, candidate);
|
||||
preflightDigest = "";
|
||||
candidateContract = null;
|
||||
conflict = false;
|
||||
}
|
||||
|
||||
async function select(path: string) {
|
||||
@@ -142,13 +148,16 @@
|
||||
const candidate = await previewConfigTree(workspaceId, {
|
||||
changes: draftChanges,
|
||||
entrypoints: entrypoints(),
|
||||
toolchain_fingerprint: treeState.contract.fingerprint,
|
||||
});
|
||||
await toolchain?.evaluate(candidate.contract);
|
||||
diagnostics = [];
|
||||
candidateContract = candidate.contract;
|
||||
preflightDigest = candidate.snapshot.digest;
|
||||
status = `Preview valid · projection ${candidate.evaluation.projection_digest.slice(0, 20)}…`;
|
||||
} catch (error) {
|
||||
status = String(error);
|
||||
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;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
@@ -161,7 +170,7 @@
|
||||
status = "No draft changes to commit.";
|
||||
return;
|
||||
}
|
||||
if (preflightDigest !== treeState.snapshot.digest) {
|
||||
if (preflightDigest !== treeState.snapshot.digest || !candidateContract) {
|
||||
status = "Preview the complete candidate successfully before Commit.";
|
||||
return;
|
||||
}
|
||||
@@ -171,11 +180,13 @@
|
||||
base_revision: baseRevision,
|
||||
base_digest: baseDigest,
|
||||
changes: draftChanges,
|
||||
entrypoints: entrypoints(),
|
||||
toolchain_fingerprint: treeState.contract.fingerprint,
|
||||
entrypoints: candidateContract.entrypoints,
|
||||
toolchain_fingerprint: candidateContract.fingerprint,
|
||||
});
|
||||
draftChanges = [];
|
||||
preflightDigest = "";
|
||||
candidateContract = null;
|
||||
conflict = false;
|
||||
baseSnapshot = structuredClone(treeState.snapshot);
|
||||
baseRevision = treeState.snapshot.revision;
|
||||
baseDigest = treeState.snapshot.digest;
|
||||
@@ -184,12 +195,39 @@
|
||||
diagnostics = [];
|
||||
status = `Committed revision ${treeState.snapshot.revision}.`;
|
||||
} catch (error) {
|
||||
status = String(error);
|
||||
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;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function discardAndReload() {
|
||||
draftChanges = [];
|
||||
source = "";
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function reloadAndReapply() {
|
||||
if (!toolchain || !treeState) return;
|
||||
const localCandidate = structuredClone(treeState.snapshot);
|
||||
const remote = await fetchConfigTree(workspaceId);
|
||||
baseSnapshot = structuredClone(remote.snapshot);
|
||||
baseRevision = remote.snapshot.revision;
|
||||
baseDigest = remote.snapshot.digest;
|
||||
await toolchain.setSnapshot(remote.snapshot);
|
||||
draftChanges = await toolchain.changesBetween(remote.snapshot, localCandidate);
|
||||
const candidate = await toolchain.applyChanges(draftChanges);
|
||||
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 candidate reapplied to the latest revision. Preview again before Commit.";
|
||||
}
|
||||
|
||||
function createEntry() {
|
||||
const path = newPath.trim();
|
||||
if (!path || treeState?.snapshot.entries[path]) return;
|
||||
@@ -210,6 +248,8 @@
|
||||
treeState = { ...treeState, snapshot: candidate };
|
||||
if (baseSnapshot) draftChanges = await toolchain.changesBetween(baseSnapshot, candidate);
|
||||
preflightDigest = "";
|
||||
candidateContract = null;
|
||||
conflict = false;
|
||||
selectedPath = Object.keys(candidate.entries).toSorted()[0] ?? "";
|
||||
source = selectedPath ? candidate.entries[selectedPath].content : "";
|
||||
renamePath = selectedPath;
|
||||
@@ -231,6 +271,8 @@
|
||||
treeState = { ...treeState, snapshot: candidate };
|
||||
if (baseSnapshot) draftChanges = await toolchain.changesBetween(baseSnapshot, candidate);
|
||||
preflightDigest = "";
|
||||
candidateContract = null;
|
||||
conflict = false;
|
||||
selectedPath = to;
|
||||
source = candidate.entries[to]?.content ?? "";
|
||||
status = `Rename to ${to} staged. Preview and Commit to persist.`;
|
||||
@@ -282,6 +324,12 @@
|
||||
onComplete={(value, offset, explicit) => toolchain?.complete(selectedPath, value, offset, explicit) ?? Promise.resolve(null)}
|
||||
/>
|
||||
<p class="config-source-status" aria-live="polite">{status}</p>
|
||||
{#if conflict}
|
||||
<div class="config-source-conflict" role="alert">
|
||||
<button type="button" onclick={discardAndReload}>Discard local candidate and reload</button>
|
||||
<button type="button" onclick={reloadAndReapply}>Reload and reapply local candidate</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if diagnostics.length > 0}
|
||||
<ol class="config-source-diagnostics">
|
||||
{#each diagnostics as diagnostic}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
ConfigCommitRequest,
|
||||
ConfigEntry,
|
||||
ConfigPreviewRequest,
|
||||
ConfigTreeSnapshot,
|
||||
EvaluatedConfigCandidate,
|
||||
WorkspaceConfigTreeResponse,
|
||||
} from "./types.ts";
|
||||
@@ -41,9 +43,21 @@ export async function fetchConfigEntry(
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchConfigRevision(
|
||||
workspaceId: string,
|
||||
revision: number,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<ConfigTreeSnapshot> {
|
||||
return await readJson(
|
||||
await fetcher(`${sourceTreeUrl(workspaceId)}/revisions/${revision}`, {
|
||||
headers: { accept: "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function previewConfigTree(
|
||||
workspaceId: string,
|
||||
request: Omit<ConfigCommitRequest, "base_revision" | "base_digest">,
|
||||
request: ConfigPreviewRequest,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<EvaluatedConfigCandidate> {
|
||||
return await readJson(
|
||||
|
||||
@@ -631,6 +631,22 @@
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.config-source-conflict {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
border-top: 1px solid var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 8%, transparent);
|
||||
}
|
||||
.config-source-conflict button {
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: 0.45rem;
|
||||
background: var(--bg-raised);
|
||||
color: var(--danger);
|
||||
padding: 0.4rem 0.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.config-source-diagnostics {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { assert, assertEquals } from "jsr:@std/assert";
|
||||
import {
|
||||
commitConfigTree,
|
||||
fetchConfigEntry,
|
||||
fetchConfigRevision,
|
||||
fetchConfigTree,
|
||||
previewConfigTree,
|
||||
} from "../../src/lib/workspace/config-source/api.ts";
|
||||
@@ -23,8 +24,9 @@ Deno.test("config source API stays workspace-scoped and separates preview from c
|
||||
}) as typeof fetch;
|
||||
|
||||
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: [], toolchain_fingerprint: "sha256:toolchain" }, fetcher);
|
||||
await previewConfigTree("w/one", { changes: [], entrypoints: [] }, fetcher);
|
||||
await commitConfigTree("w/one", {
|
||||
base_revision: 4,
|
||||
base_digest: "sha256:base",
|
||||
@@ -35,14 +37,15 @@ Deno.test("config source API stays workspace-scoped and separates preview from c
|
||||
|
||||
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[2].init?.method, "POST");
|
||||
assertEquals(calls[3].init?.method, "POST");
|
||||
assertEquals(calls[4].init?.method, "POST");
|
||||
assert(
|
||||
String(calls[3].init?.body).includes('"base_digest":"sha256:base"'),
|
||||
String(calls[4].init?.body).includes('"base_digest":"sha256:base"'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -53,7 +56,7 @@ Deno.test("config source API surfaces failed evaluation instead of treating it a
|
||||
)) as typeof fetch;
|
||||
let message = "";
|
||||
try {
|
||||
await previewConfigTree("w", { changes: [], entrypoints: [], toolchain_fingerprint: "sha256:toolchain" }, fetcher);
|
||||
await previewConfigTree("w", { changes: [], entrypoints: [] }, fetcher);
|
||||
} catch (error) {
|
||||
message = String(error);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user