fix(daemon): address PR #493 review feedback for transcript export

Addresses every blocker, P2, and P3 raised on
https://github.com/nexu-io/open-design/pull/493:

- Blocker (event-shape mismatch): coalescer now switches on the
  PersistedAgentEvent kind discriminator (text/thinking/tool_use/
  tool_result), reading the shared `text` field for content kinds,
  matching what apps/web/src/providers/daemon.ts:347-394 actually
  persists into messages.events_json. Empirical confirmation: live
  SQLite contains zero `type` keys.
- Removed @ts-nocheck from the source file; added inline types for
  Db (Database.Database), ConversationRow, MessageRow, AttachmentRef,
  CommentAttachmentRef, and Block. Tests retain @ts-nocheck per
  codebase convention. Note: db.ts still uses @ts-nocheck, so the
  new types catch drift inside transcript-export.ts itself, not at
  the SQLite-helper boundary.
- parseEvents now distinguishes null / malformed / not_array / ok
  cases; non-null-but-unparseable rows emit a console.warn with
  project+message id before falling back to content.
- Switched temp-write from writeSync (which can return short) to
  writeFileSync({flag:'wx'}); explicit fsync via reopen before
  rename, per reviewer concern about partial-write durability.
- Added per-project lockfile (.transcript.lock) acquired with
  openSync(..., 'wx') and released in finally; concurrent exports
  throw the new TranscriptExportLockedError. Stale-lock recovery
  is documented as a known limitation in the file header.
- Header gains attachmentCount, commentAttachmentCount, and explicit
  attachmentsInlined: false. Per-message lines gain attachments /
  commentAttachments references (paths only, not bytes; synthesis
  reads files from disk by path). schemaVersion bumped 1 -> 2 so
  the change is explicit; v1 was never consumed.
- mkdirSync(dir, { recursive: true }) at entry covers projects
  with DB rows but no on-disk directory yet (codex bot finding).
- Refactored node:fs imports from named to default
  (import fs from 'node:fs') so vitest spies in tests #15-#17 can
  redefine properties on the underlying CJS exports object. ESM
  namespace imports of node:fs produce a frozen Module Namespace
  Object that vi.spyOn cannot mutate; default-import returns the
  CJS module.exports which is mutable.
- Inline PersistedAgentEvent union: the daemon tsconfig does not
  resolve the `@open-design/contracts/api/chat` subpath export, so
  the union is restated in the source. Schema-mismatch tests cover
  the case where the contract would diverge.
- Test count 14 -> 24: failure injection for writeFileSync /
  fsyncSync / renameSync, existing-file replacement, lockfile
  contention (lockfile-pre-create design — synchronous API can't
  race via Promise.allSettled), parse-warning cases (malformed +
  not-array), attachments header + per-message coverage, missing-
  project-dir case.

Refs nexu-io/open-design#450 (does not close).
This commit is contained in:
DevForgeAI CI/CD Engineer
2026-05-05 08:37:44 -04:00
parent 020b13d869
commit a39d430c6d
2 changed files with 700 additions and 183 deletions

View File

