@cueloop/daemon 0.1.0-alpha.49 → 0.1.0-alpha.50

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/daemon",
3
- "version": "0.1.0-alpha.49",
3
+ "version": "0.1.0-alpha.50",
4
4
  "description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -22,6 +22,9 @@
22
22
  "./client": "./src/client.ts",
23
23
  "./review": "./src/review.ts",
24
24
  "./working-tree": "./src/working-tree.ts",
25
+ "./store": "./src/store.ts",
26
+ "./paths": "./src/paths.ts",
27
+ "./retention": "./src/retention.ts",
25
28
  "./herdr-pane": "./src/herdr-pane.ts",
26
29
  "./herdr-split": "./src/herdr-split.ts",
27
30
  "./share-blob": "./src/share-blob.ts"
@@ -30,7 +33,7 @@
30
33
  "access": "public"
31
34
  },
32
35
  "dependencies": {
33
- "@cueloop/schema": "0.1.0-alpha.49",
36
+ "@cueloop/schema": "0.1.0-alpha.50",
34
37
  "valibot": "^1.4.2"
35
38
  },
36
39
  "engines": {
package/src/api.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  type WorkspaceKey,
24
24
  } from "@cueloop/schema";
25
25
  import { SessionStore } from "./store";
26
+ import { pruneExpiredSessions, resolveCleanupPeriodDays } from "./retention";
26
27
  import { HerdrTabStore, type HerdrTabHandle } from "./herdr-tab-store";
27
28
  import { DiffWatcher } from "./diff-watcher";
28
29
  import { workingTreeDiff } from "./working-tree";
