From 83ca797c0002d820d0ad704365cc455f9701e0b7 Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:07:56 +0200 Subject: [PATCH] feat(denkraum): split ideas and question import onto the store (DR-04c-2) Move putIdea and putQuestion from the frozen P1 DeskStore (lines 348-382 and 392-415) into tools/denkraum/store/ledger.mjs as LedgerStore extends ProgressStore. The only change is this.#change( to this.change(; neither operation uses the root agent id. tools/denkraum/store.mjs re-exports the P1 surface (DeskError, latestAnswer, projectQuestionPriority, compareQuestions, migrateState and DeskStore = LedgerStore), so the server and CLI in DR-06 can import one store with every operation. The root agent id stays injected (D2). Test-First: tools/denkraum/store/ledger.test.mjs::an idea revision starts incoming progress and a pending outbox entry and keeps its metadata Test-First: tools/denkraum/store/ledger.test.mjs::store.mjs exposes the P1 surface with every operation on one store Co-Authored-By: Claude Opus 5.5 (1M context) --- tools/denkraum/store.mjs | 2 + tools/denkraum/store/ledger.mjs | 68 +++++++++++++++++++++++ tools/denkraum/store/ledger.test.mjs | 82 ++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 tools/denkraum/store.mjs create mode 100644 tools/denkraum/store/ledger.mjs create mode 100644 tools/denkraum/store/ledger.test.mjs diff --git a/tools/denkraum/store.mjs b/tools/denkraum/store.mjs new file mode 100644 index 00000000..caedc4ec --- /dev/null +++ b/tools/denkraum/store.mjs @@ -0,0 +1,2 @@ +export { DeskError, latestAnswer, projectQuestionPriority, compareQuestions, migrateState } from './store/model.mjs'; +export { LedgerStore as DeskStore } from './store/ledger.mjs'; diff --git a/tools/denkraum/store/ledger.mjs b/tools/denkraum/store/ledger.mjs new file mode 100644 index 00000000..4c0a366f --- /dev/null +++ b/tools/denkraum/store/ledger.mjs @@ -0,0 +1,68 @@ +// Ideas and question import (DR-04c-2): the last P1 operations, completing the store surface. +// Split from the P1 DeskStore (putIdea/putQuestion); neither needs the root agent id. +import { randomUUID } from 'node:crypto'; +import { ProgressStore } from './progress.mjs'; +import { ensure, id, ideaDefaults, ideaPriorities, now, object, priorityRefCurrent, requireCurrentSource, sameRef, text, + validateQuestion, validIdeaCategory } from './model.mjs'; + +export class LedgerStore extends ProgressStore { + async putIdea(input) { + object(input, 'Idee'); + const requestId = id(input.requestId); const ideaId = input.id === undefined ? null : id(input.id); + ensure(ideaId ? Number.isSafeInteger(input.expectedRevision) && input.expectedRevision > 0 : input.expectedRevision === null, 'Ungültige expectedRevision.'); + const content = { title: text(input.title, 'Titel', 200), text: text(input.text, 'Idee', 8000), + source: input.source === undefined || input.source === null ? null : text(input.source, 'Quelle', 2000) }; + if (Object.hasOwn(input, 'category')) { + ensure(typeof input.category === 'string' && validIdeaCategory(input.category.trim()), 'Ungültige Ideenkategorie.'); + content.category = input.category.trim(); + } + if (Object.hasOwn(input, 'userPriority')) { + ensure(ideaPriorities.includes(input.userPriority), 'Ungültige Ideenpriorität.'); + content.userPriority = input.userPriority; + } + const payload = JSON.stringify({ id: ideaId, expectedRevision: input.expectedRevision, ...content }); + return this.change(state => { + ensure(state.schemaVersion === 2, 'Explizite V2-Migration erforderlich.', 409); + // ponytail: request replay scans the local history; index request IDs when the ledger grows large. + for (const idea of state.ideas) { + const replay = idea.revisions.find(r => r.requestId === requestId); + if (replay) { ensure(replay.payload === payload, 'requestId bereits mit anderem Inhalt verwendet.', 409); return { id: idea.id, ...replay }; } + } + let idea = ideaId ? state.ideas.find(i => i.id === ideaId) : null; + if (ideaId) ensure(idea, 'Idee nicht gefunden.', 404); + if (idea) ensure(idea.revisions.at(-1).revision === input.expectedRevision, 'Ideenrevision verändert.', 409); + else { idea = { id: randomUUID(), revisions: [] }; state.ideas.push(idea); } + const metadata = { ...ideaDefaults(idea.revisions.at(-1) ?? {}), ...content }; + const revision = { revision: idea.revisions.length + 1, eventId: randomUUID(), requestId, payload, ...metadata, createdAt: now() }; + revision.webhookDelivery = { status: 'pending', attempts: 0, lastAttemptAt: null, queuedAt: null, lastError: null }; + idea.revisions.push(revision); + state.progress.push({ eventId: revision.eventId, currentStatus: 'incoming', progressRevision: 1, + history: [{ progressRevision: 1, from: null, to: 'incoming', actor: 'user', at: revision.createdAt, reason: 'Idee gespeichert.', evidenceRefs: [] }] }); + return { id: idea.id, ...revision }; + }); + } + async putQuestion(input) { + const q = validateQuestion(input); + const requestId = input.requestId === undefined ? null : id(input.requestId); + const payload = requestId ? JSON.stringify({ id: input.id ?? null, expectedRevision: input.expectedRevision ?? null, question: { ...q, id: input.id ?? null } }) : null; + return this.change(state => { + const replay = requestId && state.questionImports?.find(r => r.requestId === requestId); + if (replay) { ensure(replay.payload === payload, 'Import-requestId bereits anders verwendet.', 409); return replay.result; } + const index = state.questions.findIndex(x => x.id === q.id); const old = state.questions[index]; + requireCurrentSource(state, { ...old, ...q }); + const detail = q.priorityDetail ?? old?.priorityDetail; + if (detail?.sourceRefs.length) { + ensure(detail.sourceRefs.every(ref => priorityRefCurrent(state, ref)), 'Prioritätsquelle ist nicht aktuell.', 409); + const sourceRef = q.sourceRef ?? old?.sourceRef; + ensure(!sourceRef || detail.sourceRefs.some(ref => sameRef(ref, sourceRef)), 'Priorität ist nicht an diese Quelle gebunden.', 409); + } + ensure(old || input.expectedRevision === undefined, 'Frage fehlt für diese Revision. Erst neu laden.', 409); + ensure(!old || input.expectedRevision === old.revision, 'Frage wurde inzwischen geändert. Erst neu laden.', 409); + if (old?.recommendationDetail && q.sourceRef && !sameRef(old.sourceRef, q.sourceRef) && q.recommendationDetail === undefined) q.recommendationDetail = undefined; + const next = { ...old, ...q, revision: (old?.revision ?? 0) + 1, createdAt: old?.createdAt ?? now(), updatedAt: now() }; + if (old) state.questions[index] = next; else state.questions.push(next); + if (requestId) { state.questionImports ??= []; state.questionImports.push({ requestId, payload, result: structuredClone(next) }); } + return next; + }); + } +} diff --git a/tools/denkraum/store/ledger.test.mjs b/tools/denkraum/store/ledger.test.mjs new file mode 100644 index 00000000..cbd9e9b6 --- /dev/null +++ b/tools/denkraum/store/ledger.test.mjs @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { LedgerStore } from './ledger.mjs'; +import { DeskError } from './model.mjs'; +import * as surface from '../store.mjs'; + +const ROOT = 'test-root-agent'; +const status = code => e => e instanceof DeskError && e.status === code; +const opt = id => ({ id, label: id, rationale: 'r', impact: 'i', tradeoff: 't', effort: 'e', reversible: 'ja' }); +const question = (extra = {}) => ({ id: 'E-1', title: 'T', context: 'C', owner: 'O', category: 'K', scope: 'S', source: 'Q', uncertainty: 'U', + recommendation: { optionIds: ['a'], rationale: 'R' }, options: [opt('a'), opt('b')], ...extra }); +const idea = (extra = {}) => ({ requestId: 'idea-1', expectedRevision: null, title: 'Idee', text: 'Beschreibung', ...extra }); +async function fresh({ v2 = true, ...config } = {}) { + const s = new LedgerStore(join(await mkdtemp(join(tmpdir(), 'denkraum-ledger-')), 'ledger.json'), { rootAgentId: ROOT, ...config }); + if (v2) { await s.putQuestion(question({ id: 'seed' })); await s.migrate((await s.read()).revision); } + return s; +} + +test('an idea revision starts incoming progress and a pending outbox entry and keeps its metadata', async () => { + const calls = []; const s = await fresh({ notifyEvent: async (event, id) => calls.push({ event, id }) }); + const first = await s.putIdea(idea({ category: ' Werkzeug ', userPriority: 'high' })); + assert.equal(first.revision, 1); assert.equal(first.category, 'Werkzeug'); assert.equal(first.userPriority, 'high'); + assert.deepEqual(first.webhookDelivery, { status: 'pending', attempts: 0, lastAttemptAt: null, queuedAt: null, lastError: null }); + const state = await s.read(); + assert.deepEqual(state.progress.map(p => [p.eventId, p.currentStatus]), [[first.eventId, 'incoming']]); + assert.equal((await s.inbox()).pending[0].eventRef.ideaId, first.id); + assert.equal((await s.flushNotifications()).status, 'queued'); + assert.deepEqual(calls[0].event, { type: 'decision-desk.event', eventId: first.eventId, contentRevision: 1, deliveryId: first.eventId }); + const second = await s.putIdea(idea({ requestId: 'idea-2', id: first.id, expectedRevision: 1, title: 'Idee 2' })); + assert.equal(second.revision, 2); assert.equal(second.category, 'Werkzeug', 'unchanged metadata carries over'); + assert.equal(second.userPriority, 'high'); + const third = await s.putIdea(idea({ requestId: 'idea-3', id: first.id, expectedRevision: 2, category: '', userPriority: 'later' })); + assert.equal(third.category, ''); assert.equal(third.userPriority, 'later'); +}); + +test('ideas replay by request id and refuse conflicting reuse, stale or unknown revisions, bad metadata and V1 ledgers', async () => { + const s = await fresh(); const first = await s.putIdea(idea()); const bytes = await readFile(s.file, 'utf8'); + assert.deepEqual(await s.putIdea(idea()), first); + await assert.rejects(s.putIdea(idea({ title: 'Anders' })), status(409)); + await assert.rejects(s.putIdea(idea({ requestId: 'idea-2', id: first.id, expectedRevision: 2 })), status(409)); + await assert.rejects(s.putIdea(idea({ requestId: 'idea-2', id: 'missing', expectedRevision: 1 })), status(404)); + for (const bad of [{ category: 'x'.repeat(81) }, { category: 7 }, { userPriority: 'sofort' }, { expectedRevision: 1 }, { title: ' ' }]) + await assert.rejects(s.putIdea(idea({ requestId: 'idea-bad', ...bad })), status(400), JSON.stringify(bad)); + assert.equal(await readFile(s.file, 'utf8'), bytes); + assert.equal((await s.putIdea(idea({ requestId: 'idea-80', category: 'x'.repeat(80) }))).category.length, 80); + await assert.rejects((await fresh({ v2: false })).putIdea(idea()), status(409)); +}); + +test('questions are created and revised only against the current revision; imports replay by request id', async () => { + const s = await fresh({ v2: false }); + const created = await s.putQuestion(question()); + assert.equal(created.revision, 1); assert.equal(created.createdAt, created.updatedAt); + await assert.rejects(s.putQuestion(question({ id: 'E-2', expectedRevision: 1 })), status(409), 'no question for this revision'); + await assert.rejects(s.putQuestion(question({ title: 'T2' })), status(409), 'revision required for an update'); + await assert.rejects(s.putQuestion(question({ title: 'T2', expectedRevision: 2 })), status(409)); + const revised = await s.putQuestion(question({ title: 'T2', expectedRevision: 1 })); + assert.equal(revised.revision, 2); assert.equal(revised.createdAt, created.createdAt); + const imported = await s.putQuestion(question({ id: 'E-3', requestId: 'import-1' })); const bytes = await readFile(s.file, 'utf8'); + assert.deepEqual(await s.putQuestion(question({ id: 'E-3', requestId: 'import-1' })), imported); + await assert.rejects(s.putQuestion(question({ id: 'E-3', requestId: 'import-1', title: 'Anders' })), status(409)); + assert.equal(await readFile(s.file, 'utf8'), bytes); + await assert.rejects(s.putQuestion(question({ options: [opt('a')] })), status(400)); +}); + +test('a question bound to an idea needs the current idea revision', async () => { + const s = await fresh(); const i = await s.putIdea(idea()); + const sourceRef = { kind: 'idea', eventId: i.eventId, ideaId: i.id, ideaRevision: 1 }; + assert.deepEqual((await s.putQuestion(question({ sourceRef }))).sourceRef, sourceRef); + await assert.rejects(s.putQuestion(question({ id: 'E-2', sourceRef: { ...sourceRef, ideaId: 'missing' } })), status(404)); + await s.putIdea(idea({ requestId: 'idea-2', id: i.id, expectedRevision: 1, text: 'Neu' })); + await assert.rejects(s.putQuestion(question({ id: 'E-2', sourceRef })), status(409), 'outdated idea revision'); +}); + +test('store.mjs exposes the P1 surface with every operation on one store', async () => { + assert.deepEqual(Object.keys(surface).sort(), ['DeskError', 'DeskStore', 'compareQuestions', 'latestAnswer', 'migrateState', 'projectQuestionPriority']); + assert.equal(surface.DeskStore, LedgerStore); + for (const op of ['read', 'change', 'migrate', 'answer', 'receipt', 'ack', 'pending', 'inbox', 'flushNotifications', 'putProgress', 'putPatch', 'putIdea', 'putQuestion']) + assert.equal(typeof LedgerStore.prototype[op], 'function', op); +});