@@ -1,4 +1,3 @@
// @ts-nocheck
// One-shot dump of a project's conversation history to disk in a structured,
// LLM-friendly JSON Lines file at <projectDir>/.transcript.jsonl.
//
@@ -13,33 +12,107 @@
// and `jq -c`/`tail`-friendly. A `schemaVersion` field on the header reserves
// room for incompatible changes later.
//
// Coalescing: events_json carries streaming `text_delta` / `thinking_delta`
// chunks plus tool_use / tool_result / thinking_start markers and telemetry
// (status / usage / raw). The export collapses runs of same-type deltas into
// terminal `text` / `thinking` blocks via arrival-order with type-change
// flush, preserving any interleaving with tool blocks. Telemetry is dropped.
// Persisted event shape: see `packages/contracts/src/api/chat.ts` →
// `PersistedAgentEvent` (the discriminator field is `kind`, NOT `type`). The
// daemon's claude-stream emits a `type:`-shaped wire format; the web app
// translates those into `kind:`-shaped AgentEvents before PUTting them back
// to be persisted. The export reads what is actually on disk, so it speaks
// the `kind:` shape.
//
// Coalescing rules:
// * `kind: 'text'` runs concatenate their `text` field into one terminal
// text block.
// * `kind: 'thinking'` runs concatenate their `text` field into one
// terminal thinking block (the field is `text`, not `thinking` — see
// contract above).
// * `kind: 'tool_use'` and `kind: 'tool_result'` flush any pending text /
// thinking accumulator and emit verbatim.
// * `kind: 'status' | 'usage' | 'raw'` drop (telemetry / parser fallback).
// * Type-change between text ↔ thinking flushes the prior accumulator.
//
// Content fallback: user-typed messages persist as plain text in
// `messages.content` with events_json = NULL (the user didn't go through the
// streaming-event pipeline). When event-derived blocks come back empty we
// fall back to a single text block from content so a typed prompt is not
// silently lost. attachments_json / comment_attachments_json are out of
// scope for this PR — call out as a known omission to revisit.
// `messages.content` with events_json = NULL (the user input never flowed
// through the streaming-event pipeline). When event-derived blocks come back
// empty we fall back to a single text block from content so a typed prompt
// is not silently lost.
//
// Attachments handling: the per-message `attachments_json` and
// `comment_attachments_json` columns are surfaced as references (path/name/
// kind/size, NOT inlined bytes). The header carries `attachmentCount`,
// `commentAttachmentCount`, and an explicit `attachmentsInlined: false`
// signal so a synthesis consumer can distinguish a complete transcript from
// one with silently omitted inputs.
//
// Concurrency: a per-project lockfile (`.transcript.lock`) is acquired with
// `openSync(..., 'wx')` and released in `finally`. A second concurrent
// export throws `TranscriptExportLockedError`. Stale-lock recovery (e.g.
// after a crash) is out of scope; the operator can clear the file manually
// via `rm .od/projects/<id>/.transcript.lock`.
import {
closeSync,
fsyncSync,
openSync,
renameSync,
unlinkSync,
writeSync,
} from 'node:fs';
import fs from 'node:fs';
import { randomBytes } from 'node:crypto';
import path from 'node:path';
import Database from 'better-sqlite3';
import { projectDir } from './projects.js';
const SCHEMA_VERSION = 1;
const SCHEMA_VERSION = 2;
const TRANSCRIPT_FILENAME = '.transcript.jsonl';
const LOCK_FILENAME = '.transcript.lock';
// Inline copy of the PersistedAgentEvent discriminated union from
// `packages/contracts/src/api/chat.ts`. The daemon tsconfig does not resolve
// the `./api/chat` subpath export, so the union is restated here. Kept
// structurally identical to the contract; if the contract diverges, this
// file will fail behaviorally first (events drop into the default branch)
// and the schema-mismatch tests will catch it.
type PersistedAgentEvent =
| { kind: 'status'; label: string; detail?: string }
| { kind: 'text'; text: string }
| { kind: 'thinking'; text: string }
| { kind: 'tool_use'; id: string; name: string; input: unknown }
| { kind: 'tool_result'; toolUseId: string; content: string; isError: boolean }
| { kind: 'usage'; inputTokens?: number; outputTokens?: number; costUsd?: number; durationMs?: number }
| { kind: 'raw'; line: string };
type Db = Database.Database;
interface ConversationRow {
id: string;
title: string | null;
createdAt: number;
updatedAt: number;
}
interface MessageRow {
id: string;
role: 'user' | 'assistant';
content: string | null;
position: number;
eventsJson: string | null;
createdAt: number;
attachmentsJson: string | null;
commentAttachmentsJson: string | null;
}
interface AttachmentRef {
path: string;
name: string;
kind: 'image' | 'file';
size?: number;
}
interface CommentAttachmentRef {
id: string;
filePath: string;
label: string;
comment: string;
}
type Block =
| { type: 'text'; text: string }
| { type: 'thinking'; thinking: string }
| { type: 'tool_use'; id: string; name: string; input: unknown }
| { type: 'tool_result'; toolUseId: string; content: string; isError: boolean };
export interface TranscriptExportOptions {
now?: () => Date;
@@ -52,137 +125,282 @@ export interface TranscriptExportResult {
bytesWritten: number;
}
export class TranscriptExportLockedError extends Error {
constructor(message: string) {
super(message);
this.name = 'TranscriptExportLockedError';
}
}
export function exportProjectTranscript(
db,
db: Db,
projectsRoot: string,
projectId: string,
options: TranscriptExportOptions = {},
): TranscriptExportResult {
const dir = projectDir(projectsRoot, projectId);
// The project may have DB rows but no on-disk directory yet (a synthesis
// caller can hit this immediately after `insertProject`). mkdirSync with
// recursive is idempotent; cheaper than guarding via existsSync.
fs.mkdirSync(dir, { recursive: true });
const finalPath = path.join(dir, TRANSCRIPT_FILENAME);
const tmpPath = path.join(
dir,
`${TRANSCRIPT_FILENAME}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`,
);
const lockPath = path.join(dir, LOCK_FILENAME);
const now = options.now ?? (() => new Date());
// Conversations ordered chronologically (oldest first) — easiest for an LLM
// to follow as a single sequence. db.listConversations sorts by updated_at
// DESC for the sidebar; we re-sort here.
const conversations = db
.prepare(
`SELECT id, title, created_at AS createdAt, updated_at AS updatedAt
FROM conversations
WHERE project_id = ?
ORDER BY created_at ASC`,
)
.all(projectId);
const messageStmt = db.prepare(
`SELECT id, role, content, position, events_json AS eventsJson,
created_at AS createdAt
FROM messages
WHERE conversation_id = ?
ORDER BY position ASC`,
);
// Build the body in two passes: first count messages so the header has the
// right total, then emit header → for each conversation { marker → messages }.
const bodyParts: { conv: any; messages: any[] }[] = [];
let messageCount = 0;
for (const conv of conversations) {
const messages = messageStmt.all(conv.id).map((row) => {
const blocks = coalesceBlocks(parseEvents(row.eventsJson));
if (blocks.length === 0 && typeof row.content === 'string' && row.content.length > 0) {
blocks.push({ type: 'text', text: row.content });
}
return {
kind: 'message',
conversationId: conv.id,
id: row.id,
role: row.role,
position: Number(row.position),
createdAt: Number(row.createdAt),
blocks,
};
});
messageCount += messages.length;
bodyParts.push({ conv, messages });
}
const lines: string[] = [
JSON.stringify({
kind: 'header',
schemaVersion: SCHEMA_VERSION,
projectId,
exportedAt: now().toISOString(),
conversationCount: conversations.length,
messageCount,
}),
];
for (const { conv, messages } of bodyParts) {
lines.push(
JSON.stringify({
kind: 'conversation',
id: conv.id,
title: conv.title ?? null,
createdAt: Number(conv.createdAt),
updatedAt: Number(conv.updatedAt),
}),
);
for (const m of messages) lines.push(JSON.stringify(m));
}
const encoded = Buffer.from(lines.join('\n') + '\n', 'utf8');
let fd: number | null = null;
let lockFd: number | null = null;
try {
fd = openSync(tmpPath, 'wx');
writeSync(fd, encoded, 0, encoded.length, 0);
fsyncSync(fd);
closeSync(fd);
fd = null;
renameSync(tmpPath, finalPath);
} catch (err) {
if (fd !== null) {
try {
closeSync(fd);
} catch {
// ignore close-after-error
}
}
try {
unlinkSync(tmpPath);
} catch {
// tmp may not exist if openSync threw
lockFd = fs.openSync(lockPath, 'wx');
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException)?.code === 'EEXIST') {
throw new TranscriptExportLockedError(
`transcript export for project ${projectId} is already in progress`,
);
}
throw err;
}
return {
path: finalPath,
conversationCount: conversations.length,
messageCount,
bytesWritten: encoded.length,
};
}
function parseEvents(raw): unknown[] {
if (!raw) return [];
try {
const v = JSON.parse(raw);
return Array.isArray(v) ? v : [];
} catch {
return [];
// Conversations ordered chronologically (oldest first) — easiest for an
// LLM to follow as a single sequence. db.listConversations sorts by
// updated_at DESC for the sidebar; we re-sort here.
const conversations = db
.prepare(
`SELECT id, title, created_at AS createdAt, updated_at AS updatedAt
FROM conversations
WHERE project_id = ?
ORDER BY created_at ASC`,
)
.all(projectId) as ConversationRow[];
const messageStmt = db.prepare(
`SELECT id, role, content, position,
events_json AS eventsJson,
created_at AS createdAt,
attachments_json AS attachmentsJson,
comment_attachments_json AS commentAttachmentsJson
FROM messages
WHERE conversation_id = ?
ORDER BY position ASC`,
);
// Build the body in two passes: first build messages so the header has
// the right totals, then emit header → for each conversation { marker →
// messages }.
interface BuiltMessage {
kind: 'message';
conversationId: string;
id: string;
role: 'user' | 'assistant';
position: number;
createdAt: number;
blocks: Block[];
attachments?: AttachmentRef[];
commentAttachments?: CommentAttachmentRef[];
}
const bodyParts: { conv: ConversationRow; messages: BuiltMessage[] }[] = [];
let messageCount = 0;
let attachmentCount = 0;
let commentAttachmentCount = 0;
for (const conv of conversations) {
const rows = messageStmt.all(conv.id) as MessageRow[];
const messages: BuiltMessage[] = rows.map((row) => {
const parsed = parseEvents(row.eventsJson);
if (parsed.reason === 'malformed' || parsed.reason === 'not_array') {
// Surface a data-quality signal on stderr so corrupted rows are
// visible in daemon logs. Best-effort fallback to content still
// fires; the export must remain a one-shot best-effort dump
// rather than aborting on a single bad row.
console.warn(
`[transcript-export] message ${row.id} (project ${projectId}): ` +
`events_json is non-null but ${parsed.reason}; falling back to content.`,
);
}
const blocks = coalesceBlocks(parsed.events);
if (blocks.length === 0 && typeof row.content === 'string' && row.content.length > 0) {
blocks.push({ type: 'text', text: row.content });
}
const attachments = parseAttachments(row.attachmentsJson);
const commentAttachments = parseCommentAttachments(row.commentAttachmentsJson);
attachmentCount += attachments.length;
commentAttachmentCount += commentAttachments.length;
const built: BuiltMessage = {
kind: 'message',
conversationId: conv.id,
id: row.id,
role: row.role,
position: Number(row.position),
createdAt: Number(row.createdAt),
blocks,
};
if (attachments.length > 0) built.attachments = attachments;
if (commentAttachments.length > 0) built.commentAttachments = commentAttachments;
return built;
});
messageCount += messages.length;
bodyParts.push({ conv, messages });
}
const lines: string[] = [
JSON.stringify({
kind: 'header',
schemaVersion: SCHEMA_VERSION,
projectId,
exportedAt: now().toISOString(),
conversationCount: conversations.length,
messageCount,
attachmentCount,
commentAttachmentCount,
// Explicit signal: attachment metadata is referenced by path; the
// bytes themselves remain on disk under the project directory and
// are not inlined into the transcript.
attachmentsInlined: false,
}),
];
for (const { conv, messages } of bodyParts) {
lines.push(
JSON.stringify({
kind: 'conversation',
id: conv.id,
title: conv.title ?? null,
createdAt: Number(conv.createdAt),
updatedAt: Number(conv.updatedAt),
}),
);
for (const m of messages) lines.push(JSON.stringify(m));
}
const encoded = Buffer.from(lines.join('\n') + '\n', 'utf8');
// Atomic write: writeFileSync with the 'wx' flag loops internally until
// the entire buffer is written or it throws. We then reopen the file
// just to fsync data to disk before the rename, addressing the partial-
// write durability concern flagged in review.
try {
fs.writeFileSync(tmpPath, encoded, { flag: 'wx' });
const fsyncFd = fs.openSync(tmpPath, 'r+');
try {
fs.fsyncSync(fsyncFd);
} finally {
fs.closeSync(fsyncFd);
}
fs.renameSync(tmpPath, finalPath);
} catch (err) {
try {
fs.unlinkSync(tmpPath);
} catch {
// tmp may not exist if writeFileSync threw before creating it
}
throw err;
}
return {
path: finalPath,
conversationCount: conversations.length,
messageCount,
bytesWritten: encoded.length,
};
} finally {
if (lockFd !== null) {
try {
fs.closeSync(lockFd);
} catch {
// ignore close-after-error
}
try {
fs.unlinkSync(lockPath);
} catch {
// lock may already be gone if the disk vanished; not fatal
}
}
}
}
function parseEvents(raw: string | null): {
events: PersistedAgentEvent[];
reason: 'ok' | 'null' | 'malformed' | 'not_array';
} {
if (raw == null) return { events: [], reason: 'null' };
let v: unknown;
try {
v = JSON.parse(raw);
} catch {
return { events: [], reason: 'malformed' };
}
if (!Array.isArray(v)) return { events: [], reason: 'not_array' };
return { events: v as PersistedAgentEvent[], reason: 'ok' };
}
function parseAttachments(raw: string | null): AttachmentRef[] {
if (raw == null) return [];
let v: unknown;
try {
v = JSON.parse(raw);
} catch {
return [];
}
if (!Array.isArray(v)) return [];
const out: AttachmentRef[] = [];
for (const item of v) {
if (!item || typeof item !== 'object') continue;
const a = item as Record<string, unknown>;
if (typeof a.path !== 'string' || typeof a.name !== 'string') continue;
if (a.kind !== 'image' && a.kind !== 'file') continue;
const ref: AttachmentRef = {
path: a.path,
name: a.name,
kind: a.kind,
};
if (typeof a.size === 'number') ref.size = a.size;
out.push(ref);
}
return out;
}
function parseCommentAttachments(raw: string | null): CommentAttachmentRef[] {
if (raw == null) return [];
let v: unknown;
try {
v = JSON.parse(raw);
} catch {
return [];
}
if (!Array.isArray(v)) return [];
const out: CommentAttachmentRef[] = [];
for (const item of v) {
if (!item || typeof item !== 'object') continue;
const c = item as Record<string, unknown>;
if (typeof c.id !== 'string') continue;
if (typeof c.filePath !== 'string') continue;
out.push({
id: c.id,
filePath: c.filePath,
label: typeof c.label === 'string' ? c.label : '',
comment: typeof c.comment === 'string' ? c.comment : '',
});
}
return out;
}
// Walk arrival-order. Maintain a single accumulator for the current run of
// text or thinking deltas; flush on type change, on thinking_start, on any
// tool block, and at end-of-stream. Telemetry events drop without flushing
// (they neither contribute content nor signal a content boundary).
function coalesceBlocks(events: any[]) {
const blocks: any[] = [];
// text or thinking events; flush on type change, on any tool block, and at
// end-of-stream. Telemetry events drop without flushing (they neither
// contribute content nor signal a content boundary).
//
// Both `kind: 'text'` and `kind: 'thinking'` carry their content in a `text`
// field per PersistedAgentEvent; the output blocks rename thinking's field
// to `thinking` so a downstream consumer can tell text apart from thinking
// without consulting `type`.
function coalesceBlocks(events: PersistedAgentEvent[]): Block[] {
const blocks: Block[] = [];
let active: 'text' | 'thinking' | null = null;
let buf = '';
@@ -198,27 +416,23 @@ function coalesceBlocks(events: any[]) {
for (const ev of events) {
if (!ev || typeof ev !== 'object') continue;
switch (ev.type) {
case 'text_delta': {
if (typeof ev.delta !== 'string') break;
switch (ev.kind) {
case 'text': {
if (typeof ev.text !== 'string') break;
if (active !== 'text') {
flush();
active = 'text';
}
buf += ev.delta;
buf += ev.text;
break;
}
case 'thinking_delta': {
if (typeof ev.delta !== 'string') break;
case 'thinking': {
if (typeof ev.text !== 'string') break;
if (active !== 'thinking') {
flush();
active = 'thinking';
}
buf += ev.delta;
break;
}
case 'thinking_start': {
flush();
buf += ev.text;
break;
}
case 'tool_use': {
@@ -227,7 +441,7 @@ function coalesceBlocks(events: any[]) {
type: 'tool_use',
id: ev.id,
name: ev.name,
input: ev.input ?? {},
input: ev.input ?? null,
});
break;
}
@@ -236,12 +450,15 @@ function coalesceBlocks(events: any[]) {
blocks.push({
type: 'tool_result',
toolUseId: ev.toolUseId,
content: ev.content,
content: typeof ev.content === 'string' ? ev.content : String(ev.content ?? ''),
isError: Boolean(ev.isError),
});
break;
}
// Telemetry: status, usage, raw — intentional drop.
// Telemetry: status, usage, raw — intentional drop. status with
// label === 'thinking' is the daemon's translated thinking_start
// marker; we don't use it as a flush trigger because adjacent
// thinking blocks merge harmlessly for an LLM consumer.
default:
break;
}

View File

@@ -1,5 +1,22 @@
// @ts-nocheck
import { afterEach, describe, expect, it } from 'vitest';
// Persisted event shape under test is `PersistedAgentEvent` from
// packages/contracts/src/api/chat.ts (the discriminator is `kind`, the
// thinking field is `text`). The daemon's claude-stream emits a different
// `type:`-shaped wire format — those events are translated to the persisted
// `kind:` shape by the web client before being PUT back for storage.
//
// All seeded events here mirror the canonical persisted shape, exactly as
// they appear in `messages.events_json` in production databases.
//
// Note on fs imports: both this file and `transcript-export.ts` use
// `import fs from 'node:fs'` (default import — the CJS module exports
// object) so that `vi.spyOn(fs, '<fn>')` in the failure-injection tests can
// actually redefine properties. ESM namespace imports of `node:fs` (`import
// * as fs from 'node:fs'`) produce a frozen Module Namespace Object that
// `vi.spyOn` cannot mutate; default-import sidesteps that restriction
// because it returns the underlying CJS `module.exports` object.
import { afterEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
@@ -10,7 +27,10 @@ import {
openDatabase,
upsertMessage,
} from '../src/db.js';
import { exportProjectTranscript } from '../src/transcript-export.js';
import {
exportProjectTranscript,
TranscriptExportLockedError,
} from '../src/transcript-export.js';
const PROJECT_ID = 'project-1';
const FIXED_NOW = () => new Date('2026-05-04T12:00:00.000Z');
@@ -20,12 +40,13 @@ let projectsRoot: string | null = null;
afterEach(() => {
closeDatabase();
vi.restoreAllMocks();
if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
tempDir = null;
projectsRoot = null;
});
function setup(): { db: any; projectsRoot: string } {
function setup(opts: { skipMkdir?: boolean } = {}): { db: any; projectsRoot: string } {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-tx-'));
const db = openDatabase(tempDir);
insertProject(db, {
@@ -35,7 +56,9 @@ function setup(): { db: any; projectsRoot: string } {
updatedAt: 1,
});
projectsRoot = path.join(tempDir, 'projects');
fs.mkdirSync(path.join(projectsRoot, PROJECT_ID), { recursive: true });
if (!opts.skipMkdir) {
fs.mkdirSync(path.join(projectsRoot, PROJECT_ID), { recursive: true });
}
return { db, projectsRoot };
}
@@ -61,13 +84,22 @@ function seedConversation(db: any, opts: { id: string; createdAt: number; update
function seedMessage(
db: any,
conversationId: string,
m: { id: string; role: 'user' | 'assistant'; content?: string; events?: any[] },
m: {
id: string;
role: 'user' | 'assistant';
content?: string;
events?: any[];
attachments?: any[];
commentAttachments?: any[];
},
) {
upsertMessage(db, conversationId, {
id: m.id,
role: m.role,
content: m.content ?? '',
events: m.events,
attachments: m.attachments,
commentAttachments: m.commentAttachments,
});
}
@@ -85,11 +117,14 @@ describe('exportProjectTranscript', () => {
expect(lines).toHaveLength(1);
expect(lines[0]).toEqual({
kind: 'header',
schemaVersion: 1,
schemaVersion: 2,
projectId: PROJECT_ID,
exportedAt: '2026-05-04T12:00:00.000Z',
conversationCount: 0,
messageCount: 0,
attachmentCount: 0,
commentAttachmentCount: 0,
attachmentsInlined: false,
});
});
@@ -99,12 +134,12 @@ describe('exportProjectTranscript', () => {
seedMessage(db, 'c1', {
id: 'm1',
role: 'user',
events: [{ type: 'text_delta', delta: 'hello' }],
events: [{ kind: 'text', text: 'hello' }],
});
seedMessage(db, 'c1', {
id: 'm2',
role: 'assistant',
events: [{ type: 'text_delta', delta: 'world' }],
events: [{ kind: 'text', text: 'world' }],
});
const result = exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW });
@@ -112,6 +147,7 @@ describe('exportProjectTranscript', () => {
expect(lines).toHaveLength(4);
expect(lines[0].kind).toBe('header');
expect(lines[0].schemaVersion).toBe(2);
expect(lines[0].conversationCount).toBe(1);
expect(lines[0].messageCount).toBe(2);
expect(lines[1]).toEqual({
@@ -132,16 +168,16 @@ describe('exportProjectTranscript', () => {
expect(lines[3].blocks).toEqual([{ type: 'text', text: 'world' }]);
});
it('coalesces adjacent text_delta chunks into a single text block', () => {
it('coalesces adjacent text events into a single text block', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
seedMessage(db, 'c1', {
id: 'm1',
role: 'assistant',
events: [
{ type: 'text_delta', delta: 'hel' },
{ type: 'text_delta', delta: 'lo' },
{ type: 'text_delta', delta: ' world' },
{ kind: 'text', text: 'hel' },
{ kind: 'text', text: 'lo' },
{ kind: 'text', text: ' world' },
],
});
@@ -157,10 +193,10 @@ describe('exportProjectTranscript', () => {
id: 'm1',
role: 'assistant',
events: [
{ type: 'text_delta', delta: 'I will read.' },
{ type: 'tool_use', id: 'tu_1', name: 'Read', input: { path: '/x' } },
{ type: 'tool_result', toolUseId: 'tu_1', content: 'file contents', isError: false },
{ type: 'text_delta', delta: ' Done.' },
{ kind: 'text', text: 'I will read.' },
{ kind: 'tool_use', id: 'tu_1', name: 'Read', input: { path: '/x' } },
{ kind: 'tool_result', toolUseId: 'tu_1', content: 'file contents', isError: false },
{ kind: 'text', text: ' Done.' },
],
});
@@ -180,11 +216,11 @@ describe('exportProjectTranscript', () => {
id: 'm1',
role: 'assistant',
events: [
{ type: 'status', label: 'streaming' },
{ type: 'thinking_delta', delta: 'reasoning' },
{ type: 'usage', usage: { input_tokens: 5 } },
{ type: 'text_delta', delta: 'answer' },
{ type: 'raw', line: '??' },
{ kind: 'status', label: 'streaming' },
{ kind: 'thinking', text: 'reasoning' },
{ kind: 'usage', inputTokens: 5 },
{ kind: 'text', text: 'answer' },
{ kind: 'raw', line: '??' },
],
});
@@ -202,9 +238,9 @@ describe('exportProjectTranscript', () => {
id: 'm1',
role: 'assistant',
events: [
{ type: 'thinking_delta', delta: 'plan' },
{ type: 'text_delta', delta: 'ok' },
{ type: 'tool_use', id: 't', name: 'X', input: {} },
{ kind: 'thinking', text: 'plan' },
{ kind: 'text', text: 'ok' },
{ kind: 'tool_use', id: 't', name: 'X', input: {} },
],
});
@@ -223,9 +259,9 @@ describe('exportProjectTranscript', () => {
id: 'm1',
role: 'assistant',
events: [
{ type: 'text_delta', delta: 'pre' },
{ type: 'thinking_delta', delta: 'mid' },
{ type: 'text_delta', delta: 'post' },
{ kind: 'text', text: 'pre' },
{ kind: 'thinking', text: 'mid' },
{ kind: 'text', text: 'post' },
],
});
@@ -237,25 +273,26 @@ describe('exportProjectTranscript', () => {
]);
});
it('treats thinking_start as a flush trigger so multi-block thinking survives', () => {
it('coalesces consecutive thinking events into one thinking block', () => {
// The persisted shape does not carry a separate thinking_start signal —
// a continuous thinking run produces one block. Adjacent thinking-only
// runs merge harmlessly for an LLM consumer (acceptable today).
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
seedMessage(db, 'c1', {
id: 'm1',
role: 'assistant',
events: [
{ type: 'thinking_start' },
{ type: 'thinking_delta', delta: 'first' },
{ type: 'thinking_start' },
{ type: 'thinking_delta', delta: 'second' },
{ type: 'text_delta', delta: 'visible' },
{ kind: 'thinking', text: 'first ' },
{ kind: 'thinking', text: 'second ' },
{ kind: 'thinking', text: 'third' },
{ kind: 'text', text: 'visible' },
],
});
const lines = readLines(exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }).path);
expect(lines[2].blocks).toEqual([
{ type: 'thinking', thinking: 'first' },
{ type: 'thinking', thinking: 'second' },
{ type: 'thinking', thinking: 'first second third' },
{ type: 'text', text: 'visible' },
]);
});
@@ -264,8 +301,8 @@ describe('exportProjectTranscript', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'older', createdAt: 100, updatedAt: 999, title: 'Older' });
seedConversation(db, { id: 'newer', createdAt: 200, updatedAt: 200, title: 'Newer' });
seedMessage(db, 'older', { id: 'm-older', role: 'user', events: [{ type: 'text_delta', delta: 'a' }] });
seedMessage(db, 'newer', { id: 'm-newer', role: 'user', events: [{ type: 'text_delta', delta: 'b' }] });
seedMessage(db, 'older', { id: 'm-older', role: 'user', events: [{ kind: 'text', text: 'a' }] });
seedMessage(db, 'newer', { id: 'm-newer', role: 'user', events: [{ kind: 'text', text: 'b' }] });
const lines = readLines(exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }).path);
const conversationLines = lines.filter((l) => l.kind === 'conversation');
@@ -275,7 +312,7 @@ describe('exportProjectTranscript', () => {
it('atomic write: leaves no .tmp file at success and does not disturb unrelated tmp files', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
seedMessage(db, 'c1', { id: 'm1', role: 'user', events: [{ type: 'text_delta', delta: 'x' }] });
seedMessage(db, 'c1', { id: 'm1', role: 'user', events: [{ kind: 'text', text: 'x' }] });
// Pre-existing orphan tmp file from a hypothetical prior failed run.
const orphan = path.join(projectsRoot, PROJECT_ID, '.transcript.jsonl.tmp.99999.deadbeef');
@@ -319,9 +356,9 @@ describe('exportProjectTranscript', () => {
role: 'assistant',
content: 'final coalesced text',
events: [
{ type: 'text_delta', delta: 'final ' },
{ type: 'text_delta', delta: 'coalesced text' },
{ type: 'tool_use', id: 'tu_1', name: 'Read', input: { path: '/x' } },
{ kind: 'text', text: 'final ' },
{ kind: 'text', text: 'coalesced text' },
{ kind: 'tool_use', id: 'tu_1', name: 'Read', input: { path: '/x' } },
],
});
@@ -341,6 +378,9 @@ describe('exportProjectTranscript', () => {
VALUES ('mbad', 'c1', 'assistant', '', 'not json', 0, ${Date.now()})`,
).run();
// Suppress the now-emitted warning so test output stays clean.
vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW });
const lines = readLines(result.path);
expect(lines).toHaveLength(3); // header + conversation + 1 message
@@ -354,4 +394,264 @@ describe('exportProjectTranscript', () => {
exportProjectTranscript(db, projectsRoot, '../etc', { now: FIXED_NOW }),
).toThrow(/invalid project id/);
});
// ---------- §1.8 atomic-write failure injection (tests #15-#17) ----------
it('cleans up tmp file when writeFileSync throws', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
seedMessage(db, 'c1', { id: 'm1', role: 'user', events: [{ kind: 'text', text: 'x' }] });
const realWrite = fs.writeFileSync;
vi.spyOn(fs, 'writeFileSync').mockImplementation((p: any, ...rest: any[]) => {
// Fail only on the transcript tmp write. Other writes (e.g. test
// fixtures) must continue to work.
if (typeof p === 'string' && p.includes('.transcript.jsonl.tmp.')) {
throw new Error('disk full');
}
return (realWrite as any)(p, ...rest);
});
expect(() =>
exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }),
).toThrow(/disk full/);
const dirEntries = fs.readdirSync(path.join(projectsRoot, PROJECT_ID));
expect(dirEntries.filter((n) => n.startsWith('.transcript.jsonl.tmp.'))).toEqual([]);
expect(dirEntries).not.toContain('.transcript.jsonl');
// Lock should also have been released.
expect(dirEntries).not.toContain('.transcript.lock');
});
it('cleans up tmp file when fsyncSync throws', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
seedMessage(db, 'c1', { id: 'm1', role: 'user', events: [{ kind: 'text', text: 'x' }] });
vi.spyOn(fs, 'fsyncSync').mockImplementation(() => {
throw new Error('fsync failed');
});
expect(() =>
exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }),
).toThrow(/fsync failed/);
const dirEntries = fs.readdirSync(path.join(projectsRoot, PROJECT_ID));
expect(dirEntries.filter((n) => n.startsWith('.transcript.jsonl.tmp.'))).toEqual([]);
expect(dirEntries).not.toContain('.transcript.jsonl');
expect(dirEntries).not.toContain('.transcript.lock');
});
it('cleans up tmp file when renameSync throws', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
seedMessage(db, 'c1', { id: 'm1', role: 'user', events: [{ kind: 'text', text: 'x' }] });
vi.spyOn(fs, 'renameSync').mockImplementation(() => {
throw new Error('rename failed');
});
expect(() =>
exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }),
).toThrow(/rename failed/);
const dirEntries = fs.readdirSync(path.join(projectsRoot, PROJECT_ID));
expect(dirEntries.filter((n) => n.startsWith('.transcript.jsonl.tmp.'))).toEqual([]);
expect(dirEntries).not.toContain('.transcript.jsonl');
expect(dirEntries).not.toContain('.transcript.lock');
});
// ---------- §1.8 existing-file replacement (test #18) ----------
it('replaces existing transcript file on second export', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
seedMessage(db, 'c1', { id: 'm1', role: 'user', events: [{ kind: 'text', text: 'x' }] });
// First export.
const result1 = exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW });
const finalPath = result1.path;
// Inject a sentinel — a downstream consumer / older transcript.
fs.writeFileSync(finalPath, '{"sentinel":true}\n');
expect(fs.readFileSync(finalPath, 'utf8')).toContain('sentinel');
// Second export should atomically replace the sentinel.
exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW });
const after = fs.readFileSync(finalPath, 'utf8');
expect(after).not.toContain('sentinel');
const lines = after.split('\n').filter((l) => l.length > 0).map((l) => JSON.parse(l));
expect(lines[0].kind).toBe('header');
expect(lines[2].id).toBe('m1');
});
// ---------- §1.5 lock contention (test #19, advisor-redesigned) ----------
it('throws TranscriptExportLockedError when lock held; succeeds after unlink', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
seedMessage(db, 'c1', { id: 'm1', role: 'user', events: [{ kind: 'text', text: 'x' }] });
const lockPath = path.join(projectsRoot, PROJECT_ID, '.transcript.lock');
const finalPath = path.join(projectsRoot, PROJECT_ID, '.transcript.jsonl');
// Pre-create the lock to simulate a concurrent export in flight.
fs.writeFileSync(lockPath, '');
expect(() =>
exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }),
).toThrow(TranscriptExportLockedError);
// No transcript should have been written while the lock was held.
expect(fs.existsSync(finalPath)).toBe(false);
// Release the lock — a subsequent export must succeed.
fs.unlinkSync(lockPath);
const result = exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW });
expect(result.path).toBe(finalPath);
expect(fs.existsSync(finalPath)).toBe(true);
expect(fs.existsSync(lockPath)).toBe(false);
});
// ---------- §1.3 parse-warning surface (tests #20-#21) ----------
it('warns when events_json is malformed JSON and falls back to content', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
db.prepare(
`INSERT INTO messages (id, conversation_id, role, content, events_json, position, created_at)
VALUES ('mmal', 'c1', 'assistant', 'fallback content', '{not valid', 0, ${Date.now()})`,
).run();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW });
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toContain('mmal');
expect(warn.mock.calls[0][0]).toContain(PROJECT_ID);
expect(warn.mock.calls[0][0]).toContain('malformed');
const lines = readLines(result.path);
expect(lines[2].id).toBe('mmal');
expect(lines[2].blocks).toEqual([{ type: 'text', text: 'fallback content' }]);
});
it('warns when events_json is JSON but not an array', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
db.prepare(
`INSERT INTO messages (id, conversation_id, role, content, events_json, position, created_at)
VALUES ('mobj', 'c1', 'assistant', 'fallback content', '{"foo":1}', 0, ${Date.now()})`,
).run();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW });
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toContain('mobj');
expect(warn.mock.calls[0][0]).toContain('not_array');
const lines = readLines(result.path);
expect(lines[2].blocks).toEqual([{ type: 'text', text: 'fallback content' }]);
});
// ---------- §1.6 attachments (tests #22-#23) ----------
it('header carries attachmentCount + commentAttachmentCount totals', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
seedMessage(db, 'c1', {
id: 'm1',
role: 'user',
events: [{ kind: 'text', text: 'a' }],
attachments: [
{ path: 'a.png', name: 'a.png', kind: 'image', size: 100 },
{ path: 'b.png', name: 'b.png', kind: 'image', size: 200 },
],
commentAttachments: [
{
id: 'ca1',
order: 0,
filePath: 'p.html',
elementId: 'e1',
selector: '#x',
label: 'L',
comment: 'C',
currentText: '',
pagePosition: { x: 0, y: 0 },
htmlHint: '',
},
],
});
seedMessage(db, 'c1', {
id: 'm2',
role: 'user',
events: [{ kind: 'text', text: 'b' }],
attachments: [{ path: 'c.png', name: 'c.png', kind: 'image' }],
});
const result = exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW });
const lines = readLines(result.path);
expect(lines[0].attachmentCount).toBe(3);
expect(lines[0].commentAttachmentCount).toBe(1);
expect(lines[0].attachmentsInlined).toBe(false);
});
it('per-message line carries attachments / commentAttachments only when present', () => {
const { db, projectsRoot } = setup();
seedConversation(db, { id: 'c1', createdAt: 100 });
seedMessage(db, 'c1', {
id: 'm-with',
role: 'user',
events: [{ kind: 'text', text: 'q' }],
attachments: [{ path: 'a.png', name: 'a.png', kind: 'image', size: 99 }],
commentAttachments: [
{
id: 'ca1',
order: 0,
filePath: 'p.html',
elementId: 'e1',
selector: '#x',
label: 'Lab',
comment: 'Cmt',
currentText: '',
pagePosition: { x: 1, y: 2 },
htmlHint: '',
},
],
});
seedMessage(db, 'c1', {
id: 'm-bare',
role: 'user',
events: [{ kind: 'text', text: 'r' }],
});
const lines = readLines(exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW }).path);
const withAtt = lines.find((l) => l.id === 'm-with');
const bare = lines.find((l) => l.id === 'm-bare');
expect(withAtt.attachments).toEqual([
{ path: 'a.png', name: 'a.png', kind: 'image', size: 99 },
]);
expect(withAtt.commentAttachments).toEqual([
{ id: 'ca1', filePath: 'p.html', label: 'Lab', comment: 'Cmt' },
]);
expect(bare.attachments).toBeUndefined();
expect(bare.commentAttachments).toBeUndefined();
});
// ---------- §1.7 missing project directory (test #24) ----------
it('creates project directory if it does not exist on disk', () => {
const { db, projectsRoot } = setup({ skipMkdir: true });
expect(fs.existsSync(path.join(projectsRoot, PROJECT_ID))).toBe(false);
seedConversation(db, { id: 'c1', createdAt: 100 });
seedMessage(db, 'c1', { id: 'm1', role: 'user', events: [{ kind: 'text', text: 'x' }] });
const result = exportProjectTranscript(db, projectsRoot, PROJECT_ID, { now: FIXED_NOW });
expect(fs.existsSync(result.path)).toBe(true);
const lines = readLines(result.path);
expect(lines[0].kind).toBe('header');
expect(lines[2].id).toBe('m1');
});
});