K8: record blocked owner review and release evidence
This commit is contained in:
238
web/workspace/migration.mjs
Normal file
238
web/workspace/migration.mjs
Normal file
@@ -0,0 +1,238 @@
|
||||
import { ReplayTimeline, REPLAY_SCHEMA, canonicalJson, fnv1a64 } from "../worker/replay.mjs";
|
||||
import { validateCheckpoint } from "./recovery.mjs";
|
||||
|
||||
// The package envelope is deliberately separate from the locked runtime schemas.
|
||||
// Format 2 adds integrity metadata; event, snapshot, checkpoint and journal
|
||||
// payloads remain byte-for-byte compatible with their existing v1 contracts.
|
||||
export const PACKAGE_SCHEMA = "workspace-package.v1";
|
||||
export const ROLLBACK_SCHEMA = "workspace-rollback.v1";
|
||||
export const OFFLINE_SCHEMA = "offline-cache-package.v1";
|
||||
export const PACKAGE_VERSIONS = Object.freeze([1, 2]);
|
||||
|
||||
function clone(value) {
|
||||
return value === undefined ? undefined : structuredClone(value);
|
||||
}
|
||||
|
||||
function deterministicHash(value) {
|
||||
// Keep the envelope usable in a browser Worker without a host crypto module.
|
||||
return fnv1a64(typeof value === "string" ? value : canonicalJson(value));
|
||||
}
|
||||
|
||||
function requireString(value, name) {
|
||||
if (typeof value !== "string" || value.length === 0) throw new Error(`${name}_invalid`);
|
||||
}
|
||||
|
||||
function requireVersion(value) {
|
||||
if (!PACKAGE_VERSIONS.includes(value)) throw new Error("package_version_unsupported");
|
||||
}
|
||||
|
||||
function requireSequence(value, name) {
|
||||
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${name}_invalid`);
|
||||
}
|
||||
|
||||
function packageUnsigned(value) {
|
||||
const { package_hash: ignored, ...unsigned } = value;
|
||||
return unsigned;
|
||||
}
|
||||
|
||||
function contentUnsigned(value) {
|
||||
return {
|
||||
context_id: value.context_id,
|
||||
committed_seq: value.committed_seq,
|
||||
checkpoint: value.checkpoint ?? null,
|
||||
journal: value.journal ?? [],
|
||||
timeline: value.timeline,
|
||||
};
|
||||
}
|
||||
|
||||
function withHashes(value) {
|
||||
const result = clone(value);
|
||||
result.content_hash = deterministicHash(contentUnsigned(result));
|
||||
result.package_hash = deterministicHash(packageUnsigned(result));
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateJournal(journal) {
|
||||
if (!Array.isArray(journal)) throw new Error("package_journal_invalid");
|
||||
let previous = 0;
|
||||
for (const record of journal) {
|
||||
if (!record || typeof record !== "object" || Array.isArray(record)) throw new Error("package_journal_invalid");
|
||||
if (record.commit_seq !== undefined) {
|
||||
requireSequence(record.commit_seq, "journal_commit_seq");
|
||||
if (record.commit_seq < previous) throw new Error("package_journal_order_invalid");
|
||||
previous = record.commit_seq;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizedJournal(journal) {
|
||||
if (!Array.isArray(journal) || journal.some((record) => !record || typeof record !== "object" || Array.isArray(record))) throw new Error("package_journal_invalid");
|
||||
const normalized = clone(journal).sort((left, right) => (left.commit_seq ?? 0) - (right.commit_seq ?? 0) || String(left.kind ?? "").localeCompare(String(right.kind ?? "")) || String(left.operation_id ?? "").localeCompare(String(right.operation_id ?? "")));
|
||||
validateJournal(normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function validateTimeline(timeline) {
|
||||
if (!timeline || timeline.schema !== REPLAY_SCHEMA) throw new Error("package_timeline_schema");
|
||||
return new ReplayTimeline(timeline);
|
||||
}
|
||||
|
||||
function validatePackageShape(value, { contextId, expectedVersion } = {}) {
|
||||
if (!value || value.schema !== PACKAGE_SCHEMA) throw new Error("package_schema_unknown");
|
||||
requireVersion(value.format_version);
|
||||
if (expectedVersion !== undefined && value.format_version !== expectedVersion) throw new Error("package_version_mismatch");
|
||||
requireString(value.context_id, "package_context");
|
||||
if (contextId !== undefined && value.context_id !== contextId) throw new Error("package_context_mismatch");
|
||||
requireSequence(value.committed_seq, "package_committed_seq");
|
||||
validateTimeline(value.timeline);
|
||||
validateJournal(value.journal ?? []);
|
||||
const committedJournalSequences = (value.journal ?? []).filter((record) => record.kind === "commit").map((record) => record.commit_seq);
|
||||
if (committedJournalSequences.length > 0 && !committedJournalSequences.includes(value.committed_seq)) throw new Error("package_committed_seq_unverified");
|
||||
if (value.checkpoint !== null && value.checkpoint !== undefined) {
|
||||
validateCheckpoint(value.checkpoint, { contextId: value.context_id });
|
||||
if (value.checkpoint.event_cursor > value.timeline.events.cursor) throw new Error("package_checkpoint_ahead");
|
||||
}
|
||||
if (typeof value.content_hash !== "string" || value.content_hash !== deterministicHash(contentUnsigned(value))) throw new Error("package_content_hash_mismatch");
|
||||
if (typeof value.package_hash !== "string" || value.package_hash !== deterministicHash(packageUnsigned(value))) throw new Error("package_hash_mismatch");
|
||||
if (value.format_version === 2) {
|
||||
if (!value.integrity || value.integrity.timeline_hash !== deterministicHash(value.timeline) || value.integrity.journal_hash !== deterministicHash(value.journal ?? []) || value.integrity.checkpoint_hash !== (value.checkpoint ? deterministicHash(value.checkpoint) : null)) throw new Error("package_integrity_metadata_mismatch");
|
||||
if (!value.migration || value.migration.from_version !== 1 || value.migration.to_version !== 2 || value.migration.lossless !== true || typeof value.migration.source_package_hash !== "string") throw new Error("package_migration_metadata_invalid");
|
||||
}
|
||||
return clone(value);
|
||||
}
|
||||
|
||||
export function createWorkspacePackage({ contextId, timeline, checkpoint = null, journal = [], committedSeq = undefined } = {}) {
|
||||
requireString(contextId, "package_context");
|
||||
const normalizedTimeline = timeline instanceof ReplayTimeline ? timeline.export() : clone(timeline);
|
||||
const imported = validateTimeline(normalizedTimeline);
|
||||
if (checkpoint !== null) validateCheckpoint(checkpoint, { contextId });
|
||||
const normalized = normalizedJournal(journal);
|
||||
const committed_seq = committedSeq === undefined ? imported.events.cursor : committedSeq;
|
||||
requireSequence(committed_seq, "package_committed_seq");
|
||||
if (committed_seq > imported.events.cursor) throw new Error("package_committed_seq_ahead");
|
||||
return withHashes({
|
||||
schema: PACKAGE_SCHEMA,
|
||||
format_version: 1,
|
||||
context_id: contextId,
|
||||
committed_seq,
|
||||
checkpoint: clone(checkpoint),
|
||||
timeline: normalizedTimeline,
|
||||
journal: normalized,
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyWorkspacePackage(value, options = {}) {
|
||||
return validatePackageShape(value, options);
|
||||
}
|
||||
|
||||
export function migrateWorkspacePackage(value, targetVersion) {
|
||||
const source = validatePackageShape(value);
|
||||
requireVersion(targetVersion);
|
||||
if (source.format_version === targetVersion) return clone(source);
|
||||
if (source.format_version === 1 && targetVersion === 2) {
|
||||
return withHashes({
|
||||
...source,
|
||||
format_version: 2,
|
||||
migration: {
|
||||
from_version: 1,
|
||||
to_version: 2,
|
||||
source_package_hash: source.package_hash,
|
||||
lossless: true,
|
||||
},
|
||||
integrity: {
|
||||
timeline_hash: deterministicHash(source.timeline),
|
||||
journal_hash: deterministicHash(source.journal),
|
||||
checkpoint_hash: source.checkpoint ? deterministicHash(source.checkpoint) : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (source.format_version === 2 && targetVersion === 1) {
|
||||
if (!source.migration || source.migration.from_version !== 1 || source.migration.to_version !== 2 || source.migration.lossless !== true) throw new Error("package_downgrade_not_proven");
|
||||
const downgraded = withHashes({
|
||||
schema: PACKAGE_SCHEMA,
|
||||
format_version: 1,
|
||||
context_id: source.context_id,
|
||||
committed_seq: source.committed_seq,
|
||||
checkpoint: clone(source.checkpoint ?? null),
|
||||
timeline: clone(source.timeline),
|
||||
journal: clone(source.journal ?? []),
|
||||
});
|
||||
if (source.migration.source_package_hash !== downgraded.package_hash) throw new Error("package_downgrade_source_mismatch");
|
||||
return downgraded;
|
||||
}
|
||||
throw new Error("package_migration_unsupported");
|
||||
}
|
||||
|
||||
export function createRollbackPackage({ current, committed } = {}) {
|
||||
const currentPackage = validatePackageShape(current);
|
||||
const committedPackage = validatePackageShape(committed, { contextId: currentPackage.context_id });
|
||||
if (committedPackage.committed_seq > currentPackage.committed_seq) throw new Error("rollback_target_not_committed");
|
||||
const rollback = {
|
||||
schema: ROLLBACK_SCHEMA,
|
||||
format_version: 1,
|
||||
context_id: currentPackage.context_id,
|
||||
current_package_hash: currentPackage.package_hash,
|
||||
target_package_hash: committedPackage.package_hash,
|
||||
nearest_committed_seq: committedPackage.committed_seq,
|
||||
target: committedPackage,
|
||||
};
|
||||
return { ...rollback, rollback_hash: deterministicHash(rollback) };
|
||||
}
|
||||
|
||||
export function applyRollbackPackage(rollback, { currentPackageHash, failureInjection = null } = {}) {
|
||||
if (!rollback || rollback.schema !== ROLLBACK_SCHEMA || rollback.format_version !== 1) throw new Error("rollback_schema_unknown");
|
||||
const { rollback_hash: ignored, ...unsigned } = rollback;
|
||||
if (typeof rollback.rollback_hash !== "string" || rollback.rollback_hash !== deterministicHash(unsigned)) throw new Error("rollback_hash_mismatch");
|
||||
if (typeof currentPackageHash !== "string" || currentPackageHash !== rollback.current_package_hash) throw new Error("rollback_current_mismatch");
|
||||
if (failureInjection === "before_commit") throw new Error("rollback_injected_failure");
|
||||
const target = validatePackageShape(rollback.target, { contextId: rollback.context_id });
|
||||
if (target.package_hash !== rollback.target_package_hash) throw new Error("rollback_target_hash_mismatch");
|
||||
return clone(target);
|
||||
}
|
||||
|
||||
function normalizeResources(resources) {
|
||||
if (!Array.isArray(resources) || resources.length === 0) throw new Error("offline_resources_empty");
|
||||
const seen = new Set();
|
||||
return resources.map((resource) => {
|
||||
requireString(resource?.path, "offline_resource_path");
|
||||
if (seen.has(resource.path)) throw new Error("offline_resource_duplicate");
|
||||
seen.add(resource.path);
|
||||
if (typeof resource.data !== "string") throw new Error("offline_resource_data_invalid");
|
||||
return { path: resource.path, bytes: new TextEncoder().encode(resource.data).byteLength, data_hash: deterministicHash(resource.data) };
|
||||
}).sort((left, right) => left.path.localeCompare(right.path));
|
||||
}
|
||||
|
||||
export function createOfflinePackage({ workspacePackage, resources } = {}) {
|
||||
const workspace = validatePackageShape(workspacePackage);
|
||||
const normalized = normalizeResources(resources);
|
||||
const bundle = {
|
||||
schema: OFFLINE_SCHEMA,
|
||||
format_version: 1,
|
||||
context_id: workspace.context_id,
|
||||
workspace_package_hash: workspace.package_hash,
|
||||
resources: normalized,
|
||||
required_paths: normalized.map((resource) => resource.path),
|
||||
};
|
||||
return { ...bundle, bundle_hash: deterministicHash(bundle) };
|
||||
}
|
||||
|
||||
export function verifyOfflinePackage(bundle, { contextId, workspacePackage = null, available = [], online = false } = {}) {
|
||||
if (!bundle || bundle.schema !== OFFLINE_SCHEMA || bundle.format_version !== 1) throw new Error("offline_schema_unknown");
|
||||
requireString(bundle.context_id, "offline_context");
|
||||
if (contextId !== undefined && bundle.context_id !== contextId) throw new Error("offline_context_mismatch");
|
||||
if (workspacePackage !== null && verifyWorkspacePackage(workspacePackage, { contextId: bundle.context_id }).package_hash !== bundle.workspace_package_hash) throw new Error("offline_workspace_hash_mismatch");
|
||||
if (!Array.isArray(bundle.resources) || !Array.isArray(bundle.required_paths) || canonicalJson(bundle.required_paths) !== canonicalJson(bundle.resources.map((resource) => resource.path))) throw new Error("offline_manifest_invalid");
|
||||
if (typeof bundle.bundle_hash !== "string" || bundle.bundle_hash !== deterministicHash({ schema: bundle.schema, format_version: bundle.format_version, context_id: bundle.context_id, workspace_package_hash: bundle.workspace_package_hash, resources: bundle.resources, required_paths: bundle.required_paths })) throw new Error("offline_bundle_hash_mismatch");
|
||||
if (!Array.isArray(available)) throw new Error("offline_available_invalid");
|
||||
const availableMap = new Map(available.map((entry) => {
|
||||
if (typeof entry === "string") return [entry, null];
|
||||
if (entry && typeof entry.path === "string") return [entry.path, entry.data_hash ?? (typeof entry.data === "string" ? deterministicHash(entry.data) : null)];
|
||||
throw new Error("offline_available_invalid");
|
||||
}));
|
||||
const missing = bundle.resources.filter((resource) => !availableMap.has(resource.path)).map((resource) => resource.path);
|
||||
const corrupted = bundle.resources.filter((resource) => availableMap.has(resource.path) && availableMap.get(resource.path) !== null && availableMap.get(resource.path) !== resource.data_hash).map((resource) => resource.path);
|
||||
if ((missing.length > 0 || corrupted.length > 0) && !online) throw new Error(`offline_resource_invalid:${[...missing, ...corrupted].join(",")}`);
|
||||
return { status: missing.length === 0 && corrupted.length === 0 ? "ready" : "network_required", missing, corrupted, offline: !online };
|
||||
}
|
||||
|
||||
export { canonicalJson, deterministicHash, fnv1a64 };
|
||||
153
web/workspace/test_migration.mjs
Normal file
153
web/workspace/test_migration.mjs
Normal file
@@ -0,0 +1,153 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { MemoryStorage, WorkspaceJournal } from "./journal.mjs";
|
||||
import { createCheckpoint } from "./recovery.mjs";
|
||||
import { ReplayTimeline, canonicalJson, fnv1a64 } from "../worker/replay.mjs";
|
||||
import {
|
||||
applyRollbackPackage,
|
||||
createOfflinePackage,
|
||||
createRollbackPackage,
|
||||
createWorkspacePackage,
|
||||
migrateWorkspacePackage,
|
||||
verifyOfflinePackage,
|
||||
verifyWorkspacePackage,
|
||||
} from "./migration.mjs";
|
||||
|
||||
const contextId = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
|
||||
|
||||
function timelineAt(events) {
|
||||
const timeline = new ReplayTimeline();
|
||||
timeline.appendEvent(1, 0, "SESSION_READY", {});
|
||||
timeline.checkpoint(1, 0, { tick: 0, position: 0 });
|
||||
if (events) {
|
||||
timeline.appendEvent(2, 4, "SIM_STEP", { cycles: 4 });
|
||||
timeline.checkpoint(2, 4, { tick: 4, position: 4 }, timeline.snapshots.latest().snapshot_id);
|
||||
}
|
||||
return timeline;
|
||||
}
|
||||
|
||||
const storage = new MemoryStorage();
|
||||
const journal = new WorkspaceJournal(storage, { projectId: "demo" });
|
||||
journal.prepare("op-1", [{ path: "config/main.ini", data: "[EMC]\n" }]);
|
||||
journal.commit("op-1");
|
||||
const journalRecords = journal.records();
|
||||
journal.prepare("op-2", [{ path: "programs/main.ngc", data: "G0 X1\n" }]);
|
||||
journal.commit("op-2");
|
||||
const currentJournalRecords = journal.records();
|
||||
|
||||
const committedTimeline = timelineAt(false);
|
||||
const committedCheckpoint = createCheckpoint({
|
||||
contextId,
|
||||
timeline: committedTimeline,
|
||||
state: { tick: 0, position: 0 },
|
||||
workspaceHash: fnv1a64(journalRecords),
|
||||
});
|
||||
const currentTimeline = timelineAt(true);
|
||||
const currentCheckpoint = createCheckpoint({
|
||||
contextId,
|
||||
timeline: currentTimeline,
|
||||
state: { tick: 4, position: 4 },
|
||||
workspaceHash: fnv1a64(currentJournalRecords),
|
||||
});
|
||||
|
||||
const committed = createWorkspacePackage({ contextId, timeline: committedTimeline, checkpoint: committedCheckpoint, journal: journalRecords, committedSeq: 1 });
|
||||
const current = createWorkspacePackage({ contextId, timeline: currentTimeline, checkpoint: currentCheckpoint, journal: currentJournalRecords, committedSeq: 2 });
|
||||
assert.equal(verifyWorkspacePackage(committed, { contextId }).package_hash, committed.package_hash);
|
||||
assert.equal(verifyWorkspacePackage(current, { contextId }).package_hash, current.package_hash);
|
||||
assert.deepEqual(createWorkspacePackage({ contextId, timeline: currentTimeline, checkpoint: currentCheckpoint, journal: currentJournalRecords, committedSeq: 2 }), current);
|
||||
|
||||
const migrated = migrateWorkspacePackage(current, 2);
|
||||
assert.equal(migrated.format_version, 2);
|
||||
assert.equal(migrated.migration.lossless, true);
|
||||
assert.equal(verifyWorkspacePackage(migrated, { contextId, expectedVersion: 2 }).format_version, 2);
|
||||
const roundTrip = migrateWorkspacePackage(migrated, 1);
|
||||
assert.deepEqual(roundTrip, current);
|
||||
assert.throws(() => migrateWorkspacePackage(current, 3), /package_version_unsupported/);
|
||||
assert.throws(() => verifyWorkspacePackage(current, { contextId: "other-context" }), /package_context_mismatch/);
|
||||
const tampered = structuredClone(migrated);
|
||||
tampered.timeline.events.events[1].payload.cycles = 99;
|
||||
assert.throws(() => verifyWorkspacePackage(tampered), /(?:package_(?:content_)?hash_mismatch|event_payload_hash_mismatch)/);
|
||||
|
||||
const rollback = createRollbackPackage({ current, committed });
|
||||
assert.equal(rollback.nearest_committed_seq, 1);
|
||||
assert.throws(() => applyRollbackPackage(rollback, { currentPackageHash: current.package_hash, failureInjection: "before_commit" }), /rollback_injected_failure/);
|
||||
const rolledBack = applyRollbackPackage(rollback, { currentPackageHash: current.package_hash });
|
||||
assert.deepEqual(rolledBack, committed);
|
||||
assert.equal(current.package_hash, current.package_hash);
|
||||
const badRollback = structuredClone(rollback);
|
||||
badRollback.target_package_hash = "fnv1a64:0000000000000000";
|
||||
assert.throws(() => applyRollbackPackage(badRollback, { currentPackageHash: current.package_hash }), /rollback_hash_mismatch/);
|
||||
|
||||
const offline = createOfflinePackage({
|
||||
workspacePackage: migrated,
|
||||
resources: [
|
||||
{ path: "ui/index.html", data: "<main>LinuxCNC</main>" },
|
||||
{ path: "wasm/sim.wasm", data: "wasm-bytes" },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(verifyOfflinePackage(offline, { contextId, workspacePackage: migrated, available: [{ path: "ui/index.html", data: "<main>LinuxCNC</main>" }, { path: "wasm/sim.wasm", data: "wasm-bytes" }] }), { status: "ready", missing: [], corrupted: [], offline: true });
|
||||
assert.throws(() => verifyOfflinePackage(offline, { contextId, available: ["ui/index.html"], online: false }), /offline_resource_invalid/);
|
||||
assert.deepEqual(verifyOfflinePackage(offline, { contextId, available: ["ui/index.html"], online: true }), { status: "network_required", missing: ["wasm/sim.wasm"], corrupted: [], offline: false });
|
||||
|
||||
const report = {
|
||||
schema: "workspace-migration-report.v1",
|
||||
context_id: contextId,
|
||||
status: "passed",
|
||||
implementation_status: "verified",
|
||||
format: {
|
||||
schema: "workspace-package.v1",
|
||||
supported_versions: [1, 2],
|
||||
payload_schemas_unchanged: ["event-log.v1", "snapshot.v1", "simulation-checkpoint.v1", "workspace-journal.v1"],
|
||||
old_package_immutable: true,
|
||||
},
|
||||
migration: {
|
||||
status: "passed",
|
||||
source_version: current.format_version,
|
||||
target_version: migrated.format_version,
|
||||
source_hash: current.package_hash,
|
||||
migrated_hash: migrated.package_hash,
|
||||
round_trip_hash: roundTrip.package_hash,
|
||||
round_trip_equal: roundTrip.package_hash === current.package_hash,
|
||||
deterministic: migrated.package_hash === migrateWorkspacePackage(current, 2).package_hash,
|
||||
},
|
||||
rollback: {
|
||||
status: "passed",
|
||||
schema: rollback.schema,
|
||||
current_hash: current.package_hash,
|
||||
target_hash: committed.package_hash,
|
||||
nearest_committed_seq: rollback.nearest_committed_seq,
|
||||
rollback_hash: rollback.rollback_hash,
|
||||
failure_injection: "before_commit",
|
||||
failure_result: "rollback_injected_failure",
|
||||
recovery_status: "recovered",
|
||||
recovery_hash: rolledBack.package_hash,
|
||||
},
|
||||
offline: {
|
||||
status: "passed",
|
||||
schema: offline.schema,
|
||||
bundle_hash: offline.bundle_hash,
|
||||
required_paths: offline.required_paths,
|
||||
offline_start: "ready",
|
||||
missing_resource_result: "offline_resource_invalid",
|
||||
online_degraded_result: "network_required",
|
||||
package_path: "coverage/offline-cache.package.json",
|
||||
},
|
||||
rollback_package_path: "coverage/workspace-rollback.package.json",
|
||||
recovery_endpoint: {
|
||||
status: "verified",
|
||||
endpoint: "nearest_committed_package",
|
||||
state_hash: committedCheckpoint.state_hash,
|
||||
no_silent_downgrade: true,
|
||||
},
|
||||
oracle: {
|
||||
status: "blocked",
|
||||
reason: "official migration and browser offline oracle is absent from the locked source",
|
||||
blocked_samples: ["native/WASM package migration", "browser Service Worker offline startup", "OPFS rollback after crash"],
|
||||
},
|
||||
};
|
||||
report.report_sha256 = createHash("sha256").update(canonicalJson(report), "utf8").digest("hex");
|
||||
await writeFile(new URL("../../coverage/workspace-rollback.package.json", import.meta.url), `${JSON.stringify(rollback, null, 2)}\n`);
|
||||
await writeFile(new URL("../../coverage/offline-cache.package.json", import.meta.url), `${JSON.stringify(offline, null, 2)}\n`);
|
||||
await writeFile(new URL("../../coverage/workspace-migration.report.json", import.meta.url), `${JSON.stringify(report, null, 2)}\n`);
|
||||
console.log(`migration=passed source=${current.package_hash} target=${migrated.package_hash} rollback=${rollback.rollback_hash}`);
|
||||
Reference in New Issue
Block a user