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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions src/handwritten/commands/drive/watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/handwritten/output/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions test/handwritten/drive/watch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof syncSummary> & { 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()
Expand Down
Loading