fix(personas): merge partial legacy stores into per-file runtime

This commit is contained in:
Codex Local
2026-03-25 07:00:57 +01:00
parent 9d687dda5e
commit 25ad84fc69
7 changed files with 235 additions and 44 deletions
+187 -1
View File
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, writeFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, it } from "node:test";
@@ -48,6 +48,40 @@ describe("create-repos personas runtime", { concurrency: false }, () => {
});
});
it("merges missing legacy personas into an existing per-person store", async () => {
await withTempLocalDataDir(async (dir) => {
await mkdir(path.join(dir, "personas"), { recursive: true });
await writeFile(
path.join(dir, "personas", "schaeffer.json"),
`${JSON.stringify({ id: "schaeffer", name: "File Schaeffer", model: "qwen2.5:14b", summary: "per-file", editable: true }, null, 2)}\n`,
"utf8",
);
await writeFile(
path.join(dir, "personas.json"),
`${JSON.stringify([
{ id: "schaeffer", name: "Legacy Schaeffer", model: "qwen2.5:14b", summary: "legacy", editable: true },
{ id: "legacy_batty", name: "Legacy Batty", model: "mistral:7b", summary: "legacy-batty", editable: true },
], null, 2)}\n`,
"utf8",
);
const repo = createLocalPersonaRepo();
const rows = await repo.list();
assert.equal(rows.length, 2);
const schaeffer = await repo.findById("schaeffer");
assert.ok(schaeffer);
assert.equal(schaeffer.summary, "per-file");
const batty = await repo.findById("legacy_batty");
assert.ok(batty);
assert.equal(batty.summary, "legacy-batty");
const migrated = JSON.parse(await readFile(path.join(dir, "personas", "legacy_batty.json"), "utf8"));
assert.equal(migrated.id, "legacy_batty");
});
});
it("returns defensive clones from persona and source repos", async () => {
await withTempLocalDataDir(async () => {
const personaRepo = createLocalPersonaRepo();
@@ -78,6 +112,52 @@ describe("create-repos personas runtime", { concurrency: false }, () => {
});
});
it("merges missing legacy persona sources into an existing per-person store", async () => {
await withTempLocalDataDir(async (dir) => {
await mkdir(path.join(dir, "persona-sources"), { recursive: true });
await writeFile(
path.join(dir, "persona-sources", "schaeffer.json"),
`${JSON.stringify({
personaId: "schaeffer",
subjectName: "File Schaeffer",
summary: "per-file",
references: ["https://example.test/file"],
}, null, 2)}\n`,
"utf8",
);
await writeFile(
path.join(dir, "persona-sources.json"),
`${JSON.stringify({
schaeffer: {
personaId: "schaeffer",
subjectName: "Legacy Schaeffer",
summary: "legacy",
references: ["https://example.test/legacy"],
},
legacy_batty: {
personaId: "legacy_batty",
subjectName: "Legacy Batty",
summary: "legacy-batty",
references: ["https://example.test/batty"],
},
}, null, 2)}\n`,
"utf8",
);
const repo = createLocalPersonaSourceRepo();
const schaeffer = await repo.findByPersonaId("schaeffer");
assert.ok(schaeffer);
assert.equal(schaeffer.subjectName, "File Schaeffer");
const batty = await repo.findByPersonaId("legacy_batty");
assert.ok(batty);
assert.equal(batty.subjectName, "Legacy Batty");
const migrated = JSON.parse(await readFile(path.join(dir, "persona-sources", "legacy_batty.json"), "utf8"));
assert.equal(migrated.personaId, "legacy_batty");
});
});
it("returns immutable snapshots for feedback and proposals lists", async () => {
await withTempLocalDataDir(async () => {
const feedbackRepo = createLocalPersonaFeedbackRepo();
@@ -132,6 +212,112 @@ describe("create-repos personas runtime", { concurrency: false }, () => {
});
});
it("merges missing legacy feedback and proposals into an existing per-person store", async () => {
await withTempLocalDataDir(async (dir) => {
await mkdir(path.join(dir, "persona-feedback"), { recursive: true });
await mkdir(path.join(dir, "persona-proposals"), { recursive: true });
await writeFile(
path.join(dir, "persona-feedback", "schaeffer.json"),
`${JSON.stringify([
{
id: "feedback-file-1",
personaId: "schaeffer",
kind: "chat_signal",
message: "per-file-feedback",
createdAt: "2026-03-25T00:00:00.000Z",
},
], null, 2)}\n`,
"utf8",
);
await writeFile(
path.join(dir, "persona-proposals", "schaeffer.json"),
`${JSON.stringify([
{
id: "proposal-file-1",
personaId: "schaeffer",
reason: "per-file",
before: { name: "Schaeffer", model: "qwen2.5:14b", summary: "before-file" },
after: { name: "Schaeffer", model: "qwen2.5:14b", summary: "after-file" },
applied: false,
createdAt: "2026-03-25T00:00:00.000Z",
},
], null, 2)}\n`,
"utf8",
);
await writeFile(
path.join(dir, "persona-feedback.json"),
`${JSON.stringify([
{
id: "feedback-file-1",
personaId: "schaeffer",
kind: "chat_signal",
message: "legacy-duplicate",
createdAt: "2026-03-25T00:00:00.000Z",
},
{
id: "feedback-legacy-2",
personaId: "legacy_batty",
kind: "chat_signal",
message: "legacy-feedback",
createdAt: "2026-03-25T00:01:00.000Z",
},
], null, 2)}\n`,
"utf8",
);
await writeFile(
path.join(dir, "persona-proposals.json"),
`${JSON.stringify([
{
id: "proposal-file-1",
personaId: "schaeffer",
reason: "legacy-duplicate",
before: { name: "Schaeffer", model: "qwen2.5:14b", summary: "before-legacy" },
after: { name: "Schaeffer", model: "qwen2.5:14b", summary: "after-legacy" },
applied: false,
createdAt: "2026-03-25T00:00:00.000Z",
},
{
id: "proposal-legacy-2",
personaId: "legacy_batty",
reason: "legacy",
before: { name: "Batty", model: "mistral:7b", summary: "before-batty" },
after: { name: "Batty", model: "mistral:7b", summary: "after-batty" },
applied: false,
createdAt: "2026-03-25T00:01:00.000Z",
},
], null, 2)}\n`,
"utf8",
);
const feedbackRepo = createLocalPersonaFeedbackRepo();
const proposalRepo = createLocalPersonaProposalRepo();
const schaefferFeedback = await feedbackRepo.listByPersonaId("schaeffer");
assert.equal(schaefferFeedback.length, 1);
assert.equal(schaefferFeedback[0].message, "per-file-feedback");
const battyFeedback = await feedbackRepo.listByPersonaId("legacy_batty");
assert.equal(battyFeedback.length, 1);
assert.equal(battyFeedback[0].id, "feedback-legacy-2");
const schaefferProposals = await proposalRepo.listByPersonaId("schaeffer");
assert.equal(schaefferProposals.length, 1);
assert.equal(schaefferProposals[0].reason, "per-file");
const battyProposals = await proposalRepo.listByPersonaId("legacy_batty");
assert.equal(battyProposals.length, 1);
assert.equal(battyProposals[0].id, "proposal-legacy-2");
const migratedFeedback = JSON.parse(await readFile(path.join(dir, "persona-feedback", "legacy_batty.json"), "utf8"));
assert.equal(migratedFeedback.length, 1);
assert.equal(migratedFeedback[0].id, "feedback-legacy-2");
const migratedProposals = JSON.parse(await readFile(path.join(dir, "persona-proposals", "legacy_batty.json"), "utf8"));
assert.equal(migratedProposals.length, 1);
assert.equal(migratedProposals[0].id, "proposal-legacy-2");
});
});
it("returns defensive clones from session, graph and run repos", async () => {
const sessionRepo = createLocalSessionRepo();
const graphRepo = createLocalNodeGraphRepo();
+34 -37
View File
@@ -164,19 +164,18 @@ export function createLocalPersonaRepo() {
loaded = true;
const byPersonaFiles = await readJsonFiles<PersonaRecord>(files.personasDir);
if (byPersonaFiles.length > 0) {
for (const persona of byPersonaFiles) {
personas.set(persona.id, { ...persona });
}
return;
for (const persona of byPersonaFiles) {
personas.set(persona.id, { ...persona });
}
const saved = await readJson<PersonaRecord[]>(files.legacyPersonas, []);
if (saved.length > 0) {
for (const persona of saved) {
personas.set(persona.id, { ...persona });
await writeJson(personaFilePath(files.personasDir, persona.id), persona);
}
for (const persona of saved) {
if (personas.has(persona.id)) continue;
personas.set(persona.id, { ...persona });
await writeJson(personaFilePath(files.personasDir, persona.id), persona);
}
if (personas.size > 0) {
return;
}
@@ -296,15 +295,13 @@ export function createLocalPersonaSourceRepo() {
loaded = true;
const byPersonaFiles = await readJsonFiles<PersonaSourceRecord>(files.personaSourcesDir);
if (byPersonaFiles.length > 0) {
for (const source of byPersonaFiles) {
sources.set(source.personaId, { ...source });
}
return;
for (const source of byPersonaFiles) {
sources.set(source.personaId, { ...source });
}
const saved = await readJson<Record<string, PersonaSourceRecord>>(files.legacyPersonaSources, {});
for (const source of Object.values(saved)) {
if (sources.has(source.personaId)) continue;
sources.set(source.personaId, { ...source });
await writeJson(personaFilePath(files.personaSourcesDir, source.personaId), source);
}
@@ -335,26 +332,26 @@ export function createLocalPersonaFeedbackRepo() {
loaded = true;
const byPersonaFiles = await readJsonFiles<PersonaFeedbackRecord[] | PersonaFeedbackRecord>(files.personaFeedbackDir);
if (byPersonaFiles.length > 0) {
for (const payload of byPersonaFiles) {
const records = Array.isArray(payload) ? payload : [payload];
for (const record of records) {
const list = feedback.get(record.personaId) || [];
list.push({ ...record });
feedback.set(record.personaId, list);
}
for (const payload of byPersonaFiles) {
const records = Array.isArray(payload) ? payload : [payload];
for (const record of records) {
const list = feedback.get(record.personaId) || [];
list.push({ ...record });
feedback.set(record.personaId, list);
}
return;
}
const saved = await readJson<PersonaFeedbackRecord[]>(files.legacyPersonaFeedback, []);
const dirtyPersonaIds = new Set<string>();
for (const record of saved) {
const list = feedback.get(record.personaId) || [];
if (list.some((entry) => entry.id === record.id)) continue;
list.push({ ...record });
feedback.set(record.personaId, list);
dirtyPersonaIds.add(record.personaId);
}
for (const [personaId, records] of feedback.entries()) {
await writeJson(personaFilePath(files.personaFeedbackDir, personaId), records);
for (const personaId of dirtyPersonaIds) {
await writeJson(personaFilePath(files.personaFeedbackDir, personaId), feedback.get(personaId) || []);
}
}
@@ -384,26 +381,26 @@ export function createLocalPersonaProposalRepo() {
loaded = true;
const byPersonaFiles = await readJsonFiles<PersonaProposalRecord[] | PersonaProposalRecord>(files.personaProposalsDir);
if (byPersonaFiles.length > 0) {
for (const payload of byPersonaFiles) {
const records = Array.isArray(payload) ? payload : [payload];
for (const record of records) {
const list = proposals.get(record.personaId) || [];
list.push({ ...record });
proposals.set(record.personaId, list);
}
for (const payload of byPersonaFiles) {
const records = Array.isArray(payload) ? payload : [payload];
for (const record of records) {
const list = proposals.get(record.personaId) || [];
list.push({ ...record });
proposals.set(record.personaId, list);
}
return;
}
const saved = await readJson<PersonaProposalRecord[]>(files.legacyPersonaProposals, []);
const dirtyPersonaIds = new Set<string>();
for (const record of saved) {
const list = proposals.get(record.personaId) || [];
if (list.some((entry) => entry.id === record.id)) continue;
list.push({ ...record });
proposals.set(record.personaId, list);
dirtyPersonaIds.add(record.personaId);
}
for (const [personaId, records] of proposals.entries()) {
await writeJson(personaFilePath(files.personaProposalsDir, personaId), records);
for (const personaId of dirtyPersonaIds) {
await writeJson(personaFilePath(files.personaProposalsDir, personaId), proposals.get(personaId) || []);
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
# PLAN (kxkm-personas-runtime-20260325)
Updated: 2026-03-25T06:35:00Z
Updated: 2026-03-25T06:00: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:35:00Z
- Owner: Personas
- Execution: manual
- Checks: npm run check, npm run test:v2
- Summary: Done: persistence per-file, fallback legacy-read, clones defensifs, tests de non-regression, scripts smoke/build alignes.
- Summary: Done: persistence per-file, merge legacy partiel -> per-file, 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 -1
View File
@@ -1,6 +1,6 @@
# EXECUTION STATUS (kxkm-personas-runtime-20260325)
Updated: 2026-03-25T06:35:00Z
Updated: 2026-03-25T06:00:30Z
## lot-201-runtime-hardening
- Status: done
+2 -1
View File
@@ -1,9 +1,10 @@
# TODO (kxkm-personas-runtime-20260325)
Updated: 2026-03-25T06:35:00Z
Updated: 2026-03-25T06:00: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] 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, fallback legacy-read, clones defensifs, tests de non-regression, scripts smoke/build alignes.",
"summary": "Done: persistence per-file, merge legacy partiel -> per-file, clones defensifs, tests de non-regression, scripts smoke/build alignes.",
"status": "done",
"owner": "Personas",
"execution": "manual",
@@ -14,6 +14,7 @@
"depends_on": [],
"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": "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" },
+7 -1
View File
@@ -1,6 +1,6 @@
{
"project": "kxkm-personas-runtime-20260325",
"updated_at": "2026-03-25T06:35:00Z",
"updated_at": "2026-03-25T06:00:30Z",
"batches": {
"lot-201-runtime-hardening": {
"status": "done",
@@ -13,6 +13,12 @@
"output": "",
"last_error": ""
},
"partial-legacy-merge": {
"status": "done",
"attempts": 1,
"output": "apps/api/src/create-repos.ts",
"last_error": ""
},
"defensive-snapshots": {
"status": "done",
"attempts": 0,