Skip to content

Commit 76be3a6

Browse files
committed
Cleanup chats after 7days to avoid huge files
1 parent 927021e commit 76be3a6

6 files changed

Lines changed: 172 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## Unreleased
44

5+
- Delete chats older than 7 days on server startup.
6+
- Use human-readable workspace cache directory names (e.g. `my-project_a1b2c3d4`), with automatic migration from old hash-only format.
7+
58
## 0.113.1
69

710
- Fix MCP server threads blocking ECA shutdown when stuck during initialization; startup now uses daemon threads with interrupt-based cancellation for clean exit.

src/eca/cache.clj

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,51 @@
3232
key (.encodeToString encoder digest)]
3333
(subs key 0 (min 8 (count key)))))
3434

35+
(def ^:private logger-tag "[CACHE]")
36+
37+
(def ^:private max-prefix-length 30)
38+
39+
(defn ^:private workspace-dir-name
40+
"Returns a human-readable directory name for the workspace cache.
41+
Format: <sanitized-project-name>_<hash>, or just <hash> if no name is available."
42+
[workspaces uri->filename-fn]
43+
(let [hash (workspaces-hash workspaces uri->filename-fn)
44+
first-uri (some-> workspaces first :uri)
45+
project-name (when first-uri
46+
(some-> (uri->filename-fn first-uri)
47+
fs/file-name
48+
str
49+
not-empty))
50+
sanitized (when project-name
51+
(let [s (string/replace project-name #"[^a-zA-Z0-9._-]" "_")]
52+
(subs s 0 (min max-prefix-length (count s)))))]
53+
(if (not-empty sanitized)
54+
(str sanitized "_" hash)
55+
hash)))
56+
57+
(defn ^:private migrate-workspace-cache-dir!
58+
"Migrates old hash-only workspace cache directory to new human-readable format."
59+
[^File old-dir ^File new-dir]
60+
(try
61+
(fs/move old-dir new-dir)
62+
(logger/info logger-tag (str "Migrated workspace cache from " old-dir " to " new-dir))
63+
(catch Exception e
64+
(logger/warn logger-tag "Failed to migrate workspace cache directory:" (.getMessage e)))))
65+
3566
(defn workspace-cache-file
3667
"Returns a File object for a workspace-specific cache file."
3768
[workspaces filename uri->filename-fn]
38-
(io/file (global-dir)
39-
(workspaces-hash workspaces uri->filename-fn)
40-
filename))
69+
(let [dir-name (workspace-dir-name workspaces uri->filename-fn)
70+
hash-only (workspaces-hash workspaces uri->filename-fn)
71+
base (global-dir)
72+
new-dir (io/file base dir-name)
73+
old-dir (io/file base hash-only)]
74+
(when (and (not= dir-name hash-only)
75+
(not (fs/exists? new-dir))
76+
(fs/exists? old-dir))
77+
(migrate-workspace-cache-dir! old-dir new-dir))
78+
(io/file new-dir filename)))
4179

42-
(def ^:private logger-tag "[CACHE]")
4380
(def ^:private tool-call-outputs-dir-name "toolCallOutputs")
4481
(def ^:private plugins-dir-name "plugins")
4582

src/eca/db.clj

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,23 @@
205205
(-> (normalize-db-for-global-write db)
206206
(assoc :version version)
207207
(upsert-cache! (transit-global-db-file) metrics)))
208+
209+
(def ^:private seven-days-ms (* 7 24 60 60 1000))
210+
211+
(defn cleanup-old-chats!
212+
"Deletes chats older than 7 days from the db and flushes the workspace cache."
213+
[db* metrics]
214+
(let [cutoff (- (System/currentTimeMillis) seven-days-ms)
215+
removed (atom 0)]
216+
(swap! db* update :chats
217+
(fn [chats]
218+
(into {}
219+
(filter (fn [[_id chat]]
220+
(let [created-at (:created-at chat)]
221+
(if (and created-at (< created-at cutoff))
222+
(do (swap! removed inc) false)
223+
true))))
224+
chats)))
225+
(when (pos? @removed)
226+
(logger/info logger-tag (str "Cleaned up " @removed " chat(s) older than 7 days"))
227+
(update-workspaces-cache! @db* metrics))))

