fix(personas): make local migration loaders retryable
This commit is contained in:
@@ -82,6 +82,32 @@ describe("create-repos personas runtime", { concurrency: false }, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("retries a blocked legacy persona migration after the filesystem is fixed", async () => {
|
||||
await withTempLocalDataDir(async (dir) => {
|
||||
const blocker = path.join(dir, "personas");
|
||||
const migratedFile = path.join(dir, "personas", "legacy_ada.json");
|
||||
await writeFile(
|
||||
path.join(dir, "personas.json"),
|
||||
`${JSON.stringify([
|
||||
{ id: "legacy_ada", name: "Legacy Ada", model: "qwen2.5:14b", summary: "legacy summary", editable: true },
|
||||
], null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(blocker, "blocked", "utf8");
|
||||
|
||||
const repo = createLocalPersonaRepo();
|
||||
await assert.rejects(repo.findById("legacy_ada"));
|
||||
await rm(blocker, { force: true });
|
||||
|
||||
const row = await repo.findById("legacy_ada");
|
||||
assert.ok(row);
|
||||
assert.equal(row.name, "Legacy Ada");
|
||||
|
||||
const persisted = JSON.parse(await readFile(migratedFile, "utf8"));
|
||||
assert.equal(persisted.id, "legacy_ada");
|
||||
});
|
||||
});
|
||||
|
||||
it("returns defensive clones from persona and source repos", async () => {
|
||||
await withTempLocalDataDir(async () => {
|
||||
const personaRepo = createLocalPersonaRepo();
|
||||
|
||||
@@ -78,6 +78,28 @@ async function writeJson(filePath: string, data: unknown): Promise<void> {
|
||||
await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function createRetryableLoader(load: () => Promise<void>): () => Promise<void> {
|
||||
let loaded = false;
|
||||
let loadPromise: Promise<void> | null = null;
|
||||
|
||||
return async function ensureLoaded(): Promise<void> {
|
||||
if (loaded) return;
|
||||
if (!loadPromise) {
|
||||
loadPromise = (async () => {
|
||||
await load();
|
||||
loaded = true;
|
||||
})();
|
||||
}
|
||||
try {
|
||||
await loadPromise;
|
||||
} catch (error) {
|
||||
loadPromise = null;
|
||||
throw error;
|
||||
}
|
||||
loadPromise = null;
|
||||
};
|
||||
}
|
||||
|
||||
function clonePersonaRecord(persona: PersonaRecord): PersonaRecord {
|
||||
return { ...persona };
|
||||
}
|
||||
@@ -157,12 +179,8 @@ export function createLocalSessionRepo() {
|
||||
export function createLocalPersonaRepo() {
|
||||
const files = localStoreFiles();
|
||||
const personas = new Map<string, PersonaRecord>();
|
||||
let loaded = false;
|
||||
|
||||
async function ensureLoaded(): Promise<void> {
|
||||
if (loaded) return;
|
||||
loaded = true;
|
||||
|
||||
const ensureLoaded = createRetryableLoader(async () => {
|
||||
personas.clear();
|
||||
const byPersonaFiles = await readJsonFiles<PersonaRecord>(files.personasDir);
|
||||
for (const persona of byPersonaFiles) {
|
||||
personas.set(persona.id, { ...persona });
|
||||
@@ -184,7 +202,7 @@ export function createLocalPersonaRepo() {
|
||||
personas.set(seed.id, cloned);
|
||||
await writeJson(personaFilePath(files.personasDir, seed.id), cloned);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
async list(): Promise<PersonaRecord[]> {
|
||||
@@ -288,12 +306,8 @@ export function createLocalNodeRunRepo() {
|
||||
export function createLocalPersonaSourceRepo() {
|
||||
const files = localStoreFiles();
|
||||
const sources = new Map<string, PersonaSourceRecord>();
|
||||
let loaded = false;
|
||||
|
||||
async function ensureLoaded(): Promise<void> {
|
||||
if (loaded) return;
|
||||
loaded = true;
|
||||
|
||||
const ensureLoaded = createRetryableLoader(async () => {
|
||||
sources.clear();
|
||||
const byPersonaFiles = await readJsonFiles<PersonaSourceRecord>(files.personaSourcesDir);
|
||||
for (const source of byPersonaFiles) {
|
||||
sources.set(source.personaId, { ...source });
|
||||
@@ -305,7 +319,7 @@ export function createLocalPersonaSourceRepo() {
|
||||
sources.set(source.personaId, { ...source });
|
||||
await writeJson(personaFilePath(files.personaSourcesDir, source.personaId), source);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
async findByPersonaId(personaId: string): Promise<PersonaSourceRecord | null> {
|
||||
@@ -325,12 +339,8 @@ export function createLocalPersonaSourceRepo() {
|
||||
export function createLocalPersonaFeedbackRepo() {
|
||||
const files = localStoreFiles();
|
||||
const feedback = new Map<string, PersonaFeedbackRecord[]>();
|
||||
let loaded = false;
|
||||
|
||||
async function ensureLoaded(): Promise<void> {
|
||||
if (loaded) return;
|
||||
loaded = true;
|
||||
|
||||
const ensureLoaded = createRetryableLoader(async () => {
|
||||
feedback.clear();
|
||||
const byPersonaFiles = await readJsonFiles<PersonaFeedbackRecord[] | PersonaFeedbackRecord>(files.personaFeedbackDir);
|
||||
for (const payload of byPersonaFiles) {
|
||||
const records = Array.isArray(payload) ? payload : [payload];
|
||||
@@ -353,7 +363,7 @@ export function createLocalPersonaFeedbackRepo() {
|
||||
for (const personaId of dirtyPersonaIds) {
|
||||
await writeJson(personaFilePath(files.personaFeedbackDir, personaId), feedback.get(personaId) || []);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
async listByPersonaId(personaId: string): Promise<PersonaFeedbackRecord[]> {
|
||||
@@ -374,12 +384,8 @@ export function createLocalPersonaFeedbackRepo() {
|
||||
export function createLocalPersonaProposalRepo() {
|
||||
const files = localStoreFiles();
|
||||
const proposals = new Map<string, PersonaProposalRecord[]>();
|
||||
let loaded = false;
|
||||
|
||||
async function ensureLoaded(): Promise<void> {
|
||||
if (loaded) return;
|
||||
loaded = true;
|
||||
|
||||
const ensureLoaded = createRetryableLoader(async () => {
|
||||
proposals.clear();
|
||||
const byPersonaFiles = await readJsonFiles<PersonaProposalRecord[] | PersonaProposalRecord>(files.personaProposalsDir);
|
||||
for (const payload of byPersonaFiles) {
|
||||
const records = Array.isArray(payload) ? payload : [payload];
|
||||
@@ -402,7 +408,7 @@ export function createLocalPersonaProposalRepo() {
|
||||
for (const personaId of dirtyPersonaIds) {
|
||||
await writeJson(personaFilePath(files.personaProposalsDir, personaId), proposals.get(personaId) || []);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
async listByPersonaId(personaId: string): Promise<PersonaProposalRecord[]> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PLAN (kxkm-personas-runtime-20260325)
|
||||
|
||||
Updated: 2026-03-25T06:00:30Z
|
||||
Updated: 2026-03-25T06:02:30Z
|
||||
|
||||
## lot-201-runtime-hardening [done]
|
||||
- Description: Durcir le runtime personas local et basculer le store actif en per-file v2-local
|
||||
@@ -8,7 +8,7 @@ Updated: 2026-03-25T06:00:30Z
|
||||
- Owner: Personas
|
||||
- Execution: manual
|
||||
- Checks: npm run check, npm run test:v2
|
||||
- Summary: Done: persistence per-file, merge legacy partiel -> per-file, clones defensifs, tests de non-regression, scripts smoke/build alignes.
|
||||
- Summary: Done: persistence per-file, merge legacy partiel -> per-file, loaders retryables, clones defensifs, tests de non-regression, scripts smoke/build alignes.
|
||||
|
||||
## lot-202-memory-schema [pending]
|
||||
- Description: Formaliser un schema memory persona v2 avec working memory et archival memory
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# EXECUTION STATUS (kxkm-personas-runtime-20260325)
|
||||
|
||||
Updated: 2026-03-25T06:00:30Z
|
||||
Updated: 2026-03-25T06:02:30Z
|
||||
|
||||
## lot-201-runtime-hardening
|
||||
- Status: done
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# TODO (kxkm-personas-runtime-20260325)
|
||||
|
||||
Updated: 2026-03-25T06:00:30Z
|
||||
Updated: 2026-03-25T06:02:30Z
|
||||
|
||||
## lot-201-runtime-hardening
|
||||
- [x] per-file-store (done) | owner: Personas | severity: P1
|
||||
- [x] partial-legacy-merge (done) | owner: Backend API | severity: P1
|
||||
- [x] retryable-loader (done) | owner: Backend API | severity: P1
|
||||
- [x] defensive-snapshots (done) | owner: Backend API | severity: P1
|
||||
- [x] regression-tests (done) | owner: Backend API | severity: P1
|
||||
- [x] script-alignment (done) | owner: Ops/TUI | severity: P1
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{
|
||||
"id": "lot-201-runtime-hardening",
|
||||
"description": "Durcir le runtime personas local et basculer le store actif en per-file v2-local",
|
||||
"summary": "Done: persistence per-file, merge legacy partiel -> per-file, clones defensifs, tests de non-regression, scripts smoke/build alignes.",
|
||||
"summary": "Done: persistence per-file, merge legacy partiel -> per-file, loaders retryables, clones defensifs, tests de non-regression, scripts smoke/build alignes.",
|
||||
"status": "done",
|
||||
"owner": "Personas",
|
||||
"execution": "manual",
|
||||
@@ -15,6 +15,7 @@
|
||||
"tasks": [
|
||||
{ "id": "per-file-store", "query": "Migrer le store runtime personas vers data/v2-local fichier par persona", "status": "done", "owner": "Personas", "severity": "P1" },
|
||||
{ "id": "partial-legacy-merge", "query": "Fusionner les restes legacy aggregates dans un store per-file partiellement migre", "status": "done", "owner": "Backend API", "severity": "P1" },
|
||||
{ "id": "retryable-loader", "query": "Rendre les loaders de migration retryables apres une erreur filesystem transitoire", "status": "done", "owner": "Backend API", "severity": "P1" },
|
||||
{ "id": "defensive-snapshots", "query": "Supprimer les fuites de references mutables dans les repos in-memory personas", "status": "done", "owner": "Backend API", "severity": "P1" },
|
||||
{ "id": "regression-tests", "query": "Ajouter des tests pour migration legacy et immutabilite des snapshots", "status": "done", "owner": "Backend API", "severity": "P1" },
|
||||
{ "id": "script-alignment", "query": "Aligner smoke/build/log purge sur data/v2-local et compat explicite", "status": "done", "owner": "Ops/TUI", "severity": "P1" },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"project": "kxkm-personas-runtime-20260325",
|
||||
"updated_at": "2026-03-25T06:00:30Z",
|
||||
"updated_at": "2026-03-25T06:02:30Z",
|
||||
"batches": {
|
||||
"lot-201-runtime-hardening": {
|
||||
"status": "done",
|
||||
@@ -19,6 +19,12 @@
|
||||
"output": "apps/api/src/create-repos.ts",
|
||||
"last_error": ""
|
||||
},
|
||||
"retryable-loader": {
|
||||
"status": "done",
|
||||
"attempts": 1,
|
||||
"output": "apps/api/src/create-repos.ts",
|
||||
"last_error": ""
|
||||
},
|
||||
"defensive-snapshots": {
|
||||
"status": "done",
|
||||
"attempts": 0,
|
||||
|
||||
Reference in New Issue
Block a user