From 6abd823d754a2ddf707c60e1c313a6e8268e2dee Mon Sep 17 00:00:00 2001 From: Tony Ketcham Date: Fri, 17 Jul 2026 14:45:33 -0700 Subject: [PATCH] feat(effort-graph): journaled semantic writer for the Effort Graph New package @flatbread/effort-graph implementing ADRs 0002-0006: the standalone write surface for the Effort Graph, independent of core's read-only GraphQL layer. - thirteen v1 semantic mutations (ADR-0005) as a Zod discriminated union, validated against a committed index (targets exist, lifecycle transitions legal, same-Effort edges) - hybrid IDs (ADR-0006): kind prefix + kebab slug (<=48 chars) + 16-char lowercase Crockford-base32 80-bit suffix - forward-canonical edges with materialized back-edge projections written in the same save group (ADR-0004); canonical frontmatter key order and sorted edge arrays for merge-friendly YAML - write-ahead journal (ADR-0004): fsync-ordered intent record with before/after images, same-directory temp-file+rename applies with writer lock verification before each rename, committed/published markers, atomically published generation counter, idempotent crash recovery (uncommitted -> rollback, committed -> complete + reindex + publish) - per-graph writer lock with lease expiry and stale-lock reclamation - injectable EffortGraphIndexer seam for the future live-reindex bridge; no-op default keeps the writer standalone - effortGraphContent() preset exposing the six collections under .flatbread-efforts/ (polymorphic edge fields intentionally left out of refs until union refs land in core) - 33 AVA tests: ids, schemas + transformer-markdown round-trip, per-mutation file expansion, journal crash recovery, lock contention, preset shape Co-authored-by: Cursor Change-Id: I4bd555ef4e13a2d7d5933f1abb9e538fa04e5237 --- .gitignore | 3 +- packages/effort-graph/README.md | 7 + packages/effort-graph/package.json | 44 +++ .../effort-graph/src/__tests__/ids.test.ts | 89 +++++ .../src/__tests__/journal-recovery.test.ts | 137 +++++++ .../effort-graph/src/__tests__/lock.test.ts | 128 +++++++ .../effort-graph/src/__tests__/preset.test.ts | 85 +++++ .../src/__tests__/schemas.test.ts | 200 ++++++++++ .../effort-graph/src/__tests__/writer.test.ts | 354 ++++++++++++++++++ packages/effort-graph/src/errors.ts | 28 ++ packages/effort-graph/src/frontmatter.ts | 55 +++ packages/effort-graph/src/ids.ts | 62 +++ packages/effort-graph/src/index-store.ts | 67 ++++ packages/effort-graph/src/index.ts | 10 + packages/effort-graph/src/journal.ts | 222 +++++++++++ packages/effort-graph/src/lock.ts | 70 ++++ packages/effort-graph/src/planner.ts | 289 ++++++++++++++ packages/effort-graph/src/preset.ts | 52 +++ packages/effort-graph/src/schemas.ts | 174 +++++++++ packages/effort-graph/src/types.ts | 83 ++++ packages/effort-graph/src/writer.ts | 80 ++++ packages/effort-graph/tsconfig.json | 12 + packages/effort-graph/tsup.config.ts | 11 + pnpm-lock.yaml | 25 ++ 24 files changed, 2286 insertions(+), 1 deletion(-) create mode 100644 packages/effort-graph/README.md create mode 100644 packages/effort-graph/package.json create mode 100644 packages/effort-graph/src/__tests__/ids.test.ts create mode 100644 packages/effort-graph/src/__tests__/journal-recovery.test.ts create mode 100644 packages/effort-graph/src/__tests__/lock.test.ts create mode 100644 packages/effort-graph/src/__tests__/preset.test.ts create mode 100644 packages/effort-graph/src/__tests__/schemas.test.ts create mode 100644 packages/effort-graph/src/__tests__/writer.test.ts create mode 100644 packages/effort-graph/src/errors.ts create mode 100644 packages/effort-graph/src/frontmatter.ts create mode 100644 packages/effort-graph/src/ids.ts create mode 100644 packages/effort-graph/src/index-store.ts create mode 100644 packages/effort-graph/src/index.ts create mode 100644 packages/effort-graph/src/journal.ts create mode 100644 packages/effort-graph/src/lock.ts create mode 100644 packages/effort-graph/src/planner.ts create mode 100644 packages/effort-graph/src/preset.ts create mode 100644 packages/effort-graph/src/schemas.ts create mode 100644 packages/effort-graph/src/types.ts create mode 100644 packages/effort-graph/src/writer.ts create mode 100644 packages/effort-graph/tsconfig.json create mode 100644 packages/effort-graph/tsup.config.ts diff --git a/.gitignore b/.gitignore index 50c897d1..c069b1c4 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,5 @@ # logs yarn-error.log -.pnpm-debug.log \ No newline at end of file +.pnpm-debug.log +**/.flatbread-efforts/.journal/ \ No newline at end of file diff --git a/packages/effort-graph/README.md b/packages/effort-graph/README.md new file mode 100644 index 00000000..c0964573 --- /dev/null +++ b/packages/effort-graph/README.md @@ -0,0 +1,7 @@ +# `@flatbread/effort-graph` + +The standalone semantic writer for Flatbread's git-native Effort Graph. It stores typed reasoning primitives as markdown and uses a journal for multi-file mutations. + +The v1 mutation surface is exactly: `CreateEffort`, `SetEffortStatus`, `WriteIssue`, `WriteFinding`, `WriteDecision`, `WriteConstraint`, `WriteRisk`, `Supersede`, `Invalidate`, `ResolveIssue`, `AcceptDecision`, `MitigateRisk`, and `SetRiskState`. + +The journal lives at `/.journal` and is ignored by git. Use `effortGraphContent()` to add the six collections to a Flatbread configuration. Cross-collection union fields remain writer-validated rather than Flatbread `refs`. diff --git a/packages/effort-graph/package.json b/packages/effort-graph/package.json new file mode 100644 index 00000000..0601e513 --- /dev/null +++ b/packages/effort-graph/package.json @@ -0,0 +1,44 @@ +{ + "name": "@flatbread/effort-graph", + "version": "0.1.0-alpha.0", + "description": "Standalone semantic writer for the Flatbread Effort Graph — journaled multi-file mutations over epistemic markdown artifacts.", + "type": "module", + "scripts": { + "build": "tsup", + "dev": "tsup --watch src", + "test": "pnpm --dir ../.. exec ava \"packages/effort-graph/src/__tests__/**/*.test.ts\"", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/FlatbreadLabs/flatbread.git", + "directory": "packages/effort-graph" + }, + "homepage": "https://github.com/FlatbreadLabs/flatbread#readme", + "author": "Tony Ketcham ", + "license": "MIT", + "bugs": { + "url": "https://github.com/FlatbreadLabs/flatbread/issues" + }, + "exports": { + ".": "./dist/index.js" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "*.d.ts" + ], + "dependencies": { + "gray-matter": "^4.0.3", + "zod": "^3.24.1" + }, + "devDependencies": { + "@flatbread/core": "workspace:*", + "@flatbread/transformer-markdown": "workspace:*", + "@types/node": "25.6.2", + "tsup": "8.5.1", + "typescript": "6.0.3" + } +} diff --git a/packages/effort-graph/src/__tests__/ids.test.ts b/packages/effort-graph/src/__tests__/ids.test.ts new file mode 100644 index 00000000..83700c9c --- /dev/null +++ b/packages/effort-graph/src/__tests__/ids.test.ts @@ -0,0 +1,89 @@ +import test from 'ava'; +import { mkdtemp, readdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { generateArtifactId, slugify, validateArtifactId } from '../ids.js'; +import { createEffortGraphWriter } from '../writer.js'; +import { EffortGraphValidationError } from '../errors.js'; +import type { PrimitiveKind } from '../types.js'; + +const kinds: [PrimitiveKind, string][] = [ + ['effort', 'eff'], + ['issue', 'iss'], + ['finding', 'fnd'], + ['decision', 'dec'], + ['constraint', 'con'], + ['risk', 'rsk'], +]; + +test('generates valid ids for all six prefixes', (t) => { + for (const [kind, prefix] of kinds) { + const id = generateArtifactId(kind, 'Some Great Title'); + t.true(id.startsWith(`${prefix}-some-great-title--`)); + t.true(validateArtifactId(id, kind)); + } +}); + +test('suffix is exactly 16 chars of the lowercase Crockford alphabet', (t) => { + for (let i = 0; i < 25; i++) { + const id = generateArtifactId('finding', 'x'); + const suffix = id.split('--')[1]; + t.is(suffix.length, 16); + t.regex(suffix, /^[0123456789abcdefghjkmnpqrstvwxyz]{16}$/); + t.notRegex(suffix, /[ilou]/); + } +}); + +test('slug is capped at 48 characters', (t) => { + const slug = slugify('word '.repeat(40)); + t.true(slug.length <= 48); + t.true(slug.length >= 40); + t.notRegex(slug, /^-|-$/); +}); + +test('empty title slugifies to untitled', (t) => { + t.is(slugify(''), 'untitled'); + t.is(slugify('!!! ???'), 'untitled'); + const id = generateArtifactId('issue', ' '); + t.true(id.startsWith('iss-untitled--')); +}); + +test('validateArtifactId rejects malformed ids', (t) => { + const suffix = '0123456789abcdef'; + t.true(validateArtifactId(`dec-good--${suffix}`)); + // Wrong / unknown prefix. + t.false(validateArtifactId(`xxx-good--${suffix}`)); + // Prefix that does not match the expected kind. + t.false(validateArtifactId(`dec-good--${suffix}`, 'finding')); + // Uppercase anywhere. + t.false(validateArtifactId(`DEC-good--${suffix}`)); + t.false(validateArtifactId(`dec-Good--${suffix}`)); + t.false(validateArtifactId(`dec-good--0123456789ABCDEF`)); + // Bad alphabet chars in suffix (i, l, o, u are excluded). + t.false(validateArtifactId('dec-good--0123456789abcdei')); + t.false(validateArtifactId('dec-good--l123456789abcdef')); + t.false(validateArtifactId('dec-good--o123456789abcdef')); + t.false(validateArtifactId('dec-good--u123456789abcdef')); + // Malformed separators. + t.false(validateArtifactId(`dec-good-${suffix}`)); + t.false(validateArtifactId(`dec--good--${suffix}`)); + t.false(validateArtifactId(`dec-good--${suffix}x`)); + t.false(validateArtifactId(`dec---${suffix}`)); + t.false(validateArtifactId('')); +}); + +test('writer rejects a stubbed randomBytes collision before writing', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'eg-ids-')); + const writer = createEffortGraphWriter({ + rootDir: root, + randomBytes: () => new Uint8Array(10).fill(7), + }); + await writer.mutate({ type: 'CreateEffort', title: 'Same Title', body: '' }); + const error = await t.throwsAsync( + writer.mutate({ type: 'CreateEffort', title: 'Same Title', body: '' }), + { instanceOf: EffortGraphValidationError } + ); + t.truthy(error); + const files = await readdir(join(root, 'efforts')); + t.is(files.length, 1); +}); diff --git a/packages/effort-graph/src/__tests__/journal-recovery.test.ts b/packages/effort-graph/src/__tests__/journal-recovery.test.ts new file mode 100644 index 00000000..e022d2cc --- /dev/null +++ b/packages/effort-graph/src/__tests__/journal-recovery.test.ts @@ -0,0 +1,137 @@ +import test from 'ava'; +import { mkdtemp, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { recoverJournal } from '../journal.js'; +import { EffortGraphCorruptJournalError } from '../errors.js'; + +function writeEntry( + relativePath: string, + before: string | undefined, + after: string +) { + return { + relativePath, + before: { + exists: before !== undefined, + ...(before !== undefined + ? { base64: Buffer.from(before).toString('base64') } + : {}), + }, + after: { + sha256: createHash('sha256').update(after).digest('hex'), + base64: Buffer.from(after).toString('base64'), + }, + }; +} + +async function makeRoot() { + const root = await mkdtemp(join(tmpdir(), 'eg-journal-')); + await mkdir(join(root, 'efforts'), { recursive: true }); + await mkdir(join(root, '.journal', 'txns'), { recursive: true }); + return root; +} + +async function writeIntent( + root: string, + txnId: string, + writes: unknown[], + markers: Array<'committed' | 'published'> = [] +) { + const td = join(root, '.journal', 'txns', txnId); + await mkdir(td, { recursive: true }); + await writeFile( + join(td, 'intent.json'), + JSON.stringify({ + transactionId: txnId, + createdAt: new Date().toISOString(), + targetGeneration: 7, + lockToken: 'test-token', + writes, + touchedIds: ['eff-a--0123456789abcdef'], + }) + ); + for (const marker of markers) await writeFile(join(td, marker), ''); + return td; +} + +test('uncommitted txn rolls back before-images and removes temp remnants', async (t) => { + const root = await makeRoot(); + const updatedPath = join(root, 'efforts', 'a.md'); + const createdPath = join(root, 'efforts', 'b.md'); + // Simulate a partially applied transaction: a.md overwritten, b.md created. + await writeFile(updatedPath, 'NEW A'); + await writeFile(createdPath, 'NEW B'); + await writeFile(join(root, 'efforts', 'b.tmp-remnant.md'), 'partial'); + await writeIntent(root, 't-uncommitted', [ + writeEntry('efforts/a.md', 'OLD A', 'NEW A'), + writeEntry('efforts/b.md', undefined, 'NEW B'), + ]); + const result = await recoverJournal(root, async () => {}); + t.is(result.action, 'rolled_back'); + t.is(result.transactionId, 't-uncommitted'); + t.is(await readFile(updatedPath, 'utf8'), 'OLD A'); + t.false(existsSync(createdPath)); + const remnants = (await readdir(join(root, 'efforts'))).filter((n) => + n.includes('.tmp-') + ); + t.deepEqual(remnants, []); + t.deepEqual(await readdir(join(root, '.journal', 'txns')), []); +}); + +test('committed txn re-applies after-images, reindexes once, and publishes', async (t) => { + const root = await makeRoot(); + const path = join(root, 'efforts', 'a.md'); + // Stale content that does not match the after-image sha. + await writeFile(path, 'STALE'); + await writeIntent( + root, + 't-committed', + [writeEntry('efforts/a.md', 'OLD A', 'FINAL A')], + ['committed'] + ); + let reindexCalls = 0; + const result = await recoverJournal(root, async (request) => { + reindexCalls += 1; + t.is(request.targetGeneration, '7'); + t.deepEqual([...request.changedPaths], ['efforts/a.md']); + }); + t.is(result.action, 'completed'); + t.is(reindexCalls, 1); + t.is(await readFile(path, 'utf8'), 'FINAL A'); + const generation = JSON.parse( + await readFile(join(root, '.journal', 'generation.json'), 'utf8') + ); + t.is(generation.generation, 7); + t.deepEqual(await readdir(join(root, '.journal', 'txns')), []); +}); + +test('recovery is idempotent when run twice', async (t) => { + const root = await makeRoot(); + await writeFile(join(root, 'efforts', 'a.md'), 'NEW A'); + await writeIntent(root, 't-repeat', [ + writeEntry('efforts/a.md', 'OLD A', 'NEW A'), + ]); + const first = await recoverJournal(root, async () => {}); + t.is(first.action, 'rolled_back'); + const second = await recoverJournal(root, async () => {}); + t.is(second.action, 'none'); + t.is(await readFile(join(root, 'efforts', 'a.md'), 'utf8'), 'OLD A'); +}); + +test('corrupt intent.json fails closed', async (t) => { + const root = await makeRoot(); + const td = join(root, '.journal', 'txns', 't-corrupt'); + await mkdir(td, { recursive: true }); + await writeFile(join(td, 'intent.json'), '{ this is not json'); + await t.throwsAsync( + recoverJournal(root, async () => {}), + { + instanceOf: EffortGraphCorruptJournalError, + } + ); + // The corrupt transaction is left untouched — never guess. + t.true(existsSync(join(td, 'intent.json'))); +}); diff --git a/packages/effort-graph/src/__tests__/lock.test.ts b/packages/effort-graph/src/__tests__/lock.test.ts new file mode 100644 index 00000000..11169774 --- /dev/null +++ b/packages/effort-graph/src/__tests__/lock.test.ts @@ -0,0 +1,128 @@ +import test from 'ava'; +import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { hostname } from 'node:os'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { acquireWriterLock } from '../lock.js'; +import { createEffortGraphWriter } from '../writer.js'; +import { + EffortGraphLockedError, + EffortGraphValidationError, +} from '../errors.js'; + +// A pid above the platform maximum, guaranteed dead. +const DEAD_PID = 2 ** 30; + +async function makeRoot() { + const root = await mkdtemp(join(tmpdir(), 'eg-lock-')); + await mkdir(join(root, '.journal'), { recursive: true }); + return root; +} + +function lockPath(root: string) { + return join(root, '.journal', 'writer.lock'); +} + +test('second concurrent acquire throws EffortGraphLockedError', async (t) => { + const root = await makeRoot(); + const lock = await acquireWriterLock(root); + const error = (await t.throwsAsync(acquireWriterLock(root), { + instanceOf: EffortGraphLockedError, + })) as EffortGraphLockedError; + t.is(error.code, 'EFFORT_GRAPH_LOCKED'); + t.true(error.retryAfterMs > 0); + await lock.release(); + // After release a new writer can acquire. + const next = await acquireWriterLock(root); + await next.release(); +}); + +test('expired lease with a dead pid is reclaimed', async (t) => { + const root = await makeRoot(); + await writeFile( + lockPath(root), + JSON.stringify({ + token: 'stale-token', + pid: DEAD_PID, + hostname: hostname(), + startedAt: Date.now() - 10_000, + heartbeatAt: Date.now() - 10_000, + }) + ); + const lock = await acquireWriterLock(root, { leaseMs: 1000 }); + t.truthy(lock.token); + // The stale lock is preserved for forensics under a stale- name. + t.true(existsSync(`${lockPath(root)}.stale-stale-token`)); + await lock.release(); +}); + +test('expired lease with a live pid is NOT reclaimed', async (t) => { + const root = await makeRoot(); + await writeFile( + lockPath(root), + JSON.stringify({ + token: 'live-token', + pid: process.pid, + hostname: hostname(), + startedAt: Date.now() - 10_000, + heartbeatAt: Date.now() - 10_000, + }) + ); + await t.throwsAsync(acquireWriterLock(root, { leaseMs: 1000 }), { + instanceOf: EffortGraphLockedError, + }); +}); + +test('fresh lease with a dead pid is NOT reclaimed', async (t) => { + const root = await makeRoot(); + await writeFile( + lockPath(root), + JSON.stringify({ + token: 'fresh-token', + pid: DEAD_PID, + hostname: hostname(), + startedAt: Date.now(), + heartbeatAt: Date.now(), + }) + ); + await t.throwsAsync(acquireWriterLock(root, { leaseMs: 60_000 }), { + instanceOf: EffortGraphLockedError, + }); +}); + +test('release with a mismatched on-disk token leaves the lock file', async (t) => { + const root = await makeRoot(); + const lock = await acquireWriterLock(root); + const usurped = JSON.stringify({ + token: 'someone-else', + pid: process.pid, + hostname: hostname(), + startedAt: Date.now(), + heartbeatAt: Date.now(), + }); + await writeFile(lockPath(root), usurped); + await lock.release(); + t.true(existsSync(lockPath(root))); + t.is(await readFile(lockPath(root), 'utf8'), usurped); +}); + +test('lock is released after a failed mutate so the next mutate succeeds', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'eg-lock-writer-')); + const writer = createEffortGraphWriter({ rootDir: root }); + await t.throwsAsync( + writer.mutate({ + type: 'SetEffortStatus', + effortId: 'eff-missing--0123456789abcdef', + status: 'paused', + }), + { instanceOf: EffortGraphValidationError } + ); + t.false(existsSync(lockPath(root))); + const result = await writer.mutate({ + type: 'CreateEffort', + title: 'Recovers', + body: '', + }); + t.is(result.generation, '1'); +}); diff --git a/packages/effort-graph/src/__tests__/preset.test.ts b/packages/effort-graph/src/__tests__/preset.test.ts new file mode 100644 index 00000000..6aae0c76 --- /dev/null +++ b/packages/effort-graph/src/__tests__/preset.test.ts @@ -0,0 +1,85 @@ +import test from 'ava'; +import { effortGraphContent } from '../preset.js'; + +test('returns exactly six entries with exact paths and refs', (t) => { + const entries = effortGraphContent(); + t.deepEqual(entries, [ + { collection: 'Effort', path: '.flatbread-efforts/efforts' }, + { + collection: 'Issue', + path: '.flatbread-efforts/issues', + refs: { effort: 'Effort', supersedes: 'Issue', superseded_by: 'Issue' }, + }, + { + collection: 'Finding', + path: '.flatbread-efforts/findings', + refs: { + effort: 'Effort', + supersedes: 'Finding', + superseded_by: 'Finding', + }, + }, + { + collection: 'Decision', + path: '.flatbread-efforts/decisions', + refs: { + effort: 'Effort', + supersedes: 'Decision', + superseded_by: 'Decision', + rejected_by: 'Decision', + }, + }, + { + collection: 'Constraint', + path: '.flatbread-efforts/constraints', + refs: { + effort: 'Effort', + supersedes: 'Constraint', + superseded_by: 'Constraint', + }, + }, + { + collection: 'Risk', + path: '.flatbread-efforts/risks', + refs: { + effort: 'Effort', + supersedes: 'Risk', + superseded_by: 'Risk', + mitigated_by: 'Decision', + }, + }, + ]); +}); + +test('a custom root is respected in every path', (t) => { + const entries = effortGraphContent('memory/graph'); + t.deepEqual( + entries.map((e) => e.path), + [ + 'memory/graph/efforts', + 'memory/graph/issues', + 'memory/graph/findings', + 'memory/graph/decisions', + 'memory/graph/constraints', + 'memory/graph/risks', + ] + ); +}); + +test('polymorphic union fields are absent from every refs map', (t) => { + const polymorphic = [ + 'derives_from', + 'invalidates', + 'invalidated_by', + 'resolved_by', + 'evidence', + ]; + for (const entry of effortGraphContent()) { + for (const field of polymorphic) { + t.false( + Object.keys(entry.refs ?? {}).includes(field), + `${entry.collection} must not declare ${field} in refs` + ); + } + } +}); diff --git a/packages/effort-graph/src/__tests__/schemas.test.ts b/packages/effort-graph/src/__tests__/schemas.test.ts new file mode 100644 index 00000000..930be3e5 --- /dev/null +++ b/packages/effort-graph/src/__tests__/schemas.test.ts @@ -0,0 +1,200 @@ +import test from 'ava'; +import { parse as parseMarkdown } from '@flatbread/transformer-markdown'; +import { + EffortGraphMutationSchema, + EffortFrontmatterSchema, + IssueFrontmatterSchema, + RiskFrontmatterSchema, +} from '../schemas.js'; +import { serializeDocument } from '../frontmatter.js'; + +const suffix = '0123456789abcdef'; +const eff = `eff-anchor--${suffix}`; +const iss = `iss-question--${suffix}`; +const fnd = `fnd-observation--${suffix}`; +const dec = `dec-choice--${suffix}`; +const rsk = `rsk-hazard--${suffix}`; + +const validMutations: Record> = { + CreateEffort: { type: 'CreateEffort', title: 'Anchor', body: '', slug: 'a' }, + SetEffortStatus: { type: 'SetEffortStatus', effortId: eff, status: 'paused' }, + WriteIssue: { + type: 'WriteIssue', + effort: eff, + title: 'Q', + body: 'b', + kind: 'question', + derives_from: [fnd], + }, + WriteFinding: { + type: 'WriteFinding', + effort: eff, + title: 'F', + body: 'b', + kind: 'measurement', + supersedes: [fnd], + }, + WriteDecision: { + type: 'WriteDecision', + effort: eff, + title: 'D', + body: 'b', + invalidates: [dec], + }, + WriteConstraint: { + type: 'WriteConstraint', + effort: eff, + title: 'C', + body: 'b', + kind: 'hard', + }, + WriteRisk: { + type: 'WriteRisk', + effort: eff, + title: 'R', + body: 'b', + likelihood: 'low', + severity: 'high', + }, + Supersede: { type: 'Supersede', supersederId: dec, targetId: dec }, + Invalidate: { type: 'Invalidate', findingId: fnd, targetId: dec }, + ResolveIssue: { + type: 'ResolveIssue', + issueId: iss, + resolution: 'resolved', + resolvedBy: [dec], + }, + AcceptDecision: { type: 'AcceptDecision', decisionId: dec }, + MitigateRisk: { type: 'MitigateRisk', riskId: rsk, decisionId: dec }, + SetRiskState: { + type: 'SetRiskState', + riskId: rsk, + state: 'realized', + evidence: [fnd], + }, +}; + +test('each of the 13 mutation schemas accepts a valid input', (t) => { + const types = Object.keys(validMutations); + t.is(types.length, 13); + for (const type of types) { + t.notThrows( + () => EffortGraphMutationSchema.parse(validMutations[type]), + type + ); + } +}); + +test('union rejects an unknown discriminant', (t) => { + t.throws(() => EffortGraphMutationSchema.parse({ type: 'DeleteEverything' })); +}); + +test('rejects bad enum values', (t) => { + t.throws(() => + EffortGraphMutationSchema.parse({ + type: 'SetEffortStatus', + effortId: eff, + status: 'archived', + }) + ); + t.throws(() => + EffortGraphMutationSchema.parse({ + ...validMutations.WriteRisk, + likelihood: 'certain', + }) + ); + t.throws(() => + EffortGraphMutationSchema.parse({ + ...validMutations.SetRiskState, + state: 'mitigated', + }) + ); + t.throws(() => + EffortGraphMutationSchema.parse({ + ...validMutations.WriteConstraint, + kind: 'squishy', + }) + ); +}); + +test('rejects malformed ids', (t) => { + t.throws(() => + EffortGraphMutationSchema.parse({ + type: 'AcceptDecision', + decisionId: 'not-an-id', + }) + ); + t.throws(() => + EffortGraphMutationSchema.parse({ + ...validMutations.WriteIssue, + effort: 'eff-UPPER--0123456789abcdef', + }) + ); + t.throws(() => + EffortGraphMutationSchema.parse({ + ...validMutations.ResolveIssue, + resolvedBy: ['dec-short--abc'], + }) + ); +}); + +test('frontmatter schemas passthrough unknown keys', (t) => { + const parsed = EffortFrontmatterSchema.parse({ + id: eff, + title: 'Anchor', + created_at: '2026-07-17T00:00:00.000Z', + status: 'active', + hand_authored_note: 'keep me', + }); + t.is((parsed as Record).hand_authored_note, 'keep me'); + const issue = IssueFrontmatterSchema.parse({ + id: iss, + effort: eff, + title: 'Q', + created_at: '2026-07-17T00:00:00.000Z', + kind: 'question', + status: 'open', + custom: [1, 2], + }); + t.deepEqual((issue as Record).custom, [1, 2]); + t.throws(() => + RiskFrontmatterSchema.parse({ + id: rsk, + effort: eff, + title: 'R', + created_at: '2026-07-17T00:00:00.000Z', + state: 'open', + likelihood: 'low', + severity: 'never', + }) + ); +}); + +test('serialized documents round-trip through @flatbread/transformer-markdown parse', (t) => { + const frontmatter = { + id: dec, + effort: eff, + title: 'Adopt the writer', + state: 'proposed', + created_at: '2026-07-17T00:00:00.000Z', + supersedes: [`dec-older--${suffix}`], + hand_authored: 'still here', + }; + const body = '# Rationale\n\nBecause it is journaled.\n'; + const doc = serializeDocument(body, frontmatter).toString(); + const vfile = { + toString: () => doc, + basename: `${dec}.md`, + path: `decisions/${dec}.md`, + stem: dec, + data: {}, + }; + const node = parseMarkdown(vfile as Parameters[0], {}); + t.is(node.id, dec); + t.is(node.effort, eff); + t.is(node.title, 'Adopt the writer'); + t.is(node.state, 'proposed'); + t.deepEqual(node.supersedes, [`dec-older--${suffix}`]); + t.is(node.hand_authored, 'still here'); + t.is((node._content as { raw: string }).raw.trim(), body.trim()); +}); diff --git a/packages/effort-graph/src/__tests__/writer.test.ts b/packages/effort-graph/src/__tests__/writer.test.ts new file mode 100644 index 00000000..a4085a2c --- /dev/null +++ b/packages/effort-graph/src/__tests__/writer.test.ts @@ -0,0 +1,354 @@ +import test from 'ava'; +import { mkdtemp, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import matter from 'gray-matter'; +import { createEffortGraphWriter } from '../writer.js'; +import { EffortGraphValidationError } from '../errors.js'; +import type { EffortGraphWriter, MutationResult } from '../types.js'; + +async function makeWriter(): Promise<{ + root: string; + writer: EffortGraphWriter; +}> { + const root = await mkdtemp(join(tmpdir(), 'eg-writer-')); + return { root, writer: createEffortGraphWriter({ rootDir: root }) }; +} + +async function readFrontmatter(root: string, relativePath: string) { + const raw = await readFile(join(root, relativePath), 'utf8'); + return matter(raw); +} + +function soleId(result: MutationResult): string { + return result.artifacts[0].id; +} + +test('WriteDecision with supersedes materializes superseded_by on the target file', async (t) => { + const { root, writer } = await makeWriter(); + const effort = soleId( + await writer.mutate({ type: 'CreateEffort', title: 'E', body: '' }) + ); + const older = soleId( + await writer.mutate({ + type: 'WriteDecision', + effort, + title: 'Older', + body: '', + }) + ); + const result = await writer.mutate({ + type: 'WriteDecision', + effort, + title: 'Newer', + body: '', + supersedes: [older], + }); + t.is(result.touched.length, 2); + const target = await readFrontmatter(root, `decisions/${older}.md`); + t.deepEqual(target.data.superseded_by, [soleId(result)]); + const newer = result.artifacts.find((a) => a.operation === 'created')!; + t.deepEqual(newer.frontmatter.supersedes, [older]); +}); + +test('Supersede sets a Decision target state to superseded, exactly 2 files', async (t) => { + const { root, writer } = await makeWriter(); + const effort = soleId( + await writer.mutate({ type: 'CreateEffort', title: 'E', body: '' }) + ); + const a = soleId( + await writer.mutate({ type: 'WriteDecision', effort, title: 'A', body: '' }) + ); + const b = soleId( + await writer.mutate({ type: 'WriteDecision', effort, title: 'B', body: '' }) + ); + const result = await writer.mutate({ + type: 'Supersede', + supersederId: b, + targetId: a, + }); + t.is(result.touched.length, 2); + const target = await readFrontmatter(root, `decisions/${a}.md`); + t.is(target.data.state, 'superseded'); + t.deepEqual(target.data.superseded_by, [b]); + // Already-superseded target rejects a second superseder. + await t.throwsAsync( + writer.mutate({ type: 'Supersede', supersederId: b, targetId: a }), + { instanceOf: EffortGraphValidationError } + ); +}); + +test('Invalidate changes only edge fields on the target', async (t) => { + const { root, writer } = await makeWriter(); + const effort = soleId( + await writer.mutate({ type: 'CreateEffort', title: 'E', body: '' }) + ); + const decision = soleId( + await writer.mutate({ type: 'WriteDecision', effort, title: 'D', body: '' }) + ); + const before = await readFrontmatter(root, `decisions/${decision}.md`); + const finding = soleId( + await writer.mutate({ + type: 'WriteFinding', + effort, + title: 'F', + body: '', + kind: 'retrospective', + }) + ); + await writer.mutate({ + type: 'Invalidate', + findingId: finding, + targetId: decision, + }); + const after = await readFrontmatter(root, `decisions/${decision}.md`); + t.deepEqual(after.data.invalidated_by, [finding]); + t.is(after.data.state, before.data.state); + const changedKeys = Object.keys(after.data).filter( + (k) => JSON.stringify(after.data[k]) !== JSON.stringify(before.data[k]) + ); + t.deepEqual(changedKeys, ['invalidated_by']); + t.is(after.content, before.content); + // A second identical invalidation is a duplicate edge. + await t.throwsAsync( + writer.mutate({ + type: 'Invalidate', + findingId: finding, + targetId: decision, + }), + { instanceOf: EffortGraphValidationError } + ); + // A non-Finding source is rejected. + await t.throwsAsync( + writer.mutate({ + type: 'Invalidate', + findingId: decision, + targetId: finding, + }), + { instanceOf: EffortGraphValidationError } + ); +}); + +test('ResolveIssue rejects non-open issues and cross-effort sources', async (t) => { + const { writer } = await makeWriter(); + const effort = soleId( + await writer.mutate({ type: 'CreateEffort', title: 'E1', body: '' }) + ); + const otherEffort = soleId( + await writer.mutate({ type: 'CreateEffort', title: 'E2', body: '' }) + ); + const issue = soleId( + await writer.mutate({ + type: 'WriteIssue', + effort, + title: 'Q', + body: '', + kind: 'question', + }) + ); + const localFinding = soleId( + await writer.mutate({ + type: 'WriteFinding', + effort, + title: 'F1', + body: '', + kind: 'measurement', + }) + ); + const foreignFinding = soleId( + await writer.mutate({ + type: 'WriteFinding', + effort: otherEffort, + title: 'F2', + body: '', + kind: 'measurement', + }) + ); + await t.throwsAsync( + writer.mutate({ + type: 'ResolveIssue', + issueId: issue, + resolution: 'resolved', + resolvedBy: [foreignFinding], + }), + { instanceOf: EffortGraphValidationError } + ); + const resolved = await writer.mutate({ + type: 'ResolveIssue', + issueId: issue, + resolution: 'resolved', + resolvedBy: [localFinding], + }); + t.is(resolved.artifacts[0].frontmatter.status, 'resolved'); + t.deepEqual(resolved.artifacts[0].frontmatter.resolved_by, [localFinding]); + await t.throwsAsync( + writer.mutate({ + type: 'ResolveIssue', + issueId: issue, + resolution: 'wontfix', + resolvedBy: [localFinding], + }), + { instanceOf: EffortGraphValidationError } + ); +}); + +test('AcceptDecision rejects proposed siblings and leaves others untouched', async (t) => { + const { root, writer } = await makeWriter(); + const effort = soleId( + await writer.mutate({ type: 'CreateEffort', title: 'E1', body: '' }) + ); + const otherEffort = soleId( + await writer.mutate({ type: 'CreateEffort', title: 'E2', body: '' }) + ); + const winner = soleId( + await writer.mutate({ type: 'WriteDecision', effort, title: 'W', body: '' }) + ); + const sibling = soleId( + await writer.mutate({ type: 'WriteDecision', effort, title: 'S', body: '' }) + ); + const foreign = soleId( + await writer.mutate({ + type: 'WriteDecision', + effort: otherEffort, + title: 'Other', + body: '', + }) + ); + const result = await writer.mutate({ + type: 'AcceptDecision', + decisionId: winner, + }); + t.deepEqual(result.touched.map((x) => x.id).sort(), [winner, sibling].sort()); + const winnerDoc = await readFrontmatter(root, `decisions/${winner}.md`); + t.is(winnerDoc.data.state, 'accepted'); + const siblingDoc = await readFrontmatter(root, `decisions/${sibling}.md`); + t.is(siblingDoc.data.state, 'rejected'); + t.is(siblingDoc.data.rejected_by, winner); + const foreignDoc = await readFrontmatter(root, `decisions/${foreign}.md`); + t.is(foreignDoc.data.state, 'proposed'); + t.is(foreignDoc.data.rejected_by, undefined); + // A later accept must not touch the already-rejected sibling. + const later = soleId( + await writer.mutate({ type: 'WriteDecision', effort, title: 'L', body: '' }) + ); + const secondAccept = await writer.mutate({ + type: 'AcceptDecision', + decisionId: later, + }); + t.deepEqual( + secondAccept.touched.map((x) => x.id), + [later] + ); + const siblingAfter = await readFrontmatter(root, `decisions/${sibling}.md`); + t.is(siblingAfter.data.rejected_by, winner); + // Accepting a non-proposed decision is rejected. + await t.throwsAsync( + writer.mutate({ type: 'AcceptDecision', decisionId: winner }), + { instanceOf: EffortGraphValidationError } + ); +}); + +test('MitigateRisk requires an accepted Decision in the same Effort', async (t) => { + const { writer } = await makeWriter(); + const effort = soleId( + await writer.mutate({ type: 'CreateEffort', title: 'E', body: '' }) + ); + const risk = soleId( + await writer.mutate({ + type: 'WriteRisk', + effort, + title: 'R', + body: '', + likelihood: 'high', + severity: 'high', + }) + ); + const decision = soleId( + await writer.mutate({ type: 'WriteDecision', effort, title: 'D', body: '' }) + ); + await t.throwsAsync( + writer.mutate({ type: 'MitigateRisk', riskId: risk, decisionId: decision }), + { instanceOf: EffortGraphValidationError } + ); + await writer.mutate({ type: 'AcceptDecision', decisionId: decision }); + const result = await writer.mutate({ + type: 'MitigateRisk', + riskId: risk, + decisionId: decision, + }); + t.is(result.artifacts[0].frontmatter.state, 'mitigated'); + t.is(result.artifacts[0].frontmatter.mitigated_by, decision); +}); + +test('SetRiskState realized requires at least one Finding in evidence', async (t) => { + const { writer } = await makeWriter(); + const effort = soleId( + await writer.mutate({ type: 'CreateEffort', title: 'E', body: '' }) + ); + const risk = soleId( + await writer.mutate({ + type: 'WriteRisk', + effort, + title: 'R', + body: '', + likelihood: 'low', + severity: 'low', + }) + ); + const decision = soleId( + await writer.mutate({ type: 'WriteDecision', effort, title: 'D', body: '' }) + ); + await t.throwsAsync( + writer.mutate({ + type: 'SetRiskState', + riskId: risk, + state: 'realized', + evidence: [decision], + }), + { instanceOf: EffortGraphValidationError } + ); + const finding = soleId( + await writer.mutate({ + type: 'WriteFinding', + effort, + title: 'F', + body: '', + kind: 'measurement', + }) + ); + const result = await writer.mutate({ + type: 'SetRiskState', + riskId: risk, + state: 'realized', + evidence: [finding], + }); + t.is(result.artifacts[0].frontmatter.state, 'realized'); + t.deepEqual(result.artifacts[0].frontmatter.evidence, [finding]); +}); + +test('generation tokens increment across successive mutations', async (t) => { + const { writer } = await makeWriter(); + const first = await writer.mutate({ + type: 'CreateEffort', + title: 'One', + body: '', + }); + t.is(first.generation, '1'); + const effort = soleId(first); + const second = await writer.mutate({ + type: 'WriteFinding', + effort, + title: 'F', + body: '', + kind: 'note', + }); + t.is(second.generation, '2'); + const third = await writer.mutate({ + type: 'SetEffortStatus', + effortId: effort, + status: 'paused', + }); + t.is(third.generation, '3'); + t.truthy(third.artifacts[0].frontmatter); + t.is(third.artifacts[0].frontmatter.status, 'paused'); +}); diff --git a/packages/effort-graph/src/errors.ts b/packages/effort-graph/src/errors.ts new file mode 100644 index 00000000..49ba1733 --- /dev/null +++ b/packages/effort-graph/src/errors.ts @@ -0,0 +1,28 @@ +export class EffortGraphError extends Error { + constructor(message: string, readonly code: string) { + super(message); + this.name = new.target.name; + } +} +export class EffortGraphLockedError extends EffortGraphError { + readonly retryAfterMs: number; + constructor(retryAfterMs = 1000, message = 'Effort graph is locked') { + super(message, 'EFFORT_GRAPH_LOCKED'); + this.retryAfterMs = retryAfterMs; + } +} +export class EffortGraphValidationError extends EffortGraphError { + constructor(message: string) { + super(message, 'EFFORT_GRAPH_VALIDATION'); + } +} +export class EffortGraphReindexFailedError extends EffortGraphError { + constructor(message: string, readonly originalError?: unknown) { + super(message, 'EFFORT_GRAPH_REINDEX_FAILED'); + } +} +export class EffortGraphCorruptJournalError extends EffortGraphError { + constructor(message: string) { + super(message, 'EFFORT_GRAPH_CORRUPT_JOURNAL'); + } +} diff --git a/packages/effort-graph/src/frontmatter.ts b/packages/effort-graph/src/frontmatter.ts new file mode 100644 index 00000000..bbc2cc9a --- /dev/null +++ b/packages/effort-graph/src/frontmatter.ts @@ -0,0 +1,55 @@ +import matter from 'gray-matter'; +import { FrontmatterSchemas } from './schemas.js'; +import type { PrimitiveKind } from './types.js'; +const order = [ + 'id', + 'effort', + 'title', + 'slug', + 'kind', + 'status', + 'state', + 'created_at', + 'produced_in', + 'created_by', + 'derives_from', + 'supersedes', + 'superseded_by', + 'invalidates', + 'invalidated_by', + 'resolved_by', + 'rejected_by', + 'mitigated_by', + 'evidence', +]; +export function canonicalizeFrontmatter( + input: Record +): Record { + const out: Record = {}; + for (const key of order) + if (input[key] !== undefined) + out[key] = Array.isArray(input[key]) + ? [...new Set(input[key] as unknown[])].sort() + : input[key]; + for (const key of Object.keys(input) + .filter((k) => !order.includes(k)) + .sort()) + if (input[key] !== undefined) out[key] = input[key]; + return out; +} +export function parseDocument(bytes: Buffer | string, kind?: PrimitiveKind) { + const parsed = matter(bytes.toString()); + if (kind) FrontmatterSchemas[kind].parse(parsed.data); + return { + frontmatter: canonicalizeFrontmatter(parsed.data), + body: parsed.content, + }; +} +export function serializeDocument( + body: string, + frontmatter: Record +): Buffer { + return Buffer.from( + matter.stringify(body, canonicalizeFrontmatter(frontmatter)) + ); +} diff --git a/packages/effort-graph/src/ids.ts b/packages/effort-graph/src/ids.ts new file mode 100644 index 00000000..c5922efe --- /dev/null +++ b/packages/effort-graph/src/ids.ts @@ -0,0 +1,62 @@ +import { randomBytes as cryptoRandomBytes } from 'node:crypto'; +import type { PrimitiveKind } from './types.js'; +const alphabet = '0123456789abcdefghjkmnpqrstvwxyz'; +export const KIND_PREFIX: Record = { + effort: 'eff', + issue: 'iss', + finding: 'fnd', + decision: 'dec', + constraint: 'con', + risk: 'rsk', +}; +export const KIND_DIRECTORY: Record = { + effort: 'efforts', + issue: 'issues', + finding: 'findings', + decision: 'decisions', + constraint: 'constraints', + risk: 'risks', +}; +export const PREFIX_KIND = Object.fromEntries( + Object.entries(KIND_PREFIX).map(([k, v]) => [v, k]) +) as Record; +export function slugify(value: string): string { + const s = value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48) + .replace(/-+$/g, ''); + return s || 'untitled'; +} +function suffix(bytes: Uint8Array): string { + let n = 0n; + for (const b of bytes) n = (n << 8n) | BigInt(b); + let out = ''; + for (let i = 0; i < 16; i++) { + out = alphabet[Number(n & 31n)] + out; + n >>= 5n; + } + return out; +} +export function generateArtifactId( + kind: PrimitiveKind, + title: string, + randomBytes: (length: number) => Uint8Array = cryptoRandomBytes +): string { + return `${KIND_PREFIX[kind]}-${slugify(title)}--${suffix(randomBytes(10))}`; +} +export function validateArtifactId( + id: string, + expectedKind?: PrimitiveKind +): boolean { + const m = + /^([a-z]{3})-[a-z0-9]+(?:-[a-z0-9]+)*--([0123456789abcdefghjkmnpqrstvwxyz]{16})$/.exec( + id + ); + return ( + !!m && + !!PREFIX_KIND[m[1]] && + (!expectedKind || PREFIX_KIND[m[1]] === expectedKind) + ); +} diff --git a/packages/effort-graph/src/index-store.ts b/packages/effort-graph/src/index-store.ts new file mode 100644 index 00000000..7403c79d --- /dev/null +++ b/packages/effort-graph/src/index-store.ts @@ -0,0 +1,67 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { join, relative } from 'node:path'; +import { parseDocument } from './frontmatter.js'; +import { KIND_DIRECTORY, PREFIX_KIND } from './ids.js'; +import { EffortGraphValidationError } from './errors.js'; +import type { + EffortGraphIndex, + IndexedArtifact, + PrimitiveKind, +} from './types.js'; +export function createFilesystemEffortGraphIndex( + rootDir: string +): EffortGraphIndex { + async function scan(): Promise { + const result: IndexedArtifact[] = []; + for (const kind of Object.keys(KIND_DIRECTORY) as PrimitiveKind[]) { + const dir = join(rootDir, KIND_DIRECTORY[kind]); + let names: string[] = []; + try { + names = await readdir(dir); + } catch { + continue; + } + for (const name of names.filter((n) => n.endsWith('.md'))) { + const path = join(dir, name); + const parsed = parseDocument(await readFile(path), kind); + const id = String(parsed.frontmatter.id); + if (result.some((x) => x.id === id)) + throw new EffortGraphValidationError(`Duplicate id ${id} at ${path}`); + result.push({ + id, + kind, + path: relative(rootDir, path), + frontmatter: parsed.frontmatter, + body: parsed.body, + }); + } + } + return result; + } + return { + async getRecord(id) { + return (await scan()).find((x) => x.id === id); + }, + async recordsByEffort(effortId) { + return (await scan()).filter((x) => x.frontmatter.effort === effortId); + }, + async recordsByKind(kind) { + return (await scan()).filter((x) => x.kind === kind); + }, + async siblingDecisions(effortId, o = {}) { + return (await scan()).filter( + (x) => + x.kind === 'decision' && + x.frontmatter.effort === effortId && + (!o.state || x.frontmatter.state === o.state) && + x.id !== o.excludeId + ); + }, + async hasId(id) { + return !!(await scan()).find((x) => x.id === id); + }, + }; +} +export function kindFromId(id: string): PrimitiveKind | undefined { + return PREFIX_KIND[id.split('-')[0]]; +} diff --git a/packages/effort-graph/src/index.ts b/packages/effort-graph/src/index.ts new file mode 100644 index 00000000..794c6727 --- /dev/null +++ b/packages/effort-graph/src/index.ts @@ -0,0 +1,10 @@ +export * from './types.js'; +export * from './schemas.js'; +export * from './ids.js'; +export * from './frontmatter.js'; +export * from './index-store.js'; +export * from './writer.js'; +export * from './preset.js'; +export * from './errors.js'; +export { acquireWriterLock } from './lock.js'; +export { recoverJournal } from './journal.js'; diff --git a/packages/effort-graph/src/journal.ts b/packages/effort-graph/src/journal.ts new file mode 100644 index 00000000..08514bdf --- /dev/null +++ b/packages/effort-graph/src/journal.ts @@ -0,0 +1,222 @@ +import { + mkdir, + open, + readFile, + readdir, + rm, + rename, + unlink, + writeFile, +} from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { createHash, randomUUID } from 'node:crypto'; +import { + EffortGraphCorruptJournalError, + EffortGraphLockedError, + EffortGraphReindexFailedError, +} from './errors.js'; +import type { PlannedWrite, RecoveryResult, ReindexRequest } from './types.js'; + +async function fsyncFile(path: string): Promise { + const handle = await open(path, 'r+'); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +async function fsyncDir(path: string): Promise { + // Directory fsync is not supported on all platforms; best-effort only. + try { + const handle = await open(path, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } + } catch {} +} + +async function writeFileDurable(path: string, data: Buffer | string) { + await writeFile(path, data); + await fsyncFile(path); +} + +async function publishGeneration(root: string, generation: number) { + const journalDir = join(root, '.journal'); + const tmp = join(journalDir, `generation.json.tmp-${randomUUID()}`); + await writeFileDurable(tmp, JSON.stringify({ generation })); + await rename(tmp, join(journalDir, 'generation.json')); + await fsyncDir(journalDir); +} + +async function readGeneration(root: string): Promise { + try { + return ( + JSON.parse( + await readFile(join(root, '.journal', 'generation.json'), 'utf8') + ).generation || 0 + ); + } catch { + return 0; + } +} + +async function removeTempRemnants(root: string, relativePaths: string[]) { + const dirs = [...new Set(relativePaths.map((p) => dirname(join(root, p))))]; + for (const dir of dirs) { + let names: string[] = []; + try { + names = await readdir(dir); + } catch { + continue; + } + for (const name of names.filter((n) => n.includes('.tmp-'))) { + await unlink(join(dir, name)).catch(() => {}); + } + await fsyncDir(dir); + } +} + +export async function recoverJournal( + root: string, + reindex: (r: ReindexRequest) => Promise +): Promise { + const dir = join(root, '.journal', 'txns'); + await mkdir(dir, { recursive: true }); + const txns = await readdir(dir); + if (!txns.length) return { action: 'none' }; + let action: RecoveryResult['action'] = 'none', + transactionId: string | undefined; + for (const txn of txns) { + const td = join(dir, txn); + const intentPath = join(td, 'intent.json'); + let intent: any; + try { + intent = JSON.parse(await readFile(intentPath, 'utf8')); + } catch { + throw new EffortGraphCorruptJournalError(`Cannot parse ${intentPath}`); + } + transactionId = intent.transactionId; + const committed = await readFile(join(td, 'committed')) + .then(() => true) + .catch(() => false), + published = await readFile(join(td, 'published')) + .then(() => true) + .catch(() => false); + const relativePaths: string[] = intent.writes.map( + (x: any) => x.relativePath + ); + if (!committed) { + // Restore before-images in reverse lexicographic relative-path order. + const restores = [...intent.writes].sort((a: any, b: any) => + b.relativePath.localeCompare(a.relativePath) + ); + for (const w of restores) { + const p = join(root, w.relativePath); + if (w.before.exists) + await writeFileDurable(p, Buffer.from(w.before.base64, 'base64')); + else await rm(p, { force: true }); + await fsyncDir(dirname(p)); + } + await removeTempRemnants(root, relativePaths); + action = 'rolled_back'; + } else if (!published) { + for (const w of intent.writes) { + const p = join(root, w.relativePath); + const actual = await readFile(p).catch(() => undefined); + const hash = + actual && createHash('sha256').update(actual).digest('hex'); + if (hash !== w.after.sha256) { + await writeFileDurable(p, Buffer.from(w.after.base64, 'base64')); + await fsyncDir(dirname(p)); + } + } + try { + await reindex({ + rootDir: root, + transactionId: intent.transactionId, + targetGeneration: String(intent.targetGeneration), + changedPaths: relativePaths, + touchedIds: intent.touchedIds, + }); + } catch (e) { + throw new EffortGraphReindexFailedError('Recovery reindex failed', e); + } + await publishGeneration(root, intent.targetGeneration); + await writeFileDurable(join(td, 'published'), ''); + action = 'completed'; + } + if (published) { + if ((await readGeneration(root)) < intent.targetGeneration) + await publishGeneration(root, intent.targetGeneration); + action = 'completed'; + } + await rm(td, { recursive: true, force: true }); + await fsyncDir(dir); + } + return { action, transactionId }; +} + +export async function commitJournal( + root: string, + token: string, + writes: PlannedWrite[], + generation: number, + reindex: (r: ReindexRequest) => Promise, + verifyLock: () => Promise = async () => true +): Promise { + const transactionId = `${Date.now()}-${token.slice(0, 8)}`, + td = join(root, '.journal', 'txns', transactionId); + await mkdir(td, { recursive: true }); + const intent = { + transactionId, + createdAt: new Date().toISOString(), + targetGeneration: generation, + lockToken: token, + writes: writes.map((w) => ({ + relativePath: w.relativePath, + before: { + exists: !!w.beforeBytes, + ...(w.beforeBytes ? { base64: w.beforeBytes.toString('base64') } : {}), + }, + after: { + sha256: createHash('sha256').update(w.afterBytes).digest('hex'), + base64: w.afterBytes.toString('base64'), + }, + })), + touchedIds: writes.map((w) => w.id), + }; + await writeFileDurable(join(td, 'intent.json'), JSON.stringify(intent)); + await fsyncDir(td); + for (const w of [...writes].sort((a, b) => + a.relativePath.localeCompare(b.relativePath) + )) { + if (!(await verifyLock())) + throw new EffortGraphLockedError(0, 'Writer lock lost mid-transaction'); + const target = join(root, w.relativePath); + await mkdir(dirname(target), { recursive: true }); + const tmp = join(root, w.relativePath.replace(/\.md$/, `.tmp-${token}.md`)); + await writeFileDurable(tmp, w.afterBytes); + await rename(tmp, target); + await fsyncDir(dirname(target)); + } + await writeFileDurable(join(td, 'committed'), ''); + await fsyncDir(td); + try { + await reindex({ + rootDir: root, + transactionId, + targetGeneration: String(generation), + changedPaths: writes.map((w) => w.relativePath), + touchedIds: writes.map((w) => w.id), + }); + } catch (e) { + throw new EffortGraphReindexFailedError('Reindex failed', e); + } + await publishGeneration(root, generation); + await writeFileDurable(join(td, 'published'), ''); + await rm(td, { recursive: true, force: true }); + return String(generation); +} diff --git a/packages/effort-graph/src/lock.ts b/packages/effort-graph/src/lock.ts new file mode 100644 index 00000000..39f91f20 --- /dev/null +++ b/packages/effort-graph/src/lock.ts @@ -0,0 +1,70 @@ +import { hostname } from 'node:os'; +import { open, readFile, rename, unlink } from 'node:fs/promises'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { EffortGraphLockedError } from './errors.js'; +import type { WriterLockOptions } from './types.js'; +export interface WriterLock { + token: string; + path: string; + verify(): Promise; + release(): Promise; +} +export async function acquireWriterLock( + root: string, + options: WriterLockOptions = {} +): Promise { + const path = join(root, '.journal', 'writer.lock'); + const leaseMs = options.leaseMs ?? 120000; + const token = randomUUID(); + const now = Date.now(); + await import('node:fs/promises').then((fs) => + fs.mkdir(join(root, '.journal'), { recursive: true }) + ); + try { + const h = await open(path, 'wx'); + await h.writeFile( + JSON.stringify({ + token, + pid: process.pid, + hostname: hostname(), + startedAt: now, + heartbeatAt: now, + }) + ); + await h.close(); + } catch { + try { + const old = JSON.parse(await readFile(path, 'utf8')); + const expired = now - old.heartbeatAt > leaseMs; + let dead = old.hostname !== hostname(); + if (!dead && expired) { + try { + process.kill(old.pid, 0); + } catch { + dead = true; + } + } + if (expired && dead) { + await rename(path, `${path}.stale-${old.token}`); + return acquireWriterLock(root, options); + } + } catch {} + throw new EffortGraphLockedError(leaseMs); + } + const verify = async () => { + try { + return JSON.parse(await readFile(path, 'utf8')).token === token; + } catch { + return false; + } + }; + return { + token, + path, + verify, + async release() { + if (await verify()) await unlink(path).catch(() => {}); + }, + }; +} diff --git a/packages/effort-graph/src/planner.ts b/packages/effort-graph/src/planner.ts new file mode 100644 index 00000000..7d54229a --- /dev/null +++ b/packages/effort-graph/src/planner.ts @@ -0,0 +1,289 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { EffortGraphValidationError } from './errors.js'; +import { + generateArtifactId, + KIND_DIRECTORY, + validateArtifactId, +} from './ids.js'; +import { serializeDocument } from './frontmatter.js'; +import type { EffortGraphIndex, PlannedWrite, PrimitiveKind } from './types.js'; +import type { EffortGraphMutation } from './schemas.js'; +const kinds: Record = { + WriteIssue: 'issue', + WriteFinding: 'finding', + WriteDecision: 'decision', + WriteConstraint: 'constraint', + WriteRisk: 'risk', +}; +export async function planMutation( + input: EffortGraphMutation, + index: EffortGraphIndex, + root: string, + now: Date, + randomBytes?: (n: number) => Uint8Array +): Promise { + const writes = new Map(); + const get = async (id: string) => { + const r = await index.getRecord(id); + if (!r) throw new EffortGraphValidationError(`Unknown artifact ${id}`); + return r; + }; + const add = async ( + id: string, + kind: PrimitiveKind, + fm: Record, + body: string, + operation: 'create' | 'update' = 'update' + ) => { + const path = join(root, KIND_DIRECTORY[kind], `${id}.md`); + const before = + operation === 'update' + ? await readFile(path).catch(() => undefined) + : undefined; + writes.set(path, { + id, + kind, + absolutePath: path, + relativePath: join(KIND_DIRECTORY[kind], `${id}.md`), + beforeBytes: before, + afterBytes: serializeDocument(body, fm), + operation, + }); + }; + if (input.type === 'CreateEffort') { + const id = + input.id ?? generateArtifactId('effort', input.title, randomBytes); + if (!validateArtifactId(id, 'effort') || (await index.hasId(id))) + throw new EffortGraphValidationError(`Invalid or duplicate id ${id}`); + if (input.slug) { + const existingEfforts = await index.recordsByKind('effort'); + if (existingEfforts.some((r) => r.frontmatter.slug === input.slug)) + throw new EffortGraphValidationError( + `Duplicate effort slug ${input.slug}` + ); + } + await add( + id, + 'effort', + { + id, + title: input.title, + ...(input.slug !== undefined ? { slug: input.slug } : {}), + status: 'active', + created_at: input.created_at ?? now.toISOString(), + ...(input.produced_in !== undefined + ? { produced_in: input.produced_in } + : {}), + ...(input.created_by !== undefined + ? { created_by: input.created_by } + : {}), + }, + input.body, + 'create' + ); + return [...writes.values()]; + } + if (input.type === 'SetEffortStatus') { + const r = await get(input.effortId); + if (r.kind !== 'effort') + throw new EffortGraphValidationError('Not an effort'); + const old = String(r.frontmatter.status); + if (old === input.status || old === 'completed' || old === 'abandoned') + throw new EffortGraphValidationError('Illegal effort transition'); + await add(r.id, r.kind, { ...r.frontmatter, status: input.status }, r.body); + return [...writes.values()]; + } + if (input.type in kinds) { + const kind = kinds[input.type]; + const id = + (input as any).id ?? + generateArtifactId(kind, (input as any).title, randomBytes); + if (!validateArtifactId(id, kind) || (await index.hasId(id))) + throw new EffortGraphValidationError('Invalid or duplicate id'); + const effort = await get((input as any).effort); + if (effort.kind !== 'effort') + throw new EffortGraphValidationError('Invalid effort'); + const fm: any = { + ...input, + id, + created_at: (input as any).created_at ?? now.toISOString(), + }; + delete fm.type; + delete fm.body; + if (kind === 'issue') fm.status = 'open'; + if (kind === 'decision') fm.state = 'proposed'; + if (kind === 'risk') fm.state = 'open'; + await add(id, kind, fm, (input as any).body, 'create'); + for (const edge of ['supersedes', 'invalidates'] as const) { + for (const targetId of (fm[edge] as string[] | undefined) ?? []) { + const target = await get(targetId); + if (edge === 'supersedes' && target.kind !== kind) + throw new EffortGraphValidationError( + 'Supersedes must target the same kind' + ); + if ( + edge === 'supersedes' && + (target.frontmatter.superseded_by as string[] | undefined)?.length + ) + throw new EffortGraphValidationError('Target already superseded'); + if ( + edge === 'invalidates' && + !['finding', 'decision'].includes(target.kind) + ) + throw new EffortGraphValidationError( + 'Invalidation target must be a Finding or Decision' + ); + const reverse = + edge === 'supersedes' ? 'superseded_by' : 'invalidated_by'; + await add( + target.id, + target.kind, + { + ...target.frontmatter, + [reverse]: [ + ...((target.frontmatter[reverse] as string[] | undefined) ?? []), + id, + ], + }, + target.body + ); + } + } + return [...writes.values()]; + } + if (input.type === 'Supersede' || input.type === 'Invalidate') { + const a = await get( + input.type === 'Supersede' ? input.supersederId : input.findingId + ); + const b = await get(input.targetId); + const edge = input.type === 'Supersede' ? 'supersedes' : 'invalidates'; + const back = + input.type === 'Supersede' ? 'superseded_by' : 'invalidated_by'; + if ( + a.id === b.id || + (input.type === 'Supersede' && a.kind !== b.kind) || + (a.frontmatter.effort !== b.frontmatter.effort && a.kind !== 'effort') + ) + throw new EffortGraphValidationError('Invalid edge'); + if (input.type === 'Invalidate') { + if (a.kind !== 'finding') + throw new EffortGraphValidationError( + `Invalidate requires a Finding source, got ${a.kind}` + ); + if (b.kind !== 'finding' && b.kind !== 'decision') + throw new EffortGraphValidationError( + 'Invalidation target must be a Finding or Decision' + ); + } + if ( + input.type === 'Supersede' && + (b.frontmatter[back] as unknown[] | undefined)?.length + ) + throw new EffortGraphValidationError('Target already superseded'); + if (((a.frontmatter[edge] as string[] | undefined) ?? []).includes(b.id)) + throw new EffortGraphValidationError('Duplicate edge'); + await add( + a.id, + a.kind, + { + ...a.frontmatter, + [edge]: [...((a.frontmatter[edge] as string[]) || []), b.id], + }, + a.body + ); + await add( + b.id, + b.kind, + { + ...b.frontmatter, + [back]: [...((b.frontmatter[back] as string[]) || []), a.id], + ...(input.type === 'Supersede' && b.kind === 'decision' + ? { state: 'superseded' } + : {}), + }, + b.body + ); + return [...writes.values()]; + } + if (input.type === 'ResolveIssue') { + const r = await get(input.issueId); + if (r.kind !== 'issue' || r.frontmatter.status !== 'open') + throw new EffortGraphValidationError('Issue is not open'); + for (const id of input.resolvedBy) { + const x = await get(id); + if (x.frontmatter.effort !== r.frontmatter.effort) + throw new EffortGraphValidationError('Different effort'); + } + await add( + r.id, + r.kind, + { + ...r.frontmatter, + status: input.resolution, + resolved_by: input.resolvedBy, + }, + r.body + ); + return [...writes.values()]; + } + if (input.type === 'AcceptDecision') { + const r = await get(input.decisionId); + if (r.kind !== 'decision' || r.frontmatter.state !== 'proposed') + throw new EffortGraphValidationError('Decision is not proposed'); + await add(r.id, r.kind, { ...r.frontmatter, state: 'accepted' }, r.body); + if (input.rejectSiblings !== false) + for (const s of await index.siblingDecisions( + String(r.frontmatter.effort), + { state: 'proposed', excludeId: r.id } + )) + await add( + s.id, + s.kind, + { ...s.frontmatter, state: 'rejected', rejected_by: r.id }, + s.body + ); + return [...writes.values()]; + } + if (input.type === 'MitigateRisk') { + const r = await get(input.riskId), + d = await get(input.decisionId); + if ( + r.kind !== 'risk' || + r.frontmatter.state !== 'open' || + d.kind !== 'decision' || + d.frontmatter.state !== 'accepted' || + r.frontmatter.effort !== d.frontmatter.effort + ) + throw new EffortGraphValidationError('Cannot mitigate risk'); + await add( + r.id, + r.kind, + { ...r.frontmatter, state: 'mitigated', mitigated_by: d.id }, + r.body + ); + return [...writes.values()]; + } + if (input.type === 'SetRiskState') { + const r = await get(input.riskId); + if (r.kind !== 'risk' || r.frontmatter.state !== 'open') + throw new EffortGraphValidationError('Risk is not open'); + const evidence = await Promise.all(input.evidence.map(get)); + for (const x of evidence) + if (x.frontmatter.effort !== r.frontmatter.effort) + throw new EffortGraphValidationError('Different effort'); + if ( + input.state === 'realized' && + !evidence.some((x) => x.kind === 'finding') + ) + throw new EffortGraphValidationError('Realized risk requires finding'); + await add( + r.id, + r.kind, + { ...r.frontmatter, state: input.state, evidence: input.evidence }, + r.body + ); + return [...writes.values()]; + } + throw new EffortGraphValidationError('Unsupported mutation'); +} diff --git a/packages/effort-graph/src/preset.ts b/packages/effort-graph/src/preset.ts new file mode 100644 index 00000000..6d6cd9ee --- /dev/null +++ b/packages/effort-graph/src/preset.ts @@ -0,0 +1,52 @@ +import type { ContentEntry } from '@flatbread/core'; +export function effortGraphContent( + root = '.flatbread-efforts' +): ContentEntry[] { + // Union refs intentionally stay out of Flatbread refs; they target multiple collections. + return [ + { collection: 'Effort', path: `${root}/efforts` }, + { + collection: 'Issue', + path: `${root}/issues`, + refs: { effort: 'Effort', supersedes: 'Issue', superseded_by: 'Issue' }, + }, + { + collection: 'Finding', + path: `${root}/findings`, + refs: { + effort: 'Effort', + supersedes: 'Finding', + superseded_by: 'Finding', + }, + }, + { + collection: 'Decision', + path: `${root}/decisions`, + refs: { + effort: 'Effort', + supersedes: 'Decision', + superseded_by: 'Decision', + rejected_by: 'Decision', + }, + }, + { + collection: 'Constraint', + path: `${root}/constraints`, + refs: { + effort: 'Effort', + supersedes: 'Constraint', + superseded_by: 'Constraint', + }, + }, + { + collection: 'Risk', + path: `${root}/risks`, + refs: { + effort: 'Effort', + supersedes: 'Risk', + superseded_by: 'Risk', + mitigated_by: 'Decision', + }, + }, + ]; +} diff --git a/packages/effort-graph/src/schemas.ts b/packages/effort-graph/src/schemas.ts new file mode 100644 index 00000000..d600d9b6 --- /dev/null +++ b/packages/effort-graph/src/schemas.ts @@ -0,0 +1,174 @@ +import { z } from 'zod'; +const id = z + .string() + .regex(/^[a-z]{3}-[a-z0-9-]+--[0123456789abcdefghjkmnpqrstvwxyz]{16}$/); +const effort = id; +const common = { + id: id.optional(), + title: z.string().min(1), + body: z.string(), + created_at: z.string().datetime({ offset: true }).optional(), + produced_in: z.string().optional(), + created_by: z.string().optional(), +}; +// Forward edges only; back-edges are writer-materialized projections (ADR-0004). +const edges = { + derives_from: id.array().optional(), + supersedes: id.array().optional(), + invalidates: id.array().optional(), +}; +export const CreateEffortSchema = z.object({ + type: z.literal('CreateEffort'), + ...common, + slug: z.string().optional(), +}); +export const SetEffortStatusSchema = z.object({ + type: z.literal('SetEffortStatus'), + effortId: id, + status: z.enum(['active', 'paused', 'completed', 'abandoned']), +}); +const createBase = { ...common, ...edges, effort: id }; +export const WriteIssueSchema = z.object({ + type: z.literal('WriteIssue'), + ...createBase, + kind: z.string().min(1), +}); +export const WriteFindingSchema = z.object({ + type: z.literal('WriteFinding'), + ...createBase, + kind: z.string().min(1), +}); +export const WriteDecisionSchema = z.object({ + type: z.literal('WriteDecision'), + ...createBase, +}); +export const WriteConstraintSchema = z.object({ + type: z.literal('WriteConstraint'), + ...createBase, + kind: z.enum(['hard', 'soft']), +}); +export const WriteRiskSchema = z.object({ + type: z.literal('WriteRisk'), + ...createBase, + likelihood: z.enum(['low', 'medium', 'high']), + severity: z.enum(['low', 'medium', 'high']), +}); +export const SupersedeSchema = z.object({ + type: z.literal('Supersede'), + supersederId: id, + targetId: id, +}); +export const InvalidateSchema = z.object({ + type: z.literal('Invalidate'), + findingId: id, + targetId: id, +}); +export const ResolveIssueSchema = z.object({ + type: z.literal('ResolveIssue'), + issueId: id, + resolution: z.enum(['resolved', 'deferred', 'wontfix']), + resolvedBy: id.array().min(1), +}); +export const AcceptDecisionSchema = z.object({ + type: z.literal('AcceptDecision'), + decisionId: id, + rejectSiblings: z.boolean().optional().default(true), +}); +export const MitigateRiskSchema = z.object({ + type: z.literal('MitigateRisk'), + riskId: id, + decisionId: id, +}); +export const SetRiskStateSchema = z.object({ + type: z.literal('SetRiskState'), + riskId: id, + state: z.enum(['realized', 'accepted']), + evidence: id.array().min(1), +}); +export const EffortGraphMutationSchema = z.discriminatedUnion('type', [ + CreateEffortSchema, + SetEffortStatusSchema, + WriteIssueSchema, + WriteFindingSchema, + WriteDecisionSchema, + WriteConstraintSchema, + WriteRiskSchema, + SupersedeSchema, + InvalidateSchema, + ResolveIssueSchema, + AcceptDecisionSchema, + MitigateRiskSchema, + SetRiskStateSchema, +]); +export type EffortGraphMutation = z.input; +export const EffortFrontmatterSchema = z + .object({ + id, + title: z.string().min(1), + created_at: z.string(), + status: z.enum(['active', 'paused', 'completed', 'abandoned']), + slug: z.string().optional(), + }) + .passthrough(); +export const IssueFrontmatterSchema = z + .object({ + id, + effort, + title: z.string().min(1), + created_at: z.string(), + kind: z.string(), + status: z.enum(['open', 'resolved', 'deferred', 'wontfix']), + }) + .passthrough(); +export const FindingFrontmatterSchema = z + .object({ + id, + effort, + title: z.string().min(1), + created_at: z.string(), + kind: z.string(), + }) + .passthrough(); +export const DecisionFrontmatterSchema = z + .object({ + id, + effort, + title: z.string().min(1), + created_at: z.string(), + state: z.enum([ + 'proposed', + 'accepted', + 'rejected', + 'superseded', + 'deprecated', + ]), + }) + .passthrough(); +export const ConstraintFrontmatterSchema = z + .object({ + id, + effort, + title: z.string().min(1), + created_at: z.string(), + kind: z.enum(['hard', 'soft']), + }) + .passthrough(); +export const RiskFrontmatterSchema = z + .object({ + id, + effort, + title: z.string().min(1), + created_at: z.string(), + state: z.enum(['open', 'mitigated', 'realized', 'accepted']), + likelihood: z.enum(['low', 'medium', 'high']), + severity: z.enum(['low', 'medium', 'high']), + }) + .passthrough(); +export const FrontmatterSchemas = { + effort: EffortFrontmatterSchema, + issue: IssueFrontmatterSchema, + finding: FindingFrontmatterSchema, + decision: DecisionFrontmatterSchema, + constraint: ConstraintFrontmatterSchema, + risk: RiskFrontmatterSchema, +}; diff --git a/packages/effort-graph/src/types.ts b/packages/effort-graph/src/types.ts new file mode 100644 index 00000000..89d48589 --- /dev/null +++ b/packages/effort-graph/src/types.ts @@ -0,0 +1,83 @@ +export type PrimitiveKind = + | 'effort' + | 'issue' + | 'finding' + | 'decision' + | 'constraint' + | 'risk'; +export type GenerationToken = string; +export interface WrittenArtifact { + id: string; + kind: PrimitiveKind; + path: string; + frontmatter: Record; + body: string; + operation: 'created' | 'updated'; +} +export interface TouchedArtifact { + id: string; + path: string; +} +export interface MutationResult { + generation: GenerationToken; + artifacts: WrittenArtifact[]; + touched: TouchedArtifact[]; +} +export interface ReindexRequest { + rootDir: string; + transactionId: string; + targetGeneration: GenerationToken; + changedPaths: readonly string[]; + touchedIds: readonly string[]; +} +export interface EffortGraphIndexer { + reindex(request: ReindexRequest): Promise; +} +export interface IndexedArtifact { + id: string; + kind: PrimitiveKind; + path: string; + frontmatter: Record; + body: string; +} +export interface EffortGraphIndex { + getRecord(id: string): Promise; + recordsByEffort(effortId: string): Promise; + recordsByKind(kind: PrimitiveKind): Promise; + siblingDecisions( + effortId: string, + options?: { state?: string; excludeId?: string } + ): Promise; + hasId(id: string): Promise; +} +export interface WriterLockOptions { + leaseMs?: number; +} +export interface EffortGraphWriterOptions { + rootDir: string; + index?: EffortGraphIndex; + indexer?: EffortGraphIndexer; + clock?: () => Date; + randomBytes?: (length: number) => Uint8Array; + lockOptions?: Partial; +} +export interface EffortGraphWriter { + recover(): Promise; + mutate( + input: import('./schemas.js').EffortGraphMutation + ): Promise; +} +export interface RecoveryResult { + action: 'none' | 'rolled_back' | 'completed'; + transactionId?: string; +} +export type PlannedWrite = { + id: string; + kind: PrimitiveKind; + absolutePath: string; + relativePath: string; + beforeBytes?: Buffer; + afterBytes: Buffer; + operation: 'create' | 'update'; +}; +export type ArtifactInput = Record; diff --git a/packages/effort-graph/src/writer.ts b/packages/effort-graph/src/writer.ts new file mode 100644 index 00000000..a3263f8e --- /dev/null +++ b/packages/effort-graph/src/writer.ts @@ -0,0 +1,80 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { acquireWriterLock } from './lock.js'; +import { commitJournal, recoverJournal } from './journal.js'; +import { planMutation } from './planner.js'; +import { createFilesystemEffortGraphIndex } from './index-store.js'; +import { parseDocument } from './frontmatter.js'; +import type { + EffortGraphWriter, + EffortGraphWriterOptions, + MutationResult, + WrittenArtifact, +} from './types.js'; +export function createEffortGraphWriter( + options: EffortGraphWriterOptions +): EffortGraphWriter { + const index = + options.index ?? createFilesystemEffortGraphIndex(options.rootDir), + indexer = options.indexer ?? { reindex: async () => {} }, + clock = options.clock ?? (() => new Date()); + async function recover() { + const lock = await acquireWriterLock(options.rootDir, options.lockOptions); + try { + return await recoverJournal(options.rootDir, (r) => indexer.reindex(r)); + } finally { + await lock.release(); + } + } + async function mutate(input: any): Promise { + const lock = await acquireWriterLock(options.rootDir, options.lockOptions); + try { + await recoverJournal(options.rootDir, (r) => indexer.reindex(r)); + let current = 0; + try { + current = + JSON.parse( + await readFile( + join(options.rootDir, '.journal', 'generation.json'), + 'utf8' + ) + ).generation || 0; + } catch {} + const writes = await planMutation( + input, + index, + options.rootDir, + clock(), + options.randomBytes + ); + const generation = Number(current) + 1; + const tx = await commitJournal( + options.rootDir, + lock.token, + writes, + generation, + (r) => indexer.reindex(r), + () => lock.verify() + ); + const artifacts: WrittenArtifact[] = writes.map((w) => { + const parsed = parseDocument(w.afterBytes, w.kind); + return { + id: w.id, + kind: w.kind, + path: w.relativePath, + frontmatter: parsed.frontmatter, + body: parsed.body, + operation: w.operation === 'create' ? 'created' : 'updated', + }; + }); + return { + generation: tx, + artifacts, + touched: writes.map((w) => ({ id: w.id, path: w.relativePath })), + }; + } finally { + await lock.release(); + } + } + return { recover, mutate }; +} diff --git a/packages/effort-graph/tsconfig.json b/packages/effort-graph/tsconfig.json new file mode 100644 index 00000000..ad0af6d7 --- /dev/null +++ b/packages/effort-graph/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": "../..", + "ignoreDeprecations": "6.0", + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node"] + }, + "include": ["src/**/*.ts", "tsup.config.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/effort-graph/tsup.config.ts b/packages/effort-graph/tsup.config.ts new file mode 100644 index 00000000..b2df2e08 --- /dev/null +++ b/packages/effort-graph/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + dts: true, + clean: true, + sourcemap: true, + target: 'node18', + external: ['@flatbread/core'], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67458dea..e716a8d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -327,6 +327,31 @@ importers: specifier: 5.3.4 version: 5.3.4 + packages/effort-graph: + dependencies: + gray-matter: + specifier: ^4.0.3 + version: 4.0.3 + zod: + specifier: ^3.24.1 + version: 3.25.76 + devDependencies: + '@flatbread/core': + specifier: workspace:* + version: link:../core + '@flatbread/transformer-markdown': + specifier: workspace:* + version: link:../transformer-markdown + '@types/node': + specifier: 25.6.2 + version: 25.6.2 + tsup: + specifier: 8.5.1 + version: 8.5.1(@swc/core@1.13.3)(jiti@2.5.1)(postcss@8.5.14)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.1) + typescript: + specifier: 6.0.3 + version: 6.0.3 + packages/flatbread: dependencies: '@apollo/server':