Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions tools/denkraum/store.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { DeskError, latestAnswer, projectQuestionPriority, compareQuestions, migrateState } from './store/model.mjs';
export { LedgerStore as DeskStore } from './store/ledger.mjs';
68 changes: 68 additions & 0 deletions tools/denkraum/store/ledger.mjs
Original file line number Diff line number Diff line change
@@ -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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle omitted legacy priority source lists

When an existing question has a legacy priorityDetail without sourceRefs, which is accepted because validateQuestion defaults omitted reference lists to [] (model.mjs:157-170), an update that omits replacement priority details selects that raw stored object and this .length access throws an untyped TypeError. Consequently, an otherwise valid legacy ledger cannot update the question and an HTTP caller would receive an internal error instead of a DeskError; normalize the inherited detail or treat a missing sourceRefs as an empty list.

Useful? React with 👍 / 👎.

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove cleared recommendation details before returning

When an imported update changes sourceRef on a question that already has recommendationDetail and omits replacement details, this assignment creates an own recommendationDetail: undefined property in next. The initial putQuestion result therefore has that property, but JSON persistence drops it and a requestId replay returns an object without it, so direct-store callers receive a structurally different replay result; remove the property rather than assigning undefined, or normalize the result before returning it.

Useful? React with 👍 / 👎.

const next = { ...old, ...q, revision: (old?.revision ?? 0) + 1, createdAt: old?.createdAt ?? now(), updatedAt: now() };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reuse one timestamp when creating a question

When the system clock advances between these consecutive now() calls, a newly created question receives different createdAt and updatedAt values, while the new regression test explicitly requires them to be equal (ledger.test.mjs:55). This makes the test timing-dependent and can represent a brand-new record as already updated; capture one timestamp and use it for both fields on creation.

Useful? React with 👍 / 👎.

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;
});
}
}
82 changes: 82 additions & 0 deletions tools/denkraum/store/ledger.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading