@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,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
|
+
});
|