@@ -60,6 +61,7 @@ export class DaemonCore {
60
61
  constructor(home: string) {
61
62
  this.store = new SessionStore(home);
62
63
  this.store.recover();
64
+ pruneExpiredSessions(this.store, resolveCleanupPeriodDays(), Date.now());
63
65
  this.herdrTabs = new HerdrTabStore(home);
64
66
  this.diffWatcher = new DiffWatcher((repoRoot) => void this.refreshDiffsForRepo(repoRoot));
65
67
  // resume hot-reload for diff sessions that survived a daemon restart
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
  }
@@ -0,0 +1,159 @@
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
+ pruneExpiredReports,
15
+ pruneExpiredSessions,
16
+ resolveCleanupPeriodDays,
17
+ } from "./retention";
18
+
19
+ const DAY_MS = 24 * 60 * 60 * 1000;
20
+ const NOW_MS = Date.parse("2026-08-26T00:00:00Z");
21
+
22
+ function tempDir(prefix: string): string {
23
+ return mkdtempSync(join(tmpdir(), prefix));
24
+ }
25
+
26
+ function daysAgo(days: number): string {
27
+ return new Date(NOW_MS - days * DAY_MS).toISOString();
28
+ }
29
+
30
+ function session(id: string, createdAt: string, status: SessionStatus = "resolved"): ReviewSession {
31
+ return {
32
+ schemaVersion: "1",
33
+ id,
34
+ workspace: { repoRoot: "/repo", branch: "main" },
35
+ artifact: { type: "plan", content: "", meta: {} },
36
+ revisions: [],
37
+ annotations: [],
38
+ verdict: null,
39
+ status,
40
+ createdAt,
41
+ };
42
+ }
43
+
44
+ describe("resolveCleanupPeriodDays", () => {
45
+ test("defaults to 30 when no config file is present", () => {
46
+ // Arrange
47
+ const missing = join(tempDir("cueloop-retention-"), "config.toml");
48
+
49
+ // Act
50
+ const days = resolveCleanupPeriodDays({ CUELOOP_CONFIG: missing } as NodeJS.ProcessEnv);
51
+
52
+ // Assert
53
+ expect(days).toBe(DEFAULT_CLEANUP_PERIOD_DAYS);
54
+ });
55
+
56
+ test("honors [cleanup] period_days", () => {
57
+ // Arrange
58
+ const path = join(tempDir("cueloop-retention-"), "config.toml");
59
+ writeFileSync(path, "[cleanup]\nperiod_days = 7\n");
60
+
61
+ // Act
62
+ const days = resolveCleanupPeriodDays({ CUELOOP_CONFIG: path } as NodeJS.ProcessEnv);
63
+
64
+ // Assert
65
+ expect(days).toBe(7);
66
+ });
67
+
68
+ test("falls back to the default when the value is not a number", () => {
69
+ // Arrange
70
+ const path = join(tempDir("cueloop-retention-"), "config.toml");
71
+ writeFileSync(path, '[cleanup]\nperiod_days = "soon"\n');
72
+
73
+ // Act
74
+ const days = resolveCleanupPeriodDays({ CUELOOP_CONFIG: path } as NodeJS.ProcessEnv);
75
+
76
+ // Assert
77
+ expect(days).toBe(DEFAULT_CLEANUP_PERIOD_DAYS);
78
+ });
79
+ });
80
+
81
+ describe("isExpired", () => {
82
+ test("is true past the window and false inside it", () => {
83
+ // Assert
84
+ expect(isExpired(daysAgo(40), 30, NOW_MS)).toBe(true);
85
+ expect(isExpired(daysAgo(10), 30, NOW_MS)).toBe(false);
86
+ });
87
+
88
+ test("a period of zero disables expiry", () => {
89
+ // Assert
90
+ expect(isExpired(daysAgo(999), 0, NOW_MS)).toBe(false);
91
+ });
92
+ });
93
+
94
+ describe("pruneExpiredSessions", () => {
95
+ test("deletes resolved sessions past the window and keeps recent ones", () => {
96
+ // Arrange
97
+ const home = tempDir("cueloop-retention-home-");
98
+ const store = new SessionStore(home);
99
+ store.upsert(session("ses_old", daysAgo(40)));
100
+ store.upsert(session("ses_new", daysAgo(2)));
101
+
102
+ // Act
103
+ const pruned = pruneExpiredSessions(store, 30, NOW_MS);
104
+
105
+ // Assert
106
+ expect(pruned).toEqual(["ses_old"]);
107
+ expect(store.get("ses_old")).toBeUndefined();
108
+ expect(store.get("ses_new")).toBeDefined();
109
+ });
110
+
111
+ test("never deletes an active pending session however old", () => {
112
+ // Arrange
113
+ const home = tempDir("cueloop-retention-home-");
114
+ const store = new SessionStore(home);
115
+ store.upsert(session("ses_active", daysAgo(400), "pending"));
116
+
117
+ // Act
118
+ const pruned = pruneExpiredSessions(store, 30, NOW_MS);
119
+
120
+ // Assert
121
+ expect(pruned).toEqual([]);
122
+ expect(store.get("ses_active")).toBeDefined();
123
+ });
124
+
125
+ test("a period of zero prunes nothing", () => {
126
+ // Arrange
127
+ const home = tempDir("cueloop-retention-home-");
128
+ const store = new SessionStore(home);
129
+ store.upsert(session("ses_old", daysAgo(999)));
130
+
131
+ // Act
132
+ const pruned = pruneExpiredSessions(store, 0, NOW_MS);
133
+
134
+ // Assert
135
+ expect(pruned).toEqual([]);
136
+ expect(store.get("ses_old")).toBeDefined();
137
+ });
138
+ });
139
+
140
+ describe("pruneExpiredReports", () => {
141
+ test("removes timestamped reports past the window but keeps the latest report", () => {
142
+ // Arrange
143
+ const home = tempDir("cueloop-retention-home-");
144
+ const directory = reportsDir(home);
145
+ mkdirSync(directory, { recursive: true });
146
+ const latest = join(directory, LATEST_REPORT_FILENAME);
147
+ const stale = join(directory, "refine-2026-06-01T00-00-00-000Z.md");
148
+ const fresh = join(directory, "refine-2026-08-25T00-00-00-000Z.md");
149
+ for (const path of [latest, stale, fresh]) writeFileSync(path, "report");
150
+ const staleSeconds = (NOW_MS - 60 * DAY_MS) / 1000;
151
+ utimesSync(stale, staleSeconds, staleSeconds);
152
+
153
+ // Act
154
+ const pruned = pruneExpiredReports(directory, 30, NOW_MS);
155
+
156
+ // Assert
157
+ expect(pruned).toEqual(["refine-2026-06-01T00-00-00-000Z.md"]);
158
+ });
159
+ });
@@ -0,0 +1,88 @@
1
+ import { readdirSync, readFileSync, rmSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { SessionStore } from "./store";
5
+
6
+ export const DEFAULT_CLEANUP_PERIOD_DAYS = 30;
7
+ export const LATEST_REPORT_FILENAME = "report.md";
8
+ export const TIMESTAMPED_REPORT_PREFIX = "refine-";
9
+
10
+ const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
11
+
12
+ function userConfigPath(env: NodeJS.ProcessEnv): string {
13
+ const explicit = env.CUELOOP_CONFIG;
14
+ if (explicit && explicit.trim()) return explicit;
15
+ const xdgConfigHome = env.XDG_CONFIG_HOME;
16
+ const base = xdgConfigHome && xdgConfigHome.trim() ? xdgConfigHome : join(homedir(), ".config");
17
+ return join(base, "cueloop", "config.toml");
18
+ }
19
+
20
+ function readConfiguredPeriodDays(path: string): number | undefined {
21
+ let raw: Record<string, unknown>;
22
+ try {
23
+ raw = Bun.TOML.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
24
+ } catch {
25
+ return undefined;
26
+ }
27
+ const cleanup = raw["cleanup"] as Record<string, unknown> | undefined;
28
+ const value = cleanup?.["period_days"];
29
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
30
+ }
31
+
32
+ export function resolveCleanupPeriodDays(env: NodeJS.ProcessEnv = process.env): number {
33
+ return readConfiguredPeriodDays(userConfigPath(env)) ?? DEFAULT_CLEANUP_PERIOD_DAYS;
34
+ }
35
+
36
+ export function isExpired(createdAt: string, periodDays: number, nowMs: number): boolean {
37
+ if (periodDays <= 0) return false;
38
+ const createdMs = Date.parse(createdAt);
39
+ if (Number.isNaN(createdMs)) return false;
40
+ return nowMs - createdMs > periodDays * MILLISECONDS_PER_DAY;
41
+ }
42
+
43
+ export function pruneExpiredSessions(
44
+ store: SessionStore,
45
+ periodDays: number,
46
+ nowMs: number,
47
+ ): string[] {
48
+ if (periodDays <= 0) return [];
49
+ const pruned: string[] = [];
50
+ for (const session of store.list()) {
51
+ if (session.status !== "resolved") continue;
52
+ if (isExpired(session.createdAt, periodDays, nowMs) && store.delete(session.id)) {
53
+ pruned.push(session.id);
54
+ }
55
+ }
56
+ return pruned;
57
+ }
58
+
59
+ export function timestampedReportFilename(nowMs: number): string {
60
+ const stamp = new Date(nowMs).toISOString().replace(/[:.]/g, "-");
61
+ return `${TIMESTAMPED_REPORT_PREFIX}${stamp}.md`;
62
+ }
63
+
64
+ export function pruneExpiredReports(
65
+ reportsDirectory: string,
66
+ periodDays: number,
67
+ nowMs: number,
68
+ ): string[] {
69
+ if (periodDays <= 0) return [];
70
+ let entries: string[];
71
+ try {
72
+ entries = readdirSync(reportsDirectory);
73
+ } catch {
74
+ return [];
75
+ }
76
+ const cutoffMs = nowMs - periodDays * MILLISECONDS_PER_DAY;
77
+ const pruned: string[] = [];
78
+ for (const entry of entries) {
79
+ if (entry === LATEST_REPORT_FILENAME) continue;
80
+ if (!entry.startsWith(TIMESTAMPED_REPORT_PREFIX)) continue;
81
+ const path = join(reportsDirectory, entry);
82
+ if (statSync(path).mtimeMs < cutoffMs) {
83
+ rmSync(path, { force: true });
84
+ pruned.push(entry);
85
+ }
86
+ }
87
+ return pruned;
88
+ }