@cueloop/daemon 0.1.0-alpha.6 → 0.1.0-alpha.61
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 +30 -22
- package/src/api.test.ts +292 -46
- package/src/api.ts +301 -52
- package/src/capabilities.test.ts +38 -0
- package/src/capabilities.ts +30 -0
- package/src/client.test.ts +24 -0
- package/src/client.ts +151 -38
- package/src/diff-hot-reload.test.ts +170 -0
- package/src/diff-watcher.ts +105 -0
- package/src/herdr-pane.test.ts +243 -0
- package/src/herdr-pane.ts +154 -0
- package/src/herdr-split.test.ts +106 -0
- package/src/herdr-split.ts +88 -0
- package/src/herdr-tab-store.test.ts +96 -0
- package/src/herdr-tab-store.ts +69 -0
- package/src/index.ts +8 -8
- package/src/main.ts +4 -1
- package/src/paths.ts +9 -0
- package/src/protocol.ts +51 -7
- package/src/retention.test.ts +182 -0
- package/src/retention.ts +122 -0
- package/src/review.test.ts +370 -0
- package/src/review.ts +289 -0
- package/src/server.test.ts +169 -20
- package/src/server.ts +193 -84
- package/src/share-blob.test.ts +62 -0
- package/src/share-blob.ts +55 -0
- package/src/single-instance.test.ts +38 -11
- package/src/store.ts +13 -5
- package/src/validate.test.ts +196 -14
- package/src/validate.ts +138 -38
- package/src/working-tree.ts +113 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* herdr adapter scratch, kept out of the core session record: the tab cueloop
|
|
3
|
+
* opened to render each review, keyed by session id. One JSON map, atomic write.
|
|
4
|
+
* The core `ReviewSession` never mentions herdr; this side-store is the whole
|
|
5
|
+
* leak boundary.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { herdrTabsPath } from "./paths";
|
|
10
|
+
|
|
11
|
+
/** The tab and its root pane: pane id checks liveness, tab id focuses it. */
|
|
12
|
+
export interface HerdrTabHandle {
|
|
13
|
+
tabId: string;
|
|
14
|
+
paneId: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Guard a persisted entry - both ids must be present strings, or it is dropped. */
|
|
18
|
+
function isHerdrTabHandle(value: unknown): value is HerdrTabHandle {
|
|
19
|
+
return (
|
|
20
|
+
typeof value === "object" &&
|
|
21
|
+
value !== null &&
|
|
22
|
+
typeof (value as HerdrTabHandle).tabId === "string" &&
|
|
23
|
+
typeof (value as HerdrTabHandle).paneId === "string"
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class HerdrTabStore {
|
|
28
|
+
private tabs = new Map<string, HerdrTabHandle>();
|
|
29
|
+
private readonly path: string;
|
|
30
|
+
|
|
31
|
+
constructor(home: string) {
|
|
32
|
+
this.path = herdrTabsPath(home);
|
|
33
|
+
this.load();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private load(): void {
|
|
37
|
+
try {
|
|
38
|
+
const parsed: unknown = JSON.parse(readFileSync(this.path, "utf8"));
|
|
39
|
+
|
|
40
|
+
if (typeof parsed !== "object" || parsed === null) return;
|
|
41
|
+
for (const [sessionId, value] of Object.entries(parsed)) {
|
|
42
|
+
if (isHerdrTabHandle(value))
|
|
43
|
+
this.tabs.set(sessionId, { tabId: value.tabId, paneId: value.paneId });
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// absent, unreadable, or malformed: start empty - a stale tab just reopens on resubmit
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private persist(): void {
|
|
51
|
+
const tempPath = this.path + ".tmp";
|
|
52
|
+
|
|
53
|
+
writeFileSync(tempPath, JSON.stringify(Object.fromEntries(this.tabs), null, 2));
|
|
54
|
+
renameSync(tempPath, this.path);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get(sessionId: string): HerdrTabHandle | null {
|
|
58
|
+
return this.tabs.get(sessionId) ?? null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
set(sessionId: string, handle: HerdrTabHandle): void {
|
|
62
|
+
this.tabs.set(sessionId, handle);
|
|
63
|
+
this.persist();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
delete(sessionId: string): void {
|
|
67
|
+
if (this.tabs.delete(sessionId)) this.persist();
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
export
|
|
8
|
-
export {
|
|
1
|
+
/**
|
|
2
|
+
* The daemon's public surface. Consumers reach the client and the review
|
|
3
|
+
* helpers through the ./client and ./review subpaths; this barrel carries
|
|
4
|
+
* only the two names imported bare: the server and the home-dir resolver.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { DaemonServer, type DaemonOptions } from "./server";
|
|
8
|
+
export { cueloopHome } from "./paths";
|
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
|
|
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,10 @@ export function sessionsDir(home = cueloopHome()): string {
|
|
|
18
18
|
return join(home, "sessions");
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
export function reportsDir(home = cueloopHome()): string {
|
|
22
|
+
return join(home, "reports");
|
|
23
|
+
}
|
|
24
|
+
|
|
21
25
|
export function pidPath(home = cueloopHome()): string {
|
|
22
26
|
return join(home, "cueloop.pid");
|
|
23
27
|
}
|
|
@@ -25,3 +29,8 @@ export function pidPath(home = cueloopHome()): string {
|
|
|
25
29
|
export function lockPath(home = cueloopHome()): string {
|
|
26
30
|
return join(home, "cueloop.lock");
|
|
27
31
|
}
|
|
32
|
+
|
|
33
|
+
/** Adapter scratch: herdr tab handles keyed by session id, kept out of the core session record. */
|
|
34
|
+
export function herdrTabsPath(home = cueloopHome()): string {
|
|
35
|
+
return join(home, "herdr-tabs.json");
|
|
36
|
+
}
|
package/src/protocol.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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
|
*/
|
|
@@ -25,16 +25,60 @@ export type Frame = Response | EventFrame;
|
|
|
25
25
|
|
|
26
26
|
/** Incremental NDJSON splitter; tolerates partial writes. */
|
|
27
27
|
export class LineBuffer {
|
|
28
|
-
private
|
|
28
|
+
private buffered = "";
|
|
29
29
|
|
|
30
30
|
push(chunk: string, onLine: (line: string) => void): void {
|
|
31
|
-
this.
|
|
31
|
+
this.buffered += chunk;
|
|
32
32
|
for (;;) {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
const newlineIndex = this.buffered.indexOf("\n");
|
|
34
|
+
|
|
35
|
+
if (newlineIndex === -1) return;
|
|
36
|
+
const line = this.buffered.slice(0, newlineIndex).trim();
|
|
37
|
+
|
|
38
|
+
this.buffered = this.buffered.slice(newlineIndex + 1);
|
|
37
39
|
if (line) onLine(line);
|
|
38
40
|
}
|
|
39
41
|
}
|
|
40
42
|
}
|
|
43
|
+
|
|
44
|
+
/** The surface both Bun.listen and Bun.connect sockets satisfy. */
|
|
45
|
+
interface WritableSocket {
|
|
46
|
+
write(data: Buffer): number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Outbound writes honoring socket backpressure. A frame larger than the
|
|
51
|
+
* kernel buffer is written partially by the socket; the unwritten tail must
|
|
52
|
+
* wait for the drain callback or the frame truncates mid-line and the NDJSON
|
|
53
|
+
* stream corrupts for good. Frames never reorder: while a tail is pending,
|
|
54
|
+
* new writes append behind it.
|
|
55
|
+
*/
|
|
56
|
+
export class BackpressureWriter {
|
|
57
|
+
private pending: Buffer | null = null;
|
|
58
|
+
|
|
59
|
+
constructor(private socket: WritableSocket) {}
|
|
60
|
+
|
|
61
|
+
write(data: string): void {
|
|
62
|
+
const bytes = Buffer.from(data, "utf8");
|
|
63
|
+
|
|
64
|
+
if (this.pending) {
|
|
65
|
+
this.pending = Buffer.concat([this.pending, bytes]);
|
|
66
|
+
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const written = Math.max(0, this.socket.write(bytes));
|
|
70
|
+
|
|
71
|
+
if (written < bytes.length) this.pending = bytes.subarray(written);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Call from the socket's drain handler. */
|
|
75
|
+
drain(): void {
|
|
76
|
+
if (!this.pending) return;
|
|
77
|
+
const tail = this.pending;
|
|
78
|
+
|
|
79
|
+
this.pending = null;
|
|
80
|
+
const written = Math.max(0, this.socket.write(tail));
|
|
81
|
+
|
|
82
|
+
if (written < tail.length) this.pending = tail.subarray(written);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
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 { ReviewSession, SessionStatus } from "@cueloop/schema";
|
|
8
|
+
import { SessionStore } 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"): ReviewSession {
|
|
32
|
+
return {
|
|
33
|
+
schemaVersion: "1",
|
|
34
|
+
id,
|
|
35
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
36
|
+
artifact: { type: "plan", content: "", meta: {} },
|
|
37
|
+
revisions: [],
|
|
38
|
+
annotations: [],
|
|
39
|
+
verdict: 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 } as NodeJS.ProcessEnv);
|
|
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 } as NodeJS.ProcessEnv);
|
|
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 } as NodeJS.ProcessEnv);
|
|
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 SessionStore(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("never deletes an active pending session however old", () => {
|
|
130
|
+
// Arrange
|
|
131
|
+
const home = tempDir("cueloop-retention-home-");
|
|
132
|
+
const store = new SessionStore(home);
|
|
133
|
+
|
|
134
|
+
store.upsert(session("ses_active", daysAgo(400), "pending"));
|
|
135
|
+
|
|
136
|
+
// Act
|
|
137
|
+
const pruned = pruneExpiredSessions(store, 30, NOW_MS);
|
|
138
|
+
|
|
139
|
+
// Assert
|
|
140
|
+
expect(pruned).toEqual([]);
|
|
141
|
+
expect(store.get("ses_active")).toBeDefined();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("a period of zero prunes nothing", () => {
|
|
145
|
+
// Arrange
|
|
146
|
+
const home = tempDir("cueloop-retention-home-");
|
|
147
|
+
const store = new SessionStore(home);
|
|
148
|
+
|
|
149
|
+
store.upsert(session("ses_old", daysAgo(999)));
|
|
150
|
+
|
|
151
|
+
// Act
|
|
152
|
+
const pruned = pruneExpiredSessions(store, 0, NOW_MS);
|
|
153
|
+
|
|
154
|
+
// Assert
|
|
155
|
+
expect(pruned).toEqual([]);
|
|
156
|
+
expect(store.get("ses_old")).toBeDefined();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("pruneExpiredReports", () => {
|
|
161
|
+
test("removes timestamped reports past the window but keeps the latest report", () => {
|
|
162
|
+
// Arrange
|
|
163
|
+
const home = tempDir("cueloop-retention-home-");
|
|
164
|
+
const directory = reportsDir(home);
|
|
165
|
+
|
|
166
|
+
mkdirSync(directory, { recursive: true });
|
|
167
|
+
const latest = join(directory, LATEST_REPORT_FILENAME);
|
|
168
|
+
const stale = join(directory, "refine-2026-06-01T00-00-00-000Z.md");
|
|
169
|
+
const fresh = join(directory, "refine-2026-08-25T00-00-00-000Z.md");
|
|
170
|
+
|
|
171
|
+
for (const path of [latest, stale, fresh]) writeFileSync(path, "report");
|
|
172
|
+
const staleSeconds = (NOW_MS - 60 * DAY_MS) / 1000;
|
|
173
|
+
|
|
174
|
+
utimesSync(stale, staleSeconds, staleSeconds);
|
|
175
|
+
|
|
176
|
+
// Act
|
|
177
|
+
const pruned = pruneExpiredReports(directory, 30, NOW_MS);
|
|
178
|
+
|
|
179
|
+
// Assert
|
|
180
|
+
expect(pruned).toEqual(["refine-2026-06-01T00-00-00-000Z.md"]);
|
|
181
|
+
});
|
|
182
|
+
});
|
package/src/retention.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import * as v from "valibot";
|
|
5
|
+
import type { SessionStore } from "./store";
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_CLEANUP_PERIOD_DAYS = 30;
|
|
8
|
+
export const LATEST_REPORT_FILENAME = "report.md";
|
|
9
|
+
export const TIMESTAMPED_REPORT_PREFIX = "refine-";
|
|
10
|
+
|
|
11
|
+
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
const CleanupConfigSchema = v.object({
|
|
14
|
+
cleanup: v.optional(v.object({ period_days: v.optional(v.number()) })),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const RefineStateSchema = v.object({
|
|
18
|
+
analyzed: v.optional(v.record(v.string(), v.string()), {}),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export type RefineState = v.InferOutput<typeof RefineStateSchema>;
|
|
22
|
+
|
|
23
|
+
export function parseRefineState(raw: unknown): Map<string, string> {
|
|
24
|
+
const parsed = v.safeParse(RefineStateSchema, raw);
|
|
25
|
+
|
|
26
|
+
if (!parsed.success) return new Map();
|
|
27
|
+
|
|
28
|
+
return new Map(Object.entries(parsed.output.analyzed));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function userConfigPath(env: NodeJS.ProcessEnv): string {
|
|
32
|
+
const explicit = env.CUELOOP_CONFIG;
|
|
33
|
+
|
|
34
|
+
if (explicit && explicit.trim()) return explicit;
|
|
35
|
+
const xdgConfigHome = env.XDG_CONFIG_HOME;
|
|
36
|
+
const base = xdgConfigHome && xdgConfigHome.trim() ? xdgConfigHome : join(homedir(), ".config");
|
|
37
|
+
|
|
38
|
+
return join(base, "cueloop", "config.toml");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readConfiguredPeriodDays(path: string): number | undefined {
|
|
42
|
+
let raw: unknown;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
raw = Bun.TOML.parse(readFileSync(path, "utf8"));
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
const parsed = v.safeParse(CleanupConfigSchema, raw);
|
|
50
|
+
|
|
51
|
+
if (!parsed.success) return undefined;
|
|
52
|
+
const value = parsed.output.cleanup?.period_days;
|
|
53
|
+
|
|
54
|
+
return value !== undefined && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function resolveCleanupPeriodDays(env: NodeJS.ProcessEnv = process.env): number {
|
|
58
|
+
return readConfiguredPeriodDays(userConfigPath(env)) ?? DEFAULT_CLEANUP_PERIOD_DAYS;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function isExpired(createdAt: string, periodDays: number, nowMs: number): boolean {
|
|
62
|
+
if (periodDays <= 0) return false;
|
|
63
|
+
const createdMs = Date.parse(createdAt);
|
|
64
|
+
|
|
65
|
+
if (Number.isNaN(createdMs)) return false;
|
|
66
|
+
|
|
67
|
+
return nowMs - createdMs > periodDays * MILLISECONDS_PER_DAY;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function pruneExpiredSessions(
|
|
71
|
+
store: SessionStore,
|
|
72
|
+
periodDays: number,
|
|
73
|
+
nowMs: number,
|
|
74
|
+
): string[] {
|
|
75
|
+
if (periodDays <= 0) return [];
|
|
76
|
+
const pruned: string[] = [];
|
|
77
|
+
|
|
78
|
+
for (const session of store.list()) {
|
|
79
|
+
if (session.status !== "resolved") continue;
|
|
80
|
+
if (isExpired(session.createdAt, periodDays, nowMs) && store.delete(session.id)) {
|
|
81
|
+
pruned.push(session.id);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return pruned;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function timestampedReportFilename(nowMs: number): string {
|
|
89
|
+
const stamp = new Date(nowMs).toISOString().replace(/[:.]/g, "-");
|
|
90
|
+
|
|
91
|
+
return `${TIMESTAMPED_REPORT_PREFIX}${stamp}.md`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function pruneExpiredReports(
|
|
95
|
+
reportsDirectory: string,
|
|
96
|
+
periodDays: number,
|
|
97
|
+
nowMs: number,
|
|
98
|
+
): string[] {
|
|
99
|
+
if (periodDays <= 0) return [];
|
|
100
|
+
let entries: string[];
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
entries = readdirSync(reportsDirectory);
|
|
104
|
+
} catch {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
const cutoffMs = nowMs - periodDays * MILLISECONDS_PER_DAY;
|
|
108
|
+
const pruned: string[] = [];
|
|
109
|
+
|
|
110
|
+
for (const entry of entries) {
|
|
111
|
+
if (entry === LATEST_REPORT_FILENAME) continue;
|
|
112
|
+
if (!entry.startsWith(TIMESTAMPED_REPORT_PREFIX)) continue;
|
|
113
|
+
const path = join(reportsDirectory, entry);
|
|
114
|
+
|
|
115
|
+
if (statSync(path).mtimeMs < cutoffMs) {
|
|
116
|
+
rmSync(path, { force: true });
|
|
117
|
+
pruned.push(entry);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return pruned;
|
|
122
|
+
}
|