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
1 change: 1 addition & 0 deletions loopx/control_plane/coordination/authority_backup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ export async function inspectAuthorityBackup(dataRoot: string, row: JsonObject):
if (head === null) throw new Error("authority capture has no head");
const collection = canonicalTodoCollection(head, goalId, true);
return {provider: archive.summary.source_provider, head_digest: archive.summary.projection_sha256,
provider_revision: archive.summary.source_provider_revision, cursor: archive.summary.commits,
history_commits: Number(archive.summary.commits), todo_count: collection.projection.todo_ids.length,
historical_lease_count: collection.leaseIndex?.lease_todo_ids.length ?? 0,
capture_started_at: row.started_at, capture_completed_at: row.completed_at};
Expand Down
38 changes: 28 additions & 10 deletions tests/control_plane_ts/authority_backup.test.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,26 @@
import assert from "node:assert/strict";
import test from "node:test";
import {existsSync} from "node:fs";
import {mkdir, mkdtemp, readFile, rm, writeFile} from "node:fs/promises";
import {mkdir, mkdtemp, readFile, rename, rm, writeFile} from "node:fs/promises";
import {tmpdir} from "node:os";
import {dirname, join} from "node:path";
import {authorityBackupDirectory, captureAuthorityBackup} from "../../loopx/control_plane/coordination/authority_backup.ts";
import {authorityBackupDirectory, captureAuthorityBackup, inspectAuthorityBackup} from "../../loopx/control_plane/coordination/authority_backup.ts";
import {verifyAuthorityArchive} from "../../loopx/control_plane/coordination/authority_archive.ts";
import {openLocalAuthorityStore, localAuthorityProviderPaths, selectLocalSqliteAuthority} from "../../loopx/control_plane/coordination/local_authority_provider.ts";
import {legacyCoordinationWriterFencePath, COLD_SOURCE_IMPORT_WRITER_FENCE_SCHEMA} from "../../loopx/control_plane/coordination/legacy_writer_fence.ts";
import type {AuthorityStore} from "../../loopx/control_plane/coordination/authority_store.ts";
import {authorityProjectionFixture} from "./authority_projection_fixture.ts";

for (const provider of ["file", "sqlite"] as const) {
test(`${provider} owner capture keeps a complete prefix during an actual append`, async () => {
test(`${provider} owner capture retains its inspected checkpoint after a later append and source loss`, async () => {
const root = await mkdtemp(join(tmpdir(), "loopx-authority-backup-"));
try {
if (provider === "sqlite") await selectLocalSqliteAuthority(root, "goal-a", true);
const store = await openLocalAuthorityStore(root, "goal-a");
const source = join(root, "source");
if (provider === "sqlite") await selectLocalSqliteAuthority(source, "goal-a", true);
const store = await openLocalAuthorityStore(source, "goal-a");
const projection = (value: number) => authorityProjectionFixture("goal-a", [], [], "native", {value});
const first = await store.commitAuthority({expected_provider_revision: null, operation_id: "first",
events: [], receipts: [{operation_id: "first", retained: true}], next_projection: {goal_id: "goal-a", value: 1}});
events: [], receipts: [{operation_id: "first", retained: true}], next_projection: projection(1)});
assert.equal(first.status, "applied");
let appended = false;
// Wrap only scheduling of a real provider read. Both commits and the
Expand All @@ -28,7 +31,7 @@ for (const provider of ["file", "sqlite"] as const) {
if (!appended) {
appended = true;
const second = await target.commitAuthority({expected_provider_revision: first.provider_revision!,
operation_id: "second", events: [], receipts: [], next_projection: {goal_id: "goal-a", value: 2}});
operation_id: "second", events: [], receipts: [], next_projection: projection(2)});
assert.equal(second.status, "applied");
}
return page;
Expand All @@ -37,13 +40,28 @@ for (const provider of ["file", "sqlite"] as const) {
return typeof value === "function" ? value.bind(target) : value;
}});
const destination = join(root, "capture");
const result = await captureAuthorityBackup({runtime_root: root, goal_id: "goal-a", destination},
const result = await captureAuthorityBackup({runtime_root: source, goal_id: "goal-a", destination},
{createStore: () => interleaved});
assert.equal(result.state, "readable");
const exported = await verifyAuthorityArchive(join(destination, authorityBackupDirectory("goal-a"), "authority.jsonl"));
const archivePath = join(destination, authorityBackupDirectory("goal-a"), "authority.jsonl");
const exported = await verifyAuthorityArchive(archivePath);
assert.equal(exported.commits, "1");
assert.equal((await store.loadAuthority()).cursor, "2");
const later = await store.loadAuthority();
assert.equal(later.status, "loaded");
assert.equal(later.cursor, "2");
assert.equal(appended, true);
await rename(source, join(root, "unavailable-source"));
const bytes = await readFile(archivePath);
const inspected = await inspectAuthorityBackup(destination, result);
assert.equal(inspected.provider_revision, first.provider_revision);
assert.equal(inspected.cursor, "1");
assert.equal(inspected.history_commits, 1);
// A manifest's claimed position cannot replace the archive owner's
// verified source position, even when it names a real later revision.
await assert.rejects(() => inspectAuthorityBackup(destination, {...result,
archive: {...exported, source_provider_revision: later.provider_revision}}), /summary mismatch/);
assert.deepEqual(await readFile(archivePath), bytes);
assert.equal(existsSync(source), false);
} finally { await rm(root, {recursive: true, force: true}); }
});
}
Expand Down
3 changes: 3 additions & 0 deletions tests/test_state_recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,9 @@ def test_real_backup_recovers_readable_authority_after_original_sources_disappea
assert code == 0, captured
captured_position = captured["authority_read"]
assert captured_position["cursor"] == "2"
[capture] = json.loads(manifest.read_text())["execution"]["authority_captures"]
assert capture["archive"]["source_provider_revision"] == captured_position["provider_revision"]
assert capture["archive"]["commits"] == captured_position["cursor"]
# A later real source commit must not become this backup's recovery point.
code, later = _cli(*source_args, "todo", "add", "--goal-id", "goal-a", "--role", "agent",
"--claimed-by", "agent-a", "--operation-id", "after-backup",
Expand Down
Loading