src/eca/handlers.clj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,8 @@
115115
(let [config (config/all @db*)]
116116
(f.tools/init-servers! db* messenger config metrics)))
117117
(future
118-
(cache/cleanup-tool-call-outputs!))
118+
(cache/cleanup-tool-call-outputs!)
119+
(db/cleanup-old-chats! db* metrics))
119120
;; Trigger sessionStart hook after initialization
120121
(shared/future* config
121122
(f.hooks/trigger-if-matches! :sessionStart

test/eca/cache_test.clj

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,65 @@
4646
(is (string? path))
4747
(is (fs/exists? path))
4848
(is (= text (slurp path)))))))
49+
50+
(deftest workspace-dir-name-test
51+
(let [dir-name #'cache/workspace-dir-name]
52+
(testing "prefixes hash with project name from first workspace URI"
53+
(let [workspaces [{:uri "file:///home/user/my-project"}]
54+
result (dir-name workspaces identity)]
55+
(is (re-matches #"my-project_.{8}" result))))
56+
57+
(testing "sanitizes unsafe filesystem characters"
58+
(let [workspaces [{:uri "file:///home/user/my project@v2!"}]
59+
result (dir-name workspaces identity)]
60+
(is (re-matches #"my_project_v2__.{8}" result))
61+
(is (nil? (re-find #"[ @!]" result)))))
62+
63+
(testing "truncates long project names to 30 chars"
64+
(let [long-name (apply str (repeat 50 "a"))
65+
workspaces [{:uri (str "file:///home/user/" long-name)}]
66+
result (dir-name workspaces identity)]
67+
(is (<= (count result) (+ 30 1 8)))))
68+
69+
(testing "falls back to hash-only when no workspace name available"
70+
(let [result (dir-name [] identity)]
71+
(is (re-matches #".{8}" result))))
72+
73+
(testing "uses first workspace name when multiple workspaces"
74+
(let [workspaces [{:uri "file:///home/user/first-project"}
75+
{:uri "file:///home/user/second-project"}]
76+
result (dir-name workspaces identity)]
77+
(is (re-matches #"first-project_.{8}" result))))))
78+
79+
(deftest workspace-cache-file-migration-test
80+
(testing "migrates old hash-only directory to new format"
81+
(with-temp-cache-dir
82+
(let [workspaces [{:uri "file:///home/user/my-project"}]
83+
hash-only (cache/workspaces-hash workspaces identity)
84+
old-dir (io/file (cache/global-dir) hash-only)]
85+
;; Create old-format directory with a cache file
86+
(fs/create-dirs old-dir)
87+
(spit (io/file old-dir "db.transit.json") "{}")
88+
89+
(let [result (cache/workspace-cache-file workspaces "db.transit.json" identity)]
90+
(is (not (fs/exists? old-dir)) "Old directory should be renamed")
91+
(is (fs/exists? (.getParentFile result)) "New directory should exist")
92+
(is (= "{}" (slurp result)) "Migrated file content should be preserved")
93+
(is (re-find #"my-project_" (str result)) "New path should contain project name")))))
94+
95+
(testing "does not migrate when new directory already exists"
96+
(with-temp-cache-dir
97+
(let [workspaces [{:uri "file:///home/user/my-project"}]
98+
hash-only (cache/workspaces-hash workspaces identity)
99+
old-dir (io/file (cache/global-dir) hash-only)
100+
result-before (cache/workspace-cache-file workspaces "db.transit.json" identity)
101+
new-dir (.getParentFile result-before)]
102+
;; Create both directories
103+
(fs/create-dirs old-dir)
104+
(spit (io/file old-dir "db.transit.json") "old")
105+
(fs/create-dirs new-dir)
106+
(spit (io/file new-dir "db.transit.json") "new")
107+
108+
(let [result (cache/workspace-cache-file workspaces "db.transit.json" identity)]
109+
(is (fs/exists? old-dir) "Old directory should remain untouched")
110+
(is (= "new" (slurp result)) "Should use new directory content"))))))

test/eca/db_test.clj

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
(ns eca.db-test
2+
(:require
3+
[clojure.test :refer [deftest is testing]]
4+
[eca.db :as db]))
5+
6+
(set! *warn-on-reflection* true)
7+
8+
(deftest cleanup-old-chats-test
9+
(let [now (System/currentTimeMillis)
10+
eight-days-ago (- now (* 8 24 60 60 1000))
11+
two-days-ago (- now (* 2 24 60 60 1000))
12+
db* (atom {:chats {"old-chat" {:id "old-chat"
13+
:created-at eight-days-ago
14+
:messages [{:role "user" :content "hi"}]}
15+
"recent-chat" {:id "recent-chat"
16+
:created-at two-days-ago
17+
:messages [{:role "user" :content "hello"}]}
18+
"no-timestamp" {:id "no-timestamp"
19+
:messages [{:role "user" :content "hey"}]}}
20+
:workspace-folders []})]
21+
(testing "deletes old chats, keeps recent and chats without created-at"
22+
(with-redefs [db/update-workspaces-cache! (fn [_ _])]
23+
(db/cleanup-old-chats! db* nil))
24+
(is (nil? (get-in @db* [:chats "old-chat"]))
25+
"Chat older than 7 days should be removed")
26+
(is (some? (get-in @db* [:chats "recent-chat"]))
27+
"Chat newer than 7 days should be kept")
28+
(is (some? (get-in @db* [:chats "no-timestamp"]))
29+
"Chat without created-at should be kept"))))
30+
31+
(deftest cleanup-old-chats-no-op-test
32+
(let [now (System/currentTimeMillis)
33+
two-days-ago (- now (* 2 24 60 60 1000))
34+
db* (atom {:chats {"recent" {:id "recent"
35+
:created-at two-days-ago
36+
:messages [{:role "user" :content "hi"}]}}
37+
:workspace-folders []})
38+
cache-updated? (atom false)]
39+
(testing "does not flush cache when nothing to clean"
40+
(with-redefs [db/update-workspaces-cache! (fn [_ _] (reset! cache-updated? true))]
41+
(db/cleanup-old-chats! db* nil))
42+
(is (some? (get-in @db* [:chats "recent"])))
43+
(is (false? @cache-updated?)
44+
"Should not flush cache when no chats were removed"))))

0 commit comments

Comments
 (0)