@hellcoder/companion 0.96.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.
Files changed (242) hide show
  1. package/bin/cli.ts +168 -0
  2. package/bin/ctl.ts +528 -0
  3. package/bin/generate-token.ts +28 -0
  4. package/dist/apple-touch-icon.png +0 -0
  5. package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
  6. package/dist/assets/CronManager-EGwLJONv.js +1 -0
  7. package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
  8. package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
  9. package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
  10. package/dist/assets/Playground-BV3k0RbV.js +109 -0
  11. package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
  12. package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
  13. package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
  14. package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
  15. package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
  16. package/dist/assets/index-BhUa1e6X.css +1 -0
  17. package/dist/assets/index-DkqeP-R9.js +134 -0
  18. package/dist/assets/sw-register-BibwRdvC.js +1 -0
  19. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  20. package/dist/favicon.svg +8 -0
  21. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  22. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  23. package/dist/icon-192.png +0 -0
  24. package/dist/icon-512.png +0 -0
  25. package/dist/index.html +20 -0
  26. package/dist/logo-codex.svg +14 -0
  27. package/dist/logo-docker.svg +4 -0
  28. package/dist/logo.svg +14 -0
  29. package/dist/manifest.json +24 -0
  30. package/dist/sw.js +2 -0
  31. package/package.json +104 -0
  32. package/server/agent-cron-migrator.test.ts +610 -0
  33. package/server/agent-cron-migrator.ts +85 -0
  34. package/server/agent-executor.test.ts +1108 -0
  35. package/server/agent-executor.ts +346 -0
  36. package/server/agent-store.test.ts +588 -0
  37. package/server/agent-store.ts +185 -0
  38. package/server/agent-types.ts +138 -0
  39. package/server/ai-validation-settings.test.ts +128 -0
  40. package/server/ai-validation-settings.ts +35 -0
  41. package/server/ai-validator.test.ts +387 -0
  42. package/server/ai-validator.ts +271 -0
  43. package/server/auth-manager.test.ts +83 -0
  44. package/server/auth-manager.ts +150 -0
  45. package/server/auto-namer.test.ts +252 -0
  46. package/server/auto-namer.ts +78 -0
  47. package/server/backend-adapter.test.ts +38 -0
  48. package/server/backend-adapter.ts +54 -0
  49. package/server/cache-headers.test.ts +98 -0
  50. package/server/cache-headers.ts +61 -0
  51. package/server/claude-adapter.test.ts +1363 -0
  52. package/server/claude-adapter.ts +889 -0
  53. package/server/claude-container-auth.test.ts +44 -0
  54. package/server/claude-container-auth.ts +30 -0
  55. package/server/claude-protocol-contract.test.ts +71 -0
  56. package/server/claude-protocol-drift.test.ts +78 -0
  57. package/server/claude-session-discovery.test.ts +132 -0
  58. package/server/claude-session-discovery.ts +157 -0
  59. package/server/claude-session-history.test.ts +158 -0
  60. package/server/claude-session-history.ts +410 -0
  61. package/server/cli-launcher.test.ts +1343 -0
  62. package/server/cli-launcher.ts +1298 -0
  63. package/server/cli.test.ts +16 -0
  64. package/server/codex-adapter.test.ts +5545 -0
  65. package/server/codex-adapter.ts +3062 -0
  66. package/server/codex-container-auth.test.ts +50 -0
  67. package/server/codex-container-auth.ts +24 -0
  68. package/server/codex-home.test.ts +61 -0
  69. package/server/codex-home.ts +26 -0
  70. package/server/codex-protocol-contract.test.ts +96 -0
  71. package/server/codex-protocol-drift.test.ts +123 -0
  72. package/server/codex-ws-proxy.cjs +226 -0
  73. package/server/commands-discovery.test.ts +179 -0
  74. package/server/commands-discovery.ts +81 -0
  75. package/server/constants.ts +7 -0
  76. package/server/container-manager.test.ts +1211 -0
  77. package/server/container-manager.ts +1053 -0
  78. package/server/cron-scheduler.test.ts +957 -0
  79. package/server/cron-scheduler.ts +243 -0
  80. package/server/cron-store.test.ts +422 -0
  81. package/server/cron-store.ts +148 -0
  82. package/server/cron-types.ts +63 -0
  83. package/server/env-manager.test.ts +268 -0
  84. package/server/env-manager.ts +161 -0
  85. package/server/event-bus-types.ts +64 -0
  86. package/server/event-bus.test.ts +244 -0
  87. package/server/event-bus.ts +124 -0
  88. package/server/execution-store.test.ts +307 -0
  89. package/server/execution-store.ts +170 -0
  90. package/server/fs-utils.ts +15 -0
  91. package/server/git-utils.test.ts +938 -0
  92. package/server/git-utils.ts +421 -0
  93. package/server/github-pr.test.ts +498 -0
  94. package/server/github-pr.ts +379 -0
  95. package/server/image-pull-manager.test.ts +303 -0
  96. package/server/image-pull-manager.ts +279 -0
  97. package/server/index.ts +396 -0
  98. package/server/linear-agent-bridge.test.ts +1157 -0
  99. package/server/linear-agent-bridge.ts +629 -0
  100. package/server/linear-agent.test.ts +473 -0
  101. package/server/linear-agent.ts +479 -0
  102. package/server/linear-cache.test.ts +136 -0
  103. package/server/linear-cache.ts +113 -0
  104. package/server/linear-connections.test.ts +350 -0
  105. package/server/linear-connections.ts +231 -0
  106. package/server/linear-credential-migration.test.ts +337 -0
  107. package/server/linear-credential-migration.ts +63 -0
  108. package/server/linear-oauth-connections-migration.test.ts +268 -0
  109. package/server/linear-oauth-connections.test.ts +365 -0
  110. package/server/linear-oauth-connections.ts +294 -0
  111. package/server/linear-project-manager.test.ts +162 -0
  112. package/server/linear-project-manager.ts +111 -0
  113. package/server/linear-prompt-builder.test.ts +74 -0
  114. package/server/linear-prompt-builder.ts +61 -0
  115. package/server/linear-staging.test.ts +276 -0
  116. package/server/linear-staging.ts +142 -0
  117. package/server/logger.test.ts +393 -0
  118. package/server/logger.ts +259 -0
  119. package/server/metrics-collector.test.ts +413 -0
  120. package/server/metrics-collector.ts +350 -0
  121. package/server/metrics-types.ts +108 -0
  122. package/server/middleware/managed-auth.test.ts +264 -0
  123. package/server/middleware/managed-auth.ts +195 -0
  124. package/server/novnc-proxy.test.ts +333 -0
  125. package/server/novnc-proxy.ts +99 -0
  126. package/server/path-resolver.test.ts +552 -0
  127. package/server/path-resolver.ts +186 -0
  128. package/server/paths.test.ts +31 -0
  129. package/server/paths.ts +11 -0
  130. package/server/pr-poller.test.ts +191 -0
  131. package/server/pr-poller.ts +162 -0
  132. package/server/prompt-manager.test.ts +211 -0
  133. package/server/prompt-manager.ts +211 -0
  134. package/server/protocol/claude-upstream/README.md +19 -0
  135. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  136. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  137. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  138. package/server/protocol/codex-upstream/README.md +18 -0
  139. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  140. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  141. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  142. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  143. package/server/protocol-monitor.ts +50 -0
  144. package/server/recorder.test.ts +454 -0
  145. package/server/recorder.ts +374 -0
  146. package/server/recording-hub/compat-validator.test.ts +150 -0
  147. package/server/recording-hub/compat-validator.ts +284 -0
  148. package/server/recording-hub/diagnostics.test.ts +140 -0
  149. package/server/recording-hub/diagnostics.ts +299 -0
  150. package/server/recording-hub/hub-config.test.ts +44 -0
  151. package/server/recording-hub/hub-config.ts +19 -0
  152. package/server/recording-hub/hub-routes.test.ts +417 -0
  153. package/server/recording-hub/hub-routes.ts +236 -0
  154. package/server/recording-hub/hub-store.test.ts +262 -0
  155. package/server/recording-hub/hub-store.ts +265 -0
  156. package/server/recording-hub/replay-adapter.test.ts +294 -0
  157. package/server/recording-hub/replay-adapter.ts +207 -0
  158. package/server/relay-client.test.ts +337 -0
  159. package/server/relay-client.ts +320 -0
  160. package/server/replay.test.ts +200 -0
  161. package/server/replay.ts +78 -0
  162. package/server/routes/agent-routes.test.ts +1400 -0
  163. package/server/routes/agent-routes.ts +409 -0
  164. package/server/routes/cron-routes.test.ts +881 -0
  165. package/server/routes/cron-routes.ts +103 -0
  166. package/server/routes/env-routes.test.ts +383 -0
  167. package/server/routes/env-routes.ts +95 -0
  168. package/server/routes/fs-routes.test.ts +1198 -0
  169. package/server/routes/fs-routes.ts +605 -0
  170. package/server/routes/git-routes.test.ts +813 -0
  171. package/server/routes/git-routes.ts +97 -0
  172. package/server/routes/linear-agent-routes.test.ts +721 -0
  173. package/server/routes/linear-agent-routes.ts +304 -0
  174. package/server/routes/linear-connection-routes.test.ts +927 -0
  175. package/server/routes/linear-connection-routes.ts +244 -0
  176. package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
  177. package/server/routes/linear-oauth-connection-routes.ts +129 -0
  178. package/server/routes/linear-routes.test.ts +1510 -0
  179. package/server/routes/linear-routes.ts +953 -0
  180. package/server/routes/metrics-routes.test.ts +103 -0
  181. package/server/routes/metrics-routes.ts +13 -0
  182. package/server/routes/prompt-routes.ts +67 -0
  183. package/server/routes/sandbox-routes.test.ts +513 -0
  184. package/server/routes/sandbox-routes.ts +127 -0
  185. package/server/routes/settings-routes.ts +270 -0
  186. package/server/routes/skills-routes.test.ts +690 -0
  187. package/server/routes/skills-routes.ts +100 -0
  188. package/server/routes/system-routes.test.ts +637 -0
  189. package/server/routes/system-routes.ts +228 -0
  190. package/server/routes/tailscale-routes.test.ts +176 -0
  191. package/server/routes/tailscale-routes.ts +22 -0
  192. package/server/routes.test.ts +4655 -0
  193. package/server/routes.ts +1277 -0
  194. package/server/sandbox-manager.test.ts +378 -0
  195. package/server/sandbox-manager.ts +168 -0
  196. package/server/service.test.ts +1419 -0
  197. package/server/service.ts +718 -0
  198. package/server/session-creation-service.test.ts +661 -0
  199. package/server/session-creation-service.ts +473 -0
  200. package/server/session-git-info.ts +104 -0
  201. package/server/session-linear-issues.test.ts +118 -0
  202. package/server/session-linear-issues.ts +88 -0
  203. package/server/session-names.test.ts +94 -0
  204. package/server/session-names.ts +67 -0
  205. package/server/session-orchestrator.test.ts +1784 -0
  206. package/server/session-orchestrator.ts +973 -0
  207. package/server/session-state-machine.test.ts +606 -0
  208. package/server/session-state-machine.ts +207 -0
  209. package/server/session-store.test.ts +290 -0
  210. package/server/session-store.ts +146 -0
  211. package/server/session-types.ts +509 -0
  212. package/server/settings-manager.test.ts +275 -0
  213. package/server/settings-manager.ts +173 -0
  214. package/server/tailscale-manager.test.ts +553 -0
  215. package/server/tailscale-manager.ts +451 -0
  216. package/server/terminal-manager.ts +240 -0
  217. package/server/update-checker.test.ts +306 -0
  218. package/server/update-checker.ts +197 -0
  219. package/server/usage-limits.test.ts +536 -0
  220. package/server/usage-limits.ts +225 -0
  221. package/server/worktree-tracker.test.ts +243 -0
  222. package/server/worktree-tracker.ts +84 -0
  223. package/server/ws-auth.test.ts +59 -0
  224. package/server/ws-auth.ts +41 -0
  225. package/server/ws-bridge-browser-ingest.test.ts +272 -0
  226. package/server/ws-bridge-browser-ingest.ts +72 -0
  227. package/server/ws-bridge-browser.ts +112 -0
  228. package/server/ws-bridge-cli-ingest.test.ts +302 -0
  229. package/server/ws-bridge-cli-ingest.ts +81 -0
  230. package/server/ws-bridge-codex.test.ts +1837 -0
  231. package/server/ws-bridge-codex.ts +266 -0
  232. package/server/ws-bridge-controls.test.ts +124 -0
  233. package/server/ws-bridge-controls.ts +20 -0
  234. package/server/ws-bridge-persist.test.ts +296 -0
  235. package/server/ws-bridge-persist.ts +66 -0
  236. package/server/ws-bridge-publish.test.ts +234 -0
  237. package/server/ws-bridge-publish.ts +79 -0
  238. package/server/ws-bridge-replay.test.ts +44 -0
  239. package/server/ws-bridge-replay.ts +61 -0
  240. package/server/ws-bridge-types.ts +106 -0
  241. package/server/ws-bridge.test.ts +4777 -0
  242. package/server/ws-bridge.ts +1279 -0
