refactor: make workspace repository strictly read only
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { execFile, spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { lstatSync, mkdirSync } from "node:fs";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { basename, dirname, isAbsolute, join } from "node:path";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { promisify, TextDecoder } from "node:util";
|
||||
import type { WorkspaceErrorCode, WorkspaceRegistryConfig } from "./types.js";
|
||||
|
||||
@@ -387,55 +387,6 @@ export class GitWorkspaceRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/** Write only a validated API-owned artifact below the checked-out repository. */
|
||||
async writeRegistryFile(path: string, source: string): Promise<void> {
|
||||
this.assertRegistryArtifactPath(path);
|
||||
const target = join(this.repoPath, path);
|
||||
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
||||
await writeFile(target, source, { encoding: "utf8", mode: 0o600 });
|
||||
}
|
||||
|
||||
/** Create a descriptor only when no filesystem entry exists at its exact path. */
|
||||
async createRegistryFile(path: string, source: string): Promise<void> {
|
||||
this.assertRegistryArtifactPath(path);
|
||||
if (!/^(?!workspace-docs\/)[a-z][a-z0-9-]{2,62}\/workspace\.yaml$/.test(path)) {
|
||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace descriptor path is invalid");
|
||||
}
|
||||
const target = join(this.repoPath, path);
|
||||
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
||||
try {
|
||||
await writeFile(target, source, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
||||
} catch {
|
||||
throw new WorkspaceRegistryError("workspace_curator_owned", "Workspace descriptor is curator-owned");
|
||||
}
|
||||
}
|
||||
|
||||
async removeRegistryFile(path: string): Promise<void> {
|
||||
this.assertRegistryArtifactPath(path);
|
||||
await rm(join(this.repoPath, path), { force: true });
|
||||
}
|
||||
|
||||
private pendingPublicationPaths: string[] = [];
|
||||
|
||||
/** Commit and push a fixed set of validated artifact paths without exposing Git output. */
|
||||
async commitAndPush(paths: readonly string[], message: string): Promise<GitStatus> {
|
||||
if (paths.length === 0 || paths.some((path) => !this.isRegistryArtifactPath(path))) {
|
||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid");
|
||||
}
|
||||
this.pendingPublicationPaths = [...paths];
|
||||
try {
|
||||
await this.git(["add", "--", ...paths]);
|
||||
await this.git(["commit", "-m", message], this.publicationIdentity());
|
||||
await this.git(["push", "origin", `HEAD:${this.config.branch}`]);
|
||||
return await this.status();
|
||||
} catch (error) {
|
||||
// A failed commit leaves staged/working changes; a failed push leaves an ahead commit.
|
||||
// Restore the last fetched remote revision so the next refresh or explicit retry starts clean.
|
||||
await this.restoreFailedPublication();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async clone(): Promise<void> {
|
||||
try {
|
||||
await execFileAsync("git", [
|
||||
@@ -448,17 +399,6 @@ export class GitWorkspaceRepository {
|
||||
}
|
||||
}
|
||||
|
||||
private isRegistryArtifactPath(path: string): boolean {
|
||||
return /^(?!workspace-docs\/)[a-z][a-z0-9-]{2,62}\/workspace\.yaml$/.test(path)
|
||||
|| /^workspace-docs\/[a-z][a-z0-9-]{2,62}\/(?:contract\.env\.example|README\.md)$/.test(path);
|
||||
}
|
||||
|
||||
private assertRegistryArtifactPath(path: string): void {
|
||||
if (!this.isRegistryArtifactPath(path)) {
|
||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private async refresh(): Promise<void> {
|
||||
if ((await this.git(["status", "--porcelain"])).trim() !== "") {
|
||||
throw new WorkspaceRegistryError("workspace_stale", "Workspace checkout has local changes");
|
||||
@@ -481,37 +421,6 @@ export class GitWorkspaceRepository {
|
||||
}
|
||||
}
|
||||
|
||||
private publicationIdentity(): NodeJS.ProcessEnv {
|
||||
return {
|
||||
GIT_AUTHOR_NAME: this.config.gitAuthorName,
|
||||
GIT_AUTHOR_EMAIL: this.config.gitAuthorEmail,
|
||||
GIT_COMMITTER_NAME: this.config.gitAuthorName,
|
||||
GIT_COMMITTER_EMAIL: this.config.gitAuthorEmail,
|
||||
};
|
||||
}
|
||||
|
||||
private async restoreFailedPublication(): Promise<void> {
|
||||
try {
|
||||
await this.git(["reset", "--hard", `refs/remotes/origin/${this.config.branch}`]);
|
||||
// Remove only the exact untracked files this publication created, never curated content.
|
||||
const untracked = this.pendingPublicationPaths.filter((path) => {
|
||||
try {
|
||||
lstatSync(join(this.repoPath, path));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (untracked.length > 0) {
|
||||
await this.git(["clean", "-fd", "--", ...untracked]);
|
||||
}
|
||||
this.pendingPublicationPaths = [];
|
||||
} catch {
|
||||
// Keep the original sanitized publish failure. A future refresh will surface any recovery
|
||||
// problem without leaking the Git failure details through the API.
|
||||
}
|
||||
}
|
||||
|
||||
private async git(
|
||||
args: string[],
|
||||
env: NodeJS.ProcessEnv = {},
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
parseWorkspaceYaml,
|
||||
serializeWorkspaceYaml,
|
||||
validateOperationalWorkspace,
|
||||
type CanonicalWorkspace,
|
||||
type WorkspaceDescriptor,
|
||||
} from "./schema.js";
|
||||
import type { WorkspaceErrorCode, WorkspaceRegistryConfig } from "./types.js";
|
||||
@@ -40,25 +39,6 @@ export interface SessionRevisionLease {
|
||||
abort(): Promise<void>;
|
||||
}
|
||||
|
||||
export type PublishWorkspaceRequest =
|
||||
| { action: "create"; workspace: CanonicalWorkspace; baseCommit: string }
|
||||
| { action: "update"; workspace: CanonicalWorkspace; baseCommit: string; baseBlob: string }
|
||||
| { action: "delete"; id: string; baseCommit: string; baseBlob: string };
|
||||
|
||||
export class WorkspaceConflictError extends WorkspaceRegistryError {
|
||||
constructor(
|
||||
readonly fields: string[],
|
||||
readonly expected: { commit: string; blob?: string },
|
||||
readonly actual: { commit: string; blob?: string },
|
||||
readonly base?: CanonicalWorkspace,
|
||||
readonly local?: CanonicalWorkspace,
|
||||
readonly remote?: CanonicalWorkspace,
|
||||
) {
|
||||
super("workspace_conflict", "Workspace revision conflicts with the active registry");
|
||||
this.name = "WorkspaceConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
interface ActiveState {
|
||||
head: string;
|
||||
revisions: WorkspaceRevision[];
|
||||
@@ -139,69 +119,17 @@ export class WorkspaceRegistry {
|
||||
return await this.lock.run(async () => {
|
||||
try {
|
||||
const status = await this.repository.pull();
|
||||
const head = await this.reconcileGeneratedDocs(status.head!);
|
||||
await this.activate(head);
|
||||
return head === status.head ? status : { ...status, head };
|
||||
await this.activate(status.head!);
|
||||
return status;
|
||||
} catch (error) {
|
||||
return await this.gitFallback(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile API-owned generated documentation against the active catalog/descriptors at an
|
||||
* exact commit. Startup/status paths never push; only an explicit operator pull may produce a
|
||||
* single deterministic docs-only follow-up commit. Curator catalog/descriptor/Evidence bytes
|
||||
* are never modified.
|
||||
*/
|
||||
private async reconcileGeneratedDocs(commit: string): Promise<string> {
|
||||
const safeHead = safeCommit(commit);
|
||||
const catalog = parseWorkspaceCatalogYaml(await this.repository.readCatalog(safeHead));
|
||||
const catalogById = new Map(catalog.workspaces.map((entry) => [entry.id, entry]));
|
||||
const expected = new Map<string, { envExample: string; markdown: string }>();
|
||||
for (const id of catalogById.keys()) {
|
||||
const path = workspacePath(id);
|
||||
const type = await this.repository.gitObjectType(safeHead, path);
|
||||
if (type !== "blob") continue;
|
||||
const workspace = parseWorkspaceYaml(await this.repository.readWorkspace(path, safeHead));
|
||||
assertCatalogMatchesDescriptor(catalogById.get(id)!, workspace);
|
||||
expected.set(id, renderWorkspaceDocs(workspace));
|
||||
}
|
||||
|
||||
const docPaths = this.documentationPaths;
|
||||
const writes: string[] = [];
|
||||
const removals: string[] = [];
|
||||
for (const [id, docs] of expected) {
|
||||
for (const [kind, contents] of [["contract", docs.envExample], ["readme", docs.markdown]] as const) {
|
||||
const path = docPaths(id)[kind === "contract" ? "contract" : "readme"];
|
||||
const current = await this.repository.readObjectOrAbsent(safeHead, path);
|
||||
if (current !== contents) {
|
||||
await this.repository.writeRegistryFile(path, contents);
|
||||
writes.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
const presentDocs = new Set<string>();
|
||||
for (const path of await this.repository.workspaceDocsPaths(safeHead)) {
|
||||
const id = path.slice("workspace-docs/".length, path.lastIndexOf("/"));
|
||||
if (!expected.has(id)) {
|
||||
await this.repository.removeRegistryFile(path);
|
||||
removals.push(path);
|
||||
} else {
|
||||
presentDocs.add(path);
|
||||
}
|
||||
}
|
||||
if (writes.length === 0 && removals.length === 0) return safeHead;
|
||||
const next = await this.repository.commitAndPush(
|
||||
[...writes, ...removals],
|
||||
"Synchronize generated workspace documentation",
|
||||
);
|
||||
return next.head!;
|
||||
}
|
||||
|
||||
async listCatalog(): Promise<Array<WorkspaceCatalogEntry & {
|
||||
configurationState: "ready" | "configuration_required";
|
||||
revision?: WorkspaceRevision;
|
||||
configurationState: "ready";
|
||||
revision: WorkspaceRevision;
|
||||
}>> {
|
||||
const active = await this.tryActiveState();
|
||||
if (!active) {
|
||||
@@ -211,11 +139,8 @@ export class WorkspaceRegistry {
|
||||
const catalog = active.catalog ?? { schema_version: 1 as const, workspaces: [] };
|
||||
return catalog.workspaces.map((entry) => ({
|
||||
...entry,
|
||||
configurationState: active.revisions.some((revision) => revision.id === entry.id)
|
||||
? "ready" as const : "configuration_required" as const,
|
||||
...(active.revisions.find((revision) => revision.id === entry.id)
|
||||
? { revision: active.revisions.find((revision) => revision.id === entry.id) }
|
||||
: {}),
|
||||
configurationState: "ready" as const,
|
||||
revision: active.revisions.find((revision) => revision.id === entry.id)!,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -427,120 +352,6 @@ export class WorkspaceRegistry {
|
||||
return leases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish canonical YAML and derived public documentation as one optimistic Git revision.
|
||||
* The browser never provides paths or generated artifacts; those are derived server-side.
|
||||
*/
|
||||
async publish(request: PublishWorkspaceRequest): Promise<WorkspaceRevision | undefined> {
|
||||
await this.repository.ensureLayout();
|
||||
return await this.lock.run(async () => {
|
||||
if (request.action !== "create") {
|
||||
throw new WorkspaceRegistryError(
|
||||
"workspace_curator_owned",
|
||||
"Workspace descriptors are curator-owned and must be changed through Git",
|
||||
);
|
||||
}
|
||||
const status = await this.repository.pull();
|
||||
await this.activate(status.head!);
|
||||
const current = await this.activeState();
|
||||
const id = request.workspace.workspace.id;
|
||||
const existing = current.revisions.find((revision) => revision.id === id);
|
||||
if (existing) {
|
||||
throw new WorkspaceRegistryError(
|
||||
"workspace_curator_owned",
|
||||
"Workspace descriptor is curator-owned and must be changed through Git",
|
||||
);
|
||||
}
|
||||
if (request.baseCommit !== status.head) {
|
||||
throw new WorkspaceRegistryError("workspace_stale", "Workspace revision is stale");
|
||||
}
|
||||
const catalog = current.catalog ?? { schema_version: 1 as const, workspaces: [] };
|
||||
const entry = catalog.workspaces.find((candidate) => candidate.id === id);
|
||||
if (!entry) {
|
||||
throw new WorkspaceRegistryError(
|
||||
"workspace_invalid",
|
||||
"Workspace is not listed in the root catalog",
|
||||
);
|
||||
}
|
||||
assertCatalogMatchesDescriptor(entry, request.workspace);
|
||||
await this.assertEvidenceContext(request.workspace, status.head!);
|
||||
|
||||
const yamlPath = workspacePath(id);
|
||||
const docPaths = this.documentationPaths(id);
|
||||
const canonical = request.workspace;
|
||||
const source = serializeWorkspaceYaml(canonical);
|
||||
const docs = renderWorkspaceDocs(canonical);
|
||||
await this.repository.createRegistryFile(yamlPath, source);
|
||||
await this.repository.writeRegistryFile(docPaths.contract, docs.envExample);
|
||||
await this.repository.writeRegistryFile(docPaths.readme, docs.markdown);
|
||||
|
||||
const next = await this.repository.commitAndPush(
|
||||
[yamlPath, docPaths.contract, docPaths.readme],
|
||||
`Publish workspace ${id}`,
|
||||
);
|
||||
await this.activate(next.head!);
|
||||
return (await this.activeState()).revisions.find((revision) => revision.id === id);
|
||||
});
|
||||
}
|
||||
|
||||
private documentationPaths(id: string): { contract: string; readme: string } {
|
||||
workspacePath(id);
|
||||
const directory = `workspace-docs/${id}`;
|
||||
return { contract: `${directory}/contract.env.example`, readme: `${directory}/README.md` };
|
||||
}
|
||||
|
||||
private async conflictFor(
|
||||
request: PublishWorkspaceRequest,
|
||||
currentCommit: string,
|
||||
existing: WorkspaceRevision | undefined,
|
||||
local: CanonicalWorkspace | undefined,
|
||||
): Promise<WorkspaceConflictError> {
|
||||
const id = request.action === "delete" ? request.id : request.workspace.workspace.id;
|
||||
const base = await this.readSnapshotCanonical(request.baseCommit, id);
|
||||
let remote: CanonicalWorkspace | undefined;
|
||||
if (existing) {
|
||||
remote = (await this.read(id)).workspace;
|
||||
}
|
||||
return new WorkspaceConflictError(
|
||||
this.changedFields(base, remote),
|
||||
{ commit: request.baseCommit, ...(request.action === "create" ? {} : { blob: request.baseBlob }) },
|
||||
{ commit: currentCommit, ...(existing ? { blob: existing.blob } : {}) },
|
||||
base,
|
||||
local,
|
||||
remote,
|
||||
);
|
||||
}
|
||||
|
||||
private async readSnapshotCanonical(commit: string, id: string): Promise<CanonicalWorkspace | undefined> {
|
||||
try {
|
||||
const source = await readFile(this.snapshotPath(commit, id), "utf8");
|
||||
return parseWorkspaceYaml(source);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private changedFields(
|
||||
base: unknown,
|
||||
remote: unknown,
|
||||
prefix = "",
|
||||
): string[] {
|
||||
if (base === undefined || remote === undefined) {
|
||||
return base === remote ? [] : [prefix || "workspace.id"];
|
||||
}
|
||||
if (Array.isArray(base) || Array.isArray(remote) || typeof base !== "object" || typeof remote !== "object") {
|
||||
return JSON.stringify(base) === JSON.stringify(remote) ? [] : [prefix];
|
||||
}
|
||||
const baseObject = base as Record<string, unknown>;
|
||||
const remoteObject = remote as Record<string, unknown>;
|
||||
const keys = new Set([...Object.keys(baseObject), ...Object.keys(remoteObject)]);
|
||||
return [...keys].flatMap((key) => this.changedFields(
|
||||
baseObject[key],
|
||||
remoteObject[key],
|
||||
prefix ? `${prefix}.${key}` : key,
|
||||
));
|
||||
}
|
||||
|
||||
private async assertEvidenceContext(workspace: WorkspaceDescriptor, revision: string): Promise<void> {
|
||||
if (workspace.evidence?.source.type !== "filesystem") return;
|
||||
// P6 owns recursive containment. Here we deliberately validate only the declared root object.
|
||||
@@ -564,6 +375,15 @@ export class WorkspaceRegistry {
|
||||
}
|
||||
}
|
||||
const files = await this.repository.workspacePaths();
|
||||
const descriptorIds = new Set(files.map((path) => path.slice(0, -"/workspace.yaml".length)));
|
||||
for (const id of catalogById.keys()) {
|
||||
if (!descriptorIds.has(id)) {
|
||||
throw new WorkspaceRegistryError(
|
||||
"workspace_invalid",
|
||||
"Every catalog workspace must have a published descriptor",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const snapshots: Array<{
|
||||
id: string;
|
||||
|
||||
@@ -202,14 +202,11 @@ async function fixture(workspaceSource = validYaml): Promise<{
|
||||
mkdirSync(join(source, "psd-clinical"), { recursive: true });
|
||||
writeFileSync(join(source, "thoth-workspaces.yaml"), catalogYaml([
|
||||
{ id: "psd-clinical", name: workspace?.workspace.name ?? "Policlinico San Donato", ...(workspace?.workspace.description ? { description: workspace.workspace.description } : {}) },
|
||||
{ id: "research", name: "research" },
|
||||
]));
|
||||
writeFileSync(join(source, "psd-clinical", "workspace.yaml"), workspaceSource);
|
||||
if (workspaceSource.includes("type: filesystem")) {
|
||||
mkdirSync(join(source, "psd-clinical", "evidence"), { recursive: true });
|
||||
mkdirSync(join(source, "research", "evidence"), { recursive: true });
|
||||
writeFileSync(join(source, "psd-clinical", "evidence", "guide.md"), "guide v1\n");
|
||||
writeFileSync(join(source, "research", "evidence", "guide.md"), "research guide\n");
|
||||
await git(source, ["add", "-A"]);
|
||||
} else {
|
||||
await git(source, ["add", "thoth-workspaces.yaml", "psd-clinical/workspace.yaml"]);
|
||||
@@ -293,38 +290,6 @@ function config(
|
||||
};
|
||||
}
|
||||
|
||||
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 },
|
||||
semantic_index: {
|
||||
...workspace.semantic_index,
|
||||
vector_store: { ...workspace.semantic_index.vector_store, collection: id },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function filesystemWorkspace(id: string): CanonicalWorkspace {
|
||||
return parseWorkspaceYaml(withFilesystemEvidence(
|
||||
validYaml
|
||||
.replace("id: psd-clinical", `id: ${id}`)
|
||||
.replace("name: Policlinico San Donato", `name: ${id}`)
|
||||
.replace("collection: psd-clinical", `collection: ${id}`),
|
||||
id,
|
||||
)) as CanonicalWorkspace;
|
||||
}
|
||||
|
||||
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}"]),
|
||||
};
|
||||
}
|
||||
|
||||
async function pushInvalidWorkspace(source: string): Promise<void> {
|
||||
writeFileSync(join(source, "psd-clinical", "workspace.yaml"), "workspace: invalid\n");
|
||||
await git(source, ["add", "psd-clinical/workspace.yaml"]);
|
||||
@@ -373,38 +338,12 @@ function persistedState(root: string, commit: string): { active: any; manifest:
|
||||
};
|
||||
}
|
||||
|
||||
test("allows bootstrap creation from a catalog-only base and refuses later curator-owned writes", async () => {
|
||||
test("rejects a catalog entry without a descriptor instead of creating a bootstrap slot", async () => {
|
||||
const remote = await contentOnlyFixture();
|
||||
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
|
||||
|
||||
await expect(registry.bootstrap()).resolves.toMatchObject({ head: remote.initialCommit });
|
||||
await expect(registry.list()).resolves.toEqual([]);
|
||||
await expect(registry.listCatalog()).resolves.toEqual([
|
||||
expect.objectContaining({ id: "p1-filesystem", configurationState: "configuration_required" }),
|
||||
]);
|
||||
|
||||
const created = await registry.publish({
|
||||
action: "create",
|
||||
workspace: filesystemWorkspace("p1-filesystem"),
|
||||
baseCommit: remote.initialCommit,
|
||||
});
|
||||
expect(created).toMatchObject({ id: "p1-filesystem" });
|
||||
await expect(registry.list()).resolves.toEqual([
|
||||
expect.objectContaining({ id: "p1-filesystem", commit: created!.commit }),
|
||||
]);
|
||||
await expect(registry.listCatalog()).resolves.toEqual([
|
||||
expect.objectContaining({ id: "p1-filesystem", configurationState: "ready", revision: created }),
|
||||
]);
|
||||
|
||||
await expect(registry.publish({
|
||||
action: "delete",
|
||||
id: "p1-filesystem",
|
||||
baseCommit: created!.commit,
|
||||
baseBlob: created!.blob,
|
||||
})).rejects.toMatchObject({ code: "workspace_curator_owned" });
|
||||
await expect(registry.list()).resolves.toEqual([
|
||||
expect.objectContaining({ id: "p1-filesystem" }),
|
||||
]);
|
||||
await expect(registry.bootstrap()).rejects.toMatchObject({ code: "workspace_invalid" });
|
||||
expect(existsSync(join(remote.root, "registry", "state", "active.json"))).toBe(false);
|
||||
});
|
||||
test("bootstraps a checkout and activates a validated immutable snapshot", async () => {
|
||||
const remote = await fixture();
|
||||
@@ -436,45 +375,6 @@ test("concurrent first lists lazily bootstrap a clean registry once safely", asy
|
||||
expect(existsSync(join(root, "state", "active.json"))).toBe(true);
|
||||
});
|
||||
|
||||
test("publishes a filesystem descriptor only when its Evidence tree exists in the pulled base", async () => {
|
||||
const remote = await fixture(withFilesystemEvidence(validYaml));
|
||||
const root = join(remote.root, "registry");
|
||||
const registry = new WorkspaceRegistry(config(root, remote.remote));
|
||||
await registry.bootstrap();
|
||||
const evidencePath = "research/evidence";
|
||||
const initialTree = await gitOutput(remote.root, [
|
||||
"--git-dir", remote.remote, "rev-parse", `${remote.initialCommit}:${evidencePath}`,
|
||||
]);
|
||||
|
||||
const created = await registry.publish({
|
||||
action: "create",
|
||||
workspace: filesystemWorkspace("research"),
|
||||
baseCommit: remote.initialCommit,
|
||||
});
|
||||
|
||||
expect(created?.commit).not.toBe(remote.initialCommit);
|
||||
await expect(runFile("git", [
|
||||
"--git-dir", remote.remote, "cat-file", "-e", `${created!.commit}:research/workspace.yaml`,
|
||||
], { cwd: remote.root })).resolves.toBeDefined();
|
||||
await expect(runFile("git", [
|
||||
"--git-dir", remote.remote, "cat-file", "-e", `${created!.commit}:${evidencePath}/guide.md`,
|
||||
], { cwd: remote.root })).resolves.toBeDefined();
|
||||
expect(await gitOutput(remote.root, [
|
||||
"--git-dir", remote.remote, "rev-parse", `${created!.commit}:${evidencePath}`,
|
||||
])).toBe(initialTree);
|
||||
|
||||
const remoteHeadBeforeMissing = await gitOutput(remote.root, ["--git-dir", remote.remote, "rev-parse", "HEAD"]);
|
||||
await expect(registry.publish({
|
||||
action: "create",
|
||||
workspace: filesystemWorkspace("missing-tree"),
|
||||
baseCommit: created!.commit,
|
||||
})).rejects.toMatchObject({ code: "workspace_invalid" });
|
||||
expect(await gitOutput(remote.root, ["--git-dir", remote.remote, "rev-parse", "HEAD"])).toBe(
|
||||
remoteHeadBeforeMissing,
|
||||
);
|
||||
expect(await checkoutStatus(join(root, "repo"))).toEqual({ porcelain: "", divergence: "0\t0" });
|
||||
});
|
||||
|
||||
test.each(["missing", "blob"])(
|
||||
"rejects a remote filesystem descriptor with a %s Evidence root and keeps the active snapshot",
|
||||
async (invalidKind) => {
|
||||
@@ -549,27 +449,6 @@ test("creates an immutable descriptor revision for a content-only Evidence commi
|
||||
], { cwd: remote.root })).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
test("rejects a stale API update after a content-only Evidence commit", async () => {
|
||||
const remote = await fixture(withFilesystemEvidence(validYaml));
|
||||
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
|
||||
await registry.bootstrap();
|
||||
const initial = await registry.read("psd-clinical");
|
||||
const guide = join(remote.source, "psd-clinical", "evidence", "guide.md");
|
||||
writeFileSync(guide, "curator content\n");
|
||||
await git(remote.source, ["add", "psd-clinical/evidence/guide.md"]);
|
||||
await git(remote.source, ["commit", "-m", "Curator Evidence update"]);
|
||||
await git(remote.source, ["push", "origin", "main"]);
|
||||
const curatorCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]);
|
||||
|
||||
await expect(registry.publish({
|
||||
action: "update",
|
||||
workspace: filesystemWorkspace("psd-clinical"),
|
||||
baseCommit: initial.revision.commit,
|
||||
baseBlob: initial.revision.blob,
|
||||
})).rejects.toMatchObject({ code: "workspace_curator_owned" });
|
||||
expect(await gitOutput(remote.root, ["--git-dir", remote.remote, "rev-parse", "HEAD"])).toBe(curatorCommit);
|
||||
});
|
||||
|
||||
test("keeps content-only historical descriptor revisions distinguishable by commit", async () => {
|
||||
const remote = await fixture(withFilesystemEvidence(validYaml));
|
||||
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
|
||||
@@ -593,72 +472,6 @@ test("keeps content-only historical descriptor revisions distinguishable by comm
|
||||
expect(oldPinned.workspace).toEqual(newPinned.workspace);
|
||||
});
|
||||
|
||||
test("publishes one bootstrap descriptor with the configured Git author identity and refuses curator-owned mutation", 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");
|
||||
|
||||
const created = await registry.publish({
|
||||
action: "create",
|
||||
workspace: createdWorkspace,
|
||||
baseCommit: remote.initialCommit,
|
||||
});
|
||||
|
||||
expect(created).toMatchObject({ id: "research", 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/README.md"], {
|
||||
cwd: remote.root,
|
||||
})).resolves.toBeDefined();
|
||||
|
||||
const before = await gitOutput(remote.root, ["--git-dir", remote.remote, "rev-parse", "HEAD"]);
|
||||
await expect(registry.publish({
|
||||
action: "update",
|
||||
workspace: workspaceWith("research", { name: "Research", description: "Updated workspace description" }),
|
||||
baseCommit: created!.commit,
|
||||
baseBlob: created!.blob,
|
||||
})).rejects.toMatchObject({ code: "workspace_curator_owned" });
|
||||
await expect(registry.publish({
|
||||
action: "delete",
|
||||
id: "research",
|
||||
baseCommit: created!.commit,
|
||||
baseBlob: created!.blob,
|
||||
})).rejects.toMatchObject({ code: "workspace_curator_owned" });
|
||||
expect(await gitOutput(remote.root, ["--git-dir", remote.remote, "rev-parse", "HEAD"])).toBe(before);
|
||||
await expect(runFile("git", ["--git-dir", remote.remote, "cat-file", "-e", "HEAD:research/workspace.yaml"], {
|
||||
cwd: remote.root,
|
||||
})).resolves.toBeDefined();
|
||||
});
|
||||
test("rejects curator-owned update after a curator push and leaves the active snapshot intact", 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, "psd-clinical", "workspace.yaml"), validYaml.replace(
|
||||
"schema: datawarehouse", "schema: analytics",
|
||||
));
|
||||
await git(remote.source, ["add", "psd-clinical/workspace.yaml"]);
|
||||
await git(remote.source, ["commit", "-m", "Change dwh schema"]);
|
||||
await git(remote.source, ["push", "origin", "main"]);
|
||||
const actualCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]);
|
||||
|
||||
await registry.pull();
|
||||
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_curator_owned" });
|
||||
await expect(registry.read("psd-clinical")).resolves.toMatchObject({
|
||||
revision: { commit: actualCommit },
|
||||
});
|
||||
});
|
||||
test.each([
|
||||
["adds", validYaml, withDwhRestDiagnostic(validYaml)],
|
||||
["removes", withDwhRestDiagnostic(validYaml), validYaml],
|
||||
@@ -675,56 +488,7 @@ test.each([
|
||||
await registry.pull();
|
||||
const updated = await registry.read("psd-clinical");
|
||||
expect(updated.revision.commit).not.toBe(initial.revision.commit);
|
||||
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_curator_owned" });
|
||||
});
|
||||
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("research"),
|
||||
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: "research" });
|
||||
});
|
||||
|
||||
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("research"),
|
||||
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: "research" });
|
||||
});
|
||||
|
||||
test.each([
|
||||
["v1", legacyV1Yaml()],
|
||||
["v2", legacyV2Yaml()],
|
||||
@@ -752,16 +516,6 @@ test("writes only state-free revisions and never exposes revision state", async
|
||||
const read = await registry.read("psd-clinical");
|
||||
expect(listed[0]).not.toHaveProperty("state");
|
||||
expect(read.revision).not.toHaveProperty("state");
|
||||
|
||||
const published = await registry.publish({
|
||||
action: "create",
|
||||
workspace: workspaceWith("research"),
|
||||
baseCommit: remote.initialCommit,
|
||||
});
|
||||
const updated = persistedState(root, published!.commit);
|
||||
expect(updated.active.revisions[0]).not.toHaveProperty("state");
|
||||
expect(updated.manifest.revisions[0]).not.toHaveProperty("state");
|
||||
expect(published).not.toHaveProperty("state");
|
||||
});
|
||||
|
||||
test("accepts historical operational state without leaking it or rewriting the immutable snapshot", async () => {
|
||||
@@ -851,7 +605,7 @@ test("normalizes operational state in retained historical snapshots without rewr
|
||||
"name: Policlinico San Donato", "name: Current workspace",
|
||||
));
|
||||
writeFileSync(join(remote.source, "thoth-workspaces.yaml"), catalogYaml([
|
||||
{ id: "psd-clinical", name: "Current workspace" }, { id: "research", name: "Research" },
|
||||
{ id: "psd-clinical", name: "Current workspace" },
|
||||
]));
|
||||
await git(remote.source, ["add", "-A"]);
|
||||
await git(remote.source, ["commit", "-m", "Update active workspace"]);
|
||||
@@ -966,7 +720,7 @@ test("retains a historical snapshot while a resumable manifest still references
|
||||
"name: Policlinico San Donato", "name: Updated Policlinico San Donato",
|
||||
));
|
||||
writeFileSync(join(remote.source, "thoth-workspaces.yaml"), catalogYaml([
|
||||
{ id: "psd-clinical", name: "Updated Policlinico San Donato" }, { id: "research", name: "Research" },
|
||||
{ id: "psd-clinical", name: "Updated Policlinico San Donato" },
|
||||
]));
|
||||
await git(remote.source, ["add", "-A"]);
|
||||
await git(remote.source, ["commit", "-m", "Update workspace"]);
|
||||
@@ -994,7 +748,7 @@ test("a session revision lease survives stale retention scans until its manifest
|
||||
"name: Policlinico San Donato", "name: Concurrent revision",
|
||||
));
|
||||
writeFileSync(join(remote.source, "thoth-workspaces.yaml"), catalogYaml([
|
||||
{ id: "psd-clinical", name: "Concurrent revision" }, { id: "research", name: "Research" },
|
||||
{ id: "psd-clinical", name: "Concurrent revision" },
|
||||
]));
|
||||
await git(remote.source, ["add", "-A"]);
|
||||
await git(remote.source, ["commit", "-m", "Publish while session is starting"]);
|
||||
@@ -1024,7 +778,7 @@ test("lists operational descriptors retained after their workspace was removed f
|
||||
"id: psd-clinical", "id: archive-only",
|
||||
).replace("collection: psd-clinical", "collection: archive-only"));
|
||||
writeFileSync(join(remote.source, "thoth-workspaces.yaml"), catalogYaml([
|
||||
{ id: "psd-clinical", name: "Policlinico San Donato" }, { id: "research", name: "Research" },
|
||||
{ id: "psd-clinical", name: "Policlinico San Donato" },
|
||||
{ id: "archive-only", name: "Policlinico San Donato" },
|
||||
]));
|
||||
await git(remote.source, ["add", "-A"]);
|
||||
@@ -1033,6 +787,9 @@ test("lists operational descriptors retained after their workspace was removed f
|
||||
await registry.pull();
|
||||
|
||||
rmSync(join(remote.source, "psd-clinical", "workspace.yaml"));
|
||||
writeFileSync(join(remote.source, "thoth-workspaces.yaml"), catalogYaml([
|
||||
{ id: "archive-only", name: "Policlinico San Donato" },
|
||||
]));
|
||||
await git(remote.source, ["add", "-u"]);
|
||||
await git(remote.source, ["commit", "-m", "Remove original workspace"]);
|
||||
await git(remote.source, ["push", "origin", "main"]);
|
||||
@@ -1249,18 +1006,6 @@ test("never copies an installation secret canary into Git, generated artifacts,
|
||||
for (const name of readdirSync(snapshotDirectory)) {
|
||||
expect(readFileSync(join(snapshotDirectory, name), "utf8")).not.toContain(canary);
|
||||
}
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await registry.publish({
|
||||
action: "create",
|
||||
workspace: filesystemWorkspace("missing-secret-canary-tree"),
|
||||
baseCommit: status.head!,
|
||||
});
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
expect(thrown).toMatchObject({ code: "workspace_invalid" });
|
||||
expect(String(thrown)).not.toContain(canary);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE;
|
||||
else process.env.THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE = previous;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
@@ -92,6 +92,16 @@ function config(root: string, remoteUrl: string): WorkspaceRegistryConfig {
|
||||
};
|
||||
}
|
||||
|
||||
test("does not expose repository mutation or publication operations", async () => {
|
||||
const fixture = await temporaryRemote();
|
||||
const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote));
|
||||
|
||||
expect(repository).not.toHaveProperty("createRegistryFile");
|
||||
expect(repository).not.toHaveProperty("writeRegistryFile");
|
||||
expect(repository).not.toHaveProperty("removeRegistryFile");
|
||||
expect(repository).not.toHaveProperty("commitAndPush");
|
||||
});
|
||||
|
||||
test("bootstraps a persistent checkout from a local bare repository", async () => {
|
||||
const fixture = await temporaryRemote();
|
||||
const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote));
|
||||
@@ -300,19 +310,3 @@ test("parallel contenders recover a stale lock file without overlapping critical
|
||||
expect(results.filter((result) => result.status === "rejected")).toHaveLength(1);
|
||||
expect(maximum).toBe(1);
|
||||
});
|
||||
|
||||
|
||||
test("creates a descriptor only when the exact curator path is absent", async () => {
|
||||
const fixture = await temporaryRemote();
|
||||
const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote));
|
||||
await repository.bootstrap();
|
||||
const descriptorPath = "new-workspace/workspace.yaml";
|
||||
const descriptor = "curator descriptor\n";
|
||||
await expect(repository.createRegistryFile(descriptorPath, descriptor)).resolves.toBeUndefined();
|
||||
expect(readFileSync(join(fixture.root, "registry", "repo", descriptorPath), "utf8")).toBe(descriptor);
|
||||
await expect(repository.createRegistryFile(descriptorPath, "overwrite\n"))
|
||||
.rejects.toMatchObject({ code: "workspace_curator_owned" });
|
||||
expect(readFileSync(join(fixture.root, "registry", "repo", descriptorPath), "utf8")).toBe(descriptor);
|
||||
await expect(repository.createRegistryFile("workspace-docs/workspace.yaml", descriptor))
|
||||
.rejects.toMatchObject({ code: "workspace_invalid" });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user