From a10826a993f13b8ee5da808b58b450070e530015 Mon Sep 17 00:00:00 2001 From: Yuren Ju Date: Wed, 8 Jul 2026 13:47:16 +0900 Subject: [PATCH] fix(drive): emit watch events as single-line NDJSON in json mode Pretty-printed multi-line JSON events break line-oriented consumers: a drive_sync_once event lists every path in the library and easily exceeds any reassembly buffer, so the desktop app lost the event and its sync badge stayed on "Syncing..." forever. --- src/handwritten/commands/drive/watch.ts | 15 +++++++++++-- src/handwritten/output/render.ts | 2 +- test/handwritten/drive/watch.test.ts | 28 +++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/handwritten/commands/drive/watch.ts b/src/handwritten/commands/drive/watch.ts index d787b1a..a096a07 100644 --- a/src/handwritten/commands/drive/watch.ts +++ b/src/handwritten/commands/drive/watch.ts @@ -3,7 +3,7 @@ import chokidar from "chokidar" import { watch as fsWatch } from "node:fs" import { relative, resolve } from "node:path" import { loadRealtimeAuthHeaders } from "../../auth/load-sdk-client.js" -import { render } from "../../output/render.js" +import { render, shouldOutputJson } from "../../output/render.js" import { createDriveRealtimeSource } from "./realtime.js" import { DRIVE_DIR, ensureDriveRealtimeState, readDriveState, writeDriveRealtimeState } from "./state.js" import { runDriveSyncOnce, type DriveSyncSummary } from "./sync.js" @@ -45,7 +45,18 @@ export async function runDriveWatch(root: string, options: DriveWatchOptions = { const runSync = options.runSync ?? runDriveSyncOnce const debounceMs = options.debounceMs ?? 500 const remoteDebounceMs = options.remoteDebounceMs ?? 2000 - const emit = options.onEvent ?? ((event) => render({ kind: "drive_watch", display: { shape: "object" } }, event)) + // Watch is a long-lived event stream: json mode must emit NDJSON (one event + // per line) so consumers can parse line-by-line instead of reassembling + // pretty-printed multi-line JSON, which breaks past any buffer limit. + const emit = + options.onEvent ?? + ((event: unknown) => { + if (shouldOutputJson()) { + process.stdout.write(`${JSON.stringify(event)}\n`) + return + } + render({ kind: "drive_watch", display: { shape: "object" } }, event) + }) let debounceTimer: NodeJS.Timeout | undefined let debounceDeadlineMs: number | undefined let retryTimer: NodeJS.Timeout | undefined diff --git a/src/handwritten/output/render.ts b/src/handwritten/output/render.ts index 2190478..2cfe65c 100644 --- a/src/handwritten/output/render.ts +++ b/src/handwritten/output/render.ts @@ -104,7 +104,7 @@ function drillDataPath(data: unknown, dataPath: string | undefined): unknown { return value === undefined ? data : value } -function shouldOutputJson(): boolean { +export function shouldOutputJson(): boolean { // Explicit overrides win over TTY detection so users can force either // mode in CI logs, screenshots, captured output, or AI-driven tooling. if (process.env.WSPC_OUTPUT === "json") return true diff --git a/test/handwritten/drive/watch.test.ts b/test/handwritten/drive/watch.test.ts index c8424d4..1dd1fd9 100644 --- a/test/handwritten/drive/watch.test.ts +++ b/test/handwritten/drive/watch.test.ts @@ -91,6 +91,34 @@ describe("drive watch", () => { vi.useRealTimers() }) + it("emits single-line NDJSON events in json output mode", async () => { + const source = fakeSource() + const runSync = vi.fn(async () => + syncSummary({} as never) as ReturnType & { paths: unknown[] }, + ) + const writes: string[] = [] + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => { + writes.push(String(chunk)) + return true + }) as never) + vi.stubEnv("WSPC_OUTPUT", "json") + + try { + await runDriveWatch("/tmp/root", { source, runSync, readState, once: true }) + } finally { + writeSpy.mockRestore() + vi.unstubAllEnvs() + } + + expect(writes.length).toBeGreaterThan(0) + for (const chunk of writes) { + expect(chunk.endsWith("\n")).toBe(true) + const line = chunk.slice(0, -1) + expect(line).not.toContain("\n") + expect(() => JSON.parse(line)).not.toThrow() + } + }) + it("runs one sync on startup", async () => { const source = fakeSource() const onEvent = vi.fn()