@app-vitals/agent-runtime 0.0.1 → 0.1.0
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/Dockerfile +265 -0
- package/agent-account-status-ref.ts +53 -0
- package/agent-account-status-ref.unit.test.ts +24 -0
- package/agent-extension.ts +144 -0
- package/agent-extension.unit.test.ts +139 -0
- package/agent-repos-ref.ts +63 -0
- package/agent-repos-ref.unit.test.ts +84 -0
- package/agent-slack-membership-ref.ts +94 -0
- package/agent-slack-membership-ref.unit.test.ts +201 -0
- package/agent-trial-expiry-ref.ts +63 -0
- package/agent-trial-expiry-ref.unit.test.ts +84 -0
- package/anthropic-federation-guard.ts +72 -0
- package/anthropic-federation-guard.unit.test.ts +127 -0
- package/base-image-contract.smoke.test.ts +149 -0
- package/boot.ts +845 -0
- package/browser.integration.test.ts +52 -0
- package/browser.ts +34 -0
- package/chat-poller.integration.test.ts +109 -0
- package/chat-poller.ts +401 -0
- package/chat-poller.unit.test.ts +1581 -0
- package/chat-token-reporter.integration.test.ts +282 -0
- package/chat-token-reporter.ts +136 -0
- package/claude-version.ts +55 -0
- package/claude-version.unit.test.ts +73 -0
- package/claude.ts +1054 -0
- package/claude.unit.test.ts +3042 -0
- package/cleanup-after-days-ref.ts +83 -0
- package/cleanup-after-days-ref.unit.test.ts +89 -0
- package/cleanup-merged-worktrees-ref.ts +75 -0
- package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
- package/cli-args.ts +36 -0
- package/cli-args.unit.test.ts +94 -0
- package/clock.ts +47 -0
- package/config.ts +99 -0
- package/config.unit.test.ts +356 -0
- package/context-stamp.ts +189 -0
- package/context-stamp.unit.test.ts +159 -0
- package/cron-failure-reporter.ts +107 -0
- package/cron-failure-reporter.unit.test.ts +244 -0
- package/cron-handler.integration.test.ts +614 -0
- package/cron-handler.smoke.test.ts +1371 -0
- package/cron-handler.ts +750 -0
- package/cron-handler.unit.test.ts +1177 -0
- package/cron-run-reporter.integration.test.ts +806 -0
- package/cron-run-reporter.ts +398 -0
- package/crypto.ts +73 -0
- package/cutover-validate.integration.test.ts +245 -0
- package/cutover-validate.ts +84 -0
- package/entrypoint-main.ts +79 -0
- package/entrypoint.integration.test.ts +315 -0
- package/entrypoint.ts +154 -0
- package/errors.ts +61 -0
- package/fixtures/chat-service/attachment.json +15 -0
- package/fixtures/chat-service/messages.json +86 -0
- package/fixtures/chat-service/threads.json +50 -0
- package/fixtures/github-installations/installations.json +1231 -0
- package/fixtures/slack-provision-cassette.json +6 -0
- package/fixtures/specialties/fixture/manifest.yaml +35 -0
- package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
- package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
- package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
- package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
- package/fixtures/stream-json/clean-multi-turn.ts +209 -0
- package/fixtures/stream-json/progress-phases.ts +203 -0
- package/fixtures/stream-json/skill-attribution.ts +251 -0
- package/fixtures/stream-json/truncated-no-result.ts +84 -0
- package/format.ts +283 -0
- package/format.unit.test.ts +558 -0
- package/gh-helpers.ts +139 -0
- package/gh-helpers.unit.test.ts +253 -0
- package/gh-token-files.integration.test.ts +143 -0
- package/gh-token-files.ts +92 -0
- package/gh-token-files.unit.test.ts +63 -0
- package/github-app-auth.integration.test.ts +419 -0
- package/github-app-auth.ts +193 -0
- package/github-auth-deps.ts +128 -0
- package/github-auth-startup.ts +231 -0
- package/github-auth-startup.unit.test.ts +325 -0
- package/github-installations-manager.integration.test.ts +323 -0
- package/github-installations-reporter.integration.test.ts +124 -0
- package/github-installations-reporter.ts +150 -0
- package/github-installations-reporter.unit.test.ts +166 -0
- package/github-installations.integration.test.ts +88 -0
- package/github-installations.ts +462 -0
- package/github-installations.unit.test.ts +98 -0
- package/health.smoke.test.ts +244 -0
- package/health.ts +193 -0
- package/health.unit.test.ts +40 -0
- package/http-chat-service-client.integration.test.ts +345 -0
- package/http-chat-service-client.ts +287 -0
- package/http-chat-service-client.unit.test.ts +500 -0
- package/log-prefix.ts +12 -0
- package/log-prefix.unit.test.ts +24 -0
- package/markers.ts +178 -0
- package/markers.unit.test.ts +374 -0
- package/package.json +38 -4
- package/piper-voice.ts +116 -0
- package/piper-voice.unit.test.ts +124 -0
- package/pricing.ts +7 -0
- package/pricing.unit.test.ts +149 -0
- package/process-tree-kill.integration.test.ts +133 -0
- package/process-tree-kill.ts +325 -0
- package/process-tree-kill.unit.test.ts +265 -0
- package/progress-milestones.ts +86 -0
- package/progress-milestones.unit.test.ts +188 -0
- package/run-telemetry.ts +241 -0
- package/run-telemetry.unit.test.ts +235 -0
- package/sessions.integration.test.ts +273 -0
- package/sessions.ts +191 -0
- package/sessions.unit.test.ts +94 -0
- package/setup-github-auth.integration.test.ts +858 -0
- package/setup-github-auth.ts +297 -0
- package/setup.fixture.integration.test.ts +109 -0
- package/setup.integration.test.ts +1495 -0
- package/setup.ts +853 -0
- package/setup.unit.test.ts +286 -0
- package/shipwright-config-client.ts +36 -0
- package/shipwright-runtime-client.integration.test.ts +336 -0
- package/shipwright-runtime-client.ts +118 -0
- package/slack-account-status.unit.test.ts +251 -0
- package/slack-bolt-client.integration.test.ts +76 -0
- package/slack-chat-conversation.integration.test.ts +333 -0
- package/slack-progress.ts +510 -0
- package/slack-progress.unit.test.ts +806 -0
- package/slack-socket-mode-ping.integration.test.ts +137 -0
- package/slack-startup.ts +170 -0
- package/slack-startup.unit.test.ts +300 -0
- package/slack-thread-status-tracker.ts +70 -0
- package/slack-thread-status-tracker.unit.test.ts +118 -0
- package/slack-trial-expiry.unit.test.ts +269 -0
- package/slack.integration.test.ts +3979 -0
- package/slack.ts +1286 -0
- package/slack.unit.test.ts +1070 -0
- package/spawn-env.ts +58 -0
- package/spawn-env.unit.test.ts +96 -0
- package/startup-dm.integration.test.ts +156 -0
- package/startup-dm.ts +29 -0
- package/sync-config-clone.ts +238 -0
- package/sync-config-clone.unit.test.ts +429 -0
- package/test-env.ts +27 -0
- package/users.ts +90 -0
- package/users.unit.test.ts +108 -0
- package/voice.integration.test.ts +669 -0
- package/voice.ts +315 -0
- package/voice.unit.test.ts +105 -0
- package/workspace/BOOTSTRAP.md.template +43 -0
- package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
- package/workspace/IDENTITY.md.template +8 -0
- package/workspace/RUNTIME.md.template +193 -0
- package/workspace/RUNTIME.md.template.content.test.ts +75 -0
- package/workspace/SOUL.md.template +45 -0
- package/workspace/SPECIALTY.md.template +37 -0
- package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
- package/workspace/VOICE.md.template +8 -0
- package/workspace/mise.toml.template +12 -0
- package/workspace/requirements.txt.template +1 -0
- package/workspace-paths.ts +37 -0
- package/worktree-reaper.ts +244 -0
- package/worktree-reaper.unit.test.ts +380 -0
- package/README.md +0 -3
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for createFileSessionStore and threadKey in agent/src/runtime/sessions.ts
|
|
3
|
+
*
|
|
4
|
+
* Uses real file I/O to a process-scoped tmp file — no mocks, pure file I/O.
|
|
5
|
+
* This is an integration test by boundary rule: it exercises the real fs module.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { FixedClock } from "./clock.ts";
|
|
13
|
+
import { createFileSessionStore, threadKey } from "./sessions.ts";
|
|
14
|
+
|
|
15
|
+
// ─── Shared store backed by a process-scoped tmp file ─────────────────────────
|
|
16
|
+
|
|
17
|
+
const SESSIONS_FILE = `/tmp/shipwright-sessions-test-${process.pid}.json`;
|
|
18
|
+
const store = createFileSessionStore(SESSIONS_FILE);
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
try {
|
|
22
|
+
rmSync(SESSIONS_FILE, { force: true });
|
|
23
|
+
} catch {
|
|
24
|
+
// file may not exist — safe to ignore
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// ─── threadKey ─────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
describe("threadKey", () => {
|
|
31
|
+
test("returns channel:ts format", () => {
|
|
32
|
+
expect(threadKey("C123", "1234567890.000100")).toBe(
|
|
33
|
+
"C123:1234567890.000100",
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("different channels produce different keys", () => {
|
|
38
|
+
expect(threadKey("C1", "ts")).not.toBe(threadKey("C2", "ts"));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("handles DM channel prefix", () => {
|
|
42
|
+
expect(threadKey("D99999ZZ", "0000001.000001")).toBe(
|
|
43
|
+
"D99999ZZ:0000001.000001",
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("handles group channel prefix", () => {
|
|
48
|
+
expect(threadKey("GABCDEF1", "9876543.210000")).toBe(
|
|
49
|
+
"GABCDEF1:9876543.210000",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ─── createFileSessionStore ────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
describe("createFileSessionStore — basic operations", () => {
|
|
57
|
+
test("get returns undefined for missing key", async () => {
|
|
58
|
+
expect(await store.get("no-such-key")).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("set + get roundtrip", async () => {
|
|
62
|
+
await store.set("key1", "session-abc");
|
|
63
|
+
expect(await store.get("key1")).toBe("session-abc");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("set overwrites existing entry", async () => {
|
|
67
|
+
await store.set("key1", "session-old");
|
|
68
|
+
await store.set("key1", "session-new");
|
|
69
|
+
expect(await store.get("key1")).toBe("session-new");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("clear removes key", async () => {
|
|
73
|
+
await store.set("key1", "session-abc");
|
|
74
|
+
await store.clear("key1");
|
|
75
|
+
expect(await store.get("key1")).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("clear on missing key is a no-op", async () => {
|
|
79
|
+
await expect(store.clear("does-not-exist")).resolves.toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("size returns count of stored entries", async () => {
|
|
83
|
+
expect(await store.size()).toBe(0);
|
|
84
|
+
await store.set("k1", "s1");
|
|
85
|
+
expect(await store.size()).toBe(1);
|
|
86
|
+
await store.set("k2", "s2");
|
|
87
|
+
expect(await store.size()).toBe(2);
|
|
88
|
+
await store.clear("k1");
|
|
89
|
+
expect(await store.size()).toBe(1);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("multiple keys are independent", async () => {
|
|
93
|
+
await store.set("k1", "s1");
|
|
94
|
+
await store.set("k2", "s2");
|
|
95
|
+
expect(await store.get("k1")).toBe("s1");
|
|
96
|
+
expect(await store.get("k2")).toBe("s2");
|
|
97
|
+
await store.clear("k1");
|
|
98
|
+
expect(await store.get("k1")).toBeUndefined();
|
|
99
|
+
expect(await store.get("k2")).toBe("s2");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("get returns undefined when constructed on a path that no longer exists", async () => {
|
|
103
|
+
await store.set("C1:ts1", "session-gone");
|
|
104
|
+
rmSync(SESSIONS_FILE, { force: true });
|
|
105
|
+
const freshStore = createFileSessionStore(SESSIONS_FILE);
|
|
106
|
+
expect(await freshStore.get("C1:ts1")).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ─── Concurrent writes ──────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
describe("createFileSessionStore — concurrent writes", () => {
|
|
113
|
+
test("two concurrent set() calls on different keys both persist (no clobbering)", async () => {
|
|
114
|
+
const concurrentFile = join(
|
|
115
|
+
tmpdir(),
|
|
116
|
+
`sessions-concurrent-diffkeys-${process.pid}-${Date.now()}.json`,
|
|
117
|
+
);
|
|
118
|
+
const concurrentStore = createFileSessionStore(concurrentFile);
|
|
119
|
+
|
|
120
|
+
// Fire both writes without awaiting the first before starting the second —
|
|
121
|
+
// this is the load-modify-save race: without serialization, the second
|
|
122
|
+
// set()'s load() can happen before the first set()'s save() lands, so its
|
|
123
|
+
// save() would overwrite the first write.
|
|
124
|
+
await Promise.all([
|
|
125
|
+
concurrentStore.set("keyA", "session-A"),
|
|
126
|
+
concurrentStore.set("keyB", "session-B"),
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
expect(await concurrentStore.get("keyA")).toBe("session-A");
|
|
130
|
+
expect(await concurrentStore.get("keyB")).toBe("session-B");
|
|
131
|
+
expect(await concurrentStore.size()).toBe(2);
|
|
132
|
+
|
|
133
|
+
// Also verify the on-disk file itself reflects both writes — a full
|
|
134
|
+
// round-trip, not a clobbered partial write from a stale in-memory read.
|
|
135
|
+
const onDisk = JSON.parse(readFileSync(concurrentFile, "utf8"));
|
|
136
|
+
expect(Object.keys(onDisk).sort()).toEqual(["keyA", "keyB"]);
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
rmSync(concurrentFile, { force: true });
|
|
140
|
+
} catch {}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("two concurrent set() calls on the same key both apply — final value is one of the two, file is not corrupted", async () => {
|
|
144
|
+
const concurrentFile = join(
|
|
145
|
+
tmpdir(),
|
|
146
|
+
`sessions-concurrent-samekey-${process.pid}-${Date.now()}.json`,
|
|
147
|
+
);
|
|
148
|
+
const concurrentStore = createFileSessionStore(concurrentFile);
|
|
149
|
+
|
|
150
|
+
await Promise.all([
|
|
151
|
+
concurrentStore.set("key1", "session-first"),
|
|
152
|
+
concurrentStore.set("key1", "session-second"),
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
const finalValue = await concurrentStore.get("key1");
|
|
156
|
+
expect(finalValue).toBeDefined();
|
|
157
|
+
expect(["session-first", "session-second"]).toContain(finalValue as string);
|
|
158
|
+
|
|
159
|
+
// The persisted file must be valid JSON with exactly one entry for key1 —
|
|
160
|
+
// proof the second write's load+save wasn't torn or based on a stale read
|
|
161
|
+
// that silently dropped the first write's on-disk effects.
|
|
162
|
+
const onDisk = JSON.parse(readFileSync(concurrentFile, "utf8"));
|
|
163
|
+
expect(Object.keys(onDisk)).toEqual(["key1"]);
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
rmSync(concurrentFile, { force: true });
|
|
167
|
+
} catch {}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("interleaved set/get calls on the same store apply in enqueued order", async () => {
|
|
171
|
+
const concurrentFile = join(
|
|
172
|
+
tmpdir(),
|
|
173
|
+
`sessions-concurrent-order-${process.pid}-${Date.now()}.json`,
|
|
174
|
+
);
|
|
175
|
+
const concurrentStore = createFileSessionStore(concurrentFile);
|
|
176
|
+
|
|
177
|
+
// Three concurrent sets on distinct keys plus a concurrent clear — all
|
|
178
|
+
// fired together, none awaited individually first.
|
|
179
|
+
await Promise.all([
|
|
180
|
+
concurrentStore.set("a", "1"),
|
|
181
|
+
concurrentStore.set("b", "2"),
|
|
182
|
+
concurrentStore.set("c", "3"),
|
|
183
|
+
]);
|
|
184
|
+
|
|
185
|
+
expect(await concurrentStore.size()).toBe(3);
|
|
186
|
+
expect(await concurrentStore.get("a")).toBe("1");
|
|
187
|
+
expect(await concurrentStore.get("b")).toBe("2");
|
|
188
|
+
expect(await concurrentStore.get("c")).toBe("3");
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
rmSync(concurrentFile, { force: true });
|
|
192
|
+
} catch {}
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ─── TTL and prune ─────────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
describe("createFileSessionStore — TTL and prune", () => {
|
|
199
|
+
test("TTL-based expiry: expired entry returns undefined", async () => {
|
|
200
|
+
const shortTtlFile = join(
|
|
201
|
+
tmpdir(),
|
|
202
|
+
`sessions-ttl-${process.pid}-${Date.now()}.json`,
|
|
203
|
+
);
|
|
204
|
+
const t0 = new Date("2024-01-01T00:00:00.000Z");
|
|
205
|
+
// Fixed clock at t0 for the write — with real async file I/O, comparing
|
|
206
|
+
// against real wall-clock time (via a busy-wait) races the I/O latency
|
|
207
|
+
// itself. A second store on the same file with a clock fixed well past
|
|
208
|
+
// the TTL gives a deterministic "later" read with no timing dependency.
|
|
209
|
+
const earlyStore = createFileSessionStore(
|
|
210
|
+
shortTtlFile,
|
|
211
|
+
1000,
|
|
212
|
+
FixedClock(t0),
|
|
213
|
+
);
|
|
214
|
+
await earlyStore.set("k", "session-xyz");
|
|
215
|
+
expect(await earlyStore.get("k")).toBe("session-xyz");
|
|
216
|
+
const lateStore = createFileSessionStore(
|
|
217
|
+
shortTtlFile,
|
|
218
|
+
1000,
|
|
219
|
+
FixedClock(new Date(t0.getTime() + 5000)),
|
|
220
|
+
);
|
|
221
|
+
expect(await lateStore.get("k")).toBeUndefined();
|
|
222
|
+
try {
|
|
223
|
+
rmSync(shortTtlFile, { force: true });
|
|
224
|
+
} catch {}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("prune removes expired entries and returns count", async () => {
|
|
228
|
+
const pruneFile = join(
|
|
229
|
+
tmpdir(),
|
|
230
|
+
`sessions-prune-${process.pid}-${Date.now()}.json`,
|
|
231
|
+
);
|
|
232
|
+
const t0 = new Date("2024-01-01T00:00:00.000Z");
|
|
233
|
+
const earlyStore = createFileSessionStore(pruneFile, 1000, FixedClock(t0));
|
|
234
|
+
await earlyStore.set("k1", "s1");
|
|
235
|
+
await earlyStore.set("k2", "s2");
|
|
236
|
+
const lateStore = createFileSessionStore(
|
|
237
|
+
pruneFile,
|
|
238
|
+
1000,
|
|
239
|
+
FixedClock(new Date(t0.getTime() + 5000)),
|
|
240
|
+
);
|
|
241
|
+
const pruned = await lateStore.prune();
|
|
242
|
+
expect(pruned).toBe(2);
|
|
243
|
+
expect(await lateStore.size()).toBe(0);
|
|
244
|
+
try {
|
|
245
|
+
rmSync(pruneFile, { force: true });
|
|
246
|
+
} catch {}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("prune returns 0 when nothing is expired", async () => {
|
|
250
|
+
await store.set("k1", "s1");
|
|
251
|
+
await store.set("k2", "s2");
|
|
252
|
+
expect(await store.prune()).toBe(0);
|
|
253
|
+
expect(await store.size()).toBe(2);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("legacy string format support: get returns sessionId string", async () => {
|
|
257
|
+
// Write a legacy-format file manually
|
|
258
|
+
const legacyFile = join(
|
|
259
|
+
tmpdir(),
|
|
260
|
+
`sessions-legacy-${process.pid}-${Date.now()}.json`,
|
|
261
|
+
);
|
|
262
|
+
writeFileSync(
|
|
263
|
+
legacyFile,
|
|
264
|
+
JSON.stringify({ "C1:ts1": "legacy-session-id" }),
|
|
265
|
+
);
|
|
266
|
+
const legacyStore = createFileSessionStore(legacyFile, 60_000);
|
|
267
|
+
// Legacy string entries have no TTL — always valid
|
|
268
|
+
expect(await legacyStore.get("C1:ts1")).toBe("legacy-session-id");
|
|
269
|
+
try {
|
|
270
|
+
rmSync(legacyFile, { force: true });
|
|
271
|
+
} catch {}
|
|
272
|
+
});
|
|
273
|
+
});
|
package/sessions.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { type Clock, SystemClock } from "./clock.ts";
|
|
3
|
+
|
|
4
|
+
interface SessionEntry {
|
|
5
|
+
sessionId: string;
|
|
6
|
+
updatedAt: number; // epoch ms
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** On-disk format: values can be a plain string (legacy) or a SessionEntry object. */
|
|
10
|
+
type SessionMap = Record<string, string | SessionEntry>;
|
|
11
|
+
|
|
12
|
+
/** Default TTL: 7 days in milliseconds. Sessions older than this are pruned on load. */
|
|
13
|
+
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
/** Key is channel:thread_ts (or channel:ts for the root of a new thread). */
|
|
16
|
+
export function threadKey(channel: string, threadTs: string): string {
|
|
17
|
+
return `${channel}:${threadTs}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Per-file write queue: serializes async operations against the same file
|
|
22
|
+
* path so overlapping load-modify-save sequences (get/set/clear/prune) apply
|
|
23
|
+
* in enqueued order instead of racing. Without this, two concurrent set()
|
|
24
|
+
* calls can each load() the same pre-write map, then save() over one
|
|
25
|
+
* another — the second save() to land wins and the first write is silently
|
|
26
|
+
* lost even though both callers believe their write succeeded.
|
|
27
|
+
*
|
|
28
|
+
* Keyed by file path (module-level) so every createFileSessionStore() call
|
|
29
|
+
* targeting the same path shares one queue, matching the file-level
|
|
30
|
+
* contention this is meant to serialize.
|
|
31
|
+
*/
|
|
32
|
+
const writeQueues = new Map<string, Promise<unknown>>();
|
|
33
|
+
|
|
34
|
+
function enqueue<T>(file: string, fn: () => Promise<T>): Promise<T> {
|
|
35
|
+
const prev = writeQueues.get(file) ?? Promise.resolve();
|
|
36
|
+
const next = prev.then(fn, fn);
|
|
37
|
+
// Swallow so a failed op doesn't poison the chain for subsequent callers —
|
|
38
|
+
// the caller of enqueue() still observes the original rejection via `next`.
|
|
39
|
+
writeQueues.set(
|
|
40
|
+
file,
|
|
41
|
+
next.catch(() => {}),
|
|
42
|
+
);
|
|
43
|
+
return next;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Create a file-backed session store at a specific path.
|
|
48
|
+
*
|
|
49
|
+
* Module-level default store removed — callers must wire their own store
|
|
50
|
+
* via createFileSessionStore(config.paths.sessions).
|
|
51
|
+
*
|
|
52
|
+
* @param clock - Injectable clock for deterministic testing. Defaults to SystemClock.
|
|
53
|
+
*/
|
|
54
|
+
export function createFileSessionStore(
|
|
55
|
+
file: string,
|
|
56
|
+
ttlMs: number = DEFAULT_TTL_MS,
|
|
57
|
+
clock: Clock = SystemClock(),
|
|
58
|
+
): {
|
|
59
|
+
get: (key: string) => Promise<string | undefined>;
|
|
60
|
+
set: (key: string, id: string) => Promise<void>;
|
|
61
|
+
clear: (key: string) => Promise<void>;
|
|
62
|
+
prune: () => Promise<number>;
|
|
63
|
+
size: () => Promise<number>;
|
|
64
|
+
} {
|
|
65
|
+
async function load(): Promise<SessionMap> {
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(await readFile(file, "utf8")) as SessionMap;
|
|
68
|
+
} catch {
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function save(map: SessionMap): Promise<void> {
|
|
73
|
+
await writeFile(file, JSON.stringify(map, null, 2));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Extract sessionId from either legacy string or SessionEntry format. */
|
|
77
|
+
function resolveId(value: string | SessionEntry): string {
|
|
78
|
+
return typeof value === "string" ? value : value.sessionId;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Check if entry is expired. Legacy string entries are always considered valid (no timestamp). */
|
|
82
|
+
function isExpired(value: string | SessionEntry, now: number): boolean {
|
|
83
|
+
if (typeof value === "string") return false; // legacy format, no TTL
|
|
84
|
+
return now - value.updatedAt > ttlMs;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
get: (key) =>
|
|
89
|
+
enqueue(file, async () => {
|
|
90
|
+
const map = await load();
|
|
91
|
+
const entry = map[key];
|
|
92
|
+
if (!entry) return undefined;
|
|
93
|
+
if (isExpired(entry, clock.now().getTime())) {
|
|
94
|
+
// Lazy prune: remove expired entry on access
|
|
95
|
+
delete map[key];
|
|
96
|
+
await save(map);
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
return resolveId(entry);
|
|
100
|
+
}),
|
|
101
|
+
set: (key, id) =>
|
|
102
|
+
enqueue(file, async () => {
|
|
103
|
+
const map = await load();
|
|
104
|
+
map[key] = { sessionId: id, updatedAt: clock.now().getTime() };
|
|
105
|
+
await save(map);
|
|
106
|
+
}),
|
|
107
|
+
clear: (key) =>
|
|
108
|
+
enqueue(file, async () => {
|
|
109
|
+
const map = await load();
|
|
110
|
+
delete map[key];
|
|
111
|
+
await save(map);
|
|
112
|
+
}),
|
|
113
|
+
/** Remove all expired sessions. Returns count of pruned entries. */
|
|
114
|
+
prune: () =>
|
|
115
|
+
enqueue(file, async () => {
|
|
116
|
+
const map = await load();
|
|
117
|
+
const now = clock.now().getTime();
|
|
118
|
+
let pruned = 0;
|
|
119
|
+
for (const key of Object.keys(map)) {
|
|
120
|
+
if (isExpired(map[key], now)) {
|
|
121
|
+
delete map[key];
|
|
122
|
+
pruned++;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (pruned > 0) await save(map);
|
|
126
|
+
return pruned;
|
|
127
|
+
}),
|
|
128
|
+
size: () => enqueue(file, async () => Object.keys(await load()).length),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── Periodic prune ───────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/** Default sweep interval for startSessionPruner: 6 hours. */
|
|
135
|
+
export const SESSION_PRUNE_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
136
|
+
|
|
137
|
+
/** The slice of the store startSessionPruner needs — keeps callers/tests thin. */
|
|
138
|
+
interface PrunableSessionStore {
|
|
139
|
+
prune: () => Promise<number>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* One prune sweep. Never throws: a prune failure (unreadable/locked file,
|
|
144
|
+
* disk blip) is logged and swallowed, since this is pure housekeeping.
|
|
145
|
+
* Returns the number of entries removed (0 on failure).
|
|
146
|
+
*/
|
|
147
|
+
export async function pruneSessionsOnce(
|
|
148
|
+
store: PrunableSessionStore,
|
|
149
|
+
label: string,
|
|
150
|
+
): Promise<number> {
|
|
151
|
+
try {
|
|
152
|
+
const pruned = await store.prune();
|
|
153
|
+
if (pruned > 0) {
|
|
154
|
+
console.log(`[sessions] pruned ${pruned} expired ${label} entries`);
|
|
155
|
+
}
|
|
156
|
+
return pruned;
|
|
157
|
+
} catch (err) {
|
|
158
|
+
console.warn(
|
|
159
|
+
`[sessions] prune failed for ${label}: ${String(err)} — swallowing`,
|
|
160
|
+
);
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Start the periodic TTL sweep for a file-backed session store.
|
|
167
|
+
*
|
|
168
|
+
* Without this, `prune()` has no production call site and entries only ever
|
|
169
|
+
* expire lazily on `get()` — which never happens for a key that is written
|
|
170
|
+
* once and never read again (DTW-1.3's per-dispatch `dev-task:{id}:{nonce}`
|
|
171
|
+
* keys, whose owning loop normally clears them but can't after an abrupt
|
|
172
|
+
* process kill). The sweep runs once immediately (clearing whatever a previous
|
|
173
|
+
* process left behind) and then on `intervalMs`.
|
|
174
|
+
*
|
|
175
|
+
* `setIntervalFn` is injectable so tests can drive the schedule deterministically.
|
|
176
|
+
*/
|
|
177
|
+
export function startSessionPruner(
|
|
178
|
+
store: PrunableSessionStore,
|
|
179
|
+
label: string,
|
|
180
|
+
opts?: {
|
|
181
|
+
intervalMs?: number;
|
|
182
|
+
setIntervalFn?: (fn: () => void, ms: number) => unknown;
|
|
183
|
+
},
|
|
184
|
+
): void {
|
|
185
|
+
const intervalMs = opts?.intervalMs ?? SESSION_PRUNE_INTERVAL_MS;
|
|
186
|
+
const schedule =
|
|
187
|
+
opts?.setIntervalFn ??
|
|
188
|
+
((fn: () => void, ms: number) => setInterval(fn, ms));
|
|
189
|
+
void pruneSessionsOnce(store, label);
|
|
190
|
+
schedule(() => void pruneSessionsOnce(store, label), intervalMs);
|
|
191
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the periodic session-store prune wiring in agent/src/runtime/sessions.ts.
|
|
3
|
+
*
|
|
4
|
+
* Pure logic over an injected store double and an injected setInterval — no file
|
|
5
|
+
* I/O and no real timers, so this is a unit test (the file-backed store itself is
|
|
6
|
+
* covered in sessions.integration.test.ts).
|
|
7
|
+
*
|
|
8
|
+
* Regression context (DTW-1.3 review): before this wiring, `prune()` had zero
|
|
9
|
+
* production call sites, so entries only ever expired lazily on `get()` — which
|
|
10
|
+
* never happens for a per-dispatch `dev-task:{id}:{nonce}` key that is written
|
|
11
|
+
* once and never read again.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, expect, test } from "bun:test";
|
|
15
|
+
import {
|
|
16
|
+
pruneSessionsOnce,
|
|
17
|
+
SESSION_PRUNE_INTERVAL_MS,
|
|
18
|
+
startSessionPruner,
|
|
19
|
+
} from "./sessions.ts";
|
|
20
|
+
|
|
21
|
+
describe("pruneSessionsOnce", () => {
|
|
22
|
+
test("returns the store's pruned count", async () => {
|
|
23
|
+
const store = { prune: async () => 3 };
|
|
24
|
+
expect(await pruneSessionsOnce(store, "sessions.json")).toBe(3);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("swallows a prune failure and reports 0 — housekeeping never crashes a caller", async () => {
|
|
28
|
+
const store = {
|
|
29
|
+
prune: async () => {
|
|
30
|
+
throw new Error("file unreadable");
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
expect(await pruneSessionsOnce(store, "sessions.json")).toBe(0);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("startSessionPruner", () => {
|
|
38
|
+
test("sweeps once immediately and schedules a repeat on the default interval", async () => {
|
|
39
|
+
let pruneCalls = 0;
|
|
40
|
+
const store = {
|
|
41
|
+
prune: async () => {
|
|
42
|
+
pruneCalls += 1;
|
|
43
|
+
return 1;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
const scheduled: Array<{ fn: () => void; ms: number }> = [];
|
|
47
|
+
|
|
48
|
+
startSessionPruner(store, "sessions.json", {
|
|
49
|
+
setIntervalFn: (fn, ms) => {
|
|
50
|
+
scheduled.push({ fn, ms });
|
|
51
|
+
return 0;
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// The startup sweep clears whatever a previous (possibly killed) process
|
|
56
|
+
// stranded, without waiting a full interval.
|
|
57
|
+
expect(pruneCalls).toBe(1);
|
|
58
|
+
expect(scheduled).toHaveLength(1);
|
|
59
|
+
expect(scheduled[0]?.ms).toBe(SESSION_PRUNE_INTERVAL_MS);
|
|
60
|
+
|
|
61
|
+
// ...and each timer fire is another sweep.
|
|
62
|
+
scheduled[0]?.fn();
|
|
63
|
+
expect(pruneCalls).toBe(2);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("honors an explicit interval", () => {
|
|
67
|
+
const store = { prune: async () => 0 };
|
|
68
|
+
const intervals: number[] = [];
|
|
69
|
+
startSessionPruner(store, "sessions.json", {
|
|
70
|
+
intervalMs: 1234,
|
|
71
|
+
setIntervalFn: (_fn, ms) => {
|
|
72
|
+
intervals.push(ms);
|
|
73
|
+
return 0;
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
expect(intervals).toEqual([1234]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("a throwing store does not propagate out of the scheduled sweep", () => {
|
|
80
|
+
const store = {
|
|
81
|
+
prune: async () => {
|
|
82
|
+
throw new Error("boom");
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
const scheduled: Array<() => void> = [];
|
|
86
|
+
startSessionPruner(store, "sessions.json", {
|
|
87
|
+
setIntervalFn: (fn) => {
|
|
88
|
+
scheduled.push(fn);
|
|
89
|
+
return 0;
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
expect(() => scheduled[0]?.()).not.toThrow();
|
|
93
|
+
});
|
|
94
|
+
});
|