@cueloop/daemon 0.1.0-alpha.9 → 0.1.0-alpha.91

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/package.json +39 -22
  2. package/src/api.test.ts +1060 -65
  3. package/src/api.ts +1398 -89
  4. package/src/capabilities.test.ts +57 -0
  5. package/src/capabilities.ts +89 -0
  6. package/src/client.test.ts +24 -0
  7. package/src/client.ts +669 -86
  8. package/src/curate.ts +109 -0
  9. package/src/daemon-version.test.ts +142 -0
  10. package/src/diff-hot-reload.test.ts +419 -0
  11. package/src/diff-watcher.ts +293 -0
  12. package/src/gh.test.ts +59 -0
  13. package/src/gh.ts +74 -0
  14. package/src/ghostty-thread-surface-store.test.ts +49 -0
  15. package/src/ghostty-thread-surface-store.ts +101 -0
  16. package/src/ghostty-thread-surface.test.ts +398 -0
  17. package/src/ghostty-thread-surface.ts +267 -0
  18. package/src/harness-delivery.test.ts +268 -0
  19. package/src/harness-state-store.test.ts +93 -0
  20. package/src/harness-state-store.ts +213 -0
  21. package/src/herdr-split.test.ts +106 -0
  22. package/src/herdr-split.ts +92 -0
  23. package/src/herdr-thread-surface-store.test.ts +81 -0
  24. package/src/herdr-thread-surface-store.ts +64 -0
  25. package/src/herdr-thread-surface.test.ts +411 -0
  26. package/src/herdr-thread-surface.ts +355 -0
  27. package/src/index.ts +8 -9
  28. package/src/interruptible-wait.test.ts +127 -0
  29. package/src/interruptible-wait.ts +66 -0
  30. package/src/jj-working-tree.ts +131 -0
  31. package/src/main.ts +4 -1
  32. package/src/paths.ts +49 -0
  33. package/src/pr-poller.test.ts +102 -0
  34. package/src/pr-poller.ts +73 -0
  35. package/src/project-files.test.ts +124 -0
  36. package/src/project-files.ts +56 -0
  37. package/src/protocol.ts +79 -7
  38. package/src/retention.test.ts +191 -0
  39. package/src/retention.ts +123 -0
  40. package/src/server.test.ts +453 -42
  41. package/src/server.ts +433 -88
  42. package/src/share-blob.test.ts +112 -0
  43. package/src/share-blob.ts +55 -0
  44. package/src/single-instance.test.ts +162 -13
  45. package/src/store.conformance.test.ts +41 -0
  46. package/src/store.jsonl.test.ts +159 -0
  47. package/src/store.ts +264 -34
  48. package/src/testing/store-conformance.ts +194 -0
  49. package/src/thread-review.test.ts +483 -0
  50. package/src/thread-review.ts +398 -0
  51. package/src/thread-surface-config.test.ts +63 -0
  52. package/src/thread-surface-config.ts +61 -0
  53. package/src/validate.test.ts +282 -37
  54. package/src/validate.ts +398 -33
  55. package/src/vcs-source.test.ts +180 -0
  56. package/src/vcs-source.ts +228 -0
  57. package/src/version.ts +6 -0
  58. package/src/working-tree.ts +149 -0
  59. package/src/review.test.ts +0 -167
  60. package/src/review.ts +0 -174
package/src/main.ts CHANGED
@@ -2,9 +2,12 @@
2
2
 
3
3
  import { DaemonServer } from "./server";
4
4
 
5
- const idleExitMs = process.env.CUELOOP_IDLE_EXIT_MS ? Number(process.env.CUELOOP_IDLE_EXIT_MS) : undefined;
5
+ const idleExitMs = process.env.CUELOOP_IDLE_EXIT_MS
6
+ ? Number(process.env.CUELOOP_IDLE_EXIT_MS)
7
+ : undefined;
6
8
  const server = new DaemonServer({ idleExitMs });
7
9
  const path = server.start();
