fix(personas): merge partial legacy stores into per-file runtime
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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) || []);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user