session-steward 0.9.0 → 0.10.1

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/dist/index.html CHANGED
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1.0"/>
6
6
  <link href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%23171717'/%3E%3Cpath d='M16 4 26 8v7c0 6-4 11-10 13C10 26 6 21 6 15V8Z' fill='%23f5f5f5'/%3E%3Cpath d='m9.5 16 2.2-2.2 3 3 6.5-6.5 2.2 2.2-8.7 8.7Z' fill='%23171717'/%3E%3C/svg%3E" rel="icon"/>
7
7
  <title>Session Steward</title>
8
- <script type="module" crossorigin src="/assets/index-C94A1O5c.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-BDiEQG6G.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-CXq8Tw8T.css">
10
10
  </head>
11
11
  <body>
@@ -0,0 +1,205 @@
1
+ import { execFile as execFileCallback } from "node:child_process";
2
+ import { promises as fs } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { promisify } from "node:util";
7
+
8
+ import { getDefaultConfigDirectory } from "./platform.mjs";
9
+
10
+ const execFile = promisify(execFileCallback);
11
+ const LABEL = "com.mallikcheripally.session-steward.cleanup";
12
+ const WINDOWS_TASK_NAME = "Session Steward Cleanup";
13
+ const RUNNER_PATH = fileURLToPath(new URL("../bin/session-steward-scheduler.mjs", import.meta.url));
14
+
15
+ function xml(value) {
16
+ return String(value)
17
+ .replaceAll("&", "&amp;")
18
+ .replaceAll("<", "&lt;")
19
+ .replaceAll(">", "&gt;")
20
+ .replaceAll('"', "&quot;")
21
+ .replaceAll("'", "&apos;");
22
+ }
23
+
24
+ function systemdArgument(value) {
25
+ return `"${String(value)
26
+ .replaceAll("%", "%%")
27
+ .replaceAll("\\", "\\\\")
28
+ .replaceAll('"', '\\"')
29
+ .replaceAll("$", "\\$")
30
+ .replaceAll("`", "\\`")}"`;
31
+ }
32
+
33
+ function windowsArgument(value) {
34
+ return `"${String(value).replaceAll(/(\\*)"/gu, "$1$1\\\"").replaceAll(/(\\+)$/gu, "$1$1")}"`;
35
+ }
36
+
37
+ async function writePrivateFile(filePath, contents) {
38
+ await fs.mkdir(path.dirname(filePath), { mode: 0o700, recursive: true });
39
+ await fs.writeFile(filePath, contents, { encoding: "utf8", mode: 0o600 });
40
+ }
41
+
42
+ async function exists(filePath) {
43
+ try {
44
+ await fs.access(filePath);
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ export function createCleanupSchedulerService({
52
+ configDirectory = getDefaultConfigDirectory(),
53
+ environment = process.env,
54
+ execute = execFile,
55
+ home = os.homedir(),
56
+ nodePath = process.execPath,
57
+ platform = process.platform,
58
+ runnerPath = RUNNER_PATH,
59
+ userId = typeof process.getuid === "function" ? process.getuid() : null,
60
+ } = {}) {
61
+ const launchAgentPath = path.join(home, "Library", "LaunchAgents", `${LABEL}.plist`);
62
+ const xdgConfigHome = path.isAbsolute(environment.XDG_CONFIG_HOME || "")
63
+ ? environment.XDG_CONFIG_HOME
64
+ : path.join(home, ".config");
65
+ const systemdDirectory = path.join(xdgConfigHome, "systemd", "user");
66
+ const systemdServicePath = path.join(systemdDirectory, "session-steward-cleanup.service");
67
+ const systemdTimerPath = path.join(systemdDirectory, "session-steward-cleanup.timer");
68
+
69
+ async function command(commandName, args, { allowFailure = false } = {}) {
70
+ try {
71
+ await execute(commandName, args, { windowsHide: true });
72
+ return true;
73
+ } catch (error) {
74
+ if (allowFailure) return false;
75
+ throw new Error("Session Steward could not update the automatic cleanup scheduler.", {
76
+ cause: error,
77
+ });
78
+ }
79
+ }
80
+
81
+ async function start() {
82
+ if (platform === "darwin") {
83
+ if (!Number.isSafeInteger(userId)) throw new Error("Session Steward could not identify this user.");
84
+ const target = `gui/${userId}`;
85
+ await writePrivateFile(launchAgentPath, `<?xml version="1.0" encoding="UTF-8"?>
86
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
87
+ <plist version="1.0"><dict>
88
+ <key>Label</key><string>${LABEL}</string>
89
+ <key>ProgramArguments</key><array>
90
+ <string>${xml(nodePath)}</string>
91
+ <string>${xml(runnerPath)}</string>
92
+ <string>--run-due</string>
93
+ <string>--config-directory</string>
94
+ <string>${xml(configDirectory)}</string>
95
+ </array>
96
+ <key>RunAtLoad</key><true/>
97
+ <key>StartInterval</key><integer>900</integer>
98
+ </dict></plist>
99
+ `);
100
+ await command("launchctl", ["bootout", `${target}/${LABEL}`], { allowFailure: true });
101
+ await command("launchctl", ["bootstrap", target, launchAgentPath]);
102
+ await command("launchctl", ["enable", `${target}/${LABEL}`]);
103
+ return status();
104
+ }
105
+
106
+ if (platform === "linux") {
107
+ await writePrivateFile(systemdServicePath, `[Unit]
108
+ Description=Session Steward automatic cleanup
109
+
110
+ [Service]
111
+ Type=oneshot
112
+ ExecStart=${systemdArgument(nodePath)} ${systemdArgument(runnerPath)} --run-due --config-directory ${systemdArgument(configDirectory)}
113
+ `);
114
+ await writePrivateFile(systemdTimerPath, `[Unit]
115
+ Description=Run Session Steward automatic cleanup
116
+
117
+ [Timer]
118
+ OnBootSec=2min
119
+ OnUnitActiveSec=15min
120
+ Persistent=true
121
+
122
+ [Install]
123
+ WantedBy=timers.target
124
+ `);
125
+ await command("systemctl", ["--user", "daemon-reload"]);
126
+ await command("systemctl", ["--user", "enable", "--now", "session-steward-cleanup.timer"]);
127
+ return status();
128
+ }
129
+
130
+ if (platform === "win32") {
131
+ const taskCommand = [
132
+ nodePath,
133
+ runnerPath,
134
+ "--run-due",
135
+ "--config-directory",
136
+ configDirectory,
137
+ ].map(windowsArgument).join(" ");
138
+ await command("schtasks", [
139
+ "/Create", "/F", "/SC", "MINUTE", "/MO", "15",
140
+ "/TN", WINDOWS_TASK_NAME,
141
+ "/TR", taskCommand,
142
+ ]);
143
+ return status();
144
+ }
145
+
146
+ throw new Error(`Automatic cleanup is not supported on ${platform}.`);
147
+ }
148
+
149
+ async function stop() {
150
+ if (platform === "darwin") {
151
+ if (Number.isSafeInteger(userId)) {
152
+ await command("launchctl", ["bootout", `gui/${userId}/${LABEL}`], {
153
+ allowFailure: true,
154
+ });
155
+ }
156
+ await fs.rm(launchAgentPath, { force: true });
157
+ return status();
158
+ }
159
+ if (platform === "linux") {
160
+ await command("systemctl", ["--user", "disable", "--now", "session-steward-cleanup.timer"], {
161
+ allowFailure: true,
162
+ });
163
+ await Promise.all([
164
+ fs.rm(systemdServicePath, { force: true }),
165
+ fs.rm(systemdTimerPath, { force: true }),
166
+ ]);
167
+ await command("systemctl", ["--user", "daemon-reload"], { allowFailure: true });
168
+ return status();
169
+ }
170
+ if (platform === "win32") {
171
+ await command("schtasks", ["/Delete", "/F", "/TN", WINDOWS_TASK_NAME], {
172
+ allowFailure: true,
173
+ });
174
+ return status();
175
+ }
176
+ return { platform, running: false, supported: false };
177
+ }
178
+
179
+ async function status() {
180
+ if (platform === "darwin") {
181
+ const running = Number.isSafeInteger(userId)
182
+ ? await command("launchctl", ["print", `gui/${userId}/${LABEL}`], { allowFailure: true })
183
+ : false;
184
+ return { platform, running, supported: true };
185
+ }
186
+ if (platform === "linux") {
187
+ const configured = await exists(systemdTimerPath);
188
+ const running = configured
189
+ ? await command("systemctl", ["--user", "is-active", "--quiet", "session-steward-cleanup.timer"], {
190
+ allowFailure: true,
191
+ })
192
+ : false;
193
+ return { platform, running, supported: true };
194
+ }
195
+ if (platform === "win32") {
196
+ const running = await command("schtasks", ["/Query", "/TN", WINDOWS_TASK_NAME], {
197
+ allowFailure: true,
198
+ });
199
+ return { platform, running, supported: true };
200
+ }
201
+ return { platform, running: false, supported: false };
202
+ }
203
+
204
+ return { start, status, stop };
205
+ }
@@ -0,0 +1,439 @@
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import { getProvider } from "./providers/index.mjs";
6
+ import { runSessionCleanup } from "./session-cleanup.mjs";
7
+ import { getDefaultConfigDirectory } from "./settings.mjs";
8
+
9
+ const STATE_VERSION = 1;
10
+ const MAX_SCHEDULES = 100;
11
+ const MAX_SESSIONS_PER_RUN = 100;
12
+ const RUN_CLAIM_TIMEOUT_MS = 6 * 60 * 60 * 1_000;
13
+ const LOCK_TIMEOUT_MS = 5_000;
14
+ const STALE_LOCK_MS = 60_000;
15
+ const DAY_MS = 24 * 60 * 60 * 1_000;
16
+ const MAX_DAY_COUNT = 3_650;
17
+ const LEGACY_FREQUENCY_DAYS = Object.freeze({ daily: 1, weekly: 7 });
18
+ const PROVIDERS = new Set(["codex", "claude-code"]);
19
+ const ARCHIVE_STATUSES = new Set(["all", "active", "archived"]);
20
+ const CLEANUP_MODES = new Set(["standard", "thorough"]);
21
+ const SELECTION_ORDERS = new Set(["oldest", "largest"]);
22
+
23
+ function emptyState() {
24
+ return { schedules: [], version: STATE_VERSION };
25
+ }
26
+
27
+ function requiredChoice(value, values, label) {
28
+ if (!values.has(value)) throw new Error(`${label} is not supported.`);
29
+ return value;
30
+ }
31
+
32
+ function optionalText(value, label, maximumLength) {
33
+ if (value === undefined || value === null || value === "") return null;
34
+ if (typeof value !== "string" || value.includes("\0") || value.length > maximumLength) {
35
+ throw new Error(`${label} is not valid.`);
36
+ }
37
+ const trimmed = value.trim();
38
+ if (!trimmed) return null;
39
+ return trimmed;
40
+ }
41
+
42
+ function requiredDayCount(value, label) {
43
+ if (!Number.isSafeInteger(value) || value < 1 || value > MAX_DAY_COUNT) {
44
+ throw new Error(`${label} must be a whole number between 1 and ${MAX_DAY_COUNT}.`);
45
+ }
46
+ return value;
47
+ }
48
+
49
+ function normalizeDefinition(definition) {
50
+ if (!definition || typeof definition !== "object") {
51
+ throw new Error("Enter cleanup schedule settings.");
52
+ }
53
+ const name = optionalText(definition.name, "Schedule name", 100);
54
+ if (!name) throw new Error("Enter a schedule name.");
55
+ const provider = requiredChoice(definition.provider, PROVIDERS, "Provider");
56
+ const inactiveDays = requiredDayCount(definition.inactiveDays, "Inactivity period in days");
57
+ const runEveryDays = requiredDayCount(
58
+ definition.runEveryDays ?? LEGACY_FREQUENCY_DAYS[definition.frequency],
59
+ "Run interval in days",
60
+ );
61
+ const cleanupMode = requiredChoice(
62
+ definition.cleanupMode ?? "thorough",
63
+ CLEANUP_MODES,
64
+ "Cleanup mode",
65
+ );
66
+ const archiveStatus = requiredChoice(
67
+ definition.archiveStatus ?? "all",
68
+ ARCHIVE_STATUSES,
69
+ "Archive status",
70
+ );
71
+ const selectionOrder = requiredChoice(
72
+ definition.selectionOrder ?? "oldest",
73
+ SELECTION_ORDERS,
74
+ "Selection order",
75
+ );
76
+ const minimumTranscriptBytes = definition.minimumTranscriptBytes ?? null;
77
+ if (
78
+ minimumTranscriptBytes !== null
79
+ && (!Number.isSafeInteger(minimumTranscriptBytes) || minimumTranscriptBytes <= 0)
80
+ ) {
81
+ throw new Error("Minimum transcript bytes must be a positive whole number.");
82
+ }
83
+ const maxSessions = definition.maxSessions ?? 25;
84
+ if (!Number.isSafeInteger(maxSessions) || maxSessions < 1 || maxSessions > MAX_SESSIONS_PER_RUN) {
85
+ throw new Error(`Maximum sessions per run must be between 1 and ${MAX_SESSIONS_PER_RUN}.`);
86
+ }
87
+ const providerHomeOverride = definition.providerHomeOverride ?? null;
88
+ if (
89
+ providerHomeOverride !== null
90
+ && (
91
+ typeof providerHomeOverride !== "string"
92
+ || providerHomeOverride.includes("\0")
93
+ || !path.isAbsolute(providerHomeOverride)
94
+ )
95
+ ) {
96
+ throw new Error("Provider folder override is not valid.");
97
+ }
98
+
99
+ return {
100
+ archiveStatus,
101
+ cleanupMode,
102
+ enabled: definition.enabled !== false,
103
+ inactiveDays,
104
+ includeInternals: Boolean(definition.includeInternals),
105
+ includeSupporting: Boolean(definition.includeSupporting),
106
+ maxSessions,
107
+ minimumTranscriptBytes,
108
+ name,
109
+ provider,
110
+ providerHomeOverride,
111
+ runEveryDays,
112
+ selectionOrder,
113
+ workspace: optionalText(definition.workspace, "Workspace", 4_096),
114
+ };
115
+ }
116
+
117
+ function nextRunAt(schedule, now) {
118
+ return schedule.enabled ? now + schedule.runEveryDays * DAY_MS : null;
119
+ }
120
+
121
+ function normalizeStoredSchedule(schedule) {
122
+ if (Number.isSafeInteger(schedule?.runEveryDays)) return schedule;
123
+ const runEveryDays = LEGACY_FREQUENCY_DAYS[schedule?.frequency];
124
+ if (!runEveryDays) return schedule;
125
+ const { frequency: _frequency, ...rest } = schedule;
126
+ return { ...rest, runEveryDays };
127
+ }
128
+
129
+ async function readState(statePath) {
130
+ try {
131
+ const parsed = JSON.parse(await fs.readFile(statePath, "utf8"));
132
+ if (parsed?.version !== STATE_VERSION || !Array.isArray(parsed.schedules)) return emptyState();
133
+ return {
134
+ ...parsed,
135
+ schedules: parsed.schedules.map(normalizeStoredSchedule),
136
+ };
137
+ } catch (error) {
138
+ if (error?.code === "ENOENT" || error instanceof SyntaxError) return emptyState();
139
+ throw new Error("Session Steward could not read cleanup schedules.", { cause: error });
140
+ }
141
+ }
142
+
143
+ async function writeState(statePath, state) {
144
+ const directory = path.dirname(statePath);
145
+ const temporaryPath = path.join(
146
+ directory,
147
+ `.cleanup-schedules-${process.pid}-${randomBytes(8).toString("hex")}.tmp`,
148
+ );
149
+ await fs.mkdir(directory, { mode: 0o700, recursive: true });
150
+ let handle;
151
+ try {
152
+ handle = await fs.open(temporaryPath, "wx", 0o600);
153
+ await handle.writeFile(`${JSON.stringify(state, null, 2)}\n`, "utf8");
154
+ await handle.sync();
155
+ await handle.close();
156
+ handle = null;
157
+ await fs.rename(temporaryPath, statePath);
158
+ } catch (error) {
159
+ await handle?.close().catch(() => {});
160
+ await fs.rm(temporaryPath, { force: true }).catch(() => {});
161
+ throw new Error("Session Steward could not save cleanup schedules.", { cause: error });
162
+ }
163
+ }
164
+
165
+ function delay(milliseconds) {
166
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
167
+ }
168
+
169
+ async function acquireLock(lockPath) {
170
+ const startedAt = Date.now();
171
+ while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
172
+ try {
173
+ await fs.mkdir(path.dirname(lockPath), { mode: 0o700, recursive: true });
174
+ return await fs.open(lockPath, "wx", 0o600);
175
+ } catch (error) {
176
+ if (error?.code !== "EEXIST") throw error;
177
+ try {
178
+ const stats = await fs.stat(lockPath);
179
+ if (Date.now() - stats.mtimeMs > STALE_LOCK_MS) {
180
+ await fs.rm(lockPath, { force: true });
181
+ continue;
182
+ }
183
+ } catch (statError) {
184
+ if (statError?.code !== "ENOENT") throw statError;
185
+ }
186
+ await delay(25);
187
+ }
188
+ }
189
+ throw new Error("Cleanup schedules are busy. Try again shortly.");
190
+ }
191
+
192
+ function publicSchedule(schedule) {
193
+ const { providerHomeOverride: _providerHomeOverride, ...safe } = schedule;
194
+ return structuredClone(safe);
195
+ }
196
+
197
+ function privateSchedule(schedule) {
198
+ return structuredClone(schedule);
199
+ }
200
+
201
+ export function createCleanupScheduleStore({
202
+ configDirectory = getDefaultConfigDirectory(),
203
+ createId = randomUUID,
204
+ now = Date.now,
205
+ } = {}) {
206
+ const statePath = path.join(configDirectory, "cleanup-schedules.json");
207
+ const lockPath = path.join(configDirectory, "cleanup-schedules.lock");
208
+
209
+ async function mutate(mutator) {
210
+ const lock = await acquireLock(lockPath);
211
+ try {
212
+ const state = await readState(statePath);
213
+ const value = await mutator(state);
214
+ await writeState(statePath, state);
215
+ return value;
216
+ } finally {
217
+ await lock.close().catch(() => {});
218
+ await fs.rm(lockPath, { force: true }).catch(() => {});
219
+ }
220
+ }
221
+
222
+ return {
223
+ async claim(id, { force = false } = {}) {
224
+ return mutate((state) => {
225
+ const schedule = state.schedules.find((item) => item.id === id);
226
+ if (!schedule) throw new Error("That cleanup schedule does not exist.");
227
+ const currentTime = now();
228
+ if (!schedule.enabled && !force) return null;
229
+ if (!force && schedule.nextRunAtMs > currentTime) return null;
230
+ if (
231
+ Number.isFinite(schedule.runningSinceMs)
232
+ && currentTime - schedule.runningSinceMs < RUN_CLAIM_TIMEOUT_MS
233
+ ) {
234
+ return null;
235
+ }
236
+ schedule.runningSinceMs = currentTime;
237
+ schedule.nextRunAtMs = nextRunAt(schedule, currentTime);
238
+ schedule.updatedAtMs = currentTime;
239
+ return privateSchedule(schedule);
240
+ });
241
+ },
242
+
243
+ async complete(id, run) {
244
+ return mutate((state) => {
245
+ const schedule = state.schedules.find((item) => item.id === id);
246
+ if (!schedule) return null;
247
+ schedule.lastRun = run;
248
+ schedule.runningSinceMs = null;
249
+ schedule.updatedAtMs = now();
250
+ return publicSchedule(schedule);
251
+ });
252
+ },
253
+
254
+ async list() {
255
+ const state = await readState(statePath);
256
+ return state.schedules.map(publicSchedule);
257
+ },
258
+
259
+ async remove(id) {
260
+ return mutate((state) => {
261
+ const index = state.schedules.findIndex((item) => item.id === id);
262
+ if (index < 0) throw new Error("That cleanup schedule does not exist.");
263
+ const [removed] = state.schedules.splice(index, 1);
264
+ return publicSchedule(removed);
265
+ });
266
+ },
267
+
268
+ async save(definition, { id } = {}) {
269
+ const normalized = normalizeDefinition(definition);
270
+ return mutate((state) => {
271
+ const currentTime = now();
272
+ if (id === undefined) {
273
+ if (state.schedules.length >= MAX_SCHEDULES) {
274
+ throw new Error(`Session Steward supports up to ${MAX_SCHEDULES} cleanup schedules.`);
275
+ }
276
+ const schedule = {
277
+ ...normalized,
278
+ createdAtMs: currentTime,
279
+ id: createId(),
280
+ lastRun: null,
281
+ nextRunAtMs: nextRunAt(normalized, currentTime),
282
+ runningSinceMs: null,
283
+ updatedAtMs: currentTime,
284
+ };
285
+ state.schedules.push(schedule);
286
+ return publicSchedule(schedule);
287
+ }
288
+
289
+ const index = state.schedules.findIndex((item) => item.id === id);
290
+ if (index < 0) throw new Error("That cleanup schedule does not exist.");
291
+ const existing = state.schedules[index];
292
+ const timingChanged = existing.runEveryDays !== normalized.runEveryDays
293
+ || existing.enabled !== normalized.enabled;
294
+ const schedule = {
295
+ ...existing,
296
+ ...normalized,
297
+ nextRunAtMs: timingChanged
298
+ ? nextRunAt(normalized, currentTime)
299
+ : existing.nextRunAtMs,
300
+ updatedAtMs: currentTime,
301
+ };
302
+ state.schedules[index] = schedule;
303
+ return publicSchedule(schedule);
304
+ });
305
+ },
306
+ };
307
+ }
308
+
309
+ function scheduleProviderOptions(schedule, settings) {
310
+ const home = schedule.providerHomeOverride ?? settings.getHome(schedule.provider);
311
+ if (schedule.provider === "codex") return { codexHome: home };
312
+ const options = { claudeHome: home };
313
+ if (typeof settings.getClaudeDesktopDataHome === "function") {
314
+ options.desktopDataHome = settings.getClaudeDesktopDataHome();
315
+ }
316
+ return options;
317
+ }
318
+
319
+ function listingOptions(schedule, settings, now) {
320
+ return {
321
+ archiveStatus: schedule.archiveStatus,
322
+ includeInternals: schedule.includeInternals,
323
+ includeSupporting: schedule.includeSupporting,
324
+ inactiveBeforeMs: now() - schedule.inactiveDays * DAY_MS,
325
+ minimumTranscriptBytes: schedule.minimumTranscriptBytes ?? undefined,
326
+ pageSize: schedule.maxSessions,
327
+ search: "",
328
+ sort: schedule.selectionOrder === "largest" ? "size" : "updated",
329
+ workspace: schedule.workspace ?? undefined,
330
+ ...scheduleProviderOptions(schedule, settings),
331
+ };
332
+ }
333
+
334
+ async function findCandidates(schedule, provider, settings, now) {
335
+ const options = listingOptions(schedule, settings, now);
336
+ if (options.sort === "size") {
337
+ const result = await provider.listSessions({ ...options, page: 1 });
338
+ return result.records.slice(0, schedule.maxSessions);
339
+ }
340
+
341
+ const first = await provider.listSessions({ ...options, page: 1 });
342
+ if (first.pageCount === 1) return [...first.records].reverse();
343
+ const oldest = await provider.listSessions({ ...options, page: first.pageCount });
344
+ const records = [...oldest.records].reverse();
345
+ if (records.length < schedule.maxSessions && first.pageCount > 1) {
346
+ const previous = await provider.listSessions({ ...options, page: first.pageCount - 1 });
347
+ records.push(...[...previous.records].reverse());
348
+ }
349
+ return records.slice(0, schedule.maxSessions);
350
+ }
351
+
352
+ function safeRunResult(result, candidateCount, atMs) {
353
+ return {
354
+ affectedSessionCount: result.affectedSessionCount,
355
+ atMs,
356
+ candidateCount,
357
+ cleanupFallback: result.cleanupFallback,
358
+ cleanupMode: result.cleanupMode,
359
+ deletedSessionCount: result.deletedSessionCount,
360
+ requestedCleanupMode: result.requestedCleanupMode,
361
+ status: result.status,
362
+ transcriptBytes: result.transcriptBytes,
363
+ };
364
+ }
365
+
366
+ export async function runCleanupSchedule({
367
+ cleanup = runSessionCleanup,
368
+ force = false,
369
+ id,
370
+ now = Date.now,
371
+ resolveProvider = getProvider,
372
+ scheduleStore,
373
+ settings,
374
+ signal,
375
+ } = {}) {
376
+ const schedule = await scheduleStore.claim(id, { force });
377
+ if (!schedule) return { id, status: "not-due" };
378
+ const atMs = now();
379
+ let candidateCount = 0;
380
+ let run;
381
+ try {
382
+ const provider = resolveProvider(schedule.provider);
383
+ const candidates = await findCandidates(schedule, provider, settings, now);
384
+ candidateCount = candidates.length;
385
+ if (candidates.length === 0) {
386
+ run = {
387
+ affectedSessionCount: 0,
388
+ atMs,
389
+ candidateCount: 0,
390
+ deletedSessionCount: 0,
391
+ status: "no-matches",
392
+ transcriptBytes: 0,
393
+ };
394
+ } else {
395
+ const result = await cleanup({
396
+ options: scheduleProviderOptions(schedule, settings),
397
+ provider,
398
+ recordIds: candidates.map((record) => record.id),
399
+ scope: schedule.cleanupMode === "thorough" ? "deep" : "core",
400
+ signal,
401
+ });
402
+ run = safeRunResult(result, candidates.length, atMs);
403
+ }
404
+ } catch {
405
+ run = {
406
+ affectedSessionCount: 0,
407
+ atMs,
408
+ candidateCount,
409
+ deletedSessionCount: null,
410
+ status: "failed",
411
+ transcriptBytes: 0,
412
+ };
413
+ }
414
+ await scheduleStore.complete(schedule.id, run);
415
+ return { id: schedule.id, provider: schedule.provider, ...run };
416
+ }
417
+
418
+ export async function runDueCleanupSchedules({
419
+ cleanup = runSessionCleanup,
420
+ now = Date.now,
421
+ resolveProvider = getProvider,
422
+ scheduleStore,
423
+ settings,
424
+ } = {}) {
425
+ const schedules = await scheduleStore.list();
426
+ const results = [];
427
+ for (const schedule of schedules) {
428
+ if (!schedule.enabled || schedule.nextRunAtMs > now()) continue;
429
+ results.push(await runCleanupSchedule({
430
+ cleanup,
431
+ id: schedule.id,
432
+ now,
433
+ resolveProvider,
434
+ scheduleStore,
435
+ settings,
436
+ }));
437
+ }
438
+ return results;
439
+ }