mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-23 13:02:58 +00:00
312 lines
12 KiB
TypeScript
312 lines
12 KiB
TypeScript
import type { ObsidianLiveSyncSettings, SettingsMigrationState } from "@vrtmrz/livesync-commonlib/settings";
|
|
import type { CompatibilityPause } from "@/common/databaseCompatibility.ts";
|
|
import {
|
|
REVIEW_HARNESS_SCENARIOS,
|
|
REVIEW_HARNESS_STATE_KEY,
|
|
formatReviewHarnessReport,
|
|
inspectCompatibilityReview,
|
|
inspectSettingsLifecycle,
|
|
parsePendingReviewRun,
|
|
type ReviewHarnessReportInput,
|
|
type ReviewHarnessScenarioId,
|
|
type ReviewHarnessScenarioResult,
|
|
} from "./reviewHarnessContract";
|
|
|
|
export interface ReviewHarnessRuntime {
|
|
now(): Date;
|
|
getSettings(): Partial<ObsidianLiveSyncSettings>;
|
|
getNewVaultSettings(): Partial<ObsidianLiveSyncSettings>;
|
|
getSettingsMigrationState(): SettingsMigrationState | undefined;
|
|
isCompatibilityReviewInitialised(): boolean;
|
|
getCompatibilityPause(): CompatibilityPause | undefined;
|
|
openCompatibilityReview(): Promise<void>;
|
|
getP2PComposition(): {
|
|
readonly first: unknown;
|
|
readonly second: unknown;
|
|
readonly expectedServices: unknown;
|
|
};
|
|
runVaultRoundTrip(): Promise<ReviewHarnessScenarioResult>;
|
|
readContinuation(): string | null;
|
|
writeContinuation(value: string): void;
|
|
deleteContinuation(): void;
|
|
restart(): void;
|
|
reportError(error: unknown): void;
|
|
copyText(value: string): Promise<void>;
|
|
getEnvironment(): ReviewHarnessReportInput["environment"];
|
|
}
|
|
|
|
export interface ReviewHarnessTranscriptEntry {
|
|
readonly at: string;
|
|
readonly event: string;
|
|
readonly detail?: string;
|
|
}
|
|
|
|
export interface ReviewHarnessSnapshot {
|
|
readonly results: Record<ReviewHarnessScenarioId, ReviewHarnessScenarioResult>;
|
|
readonly running: boolean;
|
|
readonly current: ReviewHarnessScenarioId | null;
|
|
readonly resumedRequestId: string | null;
|
|
readonly continuationError: string | null;
|
|
readonly transcript: readonly ReviewHarnessTranscriptEntry[];
|
|
}
|
|
|
|
const idleResult = (): ReviewHarnessScenarioResult => ({
|
|
status: "idle",
|
|
detail: "Not run",
|
|
observations: [],
|
|
});
|
|
|
|
function initialResults(): Record<ReviewHarnessScenarioId, ReviewHarnessScenarioResult> {
|
|
return Object.fromEntries(REVIEW_HARNESS_SCENARIOS.map(({ id }) => [id, idleResult()])) as Record<
|
|
ReviewHarnessScenarioId,
|
|
ReviewHarnessScenarioResult
|
|
>;
|
|
}
|
|
|
|
function inspectP2PComposition(input: ReturnType<ReviewHarnessRuntime["getP2PComposition"]>): ReviewHarnessScenarioResult {
|
|
if (input.first !== input.second) {
|
|
return {
|
|
status: "failed",
|
|
detail: "Two consecutive reads resolved different P2P replicators without a lifecycle transition.",
|
|
observations: [],
|
|
};
|
|
}
|
|
if (typeof input.first !== "object" || input.first === null) {
|
|
return {
|
|
status: "failed",
|
|
detail: "The P2P composition did not expose a current replicator.",
|
|
observations: [],
|
|
};
|
|
}
|
|
const env = "env" in input.first ? input.first.env : undefined;
|
|
const services = typeof env === "object" && env !== null && "services" in env ? env.services : undefined;
|
|
if (services !== input.expectedServices) {
|
|
return {
|
|
status: "failed",
|
|
detail: "The current P2P replicator is not bound to the active Obsidian services.",
|
|
observations: [],
|
|
};
|
|
}
|
|
return {
|
|
status: "passed",
|
|
detail: "The live P2P result resolves the current replicator and active Obsidian services.",
|
|
observations: [],
|
|
};
|
|
}
|
|
|
|
export class ReviewHarnessController {
|
|
private readonly results = initialResults();
|
|
private readonly transcript: ReviewHarnessTranscriptEntry[] = [];
|
|
private readonly listeners = new Set<() => void>();
|
|
private running = false;
|
|
private current: ReviewHarnessScenarioId | null = null;
|
|
private resumedRequestId: string | null = null;
|
|
private continuationError: string | null = null;
|
|
|
|
constructor(private readonly runtime: ReviewHarnessRuntime) {}
|
|
|
|
subscribe(listener: () => void): () => void {
|
|
this.listeners.add(listener);
|
|
return () => this.listeners.delete(listener);
|
|
}
|
|
|
|
private notify(): void {
|
|
for (const listener of this.listeners) listener();
|
|
}
|
|
|
|
private record(event: string, detail?: string): void {
|
|
this.transcript.push({
|
|
at: this.runtime.now().toISOString(),
|
|
event,
|
|
...(detail ? { detail } : {}),
|
|
});
|
|
while (this.transcript.length > 100) this.transcript.shift();
|
|
}
|
|
|
|
snapshot(): ReviewHarnessSnapshot {
|
|
return {
|
|
results: Object.fromEntries(
|
|
REVIEW_HARNESS_SCENARIOS.map(({ id }) => [id, { ...this.results[id] }])
|
|
) as Record<ReviewHarnessScenarioId, ReviewHarnessScenarioResult>,
|
|
running: this.running,
|
|
current: this.current,
|
|
resumedRequestId: this.resumedRequestId,
|
|
continuationError: this.continuationError,
|
|
transcript: [...this.transcript],
|
|
};
|
|
}
|
|
|
|
consumeContinuation(): void {
|
|
const serialised = this.runtime.readContinuation();
|
|
if (!serialised) return;
|
|
this.runtime.deleteContinuation();
|
|
const parsed = parsePendingReviewRun(serialised);
|
|
if (!parsed.pendingRun) {
|
|
this.continuationError = parsed.error ?? "The Review Harness continuation was invalid.";
|
|
this.results["compatibility-review"] = {
|
|
status: "failed",
|
|
detail: "The stored continuation was invalid and was removed.",
|
|
observations: [],
|
|
};
|
|
this.record("continuation-rejected");
|
|
this.notify();
|
|
return;
|
|
}
|
|
this.resumedRequestId = parsed.pendingRun.requestId;
|
|
this.results["compatibility-review"] = {
|
|
status: "waiting-for-user",
|
|
detail: "Obsidian returned after the requested restart. Open the compatibility review to continue.",
|
|
observations: [`requestedAt=${parsed.pendingRun.requestedAt}`],
|
|
};
|
|
this.record("continuation-consumed", parsed.pendingRun.requestId);
|
|
this.notify();
|
|
}
|
|
|
|
async runAutomaticScenarios(): Promise<void> {
|
|
for (const id of ["settings-lifecycle", "p2p-composition"] as const) {
|
|
await this.runScenario(id);
|
|
}
|
|
}
|
|
|
|
async runAllScenarios(): Promise<void> {
|
|
for (const { id } of REVIEW_HARNESS_SCENARIOS) await this.runScenario(id);
|
|
}
|
|
|
|
private inspectCompatibilityReview(): ReviewHarnessScenarioResult {
|
|
return inspectCompatibilityReview({
|
|
migration: this.runtime.getSettingsMigrationState(),
|
|
reviewInitialised: this.runtime.isCompatibilityReviewInitialised(),
|
|
pendingPause: this.runtime.getCompatibilityPause(),
|
|
});
|
|
}
|
|
|
|
async runScenario(id: ReviewHarnessScenarioId): Promise<void> {
|
|
if (this.running) return;
|
|
this.running = true;
|
|
this.current = id;
|
|
this.results[id] = { status: "running", detail: "Running", observations: [] };
|
|
this.record("scenario-started", id);
|
|
this.notify();
|
|
try {
|
|
let result: ReviewHarnessScenarioResult;
|
|
if (id === "settings-lifecycle") {
|
|
result = inspectSettingsLifecycle({
|
|
migration: this.runtime.getSettingsMigrationState(),
|
|
settings: this.runtime.getSettings(),
|
|
newVaultSettings: this.runtime.getNewVaultSettings(),
|
|
});
|
|
} else if (id === "p2p-composition") {
|
|
result = inspectP2PComposition(this.runtime.getP2PComposition());
|
|
} else if (id === "vault-round-trip") {
|
|
result = await this.runtime.runVaultRoundTrip();
|
|
} else {
|
|
const inspection = this.inspectCompatibilityReview();
|
|
result =
|
|
inspection.status === "failed" || !this.runtime.getCompatibilityPause()
|
|
? inspection
|
|
: {
|
|
status: "waiting-for-user",
|
|
detail: "Open the device-local compatibility review and complete its explicit action.",
|
|
observations: inspection.observations,
|
|
};
|
|
}
|
|
this.results[id] = result;
|
|
this.record("scenario-updated", `${id}:${result.status}`);
|
|
} catch (error) {
|
|
this.setUnexpectedFailure(id, error);
|
|
} finally {
|
|
this.running = false;
|
|
this.current = null;
|
|
this.notify();
|
|
}
|
|
}
|
|
|
|
async openCompatibilityReview(): Promise<void> {
|
|
if (this.running) return;
|
|
this.running = true;
|
|
this.current = "compatibility-review";
|
|
this.notify();
|
|
try {
|
|
await this.runtime.openCompatibilityReview();
|
|
const inspection = this.inspectCompatibilityReview();
|
|
this.results["compatibility-review"] =
|
|
inspection.status === "passed" && !this.runtime.getCompatibilityPause()
|
|
? {
|
|
status: "passed",
|
|
detail: "The device-local compatibility pause was reviewed and cleared.",
|
|
observations: inspection.observations,
|
|
}
|
|
: inspection.status === "failed"
|
|
? inspection
|
|
: {
|
|
status: "waiting-for-user",
|
|
detail: "The device-local compatibility review remains pending.",
|
|
observations: inspection.observations,
|
|
};
|
|
this.record(
|
|
"compatibility-review-updated",
|
|
this.results["compatibility-review"].status
|
|
);
|
|
} catch (error) {
|
|
this.setUnexpectedFailure("compatibility-review", error);
|
|
} finally {
|
|
this.running = false;
|
|
this.current = null;
|
|
this.notify();
|
|
}
|
|
}
|
|
|
|
private setUnexpectedFailure(id: ReviewHarnessScenarioId, error: unknown): void {
|
|
this.runtime.reportError(error);
|
|
this.results[id] = {
|
|
status: "failed",
|
|
detail: "The scenario failed unexpectedly. Review the in-app logs for diagnostic details.",
|
|
observations: [],
|
|
};
|
|
this.record("scenario-failed", id);
|
|
}
|
|
|
|
prepareCompatibilityReviewRestart(): void {
|
|
const requestedAt = this.runtime.now().toISOString();
|
|
const pending = {
|
|
formatVersion: 1,
|
|
requestId: `compatibility-review-${requestedAt}`,
|
|
scenarioId: "compatibility-review",
|
|
stage: "awaiting-restart",
|
|
requestedAt,
|
|
} as const;
|
|
this.runtime.writeContinuation(JSON.stringify(pending));
|
|
this.results["compatibility-review"] = {
|
|
status: "waiting-for-user",
|
|
detail: "Restart requested. The one-shot continuation will be removed before the review resumes.",
|
|
observations: [],
|
|
};
|
|
this.record("restart-requested", pending.requestId);
|
|
this.notify();
|
|
this.runtime.restart();
|
|
}
|
|
|
|
createReport(): string {
|
|
return formatReviewHarnessReport({
|
|
generatedAt: this.runtime.now().toISOString(),
|
|
environment: this.runtime.getEnvironment(),
|
|
scenarios: REVIEW_HARNESS_SCENARIOS.map(({ id, title, mode }) => ({
|
|
id,
|
|
title,
|
|
mode,
|
|
status: this.results[id].status,
|
|
detail: this.results[id].detail,
|
|
})),
|
|
transcript: this.transcript,
|
|
});
|
|
}
|
|
|
|
async copyReport(): Promise<void> {
|
|
await this.runtime.copyText(this.createReport());
|
|
this.record("report-copied");
|
|
this.notify();
|
|
}
|
|
}
|
|
|
|
export { REVIEW_HARNESS_STATE_KEY };
|