Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bootstrap-storage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sapiom/harness": patch
---

Internal storage groundwork for automatic Agent Map bootstrap. Clean up temporary state after failed writes and ignore unrelated files when reading durable project intents. No user-facing behavior changes in this release.
181 changes: 181 additions & 0 deletions packages/harness/src/core/project-bootstrap-outbox.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { randomUUID } from "node:crypto";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";

import { afterEach, describe, expect, it } from "vitest";

import {
ProjectBootstrapOutbox,
ProjectBootstrapOutboxError,
} from "./project-bootstrap-outbox.js";
import { StudioProjectCatalog } from "./studio-project-catalog.js";

describe("ProjectBootstrapOutbox", () => {
const roots: string[] = [];

afterEach(async () => {
await Promise.all(
roots
.splice(0)
.map((root) => fs.rm(root, { recursive: true, force: true })),
);
});

async function fixture() {
const root = await fs.mkdtemp(
path.join(os.tmpdir(), "project-bootstrap-outbox-"),
);
roots.push(root);
const outbox = new ProjectBootstrapOutbox(path.join(root, "outbox"));
const lifecycle = {
beforeProjectsCreatedCommit: (
projects: Parameters<ProjectBootstrapOutbox["stage"]>[0],
) => outbox.stage(projects),
};
return {
root,
outbox,
lifecycle,
catalogPath: path.join(root, "studio-projects.json"),
};
}

it("stages an explicit project before its catalog commit survives a restart", async () => {
const { catalogPath, lifecycle, outbox } = await fixture();
const catalog = new StudioProjectCatalog(
catalogPath,
undefined,
undefined,
lifecycle,
);

const project = await catalog.create("Explicit project");
const restarted = new ProjectBootstrapOutbox(
path.join(path.dirname(catalogPath), "outbox"),
);

expect(await restarted.pending()).toEqual([
{
projectId: project.projectId,
projectCreatedAt: project.createdAt,
},
]);
await outbox.complete(project.projectId);
expect(await restarted.pending()).toEqual([]);
});

it("ignores a strict stale writer temporary without blocking a valid marker", async () => {
const { catalogPath, lifecycle } = await fixture();
const catalog = new StudioProjectCatalog(
catalogPath,
undefined,
undefined,
lifecycle,
);
const project = await catalog.create("Interrupted writer");
const outboxRoot = path.join(path.dirname(catalogPath), "outbox");
const staleTemporary = path.join(
outboxRoot,
`${project.projectId}.json.tmp-123-${randomUUID()}`,
);
await fs.writeFile(staleTemporary, "partial marker", { mode: 0o600 });

const restarted = new ProjectBootstrapOutbox(outboxRoot);
await expect(restarted.pending()).resolves.toEqual([
{
projectId: project.projectId,
projectCreatedAt: project.createdAt,
},
]);
await expect(fs.stat(staleTemporary)).resolves.toBeDefined();
});

it("ignores unrelated directory entries without blocking a valid marker or deleting them", async () => {
const { root, outbox, catalogPath, lifecycle } = await fixture();
const catalog = new StudioProjectCatalog(catalogPath, undefined, undefined, lifecycle);
const project = await catalog.create("Pending bootstrap");
const outboxRoot = path.join(root, "outbox");
const unrelated = [".DS_Store", "notes.txt", "project-marker.tmp-unknown"];
for (const name of unrelated) {
await fs.writeFile(path.join(outboxRoot, name), "unrelated", { mode: 0o600 });
}
await fs.mkdir(path.join(outboxRoot, "backups"));

await expect(outbox.pending()).resolves.toEqual([{
projectId: project.projectId,
projectCreatedAt: project.createdAt,
}]);
for (const name of [...unrelated, "backups"]) {
await expect(fs.stat(path.join(outboxRoot, name))).resolves.toBeDefined();
}
});

it.each(["project_invalid.json", `project_${randomUUID()}.json`])(
"fails closed on malformed reserved project marker %s",
async (name) => {
const { root, outbox } = await fixture();
const outboxRoot = path.join(root, "outbox");
await fs.mkdir(outboxRoot, { recursive: true });
const file = path.join(outboxRoot, name);
await fs.writeFile(file, "malformed reserved state", { mode: 0o600 });

await expect(outbox.pending()).rejects.toBeInstanceOf(ProjectBootstrapOutboxError);
await expect(fs.stat(file)).resolves.toBeDefined();
},
);

it("stages only reconcile-created projects and never enrolls a legacy catalog project", async () => {
const { root, catalogPath, lifecycle, outbox } = await fixture();
const legacyRoot = path.join(root, "legacy-project");
const newRoot = path.join(root, "new-project");
await Promise.all([fs.mkdir(legacyRoot), fs.mkdir(newRoot)]);
const legacy = await new StudioProjectCatalog(catalogPath).reconcile([
{ workspaceKey: "legacy-root", cwd: legacyRoot },
]);
const legacyProjectId = legacy.projects[0]!.projectId;

const catalog = new StudioProjectCatalog(
catalogPath,
undefined,
undefined,
lifecycle,
);
const reconciled = await catalog.reconcile([
{ workspaceKey: "legacy-root", cwd: legacyRoot },
{ workspaceKey: "new-root", cwd: newRoot },
]);
const newProjectId = reconciled.workspaceScopes.find(
(scope) => scope.cwd === newRoot,
)!.projectId!;

expect(newProjectId).not.toBe(legacyProjectId);
expect(await outbox.pending()).toEqual([
expect.objectContaining({ projectId: newProjectId }),
]);
expect(
(await outbox.pending()).some(
(entry) => entry.projectId === legacyProjectId,
),
).toBe(false);
});

it("aborts catalog creation when the write-ahead marker cannot commit", async () => {
const { catalogPath } = await fixture();
const catalog = new StudioProjectCatalog(
catalogPath,
undefined,
undefined,
{
beforeProjectsCreatedCommit: async () => {
throw new Error("simulated outbox outage");
},
},
);

await expect(catalog.create("Must remain absent")).rejects.toThrow(
"simulated outbox outage",
);
expect(await new StudioProjectCatalog(catalogPath).list()).toEqual([]);
});
});
182 changes: 182 additions & 0 deletions packages/harness/src/core/project-bootstrap-outbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { randomUUID } from "node:crypto";
import * as fs from "node:fs/promises";
import * as path from "node:path";

