224 lines
7.5 KiB
JavaScript
224 lines
7.5 KiB
JavaScript
// ── canonical identifiers ────────────────────────────────────────────
|
||
|
||
const CANONICAL_KEY = "confidence-engine-investigation";
|
||
const LEGACY_KEY = "confidence-engine-session";
|
||
const SCHEMA_VERSION = 1;
|
||
|
||
import { restartSnapshot } from "../restart-investigation.js";
|
||
|
||
// Multi-Investigation key prefix (v0.60c)
|
||
const INVESTIGATION_PREFIX = "confidence-engine-investigation:";
|
||
|
||
// ── helpers ──────────────────────────────────────────────────────────
|
||
|
||
function safeGet(storage, key) {
|
||
if (!storage || typeof storage.getItem !== "function") return null;
|
||
try { return storage.getItem(key); } catch (_) { return null; }
|
||
}
|
||
|
||
function isPlainObject(value) {
|
||
return (
|
||
value !== null &&
|
||
typeof value === "object" &&
|
||
!Array.isArray(value) &&
|
||
Object.prototype.toString.call(value) === "[object Object]"
|
||
);
|
||
}
|
||
|
||
// ── loadInvestigation (identity-aware) ───────────────────────────────
|
||
|
||
/**
|
||
* Returns the persisted investigation snapshot keyed by durable id
|
||
* (normalised to current schema), or null when no usable state exists.
|
||
*
|
||
* When called without an id argument, reads from the legacy singleton key
|
||
* for backward-compatible consumers that have not yet migrated.
|
||
*/
|
||
export function loadInvestigation(id) {
|
||
const storage = _getTargetStorage();
|
||
if (!storage) return null;
|
||
|
||
// — identity-aware contract: select by durable id —
|
||
if (id != null) {
|
||
const raw = safeGet(storage, `${INVESTIGATION_PREFIX}${id}`);
|
||
if (raw === null) return null;
|
||
try {
|
||
const parsed = JSON.parse(raw);
|
||
if (isPlainObject(parsed)) {
|
||
if (!("schemaVersion" in parsed)) parsed.schemaVersion = SCHEMA_VERSION;
|
||
return parsed;
|
||
}
|
||
} catch (_) { return null; }
|
||
return null;
|
||
}
|
||
|
||
// — backward-compatible singleton path (no id supplied) —
|
||
// 1 – canonical key first
|
||
let raw = safeGet(storage, CANONICAL_KEY);
|
||
if (raw !== null) {
|
||
try {
|
||
const parsed = JSON.parse(raw);
|
||
if (isPlainObject(parsed)) {
|
||
if (!("schemaVersion" in parsed)) parsed.schemaVersion = SCHEMA_VERSION;
|
||
return parsed;
|
||
}
|
||
} catch (_) { /* malformed — fall through to legacy */ }
|
||
}
|
||
|
||
// 2 – legacy fallback
|
||
const legacyStorage = _getLegacyStorage();
|
||
if (!legacyStorage) return null;
|
||
|
||
raw = safeGet(legacyStorage, LEGACY_KEY);
|
||
if (raw === null) return null;
|
||
let parsed;
|
||
try {
|
||
parsed = JSON.parse(raw);
|
||
} catch (_) { return null; }
|
||
|
||
if (!isPlainObject(parsed)) return null;
|
||
|
||
// Normalise schemaVersion for legacy data
|
||
if (!("schemaVersion" in parsed)) parsed.schemaVersion = SCHEMA_VERSION;
|
||
|
||
// Migrate into canonical key
|
||
_persist(storage, CANONICAL_KEY, JSON.stringify(parsed));
|
||
|
||
return parsed;
|
||
}
|
||
|
||
// ── saveInvestigation (identity-aware) ───────────────────────────────
|
||
|
||
/**
|
||
* Persists the supplied snapshot under its durable id key.
|
||
* The caller's object is never mutated.
|
||
*
|
||
* When called without an id argument, writes to the legacy singleton key
|
||
* for backward-compatible consumers that have not yet migrated.
|
||
*/
|
||
export function saveInvestigation(snapshot, id) {
|
||
const storage = _getTargetStorage();
|
||
if (!storage) return; // silently no-op in non-browser
|
||
|
||
try {
|
||
const record = JSON.parse(JSON.stringify(snapshot));
|
||
record.schemaVersion = SCHEMA_VERSION;
|
||
const key = id != null ? `${INVESTIGATION_PREFIX}${id}` : CANONICAL_KEY;
|
||
_persist(storage, key, JSON.stringify(record));
|
||
} catch (_) { /* storage errors must not crash caller */ }
|
||
}
|
||
|
||
// ── clearInvestigation (identity-aware) ──────────────────────────────
|
||
|
||
/**
|
||
* Removes a specific investigation by durable id.
|
||
* When called without an id, clears the legacy singleton keys only.
|
||
*/
|
||
export function clearInvestigation(id) {
|
||
const storage = _getTargetStorage();
|
||
if (!storage) return;
|
||
if (id != null) {
|
||
try { storage.removeItem(`${INVESTIGATION_PREFIX}${id}`); } catch (_) {}
|
||
} else {
|
||
try { storage.removeItem(CANONICAL_KEY); } catch (_) {}
|
||
const legacyStorage = _getLegacyStorage();
|
||
if (legacyStorage) {
|
||
try { legacyStorage.removeItem(LEGACY_KEY); } catch (_) {}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── listInvestigations (durable-ID only) ───────────────────────
|
||
|
||
/**
|
||
* Enumerates all durable-ID Investigation records and returns lightweight summaries.
|
||
* Skips malformed entries; excludes legacy singleton, sessionStorage state, unrelated keys.
|
||
*/
|
||
export function listInvestigations() {
|
||
const storage = _getTargetStorage();
|
||
if (!storage) return [];
|
||
|
||
const summaries = [];
|
||
|
||
for (let i = 0; i < storage.length; i++) {
|
||
const key = storage.key(i);
|
||
if (!key || !key.startsWith(INVESTIGATION_PREFIX)) continue;
|
||
|
||
try {
|
||
const raw = safeGet(storage, key);
|
||
if (!raw) continue;
|
||
const record = JSON.parse(raw);
|
||
if (!isPlainObject(record)) continue;
|
||
if (!record.id) continue;
|
||
|
||
summaries.push({
|
||
id: record.id,
|
||
scenario: record.scenario ?? null,
|
||
updatedAt: record.updatedAt ?? null,
|
||
investigationRevision: record.investigationRevision ?? 0,
|
||
reportExists: !!record.investigationReport,
|
||
reportGeneratedFromRevision: record.investigationReport
|
||
? record.investigationReport.generatedFromRevision ?? null
|
||
: null,
|
||
});
|
||
} catch (_) {
|
||
// Malformed durable-ID entry — skip silently
|
||
}
|
||
}
|
||
|
||
summaries.sort((a, b) => {
|
||
const ta = a.updatedAt ?? "";
|
||
const tb = b.updatedAt ?? "";
|
||
if (ta > tb) return -1;
|
||
if (ta < tb) return 1;
|
||
return a.id > b.id ? -1 : 1;
|
||
});
|
||
|
||
return summaries;
|
||
}
|
||
|
||
// ── restartInvestigation (semantic reset within container) ───────────
|
||
|
||
/**
|
||
* Resets the Investigation to a clean state suitable for a new reasoning pass.
|
||
*
|
||
* Preserved: id, scenario, schemaVersion (container-level identity/framing).
|
||
* Reset: situationGraph → null, selectedQuestion → null, summary → null,
|
||
* focusedContributions → [], findings → [], investigationReport → null,
|
||
* investigationRevision → 0, updatedAt → new ISO timestamp.
|
||
*/
|
||
export function restartInvestigation(id) {
|
||
const storage = _getTargetStorage();
|
||
if (!storage) return;
|
||
|
||
try {
|
||
const key = id != null ? `${INVESTIGATION_PREFIX}${id}` : CANONICAL_KEY;
|
||
const raw = safeGet(storage, key);
|
||
if (raw === null) return; // nothing to restart
|
||
const record = JSON.parse(raw);
|
||
if (!isPlainObject(record)) return;
|
||
|
||
_persist(storage, key, JSON.stringify(restartSnapshot(record)));
|
||
} catch (_) { /* storage errors must not crash caller */ }
|
||
}
|
||
|
||
// ── internals ────────────────────────────────────────────────────────
|
||
|
||
function _persist(storage, key, value) {
|
||
try { storage.setItem(key, value); } catch (_) {}
|
||
}
|
||
|
||
function _getTargetStorage() {
|
||
if (typeof globalThis.window === "undefined") return null;
|
||
const s = globalThis.window.localStorage;
|
||
if (!s || typeof s.getItem !== "function") return null;
|
||
return s;
|
||
}
|
||
|
||
function _getLegacyStorage() {
|
||
if (typeof globalThis.window === "undefined") return null;
|
||
const s = globalThis.window.sessionStorage;
|
||
if (!s || typeof s.getItem !== "function") return null;
|
||
return s;
|
||
}
|