@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.
- package/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
- package/dist/assets/CronManager-EGwLJONv.js +1 -0
- package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
- package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
- package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
- package/dist/assets/Playground-BV3k0RbV.js +109 -0
- package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
- package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
- package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
- package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
- package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
- package/dist/assets/index-BhUa1e6X.css +1 -0
- package/dist/assets/index-DkqeP-R9.js +134 -0
- package/dist/assets/sw-register-BibwRdvC.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +20 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/sw.js +2 -0
- package/package.json +104 -0
- package/server/agent-cron-migrator.test.ts +610 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.test.ts +1108 -0
- package/server/agent-executor.ts +346 -0
- package/server/agent-store.test.ts +588 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-types.ts +138 -0
- package/server/ai-validation-settings.test.ts +128 -0
- package/server/ai-validation-settings.ts +35 -0
- package/server/ai-validator.test.ts +387 -0
- package/server/ai-validator.ts +271 -0
- package/server/auth-manager.test.ts +83 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-namer.test.ts +252 -0
- package/server/auto-namer.ts +78 -0
- package/server/backend-adapter.test.ts +38 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.test.ts +98 -0
- package/server/cache-headers.ts +61 -0
- package/server/claude-adapter.test.ts +1363 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.test.ts +44 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-protocol-contract.test.ts +71 -0
- package/server/claude-protocol-drift.test.ts +78 -0
- package/server/claude-session-discovery.test.ts +132 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.test.ts +158 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.test.ts +1343 -0
- package/server/cli-launcher.ts +1298 -0
- package/server/cli.test.ts +16 -0
- package/server/codex-adapter.test.ts +5545 -0
- package/server/codex-adapter.ts +3062 -0
- package/server/codex-container-auth.test.ts +50 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.test.ts +61 -0
- package/server/codex-home.ts +26 -0
- package/server/codex-protocol-contract.test.ts +96 -0
- package/server/codex-protocol-drift.test.ts +123 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.test.ts +179 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.test.ts +1211 -0
- package/server/container-manager.ts +1053 -0
- package/server/cron-scheduler.test.ts +957 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.test.ts +422 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/env-manager.test.ts +268 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +64 -0
- package/server/event-bus.test.ts +244 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.test.ts +307 -0
- package/server/execution-store.ts +170 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.test.ts +938 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.test.ts +498 -0
- package/server/github-pr.ts +379 -0
- package/server/image-pull-manager.test.ts +303 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +396 -0
- package/server/linear-agent-bridge.test.ts +1157 -0
- package/server/linear-agent-bridge.ts +629 -0
- package/server/linear-agent.test.ts +473 -0
- package/server/linear-agent.ts +479 -0
- package/server/linear-cache.test.ts +136 -0
- package/server/linear-cache.ts +113 -0
- package/server/linear-connections.test.ts +350 -0
- package/server/linear-connections.ts +231 -0
- package/server/linear-credential-migration.test.ts +337 -0
- package/server/linear-credential-migration.ts +63 -0
- package/server/linear-oauth-connections-migration.test.ts +268 -0
- package/server/linear-oauth-connections.test.ts +365 -0
- package/server/linear-oauth-connections.ts +294 -0
- package/server/linear-project-manager.test.ts +162 -0
- package/server/linear-project-manager.ts +111 -0
- package/server/linear-prompt-builder.test.ts +74 -0
- package/server/linear-prompt-builder.ts +61 -0
- package/server/linear-staging.test.ts +276 -0
- package/server/linear-staging.ts +142 -0
- package/server/logger.test.ts +393 -0
- package/server/logger.ts +259 -0
- package/server/metrics-collector.test.ts +413 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.test.ts +264 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.test.ts +333 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.test.ts +552 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.test.ts +31 -0
- package/server/paths.ts +11 -0
- package/server/pr-poller.test.ts +191 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.test.ts +211 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/recorder.test.ts +454 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.test.ts +150 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.test.ts +140 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.test.ts +44 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.test.ts +417 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.test.ts +262 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.test.ts +294 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.test.ts +337 -0
- package/server/relay-client.ts +320 -0
- package/server/replay.test.ts +200 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.test.ts +1400 -0
- package/server/routes/agent-routes.ts +409 -0
- package/server/routes/cron-routes.test.ts +881 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.test.ts +383 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/fs-routes.test.ts +1198 -0
- package/server/routes/fs-routes.ts +605 -0
- package/server/routes/git-routes.test.ts +813 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/linear-agent-routes.test.ts +721 -0
- package/server/routes/linear-agent-routes.ts +304 -0
- package/server/routes/linear-connection-routes.test.ts +927 -0
- package/server/routes/linear-connection-routes.ts +244 -0
- package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
- package/server/routes/linear-oauth-connection-routes.ts +129 -0
- package/server/routes/linear-routes.test.ts +1510 -0
- package/server/routes/linear-routes.ts +953 -0
- package/server/routes/metrics-routes.test.ts +103 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/sandbox-routes.test.ts +513 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +270 -0
- package/server/routes/skills-routes.test.ts +690 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/system-routes.test.ts +637 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.test.ts +176 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes.test.ts +4655 -0
- package/server/routes.ts +1277 -0
- package/server/sandbox-manager.test.ts +378 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.test.ts +1419 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.test.ts +661 -0
- package/server/session-creation-service.ts +473 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-linear-issues.test.ts +118 -0
- package/server/session-linear-issues.ts +88 -0
- package/server/session-names.test.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.test.ts +1784 -0
- package/server/session-orchestrator.ts +973 -0
- package/server/session-state-machine.test.ts +606 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.test.ts +290 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +509 -0
- package/server/settings-manager.test.ts +275 -0
- package/server/settings-manager.ts +173 -0
- package/server/tailscale-manager.test.ts +553 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.test.ts +306 -0
- package/server/update-checker.ts +197 -0
- package/server/usage-limits.test.ts +536 -0
- package/server/usage-limits.ts +225 -0
- package/server/worktree-tracker.test.ts +243 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.test.ts +59 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.test.ts +272 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.test.ts +302 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.test.ts +1837 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.test.ts +124 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.test.ts +296 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.test.ts +234 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.test.ts +44 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +106 -0
- package/server/ws-bridge.test.ts +4777 -0
- 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
|
+
}
|