@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,610 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import type { CronJob } from "./cron-types.js";
3
+ import type { AgentConfig } from "./agent-types.js";
4
+
5
+ // ─── Mocks ──────────────────────────────────────────────────────────────────
6
+
7
+ /**
8
+ * Mock for node:fs. We use vi.hoisted so the mock functions are created before
9
+ * vi.mock() is hoisted, allowing us to control return values per-test.
10
+ */
11
+ const fsMock = vi.hoisted(() => ({
12
+ existsSync: vi.fn<(path: string) => boolean>(),
13
+ readdirSync: vi.fn<(path: string) => string[]>(),
14
+ readFileSync: vi.fn<(path: string, encoding: string) => string>(),
15
+ writeFileSync: vi.fn<(path: string, data: string, encoding: string) => void>(),
16
+ }));
17
+
18
+ vi.mock("node:fs", () => ({
19
+ existsSync: fsMock.existsSync,
20
+ readdirSync: fsMock.readdirSync,
21
+ readFileSync: fsMock.readFileSync,
22
+ writeFileSync: fsMock.writeFileSync,
23
+ }));
24
+
25
+ /**
26
+ * Mock for agent-store.js. We stub listAgents and createAgent to avoid
27
+ * real filesystem operations and to verify the migrator calls them correctly.
28
+ */
29
+ const agentStoreMock = vi.hoisted(() => ({
30
+ listAgents: vi.fn<() => AgentConfig[]>(),
31
+ createAgent: vi.fn<(data: unknown) => AgentConfig>(),
32
+ }));
33
+
34
+ vi.mock("./agent-store.js", () => ({
35
+ listAgents: agentStoreMock.listAgents,
36
+ createAgent: agentStoreMock.createAgent,
37
+ }));
38
+
39
+ /**
40
+ * Mock for paths.js. We point COMPANION_HOME at a fake temp directory
41
+ * so that CRON_DIR and MIGRATION_FLAG paths are deterministic in tests.
42
+ */
43
+ vi.mock("./paths.js", () => ({
44
+ COMPANION_HOME: "/tmp/test-companion-home",
45
+ }));
46
+
47
+ // ─── Helpers ────────────────────────────────────────────────────────────────
48
+
49
+ const COMPANION_HOME = "/tmp/test-companion-home";
50
+ const CRON_DIR = `${COMPANION_HOME}/cron`;
51
+ const MIGRATION_FLAG = `${COMPANION_HOME}/.cron-migrated`;
52
+
53
+ /**
54
+ * Build a valid CronJob object with sensible defaults.
55
+ * Override any fields via the `overrides` parameter.
56
+ */
57
+ function makeCronJob(overrides: Partial<CronJob> = {}): CronJob {
58
+ return {
59
+ id: "daily-check",
60
+ name: "Daily Check",
61
+ prompt: "Run the daily checks",
62
+ schedule: "0 8 * * *",
63
+ recurring: true,
64
+ backendType: "claude",
65
+ model: "claude-sonnet-4-6",
66
+ cwd: "/home/user/project",
67
+ enabled: true,
68
+ permissionMode: "bypassPermissions",
69
+ createdAt: Date.now(),
70
+ updatedAt: Date.now(),
71
+ consecutiveFailures: 0,
72
+ totalRuns: 5,
73
+ ...overrides,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Build a minimal AgentConfig used as a return value from listAgents.
79
+ */
80
+ function makeAgentConfig(overrides: Partial<AgentConfig> = {}): AgentConfig {
81
+ return {
82
+ id: "daily-check",
83
+ version: 1,
84
+ name: "Daily Check",
85
+ description: "",
86
+ backendType: "claude",
87
+ model: "claude-sonnet-4-6",
88
+ permissionMode: "bypassPermissions",
89
+ cwd: "/home/user/project",
90
+ prompt: "Run the daily checks",
91
+ enabled: true,
92
+ createdAt: Date.now(),
93
+ updatedAt: Date.now(),
94
+ totalRuns: 0,
95
+ consecutiveFailures: 0,
96
+ ...overrides,
97
+ };
98
+ }
99
+
100
+ // ─── Test Suite ─────────────────────────────────────────────────────────────
101
+
102
+ let migrateCronJobsToAgents: typeof import("./agent-cron-migrator.js").migrateCronJobsToAgents;
103
+
104
+ beforeEach(async () => {
105
+ vi.clearAllMocks();
106
+
107
+ // Reset module cache so each test gets a fresh import
108
+ vi.resetModules();
109
+ const mod = await import("./agent-cron-migrator.js");
110
+ migrateCronJobsToAgents = mod.migrateCronJobsToAgents;
111
+ });
112
+
113
+ afterEach(() => {
114
+ vi.restoreAllMocks();
115
+ });
116
+
117
+ // ===========================================================================
118
+ // Early return: migration flag already exists
119
+ // ===========================================================================
120
+ describe("when migration flag already exists", () => {
121
+ it("returns early with {migrated: 0, skipped: 0} without reading cron directory", () => {
122
+ // If the .cron-migrated flag file exists, the function should bail out
123
+ // immediately without touching the filesystem further or calling agent-store.
124
+ fsMock.existsSync.mockImplementation((path: string) => {
125
+ if (path === MIGRATION_FLAG) return true;
126
+ return false;
127
+ });
128
+
129
+ const result = migrateCronJobsToAgents();
130
+
131
+ expect(result).toEqual({ migrated: 0, skipped: 0 });
132
+ // Should NOT read the cron directory or call agent-store at all
133
+ expect(fsMock.readdirSync).not.toHaveBeenCalled();
134
+ expect(agentStoreMock.listAgents).not.toHaveBeenCalled();
135
+ expect(agentStoreMock.createAgent).not.toHaveBeenCalled();
136
+ // Should NOT write the migration flag (it already exists)
137
+ expect(fsMock.writeFileSync).not.toHaveBeenCalled();
138
+ });
139
+ });
140
+
141
+ // ===========================================================================
142
+ // No cron directory exists
143
+ // ===========================================================================
144
+ describe("when cron directory does not exist", () => {
145
+ it("creates the migration flag and returns {migrated: 0, skipped: 0}", () => {
146
+ // When there is no .cron-migrated flag AND no cron/ directory,
147
+ // the function should write the flag (nothing to migrate) and return zeros.
148
+ fsMock.existsSync.mockImplementation((path: string) => {
149
+ if (path === MIGRATION_FLAG) return false;
150
+ if (path === CRON_DIR) return false;
151
+ return false;
152
+ });
153
+
154
+ const result = migrateCronJobsToAgents();
155
+
156
+ expect(result).toEqual({ migrated: 0, skipped: 0 });
157
+ // Should write the migration flag to prevent future runs
158
+ expect(fsMock.writeFileSync).toHaveBeenCalledOnce();
159
+ expect(fsMock.writeFileSync).toHaveBeenCalledWith(
160
+ MIGRATION_FLAG,
161
+ expect.any(String),
162
+ "utf-8",
163
+ );
164
+ // Should NOT attempt to read cron files or touch agent-store
165
+ expect(fsMock.readdirSync).not.toHaveBeenCalled();
166
+ expect(agentStoreMock.listAgents).not.toHaveBeenCalled();
167
+ expect(agentStoreMock.createAgent).not.toHaveBeenCalled();
168
+ });
169
+ });
170
+
171
+ // ===========================================================================
172
+ // Successful migration of cron jobs
173
+ // ===========================================================================
174
+ describe("migrating cron job files to agents", () => {
175
+ it("creates an agent for each valid cron job file and returns correct counts", () => {
176
+ // Two valid cron job JSON files in the cron/ directory should produce
177
+ // two createAgent calls and the returned migrated count should be 2.
178
+ const job1 = makeCronJob({ id: "job-one", name: "Job One" });
179
+ const job2 = makeCronJob({
180
+ id: "job-two",
181
+ name: "Job Two",
182
+ schedule: "*/30 * * * *",
183
+ recurring: true,
184
+ backendType: "codex",
185
+ model: "gpt-5.3-codex",
186
+ envSlug: "production",
187
+ codexInternetAccess: true,
188
+ });
189
+
190
+ fsMock.existsSync.mockImplementation((path: string) => {
191
+ if (path === MIGRATION_FLAG) return false;
192
+ if (path === CRON_DIR) return true;
193
+ return false;
194
+ });
195
+ fsMock.readdirSync.mockReturnValue(["job-one.json", "job-two.json"]);
196
+ fsMock.readFileSync.mockImplementation((path: string) => {
197
+ if (path === `${CRON_DIR}/job-one.json`) return JSON.stringify(job1);
198
+ if (path === `${CRON_DIR}/job-two.json`) return JSON.stringify(job2);
199
+ throw new Error(`Unexpected readFileSync call: ${path}`);
200
+ });
201
+
202
+ // No existing agents, so nothing is skipped
203
+ agentStoreMock.listAgents.mockReturnValue([]);
204
+ agentStoreMock.createAgent.mockReturnValue(makeAgentConfig());
205
+
206
+ const result = migrateCronJobsToAgents();
207
+
208
+ expect(result).toEqual({ migrated: 2, skipped: 0 });
209
+ expect(agentStoreMock.createAgent).toHaveBeenCalledTimes(2);
210
+
211
+ // Verify the first call includes the correct agent configuration
212
+ const firstCallArg = agentStoreMock.createAgent.mock.calls[0][0];
213
+ expect(firstCallArg).toMatchObject({
214
+ version: 1,
215
+ name: "Job One",
216
+ description: "Migrated from scheduled job: Job One",
217
+ icon: "\u23F0",
218
+ backendType: "claude",
219
+ model: "claude-sonnet-4-6",
220
+ permissionMode: "bypassPermissions",
221
+ prompt: "Run the daily checks",
222
+ enabled: true,
223
+ triggers: {
224
+ schedule: {
225
+ enabled: true,
226
+ expression: "0 8 * * *",
227
+ recurring: true,
228
+ },
229
+ },
230
+ });
231
+
232
+ // Verify the second call maps codex-specific fields correctly
233
+ const secondCallArg = agentStoreMock.createAgent.mock.calls[1][0];
234
+ expect(secondCallArg).toMatchObject({
235
+ name: "Job Two",
236
+ backendType: "codex",
237
+ model: "gpt-5.3-codex",
238
+ envSlug: "production",
239
+ codexInternetAccess: true,
240
+ triggers: {
241
+ schedule: {
242
+ enabled: true,
243
+ expression: "*/30 * * * *",
244
+ recurring: true,
245
+ },
246
+ },
247
+ });
248
+
249
+ // Should write the migration flag after completion
250
+ expect(fsMock.writeFileSync).toHaveBeenCalledWith(
251
+ MIGRATION_FLAG,
252
+ expect.any(String),
253
+ "utf-8",
254
+ );
255
+ });
256
+
257
+ it("only processes .json files, ignoring other file types in cron directory", () => {
258
+ // Non-JSON files (e.g. .bak, .md) in the cron directory should be skipped
259
+ // by the .endsWith(".json") filter before any parsing occurs.
260
+ const job = makeCronJob({ id: "only-json", name: "Only JSON" });
261
+
262
+ fsMock.existsSync.mockImplementation((path: string) => {
263
+ if (path === MIGRATION_FLAG) return false;
264
+ if (path === CRON_DIR) return true;
265
+ return false;
266
+ });
267
+ fsMock.readdirSync.mockReturnValue([
268
+ "valid-job.json",
269
+ "backup.bak",
270
+ "notes.md",
271
+ ".hidden",
272
+ ]);
273
+ fsMock.readFileSync.mockImplementation((path: string) => {
274
+ if (path === `${CRON_DIR}/valid-job.json`) return JSON.stringify(job);
275
+ throw new Error(`Unexpected readFileSync call: ${path}`);
276
+ });
277
+ agentStoreMock.listAgents.mockReturnValue([]);
278
+ agentStoreMock.createAgent.mockReturnValue(makeAgentConfig());
279
+
280
+ const result = migrateCronJobsToAgents();
281
+
282
+ // Only the one .json file should be processed
283
+ expect(result).toEqual({ migrated: 1, skipped: 0 });
284
+ expect(fsMock.readFileSync).toHaveBeenCalledOnce();
285
+ expect(agentStoreMock.createAgent).toHaveBeenCalledOnce();
286
+ });
287
+
288
+ it("passes cwd from the cron job to the created agent", () => {
289
+ // The agent's cwd should match the cron job's cwd exactly.
290
+ const job = makeCronJob({ name: "CWD Test", cwd: "/custom/working/dir" });
291
+
292
+ fsMock.existsSync.mockImplementation((path: string) => {
293
+ if (path === MIGRATION_FLAG) return false;
294
+ if (path === CRON_DIR) return true;
295
+ return false;
296
+ });
297
+ fsMock.readdirSync.mockReturnValue(["cwd-test.json"]);
298
+ fsMock.readFileSync.mockReturnValue(JSON.stringify(job));
299
+ agentStoreMock.listAgents.mockReturnValue([]);
300
+ agentStoreMock.createAgent.mockReturnValue(makeAgentConfig());
301
+
302
+ migrateCronJobsToAgents();
303
+
304
+ expect(agentStoreMock.createAgent).toHaveBeenCalledWith(
305
+ expect.objectContaining({ cwd: "/custom/working/dir" }),
306
+ );
307
+ });
308
+
309
+ it("maps disabled cron job to disabled agent with disabled schedule trigger", () => {
310
+ // A cron job with enabled=false should produce an agent with enabled=false
311
+ // and triggers.schedule.enabled=false.
312
+ const disabledJob = makeCronJob({ name: "Disabled Job", enabled: false });
313
+
314
+ fsMock.existsSync.mockImplementation((path: string) => {
315
+ if (path === MIGRATION_FLAG) return false;
316
+ if (path === CRON_DIR) return true;
317
+ return false;
318
+ });
319
+ fsMock.readdirSync.mockReturnValue(["disabled.json"]);
320
+ fsMock.readFileSync.mockReturnValue(JSON.stringify(disabledJob));
321
+ agentStoreMock.listAgents.mockReturnValue([]);
322
+ agentStoreMock.createAgent.mockReturnValue(makeAgentConfig());
323
+
324
+ migrateCronJobsToAgents();
325
+
326
+ expect(agentStoreMock.createAgent).toHaveBeenCalledWith(
327
+ expect.objectContaining({
328
+ enabled: false,
329
+ triggers: {
330
+ schedule: {
331
+ enabled: false,
332
+ expression: "0 8 * * *",
333
+ recurring: true,
334
+ },
335
+ },
336
+ }),
337
+ );
338
+ });
339
+ });
340
+
341
+ // ===========================================================================
342
+ // Skipping: agent with same name already exists
343
+ // ===========================================================================
344
+ describe("when an agent with the same name already exists", () => {
345
+ it("skips the cron job and increments the skipped count", () => {
346
+ // If listAgents returns an agent whose name matches (case-insensitive)
347
+ // the cron job name, that job should be skipped without calling createAgent.
348
+ const job = makeCronJob({ name: "Existing Agent" });
349
+ const existingAgent = makeAgentConfig({ name: "Existing Agent" });
350
+
351
+ fsMock.existsSync.mockImplementation((path: string) => {
352
+ if (path === MIGRATION_FLAG) return false;
353
+ if (path === CRON_DIR) return true;
354
+ return false;
355
+ });
356
+ fsMock.readdirSync.mockReturnValue(["existing.json"]);
357
+ fsMock.readFileSync.mockReturnValue(JSON.stringify(job));
358
+ agentStoreMock.listAgents.mockReturnValue([existingAgent]);
359
+
360
+ const result = migrateCronJobsToAgents();
361
+
362
+ expect(result).toEqual({ migrated: 0, skipped: 1 });
363
+ expect(agentStoreMock.createAgent).not.toHaveBeenCalled();
364
+ });
365
+
366
+ it("performs case-insensitive name comparison when checking for duplicates", () => {
367
+ // The duplicate check uses .toLowerCase() on both sides, so
368
+ // "DAILY CHECK" should match an existing agent named "daily check".
369
+ const job = makeCronJob({ name: "DAILY CHECK" });
370
+ const existingAgent = makeAgentConfig({ name: "daily check" });
371
+
372
+ fsMock.existsSync.mockImplementation((path: string) => {
373
+ if (path === MIGRATION_FLAG) return false;
374
+ if (path === CRON_DIR) return true;
375
+ return false;
376
+ });
377
+ fsMock.readdirSync.mockReturnValue(["daily-check.json"]);
378
+ fsMock.readFileSync.mockReturnValue(JSON.stringify(job));
379
+ agentStoreMock.listAgents.mockReturnValue([existingAgent]);
380
+
381
+ const result = migrateCronJobsToAgents();
382
+
383
+ expect(result).toEqual({ migrated: 0, skipped: 1 });
384
+ expect(agentStoreMock.createAgent).not.toHaveBeenCalled();
385
+ });
386
+
387
+ it("migrates jobs without duplicates while skipping the ones that exist", () => {
388
+ // Mixed scenario: two cron jobs, one with a matching agent name and one without.
389
+ // Only the non-duplicate should be migrated.
390
+ const jobNew = makeCronJob({ name: "New Job" });
391
+ const jobExisting = makeCronJob({ name: "Already There" });
392
+ const existingAgent = makeAgentConfig({ name: "Already There" });
393
+
394
+ fsMock.existsSync.mockImplementation((path: string) => {
395
+ if (path === MIGRATION_FLAG) return false;
396
+ if (path === CRON_DIR) return true;
397
+ return false;
398
+ });
399
+ fsMock.readdirSync.mockReturnValue(["new-job.json", "already-there.json"]);
400
+ fsMock.readFileSync.mockImplementation((path: string) => {
401
+ if (path === `${CRON_DIR}/new-job.json`) return JSON.stringify(jobNew);
402
+ if (path === `${CRON_DIR}/already-there.json`) return JSON.stringify(jobExisting);
403
+ throw new Error(`Unexpected readFileSync call: ${path}`);
404
+ });
405
+ agentStoreMock.listAgents.mockReturnValue([existingAgent]);
406
+ agentStoreMock.createAgent.mockReturnValue(makeAgentConfig({ name: "New Job" }));
407
+
408
+ const result = migrateCronJobsToAgents();
409
+
410
+ expect(result).toEqual({ migrated: 1, skipped: 1 });
411
+ expect(agentStoreMock.createAgent).toHaveBeenCalledOnce();
412
+ expect(agentStoreMock.createAgent).toHaveBeenCalledWith(
413
+ expect.objectContaining({ name: "New Job" }),
414
+ );
415
+ });
416
+ });
417
+
418
+ // ===========================================================================
419
+ // Handling corrupt JSON files
420
+ // ===========================================================================
421
+ describe("when cron job files contain corrupt JSON", () => {
422
+ it("skips corrupt files gracefully and increments the skipped count", () => {
423
+ // Invalid JSON should be caught by the try/catch, logged, and counted
424
+ // as skipped rather than crashing the entire migration.
425
+ fsMock.existsSync.mockImplementation((path: string) => {
426
+ if (path === MIGRATION_FLAG) return false;
427
+ if (path === CRON_DIR) return true;
428
+ return false;
429
+ });
430
+ fsMock.readdirSync.mockReturnValue(["corrupt.json"]);
431
+ fsMock.readFileSync.mockReturnValue("NOT VALID JSON{{{");
432
+
433
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
434
+
435
+ const result = migrateCronJobsToAgents();
436
+
437
+ expect(result).toEqual({ migrated: 0, skipped: 1 });
438
+ expect(agentStoreMock.createAgent).not.toHaveBeenCalled();
439
+ // Should log the error for debugging
440
+ expect(consoleSpy).toHaveBeenCalledWith(
441
+ expect.stringContaining("[cron-migrator] Failed to migrate corrupt.json:"),
442
+ expect.anything(),
443
+ );
444
+
445
+ consoleSpy.mockRestore();
446
+ });
447
+
448
+ it("continues migrating valid files after encountering a corrupt file", () => {
449
+ // A corrupt file should not prevent subsequent valid files from being processed.
450
+ const validJob = makeCronJob({ name: "Valid Job" });
451
+
452
+ fsMock.existsSync.mockImplementation((path: string) => {
453
+ if (path === MIGRATION_FLAG) return false;
454
+ if (path === CRON_DIR) return true;
455
+ return false;
456
+ });
457
+ fsMock.readdirSync.mockReturnValue(["corrupt.json", "valid.json"]);
458
+ fsMock.readFileSync.mockImplementation((path: string) => {
459
+ if (path === `${CRON_DIR}/corrupt.json`) return "{broken json!!";
460
+ if (path === `${CRON_DIR}/valid.json`) return JSON.stringify(validJob);
461
+ throw new Error(`Unexpected readFileSync call: ${path}`);
462
+ });
463
+ agentStoreMock.listAgents.mockReturnValue([]);
464
+ agentStoreMock.createAgent.mockReturnValue(makeAgentConfig({ name: "Valid Job" }));
465
+
466
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
467
+
468
+ const result = migrateCronJobsToAgents();
469
+
470
+ expect(result).toEqual({ migrated: 1, skipped: 1 });
471
+ expect(agentStoreMock.createAgent).toHaveBeenCalledOnce();
472
+ expect(agentStoreMock.createAgent).toHaveBeenCalledWith(
473
+ expect.objectContaining({ name: "Valid Job" }),
474
+ );
475
+
476
+ consoleSpy.mockRestore();
477
+ });
478
+
479
+ it("handles createAgent throwing an error by counting it as skipped", () => {
480
+ // If agent-store.createAgent throws (e.g. slug collision, missing field),
481
+ // the migrator should catch it and count the job as skipped.
482
+ const job = makeCronJob({ name: "Failing Agent" });
483
+
484
+ fsMock.existsSync.mockImplementation((path: string) => {
485
+ if (path === MIGRATION_FLAG) return false;
486
+ if (path === CRON_DIR) return true;
487
+ return false;
488
+ });
489
+ fsMock.readdirSync.mockReturnValue(["failing.json"]);
490
+ fsMock.readFileSync.mockReturnValue(JSON.stringify(job));
491
+ agentStoreMock.listAgents.mockReturnValue([]);
492
+ agentStoreMock.createAgent.mockImplementation(() => {
493
+ throw new Error("Agent creation failed for testing");
494
+ });
495
+
496
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
497
+
498
+ const result = migrateCronJobsToAgents();
499
+
500
+ expect(result).toEqual({ migrated: 0, skipped: 1 });
501
+ expect(consoleSpy).toHaveBeenCalledWith(
502
+ expect.stringContaining("[cron-migrator] Failed to migrate failing.json:"),
503
+ expect.anything(),
504
+ );
505
+
506
+ consoleSpy.mockRestore();
507
+ });
508
+ });
509
+
510
+ // ===========================================================================
511
+ // Migration flag written after processing
512
+ // ===========================================================================
513
+ describe("migration flag file", () => {
514
+ it("writes the migration flag after successfully processing all files", () => {
515
+ // After processing (even if some files are skipped), the migration flag
516
+ // should be written to prevent re-running on next startup.
517
+ const job = makeCronJob({ name: "Flagged Job" });
518
+
519
+ fsMock.existsSync.mockImplementation((path: string) => {
520
+ if (path === MIGRATION_FLAG) return false;
521
+ if (path === CRON_DIR) return true;
522
+ return false;
523
+ });
524
+ fsMock.readdirSync.mockReturnValue(["flagged.json"]);
525
+ fsMock.readFileSync.mockReturnValue(JSON.stringify(job));
526
+ agentStoreMock.listAgents.mockReturnValue([]);
527
+ agentStoreMock.createAgent.mockReturnValue(makeAgentConfig());
528
+
529
+ migrateCronJobsToAgents();
530
+
531
+ // The last writeFileSync call should be the migration flag
532
+ const writeFileCalls = fsMock.writeFileSync.mock.calls;
533
+ const flagCall = writeFileCalls.find(
534
+ (call) => call[0] === MIGRATION_FLAG,
535
+ );
536
+ expect(flagCall).toBeDefined();
537
+ // The flag content should be an ISO date string
538
+ expect(flagCall![1]).toMatch(/^\d{4}-\d{2}-\d{2}T/);
539
+ });
540
+
541
+ it("writes the flag even when all jobs are skipped", () => {
542
+ // The migration flag should be written regardless of whether any jobs
543
+ // were actually migrated — skipping all jobs still counts as "done".
544
+ const job = makeCronJob({ name: "Skip Me" });
545
+ const existing = makeAgentConfig({ name: "Skip Me" });
546
+
547
+ fsMock.existsSync.mockImplementation((path: string) => {
548
+ if (path === MIGRATION_FLAG) return false;
549
+ if (path === CRON_DIR) return true;
550
+ return false;
551
+ });
552
+ fsMock.readdirSync.mockReturnValue(["skip-me.json"]);
553
+ fsMock.readFileSync.mockReturnValue(JSON.stringify(job));
554
+ agentStoreMock.listAgents.mockReturnValue([existing]);
555
+
556
+ migrateCronJobsToAgents();
557
+
558
+ expect(fsMock.writeFileSync).toHaveBeenCalledWith(
559
+ MIGRATION_FLAG,
560
+ expect.any(String),
561
+ "utf-8",
562
+ );
563
+ });
564
+ });
565
+
566
+ // ===========================================================================
567
+ // Empty cron directory
568
+ // ===========================================================================
569
+ describe("when cron directory exists but is empty", () => {
570
+ it("writes the migration flag and returns {migrated: 0, skipped: 0}", () => {
571
+ // An empty cron/ directory (no .json files) should produce zero counts
572
+ // but still mark migration as complete.
573
+ fsMock.existsSync.mockImplementation((path: string) => {
574
+ if (path === MIGRATION_FLAG) return false;
575
+ if (path === CRON_DIR) return true;
576
+ return false;
577
+ });
578
+ fsMock.readdirSync.mockReturnValue([]);
579
+
580
+ const result = migrateCronJobsToAgents();
581
+
582
+ expect(result).toEqual({ migrated: 0, skipped: 0 });
583
+ expect(agentStoreMock.createAgent).not.toHaveBeenCalled();
584
+ expect(fsMock.writeFileSync).toHaveBeenCalledWith(
585
+ MIGRATION_FLAG,
586
+ expect.any(String),
587
+ "utf-8",
588
+ );
589
+ });
590
+
591
+ it("writes the migration flag when directory has only non-JSON files", () => {
592
+ // Files that don't end with .json are filtered out, producing an empty
593
+ // list effectively identical to an empty directory.
594
+ fsMock.existsSync.mockImplementation((path: string) => {
595
+ if (path === MIGRATION_FLAG) return false;
596
+ if (path === CRON_DIR) return true;
597
+ return false;
598
+ });
599
+ fsMock.readdirSync.mockReturnValue(["readme.txt", ".gitkeep", "backup.bak"]);
600
+
601
+ const result = migrateCronJobsToAgents();
602
+
603
+ expect(result).toEqual({ migrated: 0, skipped: 0 });
604
+ expect(fsMock.writeFileSync).toHaveBeenCalledWith(
605
+ MIGRATION_FLAG,
606
+ expect.any(String),
607
+ "utf-8",
608
+ );
609
+ });
610
+ });
@@ -0,0 +1,85 @@
1
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import * as agentStore from "./agent-store.js";
4
+ import type { CronJob } from "./cron-types.js";
5
+ import { COMPANION_HOME } from "./paths.js";
6
+
7
+ const CRON_DIR = join(COMPANION_HOME, "cron");
8
+ const MIGRATION_FLAG = join(COMPANION_HOME, ".cron-migrated");
9
+
10
+ /**
11
+ * One-time migration: convert existing cron jobs into agents with schedule triggers.
12
+ * Safe to call multiple times — only runs once (uses a flag file).
13
+ */
14
+ export function migrateCronJobsToAgents(): { migrated: number; skipped: number } {
15
+ // Skip if already migrated
16
+ if (existsSync(MIGRATION_FLAG)) {
17
+ return { migrated: 0, skipped: 0 };
18
+ }
19
+
20
+ // Skip if no cron directory
21
+ if (!existsSync(CRON_DIR)) {
22
+ // Mark as migrated (nothing to migrate)
23
+ writeFileSync(MIGRATION_FLAG, new Date().toISOString(), "utf-8");
24
+ return { migrated: 0, skipped: 0 };
25
+ }
26
+
27
+ const files = readdirSync(CRON_DIR).filter((f) => f.endsWith(".json"));
28
+ let migrated = 0;
29
+ let skipped = 0;
30
+
31
+ for (const file of files) {
32
+ try {
33
+ const raw = readFileSync(join(CRON_DIR, file), "utf-8");
34
+ const job: CronJob = JSON.parse(raw);
35
+
36
+ // Check if an agent with this name already exists
37
+ const existingAgents = agentStore.listAgents();
38
+ const alreadyExists = existingAgents.some(
39
+ (a) => a.name.toLowerCase() === job.name.toLowerCase(),
40
+ );
41
+ if (alreadyExists) {
42
+ console.log(`[cron-migrator] Skipping "${job.name}" — agent with same name already exists`);
43
+ skipped++;
44
+ continue;
45
+ }
46
+
47
+ agentStore.createAgent({
48
+ version: 1,
49
+ name: job.name,
50
+ description: `Migrated from scheduled job: ${job.name}`,
51
+ icon: "⏰",
52
+ backendType: job.backendType,
53
+ model: job.model,
54
+ permissionMode: job.permissionMode,
55
+ cwd: job.cwd,
56
+ envSlug: job.envSlug,
57
+ codexInternetAccess: job.codexInternetAccess,
58
+ prompt: job.prompt,
59
+ triggers: {
60
+ schedule: {
61
+ enabled: job.enabled,
62
+ expression: job.schedule,
63
+ recurring: job.recurring,
64
+ },
65
+ },
66
+ enabled: job.enabled,
67
+ });
68
+
69
+ migrated++;
70
+ console.log(`[cron-migrator] Migrated cron job "${job.name}" to agent`);
71
+ } catch (err) {
72
+ console.error(`[cron-migrator] Failed to migrate ${file}:`, err);
73
+ skipped++;
74
+ }
75
+ }
76
+
77
+ // Mark migration as complete
78
+ writeFileSync(MIGRATION_FLAG, new Date().toISOString(), "utf-8");
79
+
80
+ if (migrated > 0 || skipped > 0) {
81
+ console.log(`[cron-migrator] Migration complete: ${migrated} migrated, ${skipped} skipped`);
82
+ }
83
+
84
+ return { migrated, skipped };
85
+ }