@cueloop/daemon 0.1.0-alpha.8 → 0.1.0-alpha.81
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.
- package/package.json +37 -22
- package/src/api.test.ts +940 -65
- package/src/api.ts +1095 -88
- package/src/capabilities.test.ts +57 -0
- package/src/capabilities.ts +89 -0
- package/src/client.test.ts +24 -0
- package/src/client.ts +669 -86
- package/src/curate.ts +109 -0
- package/src/daemon-version.test.ts +93 -0
- package/src/diff-hot-reload.test.ts +272 -0
- package/src/diff-watcher.ts +274 -0
- package/src/gh.ts +37 -0
- package/src/ghostty-thread-surface-store.test.ts +49 -0
- package/src/ghostty-thread-surface-store.ts +101 -0
- package/src/ghostty-thread-surface.test.ts +398 -0
- package/src/ghostty-thread-surface.ts +267 -0
- package/src/harness-delivery.test.ts +208 -0
- package/src/harness-state-store.test.ts +65 -0
- package/src/harness-state-store.ts +181 -0
- package/src/herdr-split.test.ts +106 -0
- package/src/herdr-split.ts +92 -0
- package/src/herdr-thread-surface-store.test.ts +81 -0
- package/src/herdr-thread-surface-store.ts +64 -0
- package/src/herdr-thread-surface.test.ts +411 -0
- package/src/herdr-thread-surface.ts +355 -0
- package/src/index.ts +8 -9
- package/src/interruptible-wait.test.ts +127 -0
- package/src/interruptible-wait.ts +66 -0
- package/src/main.ts +4 -1
- package/src/paths.ts +49 -0
- package/src/pr-poller.test.ts +76 -0
- package/src/pr-poller.ts +74 -0
- package/src/project-files.test.ts +124 -0
- package/src/project-files.ts +56 -0
- package/src/protocol.ts +79 -7
- package/src/retention.test.ts +191 -0
- package/src/retention.ts +123 -0
- package/src/server.test.ts +432 -42
- package/src/server.ts +423 -89
- package/src/share-blob.test.ts +112 -0
- package/src/share-blob.ts +55 -0
- package/src/single-instance.test.ts +162 -13
- package/src/store.conformance.test.ts +41 -0
- package/src/store.jsonl.test.ts +159 -0
- package/src/store.ts +264 -34
- package/src/testing/store-conformance.ts +194 -0
- package/src/thread-review.test.ts +394 -0
- package/src/thread-review.ts +314 -0
- package/src/thread-surface-config.test.ts +63 -0
- package/src/thread-surface-config.ts +61 -0
- package/src/validate.test.ts +214 -35
- package/src/validate.ts +306 -25
- package/src/version.ts +6 -0
- package/src/working-tree.ts +149 -0
- package/src/review.test.ts +0 -167
- package/src/review.ts +0 -174
|
@@ -0,0 +1,76 @@
|
|
|
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 makePoller(initialSha: string | null) {
|
|
6
|
+
let sha = initialSha;
|
|
7
|
+
const advanced: string[] = [];
|
|
8
|
+
const poller = new PrReviewPoller(
|
|
9
|
+
(sessionId) => advanced.push(sessionId),
|
|
10
|
+
async () => sha,
|
|
11
|
+
1_000_000,
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
poller,
|
|
16
|
+
advanced,
|
|
17
|
+
setSha: (next: string | null) => {
|
|
18
|
+
sha = next;
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("PrReviewPoller", () => {
|
|
24
|
+
test("the first observation is a silent baseline", async () => {
|
|
25
|
+
const { poller, advanced } = makePoller("sha1");
|
|
26
|
+
|
|
27
|
+
poller.trackPr("s1", "org/repo#1");
|
|
28
|
+
await poller.refreshHead("s1");
|
|
29
|
+
|
|
30
|
+
expect(advanced).toEqual([]);
|
|
31
|
+
poller.close();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("a moved head fires once; an unchanged head stays quiet", async () => {
|
|
35
|
+
const { poller, advanced, setSha } = makePoller("sha1");
|
|
36
|
+
|
|
37
|
+
poller.trackPr("s1", "org/repo#1");
|
|
38
|
+
await poller.refreshHead("s1");
|
|
39
|
+
|
|
40
|
+
setSha("sha2");
|
|
41
|
+
await poller.refreshHead("s1");
|
|
42
|
+
await poller.refreshHead("s1");
|
|
43
|
+
|
|
44
|
+
expect(advanced).toEqual(["s1"]);
|
|
45
|
+
poller.close();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("a failed head read never fires and keeps the last baseline", async () => {
|
|
49
|
+
const { poller, advanced, setSha } = makePoller("sha1");
|
|
50
|
+
|
|
51
|
+
poller.trackPr("s1", "org/repo#1");
|
|
52
|
+
await poller.refreshHead("s1");
|
|
53
|
+
|
|
54
|
+
setSha(null);
|
|
55
|
+
await poller.refreshHead("s1");
|
|
56
|
+
setSha("sha2");
|
|
57
|
+
await poller.refreshHead("s1");
|
|
58
|
+
|
|
59
|
+
expect(advanced).toEqual(["s1"]);
|
|
60
|
+
poller.close();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("untrack stops further checks", async () => {
|
|
64
|
+
const { poller, advanced, setSha } = makePoller("sha1");
|
|
65
|
+
|
|
66
|
+
poller.trackPr("s1", "org/repo#1");
|
|
67
|
+
await poller.refreshHead("s1");
|
|
68
|
+
poller.untrackPr("s1");
|
|
69
|
+
|
|
70
|
+
setSha("sha2");
|
|
71
|
+
await poller.refreshHead("s1");
|
|
72
|
+
|
|
73
|
+
expect(advanced).toEqual([]);
|
|
74
|
+
poller.close();
|
|
75
|
+
});
|
|
76
|
+
});
|
package/src/pr-poller.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Polls each open PR review for new commits so the review hot-reloads when the
|
|
3
|
+
* PR head advances. A remote PR has no push signal, so this checks the head sha
|
|
4
|
+
* on an interval - one cheap gh call - and only asks the caller to re-pull the
|
|
5
|
+
* full diff when the head actually moved. Best-effort: a missing or failing gh
|
|
6
|
+
* leaves the last known head in place and never throws.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { prHeadSha } from "./gh";
|
|
10
|
+
|
|
11
|
+
/** One cheap head-sha check per interval; the expensive full-diff pull happens only on a move. */
|
|
12
|
+
const PR_POLL_INTERVAL_MS = 30_000;
|
|
13
|
+
|
|
14
|
+
interface PrPoll {
|
|
15
|
+
pr: string;
|
|
16
|
+
/** Last head sha seen; null until the baseline check lands, so the first observation never fires. */
|
|
17
|
+
lastSha: string | null;
|
|
18
|
+
timer: ReturnType<typeof setInterval>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class PrReviewPoller {
|
|
22
|
+
private readonly polls = new Map<string, PrPoll>();
|
|
23
|
+
|
|
24
|
+
constructor(
|
|
25
|
+
private readonly onPrAdvance: (sessionId: string) => void,
|
|
26
|
+
private readonly headSha: (pr: string) => Promise<string | null> = prHeadSha,
|
|
27
|
+
private readonly intervalMs: number = PR_POLL_INTERVAL_MS,
|
|
28
|
+
) {}
|
|
29
|
+
|
|
30
|
+
/** Start polling a PR review. Idempotent per session; the baseline check runs at once. */
|
|
31
|
+
trackPr(sessionId: string, pr: string): void {
|
|
32
|
+
if (this.polls.has(sessionId)) return;
|
|
33
|
+
const timer = setInterval(() => void this.refreshHead(sessionId), this.intervalMs);
|
|
34
|
+
// the poll timer must not by itself keep the daemon alive against idle-exit
|
|
35
|
+
timer.unref?.();
|
|
36
|
+
this.polls.set(sessionId, { pr, lastSha: null, timer });
|
|
37
|
+
void this.refreshHead(sessionId);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Check the PR head once; fire onPrAdvance only when it moved off a known baseline. */
|
|
41
|
+
async refreshHead(sessionId: string): Promise<void> {
|
|
42
|
+
const poll = this.polls.get(sessionId);
|
|
43
|
+
|
|
44
|
+
if (!poll) return;
|
|
45
|
+
const sha = await this.headSha(poll.pr).catch(() => null);
|
|
46
|
+
|
|
47
|
+
if (sha === null) return;
|
|
48
|
+
// the await yields; re-read so an untrack during the call is honoured
|
|
49
|
+
const current = this.polls.get(sessionId);
|
|
50
|
+
|
|
51
|
+
if (!current) return;
|
|
52
|
+
if (current.lastSha === null) {
|
|
53
|
+
current.lastSha = sha;
|
|
54
|
+
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (sha === current.lastSha) return;
|
|
58
|
+
current.lastSha = sha;
|
|
59
|
+
this.onPrAdvance(sessionId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
untrackPr(sessionId: string): void {
|
|
63
|
+
const poll = this.polls.get(sessionId);
|
|
64
|
+
|
|
65
|
+
if (!poll) return;
|
|
66
|
+
clearInterval(poll.timer);
|
|
67
|
+
this.polls.delete(sessionId);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
close(): void {
|
|
71
|
+
for (const poll of this.polls.values()) clearInterval(poll.timer);
|
|
72
|
+
this.polls.clear();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -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
|
|
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
|
|
56
|
+
private buffered = "";
|
|
29
57
|
|
|
30
58
|
push(chunk: string, onLine: (line: string) => void): void {
|
|
31
|
-
this.
|
|
59
|
+
this.buffered += chunk;
|
|
32
60
|
for (;;) {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/** Retention: the cleanup period defaults to 30, honors [cleanup] period_days, and prune drops only the sessions and reports past the window. */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import { mkdirSync, mkdtempSync, utimesSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import type { Thread, SessionStatus } from "@cueloop/schema";
|
|
8
|
+
import { ThreadStore } from "./store";
|
|
9
|
+
import { reportsDir } from "./paths";
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_CLEANUP_PERIOD_DAYS,
|
|
12
|
+
LATEST_REPORT_FILENAME,
|
|
13
|
+
isExpired,
|
|
14
|
+
parseRefineState,
|
|
15
|
+
pruneExpiredReports,
|
|
16
|
+
pruneExpiredSessions,
|
|
17
|
+
resolveCleanupPeriodDays,
|
|
18
|
+
} from "./retention";
|
|
19
|
+
|
|
20
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
21
|
+
const NOW_MS = Date.parse("2026-08-26T00:00:00Z");
|
|
22
|
+
|
|
23
|
+
function tempDir(prefix: string): string {
|
|
24
|
+
return mkdtempSync(join(tmpdir(), prefix));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function daysAgo(days: number): string {
|
|
28
|
+
return new Date(NOW_MS - days * DAY_MS).toISOString();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function session(id: string, createdAt: string, status: SessionStatus = "resolved"): Thread {
|
|
32
|
+
return {
|
|
33
|
+
schemaVersion: "1",
|
|
34
|
+
id,
|
|
35
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
36
|
+
artifact: { type: "plan", content: "", meta: {} },
|
|
37
|
+
revisions: [],
|
|
38
|
+
annotations: [],
|
|
39
|
+
message: null,
|
|
40
|
+
status,
|
|
41
|
+
createdAt,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("resolveCleanupPeriodDays", () => {
|
|
46
|
+
test("defaults to 30 when no config file is present", () => {
|
|
47
|
+
// Arrange
|
|
48
|
+
const missing = join(tempDir("cueloop-retention-"), "config.toml");
|
|
49
|
+
|
|
50
|
+
// Act
|
|
51
|
+
const days = resolveCleanupPeriodDays({ CUELOOP_CONFIG: missing });
|
|
52
|
+
|
|
53
|
+
// Assert
|
|
54
|
+
expect(days).toBe(DEFAULT_CLEANUP_PERIOD_DAYS);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("honors [cleanup] period_days", () => {
|
|
58
|
+
// Arrange
|
|
59
|
+
const path = join(tempDir("cueloop-retention-"), "config.toml");
|
|
60
|
+
|
|
61
|
+
writeFileSync(path, "[cleanup]\nperiod_days = 7\n");
|
|
62
|
+
|
|
63
|
+
// Act
|
|
64
|
+
const days = resolveCleanupPeriodDays({ CUELOOP_CONFIG: path });
|
|
65
|
+
|
|
66
|
+
// Assert
|
|
67
|
+
expect(days).toBe(7);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("falls back to the default when the value is not a number", () => {
|
|
71
|
+
// Arrange
|
|
72
|
+
const path = join(tempDir("cueloop-retention-"), "config.toml");
|
|
73
|
+
|
|
74
|
+
writeFileSync(path, '[cleanup]\nperiod_days = "soon"\n');
|
|
75
|
+
|
|
76
|
+
// Act
|
|
77
|
+
const days = resolveCleanupPeriodDays({ CUELOOP_CONFIG: path });
|
|
78
|
+
|
|
79
|
+
// Assert
|
|
80
|
+
expect(days).toBe(DEFAULT_CLEANUP_PERIOD_DAYS);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("parseRefineState", () => {
|
|
85
|
+
test("reads the analyzed fingerprint map", () => {
|
|
86
|
+
// Assert
|
|
87
|
+
expect([...parseRefineState({ analyzed: { ses_a: "1:2:x" } })]).toEqual([["ses_a", "1:2:x"]]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("returns an empty map for the legacy or malformed shape", () => {
|
|
91
|
+
// Assert
|
|
92
|
+
expect(parseRefineState({ seenSessionIds: ["ses_a"] }).size).toBe(0);
|
|
93
|
+
expect(parseRefineState({ analyzed: { ses_a: 5 } }).size).toBe(0);
|
|
94
|
+
expect(parseRefineState(null).size).toBe(0);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("isExpired", () => {
|
|
99
|
+
test("is true past the window and false inside it", () => {
|
|
100
|
+
// Assert
|
|
101
|
+
expect(isExpired(daysAgo(40), 30, NOW_MS)).toBe(true);
|
|
102
|
+
expect(isExpired(daysAgo(10), 30, NOW_MS)).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a period of zero disables expiry", () => {
|
|
106
|
+
// Assert
|
|
107
|
+
expect(isExpired(daysAgo(999), 0, NOW_MS)).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("pruneExpiredSessions", () => {
|
|
112
|
+
test("deletes resolved sessions past the window and keeps recent ones", () => {
|
|
113
|
+
// Arrange
|
|
114
|
+
const home = tempDir("cueloop-retention-home-");
|
|
115
|
+
const store = new ThreadStore(home);
|
|
116
|
+
|
|
117
|
+
store.upsert(session("ses_old", daysAgo(40)));
|
|
118
|
+
store.upsert(session("ses_new", daysAgo(2)));
|
|
119
|
+
|
|
120
|
+
// Act
|
|
121
|
+
const pruned = pruneExpiredSessions(store, 30, NOW_MS);
|
|
122
|
+
|
|
123
|
+
// Assert
|
|
124
|
+
expect(pruned).toEqual(["ses_old"]);
|
|
125
|
+
expect(store.get("ses_old")).toBeUndefined();
|
|
126
|
+
expect(store.get("ses_new")).toBeDefined();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("keeps an expired resolved Thread while its Message is awaiting delivery", () => {
|
|
130
|
+
const store = new ThreadStore(tempDir("cueloop-retention-home-"));
|
|
131
|
+
|
|
132
|
+
store.upsert(session("ses_waiting", daysAgo(40)));
|
|
133
|
+
|
|
134
|
+
expect(pruneExpiredSessions(store, 30, NOW_MS, new Set(["ses_waiting"]))).toEqual([]);
|
|
135
|
+
expect(store.get("ses_waiting")).toBeDefined();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("never deletes an active pending session however old", () => {
|
|
139
|
+
// Arrange
|
|
140
|
+
const home = tempDir("cueloop-retention-home-");
|
|
141
|
+
const store = new ThreadStore(home);
|
|
142
|
+
|
|
143
|
+
store.upsert(session("ses_active", daysAgo(400), "pending"));
|
|
144
|
+
|
|
145
|
+
// Act
|
|
146
|
+
const pruned = pruneExpiredSessions(store, 30, NOW_MS);
|
|
147
|
+
|
|
148
|
+
// Assert
|
|
149
|
+
expect(pruned).toEqual([]);
|
|
150
|
+
expect(store.get("ses_active")).toBeDefined();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("a period of zero prunes nothing", () => {
|
|
154
|
+
// Arrange
|
|
155
|
+
const home = tempDir("cueloop-retention-home-");
|
|
156
|
+
const store = new ThreadStore(home);
|
|
157
|
+
|
|
158
|
+
store.upsert(session("ses_old", daysAgo(999)));
|
|
159
|
+
|
|
160
|
+
// Act
|
|
161
|
+
const pruned = pruneExpiredSessions(store, 0, NOW_MS);
|
|
162
|
+
|
|
163
|
+
// Assert
|
|
164
|
+
expect(pruned).toEqual([]);
|
|
165
|
+
expect(store.get("ses_old")).toBeDefined();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("pruneExpiredReports", () => {
|
|
170
|
+
test("removes timestamped reports past the window but keeps the latest report", () => {
|
|
171
|
+
// Arrange
|
|
172
|
+
const home = tempDir("cueloop-retention-home-");
|
|
173
|
+
const directory = reportsDir(home);
|
|
174
|
+
|
|
175
|
+
mkdirSync(directory, { recursive: true });
|
|
176
|
+
const latest = join(directory, LATEST_REPORT_FILENAME);
|
|
177
|
+
const stale = join(directory, "refine-2026-06-01T00-00-00-000Z.md");
|
|
178
|
+
const fresh = join(directory, "refine-2026-08-25T00-00-00-000Z.md");
|
|
179
|
+
|
|
180
|
+
for (const path of [latest, stale, fresh]) writeFileSync(path, "report");
|
|
181
|
+
const staleSeconds = (NOW_MS - 60 * DAY_MS) / 1000;
|
|
182
|
+
|
|
183
|
+
utimesSync(stale, staleSeconds, staleSeconds);
|
|
184
|
+
|
|
185
|
+
// Act
|
|
186
|
+
const pruned = pruneExpiredReports(directory, 30, NOW_MS);
|
|
187
|
+
|
|
188
|
+
// Assert
|
|
189
|
+
expect(pruned).toEqual(["refine-2026-06-01T00-00-00-000Z.md"]);
|
|
190
|
+
});
|
|
191
|
+
});
|