Commit Graph
2 Commits
Author SHA1 Message Date
netteeandGitHub 32d820e4ee fix(daemon): typecheck leaf modules (#943)
* update drift

* fix(daemon): typecheck leaf modules

* fix(daemon): decode Qoder stdout buffers

Generated-By: looper 0.5.6 (runner=fixer, agent=opencode)
2026-05-08 20:01:25 +08:00
98e40c1cfc feat(daemon): export project transcript to disk for downstream consumption (prereq for #450) (#493)
* feat(daemon): export project transcript to disk for downstream consumption

Adds exportProjectTranscript(db, projectsRoot, projectId, options?) — a
pure function that walks SQLite-backed conversation history and writes a
structured, lossless JSONL transcript to <projectDir>/.transcript.jsonl.

This is the input primitive that #450's "Finalize design package"
synthesis step needs. Landed ahead of the synthesis endpoint as a small,
reviewable, well-tested unit — no HTTP route, no LLM call, no web UI in
this diff. PR 2 will wire POST /api/projects/:id/finalize on top of it.

Format: JSONL with header line + per-conversation marker lines +
per-message lines. Compact encoding saves ~20–30% on synth-call tokens
vs pretty-printed JSON. schemaVersion field reserved on the header for
incompatible changes later.

Coalescing: events_json carries streaming text_delta / thinking_delta
chunks plus tool_use / tool_result / thinking_start markers and
telemetry. 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 (status, usage,
raw) is dropped. thinking_start is treated as an explicit flush trigger
so multiple thinking blocks in one message survive intact.

Content fallback: user-typed messages persist as plain text in
messages.content with events_json = NULL because user input does not
flow through the streaming pipeline. When event-derived blocks come
back empty, fall back to a single text block from content so user
prompts are not silently dropped.

Atomic write: tmp filename includes pid + crypto-random suffix so
concurrent exports for the same project cannot collide. fsync before
rename so a crash between rename and power loss cannot lose bytes.

Hidden file: leading dot keeps .transcript.jsonl out of listFiles
(projects.ts:54), the archive zip, and the gallery.

Tests: 14 unit cases — empty project, text/streaming coalescing, tool
ordering, telemetry filtering, type-change flush, text↔thinking↔text
interleaving, thinking_start flush, multi-conversation chronological
order, atomic-write hygiene, content fallback (both directions),
malformed events_json, and unsafe project id rejection.

Refs: nexu-io/open-design#450

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 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).

* fix(daemon): preserve thinking-segment boundaries on status thinking-start

Codex flagged this as a P2 on PR #493 a39d430:
https://github.com/nexu-io/open-design/pull/493#discussion_r3188524878

The web translator emits `{ kind: 'status', label: 'thinking' }` at every
thinking_start (apps/web/src/providers/daemon.ts:367-369). The previous
default branch dropped all status events without flushing the active
accumulator, so two thinking segments separated only by that marker
merged into one block — losing the original boundary and making the
transcript non-lossless for downstream synthesis.

coalesceBlocks now matches `status` explicitly: when `label === 'thinking'`
the prior accumulator flushes; other status labels and usage / raw drop
without flushing as before. Behavior fix within schemaVersion 2; no
shape change.

Test #25 verifies the boundary preservation:
  [thinking 'a', thinking 'b', status 'thinking', thinking 'c', thinking 'd']
  → blocks: [thinking 'ab', thinking 'cd'] (two blocks, not one)

Existing test #5 still passes because it uses status with label 'streaming'
which remains pure telemetry and does not flush.

Suite: 506 -> 507 daemon tests, all green.

Refs nexu-io/open-design#493

---------

Co-authored-by: DevForgeAI CI/CD Engineer <devforge-ai@development.ai>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:48:39 +08:00