10
+
8
11
  if (path === null) {
9
12
  // another daemon already owns this home; the client will attach to it
10
13
  console.log("cueloop daemon already running for this home");
package/src/paths.ts CHANGED
@@ -18,6 +18,35 @@ export function sessionsDir(home = cueloopHome()): string {
18
18
  return join(home, "sessions");
19
19
  }
20
20
 
21
+ /** Thread files live here, one JSONL per thread, under a per-project bucket. */
22
+ export function threadsDir(home = cueloopHome()): string {
23
+ return join(home, "threads");
24
+ }
25
+
26
+ /** A git commit is lowercase hex; anything else is not a real root commit. */
27
+ const COMMIT_SHA = /^[0-9a-f]{7,64}$/;
28
+
29
+ /**
30
+ * The per-project bucket for a workspace: its durable root-commit SHA, or a shared standalone bucket.
31
+ * A value that is not a commit SHA (including path-traversal segments an IPC caller could smuggle in)
32
+ * is refused to the standalone bucket, so a bucket name can never escape the threads directory.
33
+ */
34
+ export function threadBucket(rootCommit: string | undefined, home = cueloopHome()): string {
35
+ const bucket =
36
+ rootCommit && COMMIT_SHA.test(rootCommit) ? rootCommit.slice(0, 12) : "_standalone";
37
+
38
+ return join(threadsDir(home), bucket);
39
+ }
40
+
41
+ /** Where the one-time migration parks the old whole-record JSON files, so a downgrade can still read them. */
42
+ export function migratedSessionsDir(home = cueloopHome()): string {
43
+ return join(home, "sessions.migrated");
44
+ }
45
+
46
+ export function reportsDir(home = cueloopHome()): string {
47
+ return join(home, "reports");
48
+ }
49
+
21
50
  export function pidPath(home = cueloopHome()): string {
22
51
  return join(home, "cueloop.pid");
23
52
  }
@@ -25,3 +54,23 @@ export function pidPath(home = cueloopHome()): string {
25
54
  export function lockPath(home = cueloopHome()): string {
26
55
  return join(home, "cueloop.lock");
27
56
  }
57
+
58
+ /** The secret a connection presents to be the owner; written by the daemon, mode 0600. */
59
+ export function ownerTokenPath(home = cueloopHome()): string {
60
+ return join(home, "owner.token");
61
+ }
62
+
63
+ /** Adapter scratch for Herdr handles; the disk name stays stable across tab and pane support. */
64
+ export function herdrThreadSurfacesPath(home = cueloopHome()): string {
65
+ return join(home, "herdr-tabs.json");
66
+ }
67
+
68
+ /** Ghostty-native terminal IDs are adapter scratch, not Thread state. */
69
+ export function ghosttyThreadSurfacesPath(home = cueloopHome()): string {
70
+ return join(home, "ghostty-thread-surfaces.json");
71
+ }
72
+
73
+ /** Harness bindings and Message deliveries, stored independently of Thread records. */
74
+ export function harnessStatePath(home = cueloopHome()): string {
75
+ return join(home, "harness-state.json");
76
+ }
@@ -0,0 +1,102 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { PrReviewPoller } from "./pr-poller";
3
+
4
+ /** Build a poller whose head sha is controllable, with the timer effectively disabled. */
5
+ function refs(baseSha: string, headSha: string) {
6
+ return { baseSha, headSha };
7
+ }
8
+
9
+ function makePoller(initialRefs: ReturnType<typeof refs> | null) {
10
+ let currentRefs = initialRefs;
11
+ const advanced: Array<{ sessionId: string; refs: ReturnType<typeof refs> }> = [];
12
+ const poller = new PrReviewPoller(
13
+ (sessionId, nextRefs) => advanced.push({ sessionId, refs: nextRefs }),
14
+ async () => currentRefs,
15
+ 1_000_000,
16
+ );
17
+
18
+ return {
19
+ poller,
20
+ advanced,
21
+ setRefs: (next: ReturnType<typeof refs> | null) => {
22
+ currentRefs = next;
23
+ },
24
+ };
25
+ }
26
+
27
+ describe("PrReviewPoller", () => {
28
+ test("the first observation is a silent baseline", async () => {
29
+ const { poller, advanced } = makePoller(refs("base1", "sha1"));
30
+
31
+ poller.trackPr("s1", "org/repo#1");
32
+ await poller.refreshHead("s1");
33
+
34
+ expect(advanced).toEqual([]);
35
+ poller.close();
36
+ });
37
+
38
+ test("a moved head fires once; an unchanged head stays quiet", async () => {
39
+ const { poller, advanced, setRefs } = makePoller(refs("base1", "sha1"));
40
+
41
+ poller.trackPr("s1", "org/repo#1");
42
+ await poller.refreshHead("s1");
43
+
44
+ setRefs(refs("base1", "sha2"));
45
+ await poller.refreshHead("s1");
46
+ await poller.refreshHead("s1");
47
+
48
+ expect(advanced).toEqual([{ sessionId: "s1", refs: refs("base1", "sha2") }]);
49
+ poller.close();
50
+ });
51
+
52
+ test("a persisted reviewed head exposes a move on the first check after restart", async () => {
53
+ const { poller, advanced } = makePoller(refs("base1", "sha2"));
54
+
55
+ poller.trackPr("s1", "https://github.com/org/repo/pull/1", refs("base1", "sha1"));
56
+ await poller.refreshHead("s1");
57
+
58
+ expect(advanced).toEqual([{ sessionId: "s1", refs: refs("base1", "sha2") }]);
59
+ poller.close();
60
+ });
61
+
62
+ test("a failed head read never fires and keeps the last baseline", async () => {
63
+ const { poller, advanced, setRefs } = makePoller(refs("base1", "sha1"));
64
+
65
+ poller.trackPr("s1", "org/repo#1");
66
+ await poller.refreshHead("s1");
67
+
68
+ setRefs(null);
69
+ await poller.refreshHead("s1");
70
+ setRefs(refs("base1", "sha2"));
71
+ await poller.refreshHead("s1");
72
+
73
+ expect(advanced).toEqual([{ sessionId: "s1", refs: refs("base1", "sha2") }]);
74
+ poller.close();
75
+ });
76
+
77
+ test("untrack stops further checks", async () => {
78
+ const { poller, advanced, setRefs } = makePoller(refs("base1", "sha1"));
79
+
80
+ poller.trackPr("s1", "org/repo#1");
81
+ await poller.refreshHead("s1");
82
+ poller.untrackPr("s1");
83
+
84
+ setRefs(refs("base1", "sha2"));
85
+ await poller.refreshHead("s1");
86
+
87
+ expect(advanced).toEqual([]);
88
+ poller.close();
89
+ });
90
+
91
+ test("a moved base fires while the head stays unchanged", async () => {
92
+ const { poller, advanced, setRefs } = makePoller(refs("base1", "sha1"));
93
+
94
+ poller.trackPr("s1", "org/repo#1");
95
+ await poller.refreshHead("s1");
96
+ setRefs(refs("base2", "sha1"));
97
+ await poller.refreshHead("s1");
98
+
99
+ expect(advanced).toEqual([{ sessionId: "s1", refs: refs("base2", "sha1") }]);
100
+ poller.close();
101
+ });
102
+ });
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Polls each open PR review for new commits. A remote PR has no push signal, so
3
+ * this checks its base/head pair on an interval and only exposes refresh when
4
+ * either side moved. Best-effort: a missing or failing gh keeps the last refs.
5
+ */
6
+
7
+ import { prRefs, type PullRequestRefs } from "./gh";
8
+
9
+ /** One cheap refs check per interval; the expensive full-diff pull happens only on a move. */
10
+ const PR_POLL_INTERVAL_MS = 30_000;
11
+
12
+ interface PrPoll {
13
+ pr: string;
14
+ /** Last refs seen; null until the baseline check lands, so the first observation never fires. */
15
+ lastRefs: PullRequestRefs | null;
16
+ timer: ReturnType<typeof setInterval>;
17
+ }
18
+
19
+ export class PrReviewPoller {
20
+ private readonly polls = new Map<string, PrPoll>();
21
+
22
+ constructor(
23
+ private readonly onPrAdvance: (sessionId: string, refs: PullRequestRefs) => void,
24
+ private readonly refs: (pr: string) => Promise<PullRequestRefs | null> = prRefs,
25
+ private readonly intervalMs: number = PR_POLL_INTERVAL_MS,
26
+ ) {}
27
+
28
+ /** Start polling a PR review. Idempotent per session; the persisted reviewed head is the baseline. */
29
+ trackPr(sessionId: string, pr: string, reviewedRefs: PullRequestRefs | null = null): void {
30
+ if (this.polls.has(sessionId)) return;
31
+ const timer = setInterval(() => void this.refreshHead(sessionId), this.intervalMs);
32
+ // the poll timer must not by itself keep the daemon alive against idle-exit
33
+ timer.unref?.();
34
+ this.polls.set(sessionId, { pr, lastRefs: reviewedRefs, timer });
35
+ void this.refreshHead(sessionId);
36
+ }
37
+
38
+ /** Check the PR refs once; fire onPrAdvance only when they moved off a known baseline. */
39
+ async refreshHead(sessionId: string): Promise<void> {
40
+ const poll = this.polls.get(sessionId);
41
+
42
+ if (!poll) return;
43
+ const refs = await this.refs(poll.pr).catch(() => null);
44
+
45
+ if (refs === null) return;
46
+ // the await yields; re-read so an untrack during the call is honoured
47
+ const current = this.polls.get(sessionId);
48
+
49
+ if (!current) return;
50
+ if (current.lastRefs === null) {
51
+ current.lastRefs = refs;
52
+
53
+ return;
54
+ }
55
+ if (refs.baseSha === current.lastRefs.baseSha && refs.headSha === current.lastRefs.headSha)
56
+ return;
57
+ current.lastRefs = refs;
58
+ this.onPrAdvance(sessionId, refs);
59
+ }
60
+
61
+ untrackPr(sessionId: string): void {
62
+ const poll = this.polls.get(sessionId);
63
+
64
+ if (!poll) return;
65
+ clearInterval(poll.timer);
66
+ this.polls.delete(sessionId);
67
+ }
68
+
69
+ close(): void {
70
+ for (const poll of this.polls.values()) clearInterval(poll.timer);
71
+ this.polls.clear();
72
+ }
73
+ }
@@ -0,0 +1,124 @@
1
+ /** Read-only repo introspection: the tracked file tree and safe, bounded file reads. */
2
+
3
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { listProjectFiles, readProjectFile } from "./project-files";
8
+
9
+ function git(args: string[], cwd: string): void {
10
+ const result = Bun.spawnSync(["git", ...args], { cwd, stdout: "ignore", stderr: "ignore" });
11
+
12
+ if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed in ${cwd}`);
13
+ }
14
+
15
+ let repo: string;
16
+
17
+ beforeEach(() => {
18
+ repo = mkdtempSync(join(tmpdir(), "cueloop-project-files-"));
19
+ git(["init", "-q", "-b", "main"], repo);
20
+ git(["config", "user.email", "t@t"], repo);
21
+ git(["config", "user.name", "t"], repo);
22
+ });
23
+
24
+ afterEach(() => {
25
+ rmSync(repo, { recursive: true, force: true });
26
+ });
27
+
28
+ describe("listProjectFiles", () => {
29
+ test("returns the tracked files sorted ascending", async () => {
30
+ // Given a repo whose files are committed out of alphabetical order
31
+ writeFileSync(join(repo, "z.ts"), "export const z = 1;\n");
32
+ writeFileSync(join(repo, "a.ts"), "export const a = 1;\n");
33
+ writeFileSync(join(repo, "m.ts"), "export const m = 1;\n");
34
+ git(["add", "."], repo);
35
+ git(["commit", "-qm", "init"], repo);
36
+
37
+ // When the tracked files are listed
38
+ const files = await listProjectFiles(repo);
39
+
40
+ // Then every tracked path comes back in ascending order
41
+ expect(files).toEqual(["a.ts", "m.ts", "z.ts"]);
42
+ });
43
+
44
+ test("returns [] for a directory that is not a git repo", async () => {
45
+ // Given a plain directory with a file but no git repo
46
+ const plain = mkdtempSync(join(tmpdir(), "cueloop-project-files-plain-"));
47
+
48
+ writeFileSync(join(plain, "a.ts"), "export const a = 1;\n");
49
+ try {
50
+ // When the tracked files are listed
51
+ const files = await listProjectFiles(plain);
52
+
53
+ // Then nothing comes back and no error is thrown
54
+ expect(files).toEqual([]);
55
+ } finally {
56
+ rmSync(plain, { recursive: true, force: true });
57
+ }
58
+ });
59
+
60
+ test("returns [] when there is no repoRoot", async () => {
61
+ expect(await listProjectFiles(undefined)).toEqual([]);
62
+ });
63
+ });
64
+
65
+ describe("readProjectFile", () => {
66
+ test("reads a repo-relative file as UTF-8", async () => {
67
+ // Given a committed file with known contents
68
+ writeFileSync(join(repo, "a.ts"), "export const a = 1;\n");
69
+ git(["add", "."], repo);
70
+ git(["commit", "-qm", "init"], repo);
71
+
72
+ // When it is read by its repo-relative path
73
+ const contents = await readProjectFile(repo, "a.ts");
74
+
75
+ // Then its exact bytes come back
76
+ expect(contents).toBe("export const a = 1;\n");
77
+ });
78
+
79
+ test("returns null for a path that escapes the repo via ../", async () => {
80
+ // Given a secret file just outside the repo root
81
+ const secret = join(repo, "..", "outside-secret.txt");
82
+
83
+ writeFileSync(secret, "top secret\n");
84
+ try {
85
+ // When a traversal path tries to reach it
86
+ const contents = await readProjectFile(repo, "../outside-secret.txt");
87
+
88
+ // Then the read is refused
89
+ expect(contents).toBeNull();
90
+ } finally {
91
+ rmSync(secret, { force: true });
92
+ }
93
+ });
94
+
95
+ test("returns null for a missing file", async () => {
96
+ expect(await readProjectFile(repo, "nope.ts")).toBeNull();
97
+ });
98
+
99
+ test("returns null for a directory (not a regular file)", async () => {
100
+ // Given a directory inside the repo
101
+ mkdirSync(join(repo, "src"));
102
+
103
+ // When the directory path is read
104
+ const contents = await readProjectFile(repo, "src");
105
+
106
+ // Then it is refused
107
+ expect(contents).toBeNull();
108
+ });
109
+
110
+ test("returns null for a file larger than the cap", async () => {
111
+ // Given a file just over one megabyte
112
+ writeFileSync(join(repo, "big.bin"), "x".repeat(1024 * 1024 + 1));
113
+
114
+ // When it is read
115
+ const contents = await readProjectFile(repo, "big.bin");
116
+
117
+ // Then it is refused for exceeding the size cap
118
+ expect(contents).toBeNull();
119
+ });
120
+
121
+ test("returns null when there is no repoRoot", async () => {
122
+ expect(await readProjectFile(undefined, "a.ts")).toBeNull();
123
+ });
124
+ });
@@ -0,0 +1,56 @@
1
+ /** Read-only repo introspection for the Project panel: the tracked file tree and a file's contents. */
2
+
3
+ import { stat } from "node:fs/promises";
4
+ import { isAbsolute, relative, resolve, sep } from "node:path";
5
+
6
+ /** Ceiling on the returned tree; a pathological repo cannot flood the panel or the wire. */
7
+ const MAX_FILES = 5000;
8
+ /** Ceiling on a served file; the panel renders source, not multi-megabyte blobs. */
9
+ const MAX_FILE_BYTES = 1024 * 1024;
10
+
11
+ /** Tracked, repo-relative paths from `git ls-files`, sorted ascending and capped; [] on any failure or non-repo. */
12
+ export async function listProjectFiles(repoRoot: string | undefined): Promise<string[]> {
13
+ if (repoRoot === undefined || repoRoot.length === 0) return [];
14
+ try {
15
+ const gitProcess = Bun.spawn(["git", "-C", repoRoot, "ls-files", "-z"], {
16
+ stdout: "pipe",
17
+ stderr: "ignore",
18
+ });
19
+ const stdout = await new Response(gitProcess.stdout).text();
20
+
21
+ if ((await gitProcess.exited) !== 0) return [];
22
+ const paths = stdout.split("\0").filter((path) => path.length > 0);
23
+
24
+ return paths.toSorted().slice(0, MAX_FILES);
25
+ } catch {
26
+ return [];
27
+ }
28
+ }
29
+
30
+ /** True when `candidate` resolves outside `root` (path traversal) or is `root` itself. */
31
+ function escapesRoot(root: string, candidate: string): boolean {
32
+ const rel = relative(root, candidate);
33
+
34
+ return rel.length === 0 || rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel);
35
+ }
36
+
37
+ /** UTF-8 contents of `repoRoot/path`, or null: no repo, traversal escape, not a regular file, oversized, or any error. */
38
+ export async function readProjectFile(
39
+ repoRoot: string | undefined,
40
+ path: string,
41
+ ): Promise<string | null> {
42
+ if (repoRoot === undefined || repoRoot.length === 0) return null;
43
+ const root = resolve(repoRoot);
44
+ const target = resolve(root, path);
45
+
46
+ if (escapesRoot(root, target)) return null;
47
+ try {
48
+ const stats = await stat(target);
49
+
50
+ if (!stats.isFile() || stats.size > MAX_FILE_BYTES) return null;
51
+
52
+ return await Bun.file(target).text();
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
package/src/protocol.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  /**
2
- * Wire protocol (#14): newline-delimited JSON over the unix socket.
2
+ * Wire protocol: newline-delimited JSON over the unix socket.
3
3
  * Requests: { id, method, params } Responses: { id, result } | { id, error }
4
4
  * Events (push, after events.subscribe): { event, sessionId }
5
5
  */
6
6
 
7
+ import * as v from "valibot";
8
+
7
9
  export interface Request {
8
10
  id: number;
9
11
  method: string;
@@ -19,22 +21,92 @@ export interface Response {
19
21
  export interface EventFrame {
20
22
  event: string;
21
23
  sessionId: string;
24
+ /** The history entry the change appended, when it appended one. */
25
+ entryId?: string;
22
26
  }
23
27
 
24
28
  export type Frame = Response | EventFrame;
25
29
 
30
+ const RequestSchema = v.object({
31
+ id: v.number(),
32
+ method: v.string(),
33
+ params: v.optional(v.unknown()),
34
+ });
35
+ const ResponseSchema = v.object({
36
+ id: v.number(),
37
+ result: v.optional(v.unknown()),
38
+ error: v.optional(v.object({ code: v.string(), message: v.string() })),
39
+ });
40
+ const EventFrameSchema = v.object({
41
+ event: v.string(),
42
+ sessionId: v.string(),
43
+ entryId: v.optional(v.string()),
44
+ });
45
+
46
+ export function parseRequestFrame(line: string): Request {
47
+ return v.parse(RequestSchema, JSON.parse(line));
48
+ }
49
+
50
+ export function parseInboundFrame(line: string): Frame {
51
+ return v.parse(v.union([ResponseSchema, EventFrameSchema]), JSON.parse(line));
52
+ }
53
+
26
54
  /** Incremental NDJSON splitter; tolerates partial writes. */
27
55
  export class LineBuffer {
28
- private buf = "";
56
+ private buffered = "";
29
57
 
30
58
  push(chunk: string, onLine: (line: string) => void): void {
31
- this.buf += chunk;
59
+ this.buffered += chunk;
32
60
  for (;;) {
33
- const nl = this.buf.indexOf("\n");
34
- if (nl === -1) return;
35
- const line = this.buf.slice(0, nl).trim();
36
- this.buf = this.buf.slice(nl + 1);
61
+ const newlineIndex = this.buffered.indexOf("\n");
62
+
63
+ if (newlineIndex === -1) return;
64
+ const line = this.buffered.slice(0, newlineIndex).trim();
65
+
66
+ this.buffered = this.buffered.slice(newlineIndex + 1);
37
67
  if (line) onLine(line);
38
68
  }
39
69
  }
40
70
  }
71
+
72
+ /** The surface both Bun.listen and Bun.connect sockets satisfy. */
73
+ interface WritableSocket {
74
+ write(data: Buffer): number;
75
+ }
76
+
77
+ /**
78
+ * Outbound writes honoring socket backpressure. A frame larger than the
79
+ * kernel buffer is written partially by the socket; the unwritten tail must
80
+ * wait for the drain callback or the frame truncates mid-line and the NDJSON
81
+ * stream corrupts for good. Frames never reorder: while a tail is pending,
82
+ * new writes append behind it.
83
+ */
84
+ export class BackpressureWriter {
85
+ private pending: Buffer | null = null;
86
+
87
+ constructor(private socket: WritableSocket) {}
88
+
89
+ write(data: string): void {
90
+ const bytes = Buffer.from(data, "utf8");
91
+
92
+ if (this.pending) {
93
+ this.pending = Buffer.concat([this.pending, bytes]);
94
+
95
+ return;
96
+ }
97
+ const written = Math.max(0, this.socket.write(bytes));
98
+
99
+ if (written < bytes.length) this.pending = bytes.subarray(written);
100
+ }
101
+
102
+ /** Call from the socket's drain handler. */
103
+ drain(): void {
104
+ if (!this.pending) return;
105
+ const tail = this.pending;
106
+
107
+ this.pending = null;
108
+ const written = Math.max(0, this.socket.write(tail));
109
+
110
+ if (written < tail.length) this.pending = tail.subarray(written);
111
+ }
112
+ }