fix: recover workspace publication failures
This commit is contained in:
@@ -9,6 +9,7 @@ import { promisify } from "node:util";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { WorkspaceRepositoryLock } from "../src/workspaces/git-repository.js";
|
||||
import { WorkspaceRegistry } from "../src/workspaces/registry.js";
|
||||
import { parseWorkspaceYaml, type CanonicalWorkspace } from "../src/workspaces/schema.js";
|
||||
import type { WorkspaceRegistryConfig } from "../src/workspaces/types.js";
|
||||
|
||||
const validYaml = `workspace:
|
||||
@@ -49,6 +50,11 @@ async function git(cwd: string, args: string[]): Promise<void> {
|
||||
await runFile("git", args, { cwd });
|
||||
}
|
||||
|
||||
async function gitOutput(cwd: string, args: string[]): Promise<string> {
|
||||
const { stdout } = await runFile("git", args, { cwd });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
async function fixture(workspaceSource = validYaml): Promise<{
|
||||
root: string; remote: string; source: string; initialCommit: string;
|
||||
}> {
|
||||
@@ -71,7 +77,11 @@ async function fixture(workspaceSource = validYaml): Promise<{
|
||||
return { root, remote, source, initialCommit: stdout.trim() };
|
||||
}
|
||||
|
||||
function config(root: string, remoteUrl: string): WorkspaceRegistryConfig {
|
||||
function config(
|
||||
root: string,
|
||||
remoteUrl: string,
|
||||
overrides: Partial<WorkspaceRegistryConfig> = {},
|
||||
): WorkspaceRegistryConfig {
|
||||
return {
|
||||
root,
|
||||
remoteUrl,
|
||||
@@ -82,6 +92,25 @@ function config(root: string, remoteUrl: string): WorkspaceRegistryConfig {
|
||||
secretRoots: [],
|
||||
maxImportBytes: 1024,
|
||||
maxImportEntries: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function workspaceWith(
|
||||
id: string,
|
||||
changes: Partial<Pick<CanonicalWorkspace["workspace"], "name" | "description">> = {},
|
||||
): CanonicalWorkspace {
|
||||
const workspace = parseWorkspaceYaml(validYaml) as CanonicalWorkspace;
|
||||
return {
|
||||
...workspace,
|
||||
workspace: { ...workspace.workspace, id, name: id, ...changes },
|
||||
};
|
||||
}
|
||||
|
||||
async function checkoutStatus(checkout: string): Promise<{ porcelain: string; divergence: string }> {
|
||||
return {
|
||||
porcelain: await gitOutput(checkout, ["status", "--porcelain"]),
|
||||
divergence: await gitOutput(checkout, ["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -134,6 +163,123 @@ test("bootstraps a checkout and activates a validated immutable snapshot", async
|
||||
});
|
||||
});
|
||||
|
||||
test("publishes create, update, and delete with the configured Git author identity", async () => {
|
||||
const remote = await fixture();
|
||||
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote, {
|
||||
gitAuthorName: "Configured Workspace Publisher",
|
||||
gitAuthorEmail: "publisher@example.invalid",
|
||||
}));
|
||||
await registry.bootstrap();
|
||||
const createdWorkspace = workspaceWith("research-registry", { name: "Research registry" });
|
||||
|
||||
const created = await registry.publish({
|
||||
action: "create",
|
||||
workspace: createdWorkspace,
|
||||
baseCommit: remote.initialCommit,
|
||||
});
|
||||
|
||||
expect(created).toMatchObject({ id: "research-registry", commit: expect.stringMatching(/^[0-9a-f]{40}$/) });
|
||||
expect(await gitOutput(remote.root, ["--git-dir", remote.remote, "log", "-1", "--format=%an <%ae>"])).toBe(
|
||||
"Configured Workspace Publisher <publisher@example.invalid>",
|
||||
);
|
||||
await expect(runFile("git", ["--git-dir", remote.remote, "cat-file", "-e", "HEAD:workspace-docs/research-registry/README.md"], {
|
||||
cwd: remote.root,
|
||||
})).resolves.toBeDefined();
|
||||
|
||||
const updated = await registry.publish({
|
||||
action: "update",
|
||||
workspace: workspaceWith("research-registry", { description: "Updated workspace description" }),
|
||||
baseCommit: created!.commit,
|
||||
baseBlob: created!.blob,
|
||||
});
|
||||
|
||||
expect(updated).toMatchObject({ id: "research-registry" });
|
||||
expect(await gitOutput(remote.root, ["--git-dir", remote.remote, "show", "HEAD:workspaces/research-registry.yaml"])).toContain(
|
||||
"description: Updated workspace description",
|
||||
);
|
||||
|
||||
await expect(registry.publish({
|
||||
action: "delete",
|
||||
id: "research-registry",
|
||||
baseCommit: updated!.commit,
|
||||
baseBlob: updated!.blob,
|
||||
})).resolves.toBeUndefined();
|
||||
await expect(runFile("git", ["--git-dir", remote.remote, "cat-file", "-e", "HEAD:workspaces/research-registry.yaml"], {
|
||||
cwd: remote.root,
|
||||
})).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
test("reports stale publish conflicts with expected and actual revisions", async () => {
|
||||
const remote = await fixture();
|
||||
const root = join(remote.root, "registry");
|
||||
const registry = new WorkspaceRegistry(config(root, remote.remote));
|
||||
await registry.bootstrap();
|
||||
const initial = await registry.read("psd-clinical");
|
||||
writeFileSync(join(remote.source, "workspaces", "psd-clinical.yaml"), validYaml.replace(
|
||||
"model: nomic-embed-text-v2-moe", "model: mxbai-embed-large",
|
||||
));
|
||||
await git(remote.source, ["add", "workspaces/psd-clinical.yaml"]);
|
||||
await git(remote.source, ["commit", "-m", "Change embedding model"]);
|
||||
await git(remote.source, ["push", "origin", "main"]);
|
||||
const actualCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]);
|
||||
const actualBlob = await gitOutput(remote.source, ["rev-parse", "HEAD:workspaces/psd-clinical.yaml"]);
|
||||
|
||||
await expect(registry.publish({
|
||||
action: "update",
|
||||
workspace: workspaceWith("psd-clinical", { description: "Local stale change" }),
|
||||
baseCommit: initial.revision.commit,
|
||||
baseBlob: initial.revision.blob,
|
||||
})).rejects.toMatchObject({
|
||||
code: "workspace_conflict",
|
||||
fields: ["semantic_index.embedding.model"],
|
||||
expected: { commit: initial.revision.commit, blob: initial.revision.blob },
|
||||
actual: { commit: actualCommit, blob: actualBlob },
|
||||
});
|
||||
});
|
||||
|
||||
test("restores a clean checkout after a failed commit and retries publication", async () => {
|
||||
const remote = await fixture();
|
||||
const root = join(remote.root, "registry");
|
||||
const registry = new WorkspaceRegistry(config(root, remote.remote));
|
||||
await registry.bootstrap();
|
||||
const objects = join(root, "repo", ".git", "objects");
|
||||
chmodSync(objects, 0o500);
|
||||
const request = {
|
||||
action: "create" as const,
|
||||
workspace: workspaceWith("commit-recovery"),
|
||||
baseCommit: remote.initialCommit,
|
||||
};
|
||||
|
||||
try {
|
||||
await expect(registry.publish(request)).rejects.toMatchObject({ code: "git_unavailable" });
|
||||
} finally {
|
||||
chmodSync(objects, 0o700);
|
||||
}
|
||||
expect(await checkoutStatus(join(root, "repo"))).toEqual({ porcelain: "", divergence: "0\t0" });
|
||||
await expect(registry.pull()).resolves.toMatchObject({ head: remote.initialCommit });
|
||||
await expect(registry.publish(request)).resolves.toMatchObject({ id: "commit-recovery" });
|
||||
});
|
||||
|
||||
test("resets an ahead checkout after a rejected push and retries publication", async () => {
|
||||
const remote = await fixture();
|
||||
const root = join(remote.root, "registry");
|
||||
const registry = new WorkspaceRegistry(config(root, remote.remote));
|
||||
await registry.bootstrap();
|
||||
const hook = join(remote.remote, "hooks", "pre-receive");
|
||||
writeFileSync(hook, "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
||||
const request = {
|
||||
action: "create" as const,
|
||||
workspace: workspaceWith("push-recovery"),
|
||||
baseCommit: remote.initialCommit,
|
||||
};
|
||||
|
||||
await expect(registry.publish(request)).rejects.toMatchObject({ code: "git_push_rejected" });
|
||||
expect(await checkoutStatus(join(root, "repo"))).toEqual({ porcelain: "", divergence: "0\t0" });
|
||||
rmSync(hook);
|
||||
await expect(registry.pull()).resolves.toMatchObject({ head: remote.initialCommit });
|
||||
await expect(registry.publish(request)).resolves.toMatchObject({ id: "push-recovery" });
|
||||
});
|
||||
|
||||
test("lists a v1 descriptor in migration-required state without rendering operational artifacts", async () => {
|
||||
const legacyYaml = validYaml.replace(
|
||||
" database: postgres\n schema: vectors\n",
|
||||
|
||||
Reference in New Issue
Block a user