import type {
StudioProjectId,
StudioProjectSummary,
} from "../shared/agent-map.js";
import { isStudioProjectId } from "./studio-project-catalog.js";

interface PersistedProjectBootstrapOutboxEntry {
schemaVersion: 1;
projectId: StudioProjectId;
projectCreatedAt: string;
}

const OUTBOX_TEMP_FILE_RE =
/^(project_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\.json\.tmp-[1-9][0-9]*-([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/;

export interface ProjectBootstrapOutboxEntry {
projectId: StudioProjectId;
projectCreatedAt: string;
}

export class ProjectBootstrapOutboxError extends Error {
readonly code = "project_bootstrap_outbox_unavailable";

constructor() {
super("project bootstrap outbox is unavailable");
this.name = "ProjectBootstrapOutboxError";
}
}

function isTimestamp(value: unknown): value is string {
if (typeof value !== "string") return false;
try {
return new Date(value).toISOString() === value;
} catch {
return false;
}
}

function parseEntry(
value: unknown,
expectedProjectId: StudioProjectId,
): ProjectBootstrapOutboxEntry | null {
if (
typeof value !== "object" ||
value === null ||
Array.isArray(value) ||
Object.keys(value).sort().join(",") !==
"projectCreatedAt,projectId,schemaVersion" ||
!("schemaVersion" in value) ||
value.schemaVersion !== 1 ||
!("projectId" in value) ||
value.projectId !== expectedProjectId ||
!("projectCreatedAt" in value) ||
!isTimestamp(value.projectCreatedAt)
) {
return null;
}
return {
projectId: expectedProjectId,
projectCreatedAt: value.projectCreatedAt,
};
}

/**
* Write-ahead marker for the catalog -> bootstrap-intent boundary.
*
* A marker is committed before a new Studio project enters the catalog. The
* marker is removed only after ProjectBootstrapCoordinator has durably
* scheduled that project. Therefore either side of a process crash is safe:
* an orphan marker has no catalog project and can be discarded, while a
* committed project with a marker is recovered without guessing that older
* catalog projects should be enrolled.
*/
export class ProjectBootstrapOutbox {
private readonly root: string;

constructor(root: string) {
this.root = path.resolve(root);
}

private file(projectId: StudioProjectId): string {
if (!isStudioProjectId(projectId)) throw new ProjectBootstrapOutboxError();
const file = path.resolve(this.root, `${projectId}.json`);
if (!file.startsWith(`${this.root}${path.sep}`)) {
throw new ProjectBootstrapOutboxError();
}
return file;
}

async stage(
projects: readonly Pick<StudioProjectSummary, "projectId" | "createdAt">[],
): Promise<void> {
try {
await fs.mkdir(this.root, { recursive: true, mode: 0o700 });
for (const project of projects) {
const file = this.file(project.projectId);
try {
const existing = parseEntry(
JSON.parse(await fs.readFile(file, "utf8")) as unknown,
project.projectId,
);
if (!existing || existing.projectCreatedAt !== project.createdAt) {
throw new ProjectBootstrapOutboxError();
}
continue;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
const entry: PersistedProjectBootstrapOutboxEntry = {
schemaVersion: 1,
projectId: project.projectId,
projectCreatedAt: project.createdAt,
};
const temporary = `${file}.tmp-${process.pid}-${randomUUID()}`;
try {
await fs.writeFile(temporary, `${JSON.stringify(entry, null, 2)}\n`, {
encoding: "utf8",
mode: 0o600,
});
await fs.rename(temporary, file);
} finally {
await fs.rm(temporary, { force: true }).catch(() => {});
}
}
} catch (error) {
if (error instanceof ProjectBootstrapOutboxError) throw error;
throw new ProjectBootstrapOutboxError();
}
}

async pending(): Promise<ProjectBootstrapOutboxEntry[]> {
try {
const names = await fs.readdir(this.root);
const entries: ProjectBootstrapOutboxEntry[] = [];
for (const name of names.sort()) {
const temporary = OUTBOX_TEMP_FILE_RE.exec(name);
if (temporary && isStudioProjectId(temporary[1])) {
// A process may die after writing a private temporary marker but
// before its atomic rename. The corresponding catalog transaction
// cannot have committed yet. Ignore this exact writer-owned shape;
// deleting it could race another process that still owns the active
// catalog transaction.
continue;
}
// Desktop metadata and unrelated files do not describe project work.
// Only the reserved committed-marker namespace can block recovery.
if (!name.startsWith("project_") || !name.endsWith(".json")) continue;
const match = /^(project_[0-9a-f-]+)\.json$/.exec(name);
if (!match || !isStudioProjectId(match[1])) {
throw new ProjectBootstrapOutboxError();
}
const projectId = match[1];
const entry = parseEntry(
JSON.parse(
await fs.readFile(this.file(projectId), "utf8"),
) as unknown,
projectId,
);
if (!entry) throw new ProjectBootstrapOutboxError();
entries.push(entry);
}
return entries;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
if (error instanceof ProjectBootstrapOutboxError) throw error;
throw new ProjectBootstrapOutboxError();
}
}

async complete(projectId: StudioProjectId): Promise<void> {
try {
await fs.rm(this.file(projectId), { force: true });
} catch (error) {
if (error instanceof ProjectBootstrapOutboxError) throw error;
throw new ProjectBootstrapOutboxError();
}
}
}
Loading
Loading