@@ -0,0 +1,243 @@
1
+ import { Cron } from "croner";
2
+ import type { CronJob, CronJobExecution } from "./cron-types.js";
3
+ import type { CliLauncher } from "./cli-launcher.js";
4
+ import type { WsBridge } from "./ws-bridge.js";
5
+ import * as cronStore from "./cron-store.js";
6
+ import * as envManager from "./env-manager.js";
7
+ import * as sessionNames from "./session-names.js";
8
+
9
+ /** Max consecutive failures before auto-disabling a job */
10
+ const MAX_CONSECUTIVE_FAILURES = 5;
11
+ /** Max time to wait for CLI to connect (ms) */
12
+ const CLI_CONNECT_TIMEOUT_MS = 30_000;
13
+ /** Poll interval when waiting for CLI connection */
14
+ const CLI_CONNECT_POLL_MS = 500;
15
+
16
+ export class CronScheduler {
17
+ private timers = new Map<string, Cron>();
18
+ private launcher: CliLauncher;
19
+ private wsBridge: WsBridge;
20
+ /** In-memory execution history (last N per job) */
21
+ private executions = new Map<string, CronJobExecution[]>();
22
+ private static readonly MAX_EXECUTIONS_PER_JOB = 50;
23
+
24
+ constructor(launcher: CliLauncher, wsBridge: WsBridge) {
25
+ this.launcher = launcher;
26
+ this.wsBridge = wsBridge;
27
+ }
28
+
29
+ /** Start all enabled jobs from disk. Called once at server startup. */
30
+ startAll(): void {
31
+ const jobs = cronStore.listJobs();
32
+ let started = 0;
33
+ for (const job of jobs) {
34
+ if (job.enabled) {
35
+ this.scheduleJob(job);
36
+ started++;
37
+ }
38
+ }
39
+ if (started > 0) {
40
+ console.log(`[cron-scheduler] Started ${started} cron job(s)`);
41
+ }
42
+ }
43
+
44
+ /** Schedule (or reschedule) a single job. */
45
+ scheduleJob(job: CronJob): void {
46
+ this.stopJob(job.id);
47
+
48
+ if (!job.enabled) return;
49
+
50
+ try {
51
+ if (job.recurring) {
52
+ const cronTask = new Cron(job.schedule, {}, () => {
53
+ this.executeJob(job.id).catch((err) => {
54
+ console.error(`[cron-scheduler] Unhandled error in job "${job.name}":`, err);
55
+ });
56
+ });
57
+ this.timers.set(job.id, cronTask);
58
+ console.log(`[cron-scheduler] Scheduled "${job.name}" with cron "${job.schedule}"`);
59
+ } else {
60
+ // One-shot: schedule for the specified datetime
61
+ const targetTime = new Date(job.schedule);
62
+ if (targetTime.getTime() > Date.now()) {
63
+ const cronTask = new Cron(targetTime, () => {
64
+ this.executeJob(job.id)
65
+ .then(() => {
66
+ // Auto-disable after one-shot execution
67
+ cronStore.updateJob(job.id, { enabled: false });
68
+ this.timers.delete(job.id);
69
+ })
70
+ .catch((err) => {
71
+ console.error(`[cron-scheduler] Unhandled error in one-shot job "${job.name}":`, err);
72
+ });
73
+ });
74
+ this.timers.set(job.id, cronTask);
75
+ console.log(`[cron-scheduler] Scheduled one-shot "${job.name}" at ${targetTime.toISOString()}`);
76
+ } else {
77
+ console.log(`[cron-scheduler] Skipping one-shot "${job.name}" — target time is in the past`);
78
+ }
79
+ }
80
+ } catch (err) {
81
+ console.error(`[cron-scheduler] Failed to schedule "${job.name}":`, err);
82
+ }
83
+ }
84
+
85
+ /** Stop a job's timer. */
86
+ stopJob(jobId: string): void {
87
+ const timer = this.timers.get(jobId);
88
+ if (timer) {
89
+ timer.stop();
90
+ this.timers.delete(jobId);
91
+ }
92
+ }
93
+
94
+ /** Execute a job: create a session, send the prompt, track the result. */
95
+ async executeJob(jobId: string, opts?: { force?: boolean }): Promise<void> {
96
+ const job = cronStore.getJob(jobId);
97
+ if (!job) return;
98
+ if (!job.enabled && !opts?.force) return;
99
+
100
+ // Overlap prevention: skip if previous execution is still running
101
+ if (job.lastSessionId && this.launcher.isAlive(job.lastSessionId)) {
102
+ console.log(`[cron-scheduler] Skipping "${job.name}" — previous execution still running (${job.lastSessionId})`);
103
+ return;
104
+ }
105
+
106
+ console.log(`[cron-scheduler] Executing job "${job.name}" (${jobId})`);
107
+
108
+ const execution: CronJobExecution = {
109
+ sessionId: "",
110
+ jobId,
111
+ startedAt: Date.now(),
112
+ };
113
+
114
+ try {
115
+ // Resolve environment variables
116
+ let envVars: Record<string, string> | undefined;
117
+ if (job.envSlug) {
118
+ const env = envManager.getEnv(job.envSlug);
119
+ if (env) envVars = env.variables;
120
+ }
121
+
122
+ // Launch the session via CliLauncher
123
+ // For Codex, explicitly set sandbox and internet access for full autonomy
124
+ const sessionInfo = this.launcher.launch({
125
+ model: job.model,
126
+ permissionMode: job.permissionMode,
127
+ cwd: job.cwd,
128
+ env: envVars,
129
+ backendType: job.backendType,
130
+ codexInternetAccess: job.backendType === "codex" ? (job.codexInternetAccess ?? true) : undefined,
131
+ codexSandbox: job.backendType === "codex"
132
+ ? (job.permissionMode === "bypassPermissions" ? "danger-full-access" : "workspace-write")
133
+ : undefined,
134
+ });
135
+
136
+ execution.sessionId = sessionInfo.sessionId;
137
+
138
+ // Tag the session as cron-originated
139
+ sessionInfo.cronJobId = jobId;
140
+ sessionInfo.cronJobName = job.name;
141
+
142
+ // Set the session name
143
+ const runLabel = `⏰ ${job.name}`;
144
+ sessionNames.setName(sessionInfo.sessionId, runLabel);
145
+
146
+ // Wait for CLI to connect, then send the prompt
147
+ await this.waitForCLIConnection(sessionInfo.sessionId);
148
+
149
+ // Send the prompt with cron prefix for traceability
150
+ const fullPrompt = `[cron:${job.id} ${job.name}]\n\n${job.prompt}`;
151
+ this.wsBridge.injectUserMessage(sessionInfo.sessionId, fullPrompt);
152
+
153
+ // Update job tracking
154
+ cronStore.updateJob(jobId, {
155
+ lastRunAt: Date.now(),
156
+ lastSessionId: sessionInfo.sessionId,
157
+ totalRuns: job.totalRuns + 1,
158
+ consecutiveFailures: 0,
159
+ });
160
+
161
+ execution.success = true;
162
+ this.addExecution(jobId, execution);
163
+
164
+ } catch (err) {
165
+ console.error(`[cron-scheduler] Job "${job.name}" failed:`, err);
166
+ execution.error = err instanceof Error ? err.message : String(err);
167
+ execution.completedAt = Date.now();
168
+ this.addExecution(jobId, execution);
169
+
170
+ const failures = job.consecutiveFailures + 1;
171
+ const updates: Partial<CronJob> = {
172
+ consecutiveFailures: failures,
173
+ lastRunAt: Date.now(),
174
+ };
175
+
176
+ // Auto-disable after too many failures
177
+ if (failures >= MAX_CONSECUTIVE_FAILURES) {
178
+ updates.enabled = false;
179
+ this.stopJob(jobId);
180
+ console.warn(`[cron-scheduler] Job "${job.name}" disabled after ${failures} consecutive failures`);
181
+ }
182
+
183
+ cronStore.updateJob(jobId, updates);
184
+ }
185
+ }
186
+
187
+ /** Manual trigger (run now regardless of schedule, bypasses enabled check). */
188
+ executeJobManually(jobId: string): void {
189
+ this.executeJob(jobId, { force: true }).catch((err) => {
190
+ console.error(`[cron-scheduler] Manual execution of job "${jobId}" failed:`, err);
191
+ });
192
+ }
193
+
194
+ /** Wait for CLI to be connected (poll up to timeout). */
195
+ private async waitForCLIConnection(sessionId: string): Promise<void> {
196
+ const start = Date.now();
197
+
198
+ while (Date.now() - start < CLI_CONNECT_TIMEOUT_MS) {
199
+ const info = this.launcher.getSession(sessionId);
200
+ if (info && (info.state === "connected" || info.state === "running")) {
201
+ return;
202
+ }
203
+ if (info?.state === "exited") {
204
+ throw new Error(`CLI process exited before connecting (exit code: ${info.exitCode})`);
205
+ }
206
+ await new Promise((r) => setTimeout(r, CLI_CONNECT_POLL_MS));
207
+ }
208
+
209
+ throw new Error(`CLI process did not connect within ${CLI_CONNECT_TIMEOUT_MS / 1000}s`);
210
+ }
211
+
212
+ /** Get next run time for a job. */
213
+ getNextRunTime(jobId: string): Date | null {
214
+ const timer = this.timers.get(jobId);
215
+ if (!timer) return null;
216
+ return timer.nextRun() || null;
217
+ }
218
+
219
+ /** Get recent executions for a job. */
220
+ getExecutions(jobId: string): CronJobExecution[] {
221
+ return this.executions.get(jobId) || [];
222
+ }
223
+
224
+ private addExecution(jobId: string, execution: CronJobExecution): void {
225
+ if (!this.executions.has(jobId)) {
226
+ this.executions.set(jobId, []);
227
+ }
228
+ const list = this.executions.get(jobId)!;
229
+ list.push(execution);
230
+ if (list.length > CronScheduler.MAX_EXECUTIONS_PER_JOB) {
231
+ list.splice(0, list.length - CronScheduler.MAX_EXECUTIONS_PER_JOB);
232
+ }
233
+ }
234
+
235
+ /** Stop all timers (for graceful shutdown). */
236
+ destroy(): void {
237
+ for (const timer of this.timers.values()) {
238
+ timer.stop();
239
+ }
240
+ this.timers.clear();
241
+ this.executions.clear();
242
+ }
243
+ }
@@ -0,0 +1,422 @@
1
+ import { mkdtempSync, rmSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+
5
+ let tempDir: string;
6
+ let cronStore: typeof import("./cron-store.js");
7
+
8
+ const mockHomedir = vi.hoisted(() => {
9
+ let dir = "";
10
+ return {
11
+ get: () => dir,
12
+ set: (d: string) => {
13
+ dir = d;
14
+ },
15
+ };
16
+ });
17
+
18
+ vi.mock("node:os", async (importOriginal) => {
19
+ const actual = await importOriginal<typeof import("node:os")>();
20
+ return {
21
+ ...actual,
22
+ homedir: () => mockHomedir.get(),
23
+ };
24
+ });
25
+
26
+ beforeEach(async () => {
27
+ tempDir = mkdtempSync(join(tmpdir(), "cron-test-"));
28
+ mockHomedir.set(tempDir);
29
+ vi.resetModules();
30
+ cronStore = await import("./cron-store.js");
31
+ });
32
+
33
+ afterEach(() => {
34
+ rmSync(tempDir, { recursive: true, force: true });
35
+ });
36
+
37
+ function cronDir(): string {
38
+ return join(tempDir, ".companion", "cron");
39
+ }
40
+
41
+ function makeJobInput(overrides: Record<string, unknown> = {}) {
42
+ return {
43
+ name: "Test Job",
44
+ prompt: "Do something useful",
45
+ schedule: "0 8 * * *",
46
+ recurring: true,
47
+ backendType: "claude" as const,
48
+ model: "claude-sonnet-4-6",
49
+ cwd: "/tmp/test-repo",
50
+ enabled: true,
51
+ permissionMode: "bypassPermissions",
52
+ ...overrides,
53
+ };
54
+ }
55
+
56
+ // ===========================================================================
57
+ // Slugification (tested indirectly via createJob)
58
+ // ===========================================================================
59
+ describe("slugification via createJob", () => {
60
+ it("converts spaces to hyphens and lowercases", () => {
61
+ const job = cronStore.createJob(makeJobInput({ name: "My Daily Task" }));
62
+ expect(job.id).toBe("my-daily-task");
63
+ });
64
+
65
+ it("strips special characters", () => {
66
+ const job = cronStore.createJob(makeJobInput({ name: "Check PRs! @#$%" }));
67
+ expect(job.id).toBe("check-prs");
68
+ });
69
+
70
+ it("collapses consecutive hyphens", () => {
71
+ const job = cronStore.createJob(makeJobInput({ name: "a --- b" }));
72
+ expect(job.id).toBe("a-b");
73
+ });
74
+
75
+ it("throws when name is empty string", () => {
76
+ expect(() => cronStore.createJob(makeJobInput({ name: "" }))).toThrow("Job name is required");
77
+ });
78
+
79
+ it("throws when name is only whitespace", () => {
80
+ expect(() => cronStore.createJob(makeJobInput({ name: " " }))).toThrow("Job name is required");
81
+ });
82
+
83
+ it("throws when name contains no alphanumeric characters", () => {
84
+ expect(() => cronStore.createJob(makeJobInput({ name: "@#$%^&" }))).toThrow(
85
+ "Job name must contain alphanumeric characters",
86
+ );
87
+ });
88
+ });
89
+
90
+ // ===========================================================================
91
+ // listJobs
92
+ // ===========================================================================
93
+ describe("listJobs", () => {
94
+ it("returns empty array when no jobs exist", () => {
95
+ expect(cronStore.listJobs()).toEqual([]);
96
+ });
97
+
98
+ it("returns jobs sorted alphabetically by name", () => {
99
+ cronStore.createJob(makeJobInput({ name: "Zebra Task" }));
100
+ cronStore.createJob(makeJobInput({ name: "Alpha Task" }));
101
+ cronStore.createJob(makeJobInput({ name: "Mango Task" }));
102
+
103
+ const result = cronStore.listJobs();
104
+ expect(result.map((j) => j.name)).toEqual(["Alpha Task", "Mango Task", "Zebra Task"]);
105
+ });
106
+
107
+ it("skips corrupt JSON files", () => {
108
+ cronStore.createJob(makeJobInput({ name: "Valid Job" }));
109
+ writeFileSync(join(cronDir(), "corrupt.json"), "NOT VALID JSON{{{", "utf-8");
110
+
111
+ const result = cronStore.listJobs();
112
+ expect(result).toHaveLength(1);
113
+ expect(result[0].name).toBe("Valid Job");
114
+ });
115
+ });
116
+
117
+ // ===========================================================================
118
+ // getJob
119
+ // ===========================================================================
120
+ describe("getJob", () => {
121
+ it("returns the job when it exists", () => {
122
+ cronStore.createJob(makeJobInput({ name: "My Job" }));
123
+
124
+ const result = cronStore.getJob("my-job");
125
+ expect(result).not.toBeNull();
126
+ expect(result!.name).toBe("My Job");
127
+ expect(result!.id).toBe("my-job");
128
+ expect(result!.prompt).toBe("Do something useful");
129
+ });
130
+
131
+ it("returns null when the job does not exist", () => {
132
+ expect(cronStore.getJob("nonexistent")).toBeNull();
133
+ });
134
+ });
135
+
136
+ // ===========================================================================
137
+ // createJob
138
+ // ===========================================================================
139
+ describe("createJob", () => {
140
+ it("returns a job with correct structure and timestamps", () => {
141
+ const before = Date.now();
142
+ const job = cronStore.createJob(makeJobInput());
143
+ const after = Date.now();
144
+
145
+ expect(job.name).toBe("Test Job");
146
+ expect(job.id).toBe("test-job");
147
+ expect(job.prompt).toBe("Do something useful");
148
+ expect(job.schedule).toBe("0 8 * * *");
149
+ expect(job.recurring).toBe(true);
150
+ expect(job.backendType).toBe("claude");
151
+ expect(job.permissionMode).toBe("bypassPermissions");
152
+ expect(job.consecutiveFailures).toBe(0);
153
+ expect(job.totalRuns).toBe(0);
154
+ expect(job.createdAt).toBeGreaterThanOrEqual(before);
155
+ expect(job.createdAt).toBeLessThanOrEqual(after);
156
+ expect(job.updatedAt).toBe(job.createdAt);
157
+ });
158
+
159
+ it("persists the job to disk as JSON", () => {
160
+ cronStore.createJob(makeJobInput({ name: "Disk Check" }));
161
+
162
+ const raw = readFileSync(join(cronDir(), "disk-check.json"), "utf-8");
163
+ const parsed = JSON.parse(raw);
164
+ expect(parsed.name).toBe("Disk Check");
165
+ expect(parsed.id).toBe("disk-check");
166
+ });
167
+
168
+ it("throws when creating a duplicate slug", () => {
169
+ cronStore.createJob(makeJobInput({ name: "My Task" }));
170
+ expect(() => cronStore.createJob(makeJobInput({ name: "My Task" }))).toThrow(
171
+ 'A job with a similar name already exists ("my-task")',
172
+ );
173
+ });
174
+
175
+ it("trims the name before saving", () => {
176
+ const job = cronStore.createJob(makeJobInput({ name: " Spaced Out " }));
177
+ expect(job.name).toBe("Spaced Out");
178
+ expect(job.id).toBe("spaced-out");
179
+ });
180
+
181
+ it("throws when prompt is empty", () => {
182
+ expect(() => cronStore.createJob(makeJobInput({ prompt: "" }))).toThrow("Job prompt is required");
183
+ });
184
+
185
+ it("throws when schedule is empty", () => {
186
+ expect(() => cronStore.createJob(makeJobInput({ schedule: "" }))).toThrow("Job schedule is required");
187
+ });
188
+
189
+ it("throws when cwd is empty", () => {
190
+ expect(() => cronStore.createJob(makeJobInput({ cwd: "" }))).toThrow("Job working directory is required");
191
+ });
192
+ });
193
+
194
+ // ===========================================================================
195
+ // updateJob
196
+ // ===========================================================================
197
+ describe("updateJob", () => {
198
+ it("updates fields and preserves createdAt", async () => {
199
+ const job = cronStore.createJob(makeJobInput({ name: "Original" }));
200
+ const originalCreatedAt = job.createdAt;
201
+
202
+ await new Promise((r) => setTimeout(r, 10));
203
+
204
+ const updated = cronStore.updateJob("original", {
205
+ prompt: "Updated prompt",
206
+ });
207
+
208
+ expect(updated).not.toBeNull();
209
+ expect(updated!.prompt).toBe("Updated prompt");
210
+ expect(updated!.createdAt).toBe(originalCreatedAt);
211
+ expect(updated!.updatedAt).toBeGreaterThan(originalCreatedAt);
212
+ });
213
+
214
+ it("renames the file on disk when name/slug changes", () => {
215
+ cronStore.createJob(makeJobInput({ name: "Old Name" }));
216
+
217
+ cronStore.updateJob("old-name", { name: "New Name" });
218
+
219
+ // Old file should be gone, new file should exist
220
+ expect(() => readFileSync(join(cronDir(), "old-name.json"), "utf-8")).toThrow();
221
+ const parsed = JSON.parse(readFileSync(join(cronDir(), "new-name.json"), "utf-8"));
222
+ expect(parsed.name).toBe("New Name");
223
+ expect(parsed.id).toBe("new-name");
224
+ });
225
+
226
+ it("throws on slug collision during rename", () => {
227
+ cronStore.createJob(makeJobInput({ name: "Alpha" }));
228
+ cronStore.createJob(makeJobInput({ name: "Beta" }));
229
+
230
+ expect(() => cronStore.updateJob("alpha", { name: "Beta" })).toThrow(
231
+ 'A job with a similar name already exists ("beta")',
232
+ );
233
+ });
234
+
235
+ it("returns null for a non-existent id", () => {
236
+ expect(cronStore.updateJob("ghost", { name: "New" })).toBeNull();
237
+ });
238
+
239
+ it("updates tracking fields like consecutiveFailures", () => {
240
+ cronStore.createJob(makeJobInput({ name: "Tracked" }));
241
+
242
+ const updated = cronStore.updateJob("tracked", {
243
+ consecutiveFailures: 3,
244
+ totalRuns: 10,
245
+ lastRunAt: Date.now(),
246
+ lastSessionId: "session-123",
247
+ });
248
+
249
+ expect(updated!.consecutiveFailures).toBe(3);
250
+ expect(updated!.totalRuns).toBe(10);
251
+ expect(updated!.lastSessionId).toBe("session-123");
252
+ });
253
+ });
254
+
255
+ // ===========================================================================
256
+ // deleteJob
257
+ // ===========================================================================
258
+ describe("deleteJob", () => {
259
+ it("deletes an existing job and returns true", () => {
260
+ cronStore.createJob(makeJobInput({ name: "To Delete" }));
261
+ expect(cronStore.deleteJob("to-delete")).toBe(true);
262
+ expect(cronStore.getJob("to-delete")).toBeNull();
263
+ });
264
+
265
+ it("returns false when the job does not exist", () => {
266
+ expect(cronStore.deleteJob("missing")).toBe(false);
267
+ });
268
+
269
+ it("removes the file from disk", () => {
270
+ cronStore.createJob(makeJobInput({ name: "Disk Gone" }));
271
+ expect(() => readFileSync(join(cronDir(), "disk-gone.json"), "utf-8")).not.toThrow();
272
+
273
+ cronStore.deleteJob("disk-gone");
274
+ expect(() => readFileSync(join(cronDir(), "disk-gone.json"), "utf-8")).toThrow();
275
+ });
276
+
277
+ it("does not affect other jobs when deleting one", () => {
278
+ cronStore.createJob(makeJobInput({ name: "Keep Me" }));
279
+ cronStore.createJob(makeJobInput({ name: "Delete Me" }));
280
+
281
+ cronStore.deleteJob("delete-me");
282
+
283
+ expect(cronStore.getJob("keep-me")).not.toBeNull();
284
+ expect(cronStore.listJobs()).toHaveLength(1);
285
+ });
286
+ });
287
+
288
+ // ===========================================================================
289
+ // Edge cases & integration
290
+ // ===========================================================================
291
+ describe("edge cases", () => {
292
+ it("handles unicode in job names by stripping non-alphanumeric", () => {
293
+ // Unicode characters get stripped, leaving only alphanumeric + hyphens
294
+ const job = cronStore.createJob(makeJobInput({ name: "café résumé" }));
295
+ expect(job.id).toBe("caf-rsum");
296
+ });
297
+
298
+ it("handles very long names by preserving full slug", () => {
299
+ const longName = "a".repeat(200);
300
+ const job = cronStore.createJob(makeJobInput({ name: longName }));
301
+ expect(job.id).toBe(longName.toLowerCase());
302
+ });
303
+
304
+ it("preserves all CronJob fields through create → get round-trip", () => {
305
+ // Every field in the CronJob interface should survive serialization
306
+ const input = makeJobInput({
307
+ name: "Full Round Trip",
308
+ prompt: "Complex prompt\nwith newlines\nand special chars: @#$%",
309
+ schedule: "*/5 * * * *",
310
+ recurring: true,
311
+ backendType: "codex",
312
+ model: "gpt-5.3-codex",
313
+ cwd: "/home/user/project",
314
+ envSlug: "production",
315
+ enabled: false,
316
+ permissionMode: "plan",
317
+ codexInternetAccess: true,
318
+ });
319
+
320
+ const created = cronStore.createJob(input);
321
+ const retrieved = cronStore.getJob(created.id);
322
+
323
+ expect(retrieved).not.toBeNull();
324
+ expect(retrieved!.name).toBe("Full Round Trip");
325
+ expect(retrieved!.prompt).toBe(input.prompt);
326
+ expect(retrieved!.schedule).toBe("*/5 * * * *");
327
+ expect(retrieved!.recurring).toBe(true);
328
+ expect(retrieved!.backendType).toBe("codex");
329
+ expect(retrieved!.model).toBe("gpt-5.3-codex");
330
+ expect(retrieved!.cwd).toBe("/home/user/project");
331
+ expect(retrieved!.envSlug).toBe("production");
332
+ expect(retrieved!.enabled).toBe(false);
333
+ expect(retrieved!.permissionMode).toBe("plan");
334
+ expect(retrieved!.codexInternetAccess).toBe(true);
335
+ expect(retrieved!.consecutiveFailures).toBe(0);
336
+ expect(retrieved!.totalRuns).toBe(0);
337
+ });
338
+
339
+ it("preserves all fields through create → update → get round-trip", () => {
340
+ cronStore.createJob(makeJobInput({ name: "Update Trip" }));
341
+
342
+ cronStore.updateJob("update-trip", {
343
+ prompt: "New prompt",
344
+ schedule: "0 12 * * *",
345
+ recurring: false,
346
+ backendType: "codex",
347
+ model: "gpt-5.2",
348
+ cwd: "/new/path",
349
+ envSlug: "staging",
350
+ enabled: false,
351
+ permissionMode: "plan",
352
+ codexInternetAccess: true,
353
+ consecutiveFailures: 2,
354
+ totalRuns: 15,
355
+ lastRunAt: 1700000000000,
356
+ lastSessionId: "sess-abc",
357
+ });
358
+
359
+ const result = cronStore.getJob("update-trip");
360
+ expect(result!.prompt).toBe("New prompt");
361
+ expect(result!.schedule).toBe("0 12 * * *");
362
+ expect(result!.recurring).toBe(false);
363
+ expect(result!.backendType).toBe("codex");
364
+ expect(result!.model).toBe("gpt-5.2");
365
+ expect(result!.cwd).toBe("/new/path");
366
+ expect(result!.envSlug).toBe("staging");
367
+ expect(result!.enabled).toBe(false);
368
+ expect(result!.permissionMode).toBe("plan");
369
+ expect(result!.codexInternetAccess).toBe(true);
370
+ expect(result!.consecutiveFailures).toBe(2);
371
+ expect(result!.totalRuns).toBe(15);
372
+ expect(result!.lastRunAt).toBe(1700000000000);
373
+ expect(result!.lastSessionId).toBe("sess-abc");
374
+ });
375
+
376
+ it("can create multiple jobs and list them all", () => {
377
+ for (let i = 0; i < 10; i++) {
378
+ cronStore.createJob(makeJobInput({ name: `Job ${i}` }));
379
+ }
380
+ expect(cronStore.listJobs()).toHaveLength(10);
381
+ });
382
+
383
+ it("handles delete then re-create of same name", () => {
384
+ cronStore.createJob(makeJobInput({ name: "Recycled" }));
385
+ cronStore.deleteJob("recycled");
386
+ // Should not throw — slot is now free
387
+ const job = cronStore.createJob(makeJobInput({ name: "Recycled" }));
388
+ expect(job.id).toBe("recycled");
389
+ });
390
+
391
+ it("updateJob does not allow overriding createdAt", () => {
392
+ const job = cronStore.createJob(makeJobInput({ name: "Immutable Dates" }));
393
+ const originalCreatedAt = job.createdAt;
394
+
395
+ cronStore.updateJob("immutable-dates", { createdAt: 0 } as Partial<import("./cron-types.js").CronJob>);
396
+
397
+ const updated = cronStore.getJob("immutable-dates");
398
+ expect(updated!.createdAt).toBe(originalCreatedAt);
399
+ });
400
+
401
+ it("trims prompt and schedule whitespace on create", () => {
402
+ const job = cronStore.createJob(makeJobInput({
403
+ name: "Trim Test",
404
+ prompt: " spaced prompt ",
405
+ schedule: " 0 8 * * * ",
406
+ cwd: " /tmp/test ",
407
+ }));
408
+ expect(job.prompt).toBe("spaced prompt");
409
+ expect(job.schedule).toBe("0 8 * * *");
410
+ expect(job.cwd).toBe("/tmp/test");
411
+ });
412
+
413
+ it("skips non-JSON files in the cron directory", () => {
414
+ cronStore.createJob(makeJobInput({ name: "Valid" }));
415
+ writeFileSync(join(cronDir(), "readme.txt"), "not a job", "utf-8");
416
+ writeFileSync(join(cronDir(), "notes.md"), "# notes", "utf-8");
417
+
418
+ const jobs = cronStore.listJobs();
419
+ expect(jobs).toHaveLength(1);
420
+ expect(jobs[0].name).toBe("Valid");
421
+ });
422
+ });