@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,957 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import type { CronJob } from "./cron-types.js";
|
|
5
|
+
|
|
6
|
+
// Mock homedir so cron-store writes to a temp directory
|
|
7
|
+
const mockHomedir = vi.hoisted(() => {
|
|
8
|
+
let dir = "";
|
|
9
|
+
return {
|
|
10
|
+
get: () => dir,
|
|
11
|
+
set: (d: string) => { dir = d; },
|
|
12
|
+
};
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
vi.mock("node:os", async (importOriginal) => {
|
|
16
|
+
const actual = await importOriginal<typeof import("node:os")>();
|
|
17
|
+
return { ...actual, homedir: () => mockHomedir.get() };
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Mock session-names to avoid side effects
|
|
21
|
+
vi.mock("./session-names.js", () => ({
|
|
22
|
+
setName: vi.fn(),
|
|
23
|
+
getName: vi.fn(),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
let tempDir: string;
|
|
27
|
+
let cronStore: typeof import("./cron-store.js");
|
|
28
|
+
let CronSchedulerClass: typeof import("./cron-scheduler.js").CronScheduler;
|
|
29
|
+
|
|
30
|
+
// Minimal mock launcher
|
|
31
|
+
function createMockLauncher() {
|
|
32
|
+
const sessions = new Map<string, { sessionId: string; state: string; exitCode?: number | null; cronJobId?: string; cronJobName?: string }>();
|
|
33
|
+
let launchCount = 0;
|
|
34
|
+
return {
|
|
35
|
+
launch: vi.fn((options: Record<string, unknown>) => {
|
|
36
|
+
const sessionId = `mock-session-${++launchCount}`;
|
|
37
|
+
const info = {
|
|
38
|
+
sessionId,
|
|
39
|
+
state: "connected", // immediately connected for testing
|
|
40
|
+
model: options.model as string,
|
|
41
|
+
permissionMode: options.permissionMode as string,
|
|
42
|
+
cwd: (options.cwd as string) || "/tmp",
|
|
43
|
+
createdAt: Date.now(),
|
|
44
|
+
backendType: options.backendType as string,
|
|
45
|
+
};
|
|
46
|
+
sessions.set(sessionId, info);
|
|
47
|
+
return info;
|
|
48
|
+
}),
|
|
49
|
+
getSession: vi.fn((id: string) => sessions.get(id)),
|
|
50
|
+
isAlive: vi.fn((id: string) => {
|
|
51
|
+
const s = sessions.get(id);
|
|
52
|
+
return !!s && s.state !== "exited";
|
|
53
|
+
}),
|
|
54
|
+
sessions,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Minimal mock wsBridge
|
|
59
|
+
function createMockBridge() {
|
|
60
|
+
return {
|
|
61
|
+
injectUserMessage: vi.fn(),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function makeJob(overrides: Partial<CronJob> = {}): CronJob {
|
|
66
|
+
return {
|
|
67
|
+
id: "test-job",
|
|
68
|
+
name: "Test Job",
|
|
69
|
+
prompt: "Do something",
|
|
70
|
+
schedule: "0 8 * * *",
|
|
71
|
+
recurring: true,
|
|
72
|
+
backendType: "claude",
|
|
73
|
+
model: "claude-sonnet-4-6",
|
|
74
|
+
cwd: "/tmp/test",
|
|
75
|
+
enabled: true,
|
|
76
|
+
permissionMode: "bypassPermissions",
|
|
77
|
+
createdAt: Date.now(),
|
|
78
|
+
updatedAt: Date.now(),
|
|
79
|
+
consecutiveFailures: 0,
|
|
80
|
+
totalRuns: 0,
|
|
81
|
+
...overrides,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
beforeEach(async () => {
|
|
86
|
+
tempDir = mkdtempSync(join(tmpdir(), "cron-sched-test-"));
|
|
87
|
+
mockHomedir.set(tempDir);
|
|
88
|
+
vi.resetModules();
|
|
89
|
+
cronStore = await import("./cron-store.js");
|
|
90
|
+
const mod = await import("./cron-scheduler.js");
|
|
91
|
+
CronSchedulerClass = mod.CronScheduler;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ===========================================================================
|
|
99
|
+
// Scheduling
|
|
100
|
+
// ===========================================================================
|
|
101
|
+
describe("scheduling", () => {
|
|
102
|
+
it("schedules a recurring job and tracks the timer", () => {
|
|
103
|
+
const launcher = createMockLauncher();
|
|
104
|
+
const bridge = createMockBridge();
|
|
105
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
106
|
+
|
|
107
|
+
const job = makeJob();
|
|
108
|
+
scheduler.scheduleJob(job);
|
|
109
|
+
|
|
110
|
+
// Should have a next run time
|
|
111
|
+
const nextRun = scheduler.getNextRunTime("test-job");
|
|
112
|
+
expect(nextRun).toBeInstanceOf(Date);
|
|
113
|
+
expect(nextRun!.getTime()).toBeGreaterThan(Date.now());
|
|
114
|
+
|
|
115
|
+
scheduler.destroy();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("stops a job timer", () => {
|
|
119
|
+
const launcher = createMockLauncher();
|
|
120
|
+
const bridge = createMockBridge();
|
|
121
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
122
|
+
|
|
123
|
+
const job = makeJob();
|
|
124
|
+
scheduler.scheduleJob(job);
|
|
125
|
+
expect(scheduler.getNextRunTime("test-job")).not.toBeNull();
|
|
126
|
+
|
|
127
|
+
scheduler.stopJob("test-job");
|
|
128
|
+
expect(scheduler.getNextRunTime("test-job")).toBeNull();
|
|
129
|
+
|
|
130
|
+
scheduler.destroy();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("skips disabled jobs", () => {
|
|
134
|
+
const launcher = createMockLauncher();
|
|
135
|
+
const bridge = createMockBridge();
|
|
136
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
137
|
+
|
|
138
|
+
const job = makeJob({ enabled: false });
|
|
139
|
+
scheduler.scheduleJob(job);
|
|
140
|
+
|
|
141
|
+
expect(scheduler.getNextRunTime("test-job")).toBeNull();
|
|
142
|
+
scheduler.destroy();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("skips one-shot jobs in the past", () => {
|
|
146
|
+
const launcher = createMockLauncher();
|
|
147
|
+
const bridge = createMockBridge();
|
|
148
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
149
|
+
|
|
150
|
+
const pastDate = new Date(Date.now() - 60_000).toISOString();
|
|
151
|
+
const job = makeJob({ recurring: false, schedule: pastDate });
|
|
152
|
+
scheduler.scheduleJob(job);
|
|
153
|
+
|
|
154
|
+
expect(scheduler.getNextRunTime("test-job")).toBeNull();
|
|
155
|
+
scheduler.destroy();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("startAll loads and schedules enabled jobs from store", () => {
|
|
159
|
+
// Create jobs in store
|
|
160
|
+
cronStore.createJob({
|
|
161
|
+
name: "Enabled Job",
|
|
162
|
+
prompt: "Do it",
|
|
163
|
+
schedule: "0 9 * * *",
|
|
164
|
+
recurring: true,
|
|
165
|
+
backendType: "claude",
|
|
166
|
+
model: "claude-sonnet-4-6",
|
|
167
|
+
cwd: "/tmp",
|
|
168
|
+
enabled: true,
|
|
169
|
+
permissionMode: "bypassPermissions",
|
|
170
|
+
});
|
|
171
|
+
cronStore.createJob({
|
|
172
|
+
name: "Disabled Job",
|
|
173
|
+
prompt: "Skip me",
|
|
174
|
+
schedule: "0 10 * * *",
|
|
175
|
+
recurring: true,
|
|
176
|
+
backendType: "codex",
|
|
177
|
+
model: "o3",
|
|
178
|
+
cwd: "/tmp",
|
|
179
|
+
enabled: false,
|
|
180
|
+
permissionMode: "bypassPermissions",
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const launcher = createMockLauncher();
|
|
184
|
+
const bridge = createMockBridge();
|
|
185
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
186
|
+
scheduler.startAll();
|
|
187
|
+
|
|
188
|
+
expect(scheduler.getNextRunTime("enabled-job")).not.toBeNull();
|
|
189
|
+
expect(scheduler.getNextRunTime("disabled-job")).toBeNull();
|
|
190
|
+
|
|
191
|
+
scheduler.destroy();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ===========================================================================
|
|
196
|
+
// Execution
|
|
197
|
+
// ===========================================================================
|
|
198
|
+
describe("execution", () => {
|
|
199
|
+
it("launches a session and injects the prompt", async () => {
|
|
200
|
+
const launcher = createMockLauncher();
|
|
201
|
+
const bridge = createMockBridge();
|
|
202
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
203
|
+
|
|
204
|
+
// Create job in store so executeJob can read it
|
|
205
|
+
cronStore.createJob({
|
|
206
|
+
name: "Run Me",
|
|
207
|
+
prompt: "Check PRs",
|
|
208
|
+
schedule: "0 8 * * *",
|
|
209
|
+
recurring: true,
|
|
210
|
+
backendType: "claude",
|
|
211
|
+
model: "claude-sonnet-4-6",
|
|
212
|
+
cwd: "/tmp/repo",
|
|
213
|
+
enabled: true,
|
|
214
|
+
permissionMode: "bypassPermissions",
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
await scheduler.executeJob("run-me");
|
|
218
|
+
|
|
219
|
+
// Verify launcher was called with correct params
|
|
220
|
+
expect(launcher.launch).toHaveBeenCalledWith(
|
|
221
|
+
expect.objectContaining({
|
|
222
|
+
model: "claude-sonnet-4-6",
|
|
223
|
+
permissionMode: "bypassPermissions",
|
|
224
|
+
cwd: "/tmp/repo",
|
|
225
|
+
backendType: "claude",
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
// Verify prompt was injected with cron prefix
|
|
230
|
+
expect(bridge.injectUserMessage).toHaveBeenCalledWith(
|
|
231
|
+
expect.stringMatching(/^mock-session-/),
|
|
232
|
+
expect.stringContaining("[cron:run-me Run Me]"),
|
|
233
|
+
);
|
|
234
|
+
expect(bridge.injectUserMessage).toHaveBeenCalledWith(
|
|
235
|
+
expect.any(String),
|
|
236
|
+
expect.stringContaining("Check PRs"),
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
// Verify job tracking was updated
|
|
240
|
+
const updated = cronStore.getJob("run-me");
|
|
241
|
+
expect(updated!.totalRuns).toBe(1);
|
|
242
|
+
expect(updated!.consecutiveFailures).toBe(0);
|
|
243
|
+
expect(updated!.lastRunAt).toBeGreaterThan(0);
|
|
244
|
+
expect(updated!.lastSessionId).toMatch(/^mock-session-/);
|
|
245
|
+
|
|
246
|
+
// Verify execution history
|
|
247
|
+
const execs = scheduler.getExecutions("run-me");
|
|
248
|
+
expect(execs).toHaveLength(1);
|
|
249
|
+
expect(execs[0].success).toBe(true);
|
|
250
|
+
|
|
251
|
+
scheduler.destroy();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("skips execution when previous run is still alive", async () => {
|
|
255
|
+
const launcher = createMockLauncher();
|
|
256
|
+
const bridge = createMockBridge();
|
|
257
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
258
|
+
|
|
259
|
+
// Create job and set lastSessionId to a still-alive session
|
|
260
|
+
cronStore.createJob({
|
|
261
|
+
name: "Overlap Test",
|
|
262
|
+
prompt: "Do it",
|
|
263
|
+
schedule: "* * * * *",
|
|
264
|
+
recurring: true,
|
|
265
|
+
backendType: "claude",
|
|
266
|
+
model: "claude-sonnet-4-6",
|
|
267
|
+
cwd: "/tmp",
|
|
268
|
+
enabled: true,
|
|
269
|
+
permissionMode: "bypassPermissions",
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// Run once to get a lastSessionId
|
|
273
|
+
await scheduler.executeJob("overlap-test");
|
|
274
|
+
const firstSessionId = cronStore.getJob("overlap-test")!.lastSessionId!;
|
|
275
|
+
expect(launcher.isAlive(firstSessionId)).toBe(true);
|
|
276
|
+
|
|
277
|
+
// Try to run again — should skip
|
|
278
|
+
launcher.launch.mockClear();
|
|
279
|
+
await scheduler.executeJob("overlap-test");
|
|
280
|
+
expect(launcher.launch).not.toHaveBeenCalled();
|
|
281
|
+
|
|
282
|
+
scheduler.destroy();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("tracks failures and auto-disables after 5 consecutive failures", async () => {
|
|
286
|
+
const launcher = createMockLauncher();
|
|
287
|
+
const bridge = createMockBridge();
|
|
288
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
289
|
+
|
|
290
|
+
// Make launcher return a session that immediately exits
|
|
291
|
+
launcher.launch.mockImplementation((options: Record<string, unknown>) => {
|
|
292
|
+
const info = {
|
|
293
|
+
sessionId: `fail-${Date.now()}`,
|
|
294
|
+
state: "exited",
|
|
295
|
+
exitCode: 1,
|
|
296
|
+
model: (options.model as string) || "",
|
|
297
|
+
permissionMode: (options.permissionMode as string) || "",
|
|
298
|
+
cwd: (options.cwd as string) || "/tmp",
|
|
299
|
+
createdAt: Date.now(),
|
|
300
|
+
backendType: (options.backendType as string) || "claude",
|
|
301
|
+
};
|
|
302
|
+
launcher.sessions.set(info.sessionId, info);
|
|
303
|
+
return info;
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
cronStore.createJob({
|
|
307
|
+
name: "Failing Job",
|
|
308
|
+
prompt: "Will fail",
|
|
309
|
+
schedule: "0 8 * * *",
|
|
310
|
+
recurring: true,
|
|
311
|
+
backendType: "claude",
|
|
312
|
+
model: "claude-sonnet-4-6",
|
|
313
|
+
cwd: "/tmp",
|
|
314
|
+
enabled: true,
|
|
315
|
+
permissionMode: "bypassPermissions",
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// Execute 5 times (each should fail because the CLI exits immediately)
|
|
319
|
+
for (let i = 0; i < 5; i++) {
|
|
320
|
+
await scheduler.executeJob("failing-job");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const job = cronStore.getJob("failing-job");
|
|
324
|
+
expect(job!.enabled).toBe(false);
|
|
325
|
+
expect(job!.consecutiveFailures).toBe(5);
|
|
326
|
+
|
|
327
|
+
scheduler.destroy();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("skips disabled jobs during execution", async () => {
|
|
331
|
+
const launcher = createMockLauncher();
|
|
332
|
+
const bridge = createMockBridge();
|
|
333
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
334
|
+
|
|
335
|
+
cronStore.createJob({
|
|
336
|
+
name: "Disabled",
|
|
337
|
+
prompt: "Skip",
|
|
338
|
+
schedule: "0 8 * * *",
|
|
339
|
+
recurring: true,
|
|
340
|
+
backendType: "claude",
|
|
341
|
+
model: "claude-sonnet-4-6",
|
|
342
|
+
cwd: "/tmp",
|
|
343
|
+
enabled: false,
|
|
344
|
+
permissionMode: "bypassPermissions",
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
await scheduler.executeJob("disabled");
|
|
348
|
+
expect(launcher.launch).not.toHaveBeenCalled();
|
|
349
|
+
|
|
350
|
+
scheduler.destroy();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("passes codexSandbox=danger-full-access for Codex jobs with bypassPermissions", async () => {
|
|
354
|
+
// Codex cron jobs must launch with explicit full autonomy params:
|
|
355
|
+
// codexSandbox="danger-full-access" and codexInternetAccess=true
|
|
356
|
+
const launcher = createMockLauncher();
|
|
357
|
+
const bridge = createMockBridge();
|
|
358
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
359
|
+
|
|
360
|
+
cronStore.createJob({
|
|
361
|
+
name: "Codex Auto",
|
|
362
|
+
prompt: "Check PRs",
|
|
363
|
+
schedule: "0 8 * * *",
|
|
364
|
+
recurring: true,
|
|
365
|
+
backendType: "codex",
|
|
366
|
+
model: "gpt-5.3-codex",
|
|
367
|
+
cwd: "/tmp",
|
|
368
|
+
enabled: true,
|
|
369
|
+
permissionMode: "bypassPermissions",
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
await scheduler.executeJob("codex-auto");
|
|
373
|
+
|
|
374
|
+
expect(launcher.launch).toHaveBeenCalledWith(
|
|
375
|
+
expect.objectContaining({
|
|
376
|
+
backendType: "codex",
|
|
377
|
+
permissionMode: "bypassPermissions",
|
|
378
|
+
codexSandbox: "danger-full-access",
|
|
379
|
+
codexInternetAccess: true,
|
|
380
|
+
}),
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
scheduler.destroy();
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("defaults codexInternetAccess to true for Codex cron jobs when not explicitly set", async () => {
|
|
387
|
+
// When codexInternetAccess is not set on the job, it should default to true
|
|
388
|
+
// for Codex autonomous sessions (no point running autonomous without internet)
|
|
389
|
+
const launcher = createMockLauncher();
|
|
390
|
+
const bridge = createMockBridge();
|
|
391
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
392
|
+
|
|
393
|
+
cronStore.createJob({
|
|
394
|
+
name: "Codex Default Internet",
|
|
395
|
+
prompt: "Fetch latest",
|
|
396
|
+
schedule: "0 9 * * *",
|
|
397
|
+
recurring: true,
|
|
398
|
+
backendType: "codex",
|
|
399
|
+
model: "gpt-5.3-codex",
|
|
400
|
+
cwd: "/tmp",
|
|
401
|
+
enabled: true,
|
|
402
|
+
permissionMode: "bypassPermissions",
|
|
403
|
+
// codexInternetAccess NOT set
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
await scheduler.executeJob("codex-default-internet");
|
|
407
|
+
|
|
408
|
+
expect(launcher.launch).toHaveBeenCalledWith(
|
|
409
|
+
expect.objectContaining({
|
|
410
|
+
codexInternetAccess: true,
|
|
411
|
+
}),
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
scheduler.destroy();
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("does not pass codexSandbox for Claude jobs", async () => {
|
|
418
|
+
// Claude Code doesn't use codexSandbox — it uses --permission-mode directly
|
|
419
|
+
const launcher = createMockLauncher();
|
|
420
|
+
const bridge = createMockBridge();
|
|
421
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
422
|
+
|
|
423
|
+
cronStore.createJob({
|
|
424
|
+
name: "Claude Job",
|
|
425
|
+
prompt: "Run tests",
|
|
426
|
+
schedule: "0 8 * * *",
|
|
427
|
+
recurring: true,
|
|
428
|
+
backendType: "claude",
|
|
429
|
+
model: "claude-sonnet-4-6",
|
|
430
|
+
cwd: "/tmp",
|
|
431
|
+
enabled: true,
|
|
432
|
+
permissionMode: "bypassPermissions",
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
await scheduler.executeJob("claude-job");
|
|
436
|
+
|
|
437
|
+
const launchArgs = launcher.launch.mock.calls[0][0];
|
|
438
|
+
expect(launchArgs.codexSandbox).toBeUndefined();
|
|
439
|
+
expect(launchArgs.codexInternetAccess).toBeUndefined();
|
|
440
|
+
|
|
441
|
+
scheduler.destroy();
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// ===========================================================================
|
|
446
|
+
// Execution history
|
|
447
|
+
// ===========================================================================
|
|
448
|
+
describe("execution history", () => {
|
|
449
|
+
it("tracks multiple executions per job", async () => {
|
|
450
|
+
const launcher = createMockLauncher();
|
|
451
|
+
const bridge = createMockBridge();
|
|
452
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
453
|
+
|
|
454
|
+
cronStore.createJob({
|
|
455
|
+
name: "Multi Run",
|
|
456
|
+
prompt: "Go",
|
|
457
|
+
schedule: "0 8 * * *",
|
|
458
|
+
recurring: true,
|
|
459
|
+
backendType: "claude",
|
|
460
|
+
model: "claude-sonnet-4-6",
|
|
461
|
+
cwd: "/tmp",
|
|
462
|
+
enabled: true,
|
|
463
|
+
permissionMode: "bypassPermissions",
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// Run 3 times — need to mark previous sessions as exited to avoid overlap skip
|
|
467
|
+
await scheduler.executeJob("multi-run");
|
|
468
|
+
const sess1 = cronStore.getJob("multi-run")!.lastSessionId!;
|
|
469
|
+
launcher.sessions.get(sess1)!.state = "exited";
|
|
470
|
+
|
|
471
|
+
await scheduler.executeJob("multi-run");
|
|
472
|
+
const sess2 = cronStore.getJob("multi-run")!.lastSessionId!;
|
|
473
|
+
launcher.sessions.get(sess2)!.state = "exited";
|
|
474
|
+
|
|
475
|
+
await scheduler.executeJob("multi-run");
|
|
476
|
+
|
|
477
|
+
const execs = scheduler.getExecutions("multi-run");
|
|
478
|
+
expect(execs).toHaveLength(3);
|
|
479
|
+
// All should be successful
|
|
480
|
+
expect(execs.every((e) => e.success === true)).toBe(true);
|
|
481
|
+
// Each should have a unique session ID
|
|
482
|
+
const sessionIds = new Set(execs.map((e) => e.sessionId));
|
|
483
|
+
expect(sessionIds.size).toBe(3);
|
|
484
|
+
|
|
485
|
+
scheduler.destroy();
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("returns empty array for unknown job", () => {
|
|
489
|
+
const launcher = createMockLauncher();
|
|
490
|
+
const bridge = createMockBridge();
|
|
491
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
492
|
+
|
|
493
|
+
expect(scheduler.getExecutions("nonexistent")).toEqual([]);
|
|
494
|
+
|
|
495
|
+
scheduler.destroy();
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("records error details on failed executions", async () => {
|
|
499
|
+
const launcher = createMockLauncher();
|
|
500
|
+
const bridge = createMockBridge();
|
|
501
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
502
|
+
|
|
503
|
+
// Make launcher return sessions that immediately exit
|
|
504
|
+
launcher.launch.mockImplementation((options: Record<string, unknown>) => {
|
|
505
|
+
const info = {
|
|
506
|
+
sessionId: `fail-${Date.now()}-${Math.random()}`,
|
|
507
|
+
state: "exited",
|
|
508
|
+
exitCode: 1,
|
|
509
|
+
model: (options.model as string) || "",
|
|
510
|
+
permissionMode: (options.permissionMode as string) || "",
|
|
511
|
+
cwd: (options.cwd as string) || "/tmp",
|
|
512
|
+
createdAt: Date.now(),
|
|
513
|
+
backendType: (options.backendType as string) || "claude",
|
|
514
|
+
};
|
|
515
|
+
launcher.sessions.set(info.sessionId, info);
|
|
516
|
+
return info;
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
cronStore.createJob({
|
|
520
|
+
name: "Error Detail",
|
|
521
|
+
prompt: "Will fail",
|
|
522
|
+
schedule: "0 8 * * *",
|
|
523
|
+
recurring: true,
|
|
524
|
+
backendType: "claude",
|
|
525
|
+
model: "claude-sonnet-4-6",
|
|
526
|
+
cwd: "/tmp",
|
|
527
|
+
enabled: true,
|
|
528
|
+
permissionMode: "bypassPermissions",
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
await scheduler.executeJob("error-detail");
|
|
532
|
+
|
|
533
|
+
const execs = scheduler.getExecutions("error-detail");
|
|
534
|
+
expect(execs).toHaveLength(1);
|
|
535
|
+
expect(execs[0].error).toContain("CLI process exited before connecting");
|
|
536
|
+
expect(execs[0].completedAt).toBeGreaterThan(0);
|
|
537
|
+
|
|
538
|
+
scheduler.destroy();
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
// ===========================================================================
|
|
543
|
+
// Prompt formatting
|
|
544
|
+
// ===========================================================================
|
|
545
|
+
describe("prompt formatting", () => {
|
|
546
|
+
it("prefixes prompt with [cron:<id> <name>] tag", async () => {
|
|
547
|
+
const launcher = createMockLauncher();
|
|
548
|
+
const bridge = createMockBridge();
|
|
549
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
550
|
+
|
|
551
|
+
cronStore.createJob({
|
|
552
|
+
name: "Email Digest",
|
|
553
|
+
prompt: "Read my emails and summarize them",
|
|
554
|
+
schedule: "0 8 * * *",
|
|
555
|
+
recurring: true,
|
|
556
|
+
backendType: "claude",
|
|
557
|
+
model: "claude-sonnet-4-6",
|
|
558
|
+
cwd: "/tmp",
|
|
559
|
+
enabled: true,
|
|
560
|
+
permissionMode: "bypassPermissions",
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
await scheduler.executeJob("email-digest");
|
|
564
|
+
|
|
565
|
+
const injectedPrompt = bridge.injectUserMessage.mock.calls[0][1];
|
|
566
|
+
expect(injectedPrompt).toBe("[cron:email-digest Email Digest]\n\nRead my emails and summarize them");
|
|
567
|
+
|
|
568
|
+
scheduler.destroy();
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it("sets session name with clock emoji prefix", async () => {
|
|
572
|
+
const launcher = createMockLauncher();
|
|
573
|
+
const bridge = createMockBridge();
|
|
574
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
575
|
+
|
|
576
|
+
const { setName } = await import("./session-names.js");
|
|
577
|
+
|
|
578
|
+
cronStore.createJob({
|
|
579
|
+
name: "PR Check",
|
|
580
|
+
prompt: "Check PRs",
|
|
581
|
+
schedule: "0 8 * * *",
|
|
582
|
+
recurring: true,
|
|
583
|
+
backendType: "claude",
|
|
584
|
+
model: "claude-sonnet-4-6",
|
|
585
|
+
cwd: "/tmp",
|
|
586
|
+
enabled: true,
|
|
587
|
+
permissionMode: "bypassPermissions",
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
await scheduler.executeJob("pr-check");
|
|
591
|
+
|
|
592
|
+
expect(setName).toHaveBeenCalledWith(
|
|
593
|
+
expect.stringMatching(/^mock-session-/),
|
|
594
|
+
"⏰ PR Check",
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
scheduler.destroy();
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
// ===========================================================================
|
|
602
|
+
// Session tagging
|
|
603
|
+
// ===========================================================================
|
|
604
|
+
describe("session tagging", () => {
|
|
605
|
+
it("tags the session with cronJobId and cronJobName", async () => {
|
|
606
|
+
const launcher = createMockLauncher();
|
|
607
|
+
const bridge = createMockBridge();
|
|
608
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
609
|
+
|
|
610
|
+
cronStore.createJob({
|
|
611
|
+
name: "Tagged Session",
|
|
612
|
+
prompt: "Do work",
|
|
613
|
+
schedule: "0 8 * * *",
|
|
614
|
+
recurring: true,
|
|
615
|
+
backendType: "claude",
|
|
616
|
+
model: "claude-sonnet-4-6",
|
|
617
|
+
cwd: "/tmp",
|
|
618
|
+
enabled: true,
|
|
619
|
+
permissionMode: "bypassPermissions",
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
await scheduler.executeJob("tagged-session");
|
|
623
|
+
|
|
624
|
+
// The session should be tagged after launch
|
|
625
|
+
const sessionId = cronStore.getJob("tagged-session")!.lastSessionId!;
|
|
626
|
+
const session = launcher.sessions.get(sessionId);
|
|
627
|
+
expect(session!.cronJobId).toBe("tagged-session");
|
|
628
|
+
expect(session!.cronJobName).toBe("Tagged Session");
|
|
629
|
+
|
|
630
|
+
scheduler.destroy();
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
// ===========================================================================
|
|
635
|
+
// Failure recovery
|
|
636
|
+
// ===========================================================================
|
|
637
|
+
describe("failure recovery", () => {
|
|
638
|
+
it("resets consecutiveFailures to 0 on successful execution", async () => {
|
|
639
|
+
const launcher = createMockLauncher();
|
|
640
|
+
const bridge = createMockBridge();
|
|
641
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
642
|
+
|
|
643
|
+
// Create a job that has had previous failures
|
|
644
|
+
cronStore.createJob({
|
|
645
|
+
name: "Recovering",
|
|
646
|
+
prompt: "Try again",
|
|
647
|
+
schedule: "0 8 * * *",
|
|
648
|
+
recurring: true,
|
|
649
|
+
backendType: "claude",
|
|
650
|
+
model: "claude-sonnet-4-6",
|
|
651
|
+
cwd: "/tmp",
|
|
652
|
+
enabled: true,
|
|
653
|
+
permissionMode: "bypassPermissions",
|
|
654
|
+
});
|
|
655
|
+
// Manually set some failures
|
|
656
|
+
cronStore.updateJob("recovering", { consecutiveFailures: 3 });
|
|
657
|
+
|
|
658
|
+
// Execute successfully
|
|
659
|
+
await scheduler.executeJob("recovering");
|
|
660
|
+
|
|
661
|
+
const job = cronStore.getJob("recovering");
|
|
662
|
+
expect(job!.consecutiveFailures).toBe(0);
|
|
663
|
+
expect(job!.totalRuns).toBe(1);
|
|
664
|
+
|
|
665
|
+
scheduler.destroy();
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it("increments totalRuns on each successful execution", async () => {
|
|
669
|
+
const launcher = createMockLauncher();
|
|
670
|
+
const bridge = createMockBridge();
|
|
671
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
672
|
+
|
|
673
|
+
cronStore.createJob({
|
|
674
|
+
name: "Counter",
|
|
675
|
+
prompt: "Go",
|
|
676
|
+
schedule: "0 8 * * *",
|
|
677
|
+
recurring: true,
|
|
678
|
+
backendType: "claude",
|
|
679
|
+
model: "claude-sonnet-4-6",
|
|
680
|
+
cwd: "/tmp",
|
|
681
|
+
enabled: true,
|
|
682
|
+
permissionMode: "bypassPermissions",
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
// Run 3 times, marking previous sessions as exited
|
|
686
|
+
for (let i = 0; i < 3; i++) {
|
|
687
|
+
await scheduler.executeJob("counter");
|
|
688
|
+
const sid = cronStore.getJob("counter")!.lastSessionId!;
|
|
689
|
+
launcher.sessions.get(sid)!.state = "exited";
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const job = cronStore.getJob("counter");
|
|
693
|
+
expect(job!.totalRuns).toBe(3);
|
|
694
|
+
|
|
695
|
+
scheduler.destroy();
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it("does not auto-disable after fewer than 5 failures", async () => {
|
|
699
|
+
const launcher = createMockLauncher();
|
|
700
|
+
const bridge = createMockBridge();
|
|
701
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
702
|
+
|
|
703
|
+
// Make launcher return sessions that immediately exit
|
|
704
|
+
launcher.launch.mockImplementation((options: Record<string, unknown>) => {
|
|
705
|
+
const info = {
|
|
706
|
+
sessionId: `fail-${Date.now()}-${Math.random()}`,
|
|
707
|
+
state: "exited",
|
|
708
|
+
exitCode: 1,
|
|
709
|
+
model: (options.model as string) || "",
|
|
710
|
+
permissionMode: (options.permissionMode as string) || "",
|
|
711
|
+
cwd: (options.cwd as string) || "/tmp",
|
|
712
|
+
createdAt: Date.now(),
|
|
713
|
+
backendType: (options.backendType as string) || "claude",
|
|
714
|
+
};
|
|
715
|
+
launcher.sessions.set(info.sessionId, info);
|
|
716
|
+
return info;
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
cronStore.createJob({
|
|
720
|
+
name: "Resilient",
|
|
721
|
+
prompt: "Try hard",
|
|
722
|
+
schedule: "0 8 * * *",
|
|
723
|
+
recurring: true,
|
|
724
|
+
backendType: "claude",
|
|
725
|
+
model: "claude-sonnet-4-6",
|
|
726
|
+
cwd: "/tmp",
|
|
727
|
+
enabled: true,
|
|
728
|
+
permissionMode: "bypassPermissions",
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
// Execute 4 times (below threshold)
|
|
732
|
+
for (let i = 0; i < 4; i++) {
|
|
733
|
+
await scheduler.executeJob("resilient");
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const job = cronStore.getJob("resilient");
|
|
737
|
+
expect(job!.enabled).toBe(true); // Still enabled
|
|
738
|
+
expect(job!.consecutiveFailures).toBe(4);
|
|
739
|
+
|
|
740
|
+
scheduler.destroy();
|
|
741
|
+
});
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
// ===========================================================================
|
|
745
|
+
// Non-existent / invalid job handling
|
|
746
|
+
// ===========================================================================
|
|
747
|
+
describe("invalid job handling", () => {
|
|
748
|
+
it("silently skips execution when job does not exist in store", async () => {
|
|
749
|
+
const launcher = createMockLauncher();
|
|
750
|
+
const bridge = createMockBridge();
|
|
751
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
752
|
+
|
|
753
|
+
// Should not throw
|
|
754
|
+
await scheduler.executeJob("ghost-job");
|
|
755
|
+
expect(launcher.launch).not.toHaveBeenCalled();
|
|
756
|
+
|
|
757
|
+
scheduler.destroy();
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
it("getNextRunTime returns null for unknown job id", () => {
|
|
761
|
+
const launcher = createMockLauncher();
|
|
762
|
+
const bridge = createMockBridge();
|
|
763
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
764
|
+
|
|
765
|
+
expect(scheduler.getNextRunTime("nonexistent")).toBeNull();
|
|
766
|
+
|
|
767
|
+
scheduler.destroy();
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
it("stopJob is a no-op for unknown job id", () => {
|
|
771
|
+
const launcher = createMockLauncher();
|
|
772
|
+
const bridge = createMockBridge();
|
|
773
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
774
|
+
|
|
775
|
+
// Should not throw
|
|
776
|
+
scheduler.stopJob("unknown");
|
|
777
|
+
|
|
778
|
+
scheduler.destroy();
|
|
779
|
+
});
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
// ===========================================================================
|
|
783
|
+
// scheduleJob replaces existing timer
|
|
784
|
+
// ===========================================================================
|
|
785
|
+
describe("reschedule", () => {
|
|
786
|
+
it("replaces existing timer when scheduleJob is called again", () => {
|
|
787
|
+
const launcher = createMockLauncher();
|
|
788
|
+
const bridge = createMockBridge();
|
|
789
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
790
|
+
|
|
791
|
+
const job = makeJob({ schedule: "0 8 * * *" });
|
|
792
|
+
scheduler.scheduleJob(job);
|
|
793
|
+
const firstNextRun = scheduler.getNextRunTime("test-job");
|
|
794
|
+
|
|
795
|
+
// Reschedule with different cron
|
|
796
|
+
scheduler.scheduleJob({ ...job, schedule: "0 20 * * *" });
|
|
797
|
+
const secondNextRun = scheduler.getNextRunTime("test-job");
|
|
798
|
+
|
|
799
|
+
expect(firstNextRun).not.toBeNull();
|
|
800
|
+
expect(secondNextRun).not.toBeNull();
|
|
801
|
+
// Different schedules should produce different next run times
|
|
802
|
+
expect(firstNextRun!.getHours()).not.toBe(secondNextRun!.getHours());
|
|
803
|
+
|
|
804
|
+
scheduler.destroy();
|
|
805
|
+
});
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
// ===========================================================================
|
|
809
|
+
// One-shot scheduling
|
|
810
|
+
// ===========================================================================
|
|
811
|
+
describe("one-shot scheduling", () => {
|
|
812
|
+
it("schedules a future one-shot and has a next run time", () => {
|
|
813
|
+
const launcher = createMockLauncher();
|
|
814
|
+
const bridge = createMockBridge();
|
|
815
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
816
|
+
|
|
817
|
+
const futureDate = new Date(Date.now() + 3_600_000).toISOString(); // +1 hour
|
|
818
|
+
const job = makeJob({ recurring: false, schedule: futureDate });
|
|
819
|
+
scheduler.scheduleJob(job);
|
|
820
|
+
|
|
821
|
+
const nextRun = scheduler.getNextRunTime("test-job");
|
|
822
|
+
expect(nextRun).toBeInstanceOf(Date);
|
|
823
|
+
// Should be roughly 1 hour from now
|
|
824
|
+
expect(nextRun!.getTime()).toBeGreaterThan(Date.now());
|
|
825
|
+
expect(nextRun!.getTime()).toBeLessThanOrEqual(Date.now() + 3_600_000 + 1000);
|
|
826
|
+
|
|
827
|
+
scheduler.destroy();
|
|
828
|
+
});
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
// ===========================================================================
|
|
832
|
+
// Codex sandbox with non-bypass permission mode
|
|
833
|
+
// ===========================================================================
|
|
834
|
+
describe("Codex sandbox modes", () => {
|
|
835
|
+
it("passes codexSandbox=workspace-write for Codex jobs with plan mode", async () => {
|
|
836
|
+
// Codex jobs NOT using bypassPermissions should get workspace-write sandbox
|
|
837
|
+
const launcher = createMockLauncher();
|
|
838
|
+
const bridge = createMockBridge();
|
|
839
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
840
|
+
|
|
841
|
+
cronStore.createJob({
|
|
842
|
+
name: "Codex Plan",
|
|
843
|
+
prompt: "Suggest changes",
|
|
844
|
+
schedule: "0 8 * * *",
|
|
845
|
+
recurring: true,
|
|
846
|
+
backendType: "codex",
|
|
847
|
+
model: "gpt-5.3-codex",
|
|
848
|
+
cwd: "/tmp",
|
|
849
|
+
enabled: true,
|
|
850
|
+
permissionMode: "plan",
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
await scheduler.executeJob("codex-plan");
|
|
854
|
+
|
|
855
|
+
expect(launcher.launch).toHaveBeenCalledWith(
|
|
856
|
+
expect.objectContaining({
|
|
857
|
+
codexSandbox: "workspace-write",
|
|
858
|
+
permissionMode: "plan",
|
|
859
|
+
}),
|
|
860
|
+
);
|
|
861
|
+
|
|
862
|
+
scheduler.destroy();
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
it("respects explicit codexInternetAccess=false", async () => {
|
|
866
|
+
// If user explicitly sets codexInternetAccess to false, it should be respected
|
|
867
|
+
const launcher = createMockLauncher();
|
|
868
|
+
const bridge = createMockBridge();
|
|
869
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
870
|
+
|
|
871
|
+
cronStore.createJob({
|
|
872
|
+
name: "Codex No Internet",
|
|
873
|
+
prompt: "Work offline",
|
|
874
|
+
schedule: "0 8 * * *",
|
|
875
|
+
recurring: true,
|
|
876
|
+
backendType: "codex",
|
|
877
|
+
model: "gpt-5.3-codex",
|
|
878
|
+
cwd: "/tmp",
|
|
879
|
+
enabled: true,
|
|
880
|
+
permissionMode: "bypassPermissions",
|
|
881
|
+
codexInternetAccess: false,
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
await scheduler.executeJob("codex-no-internet");
|
|
885
|
+
|
|
886
|
+
expect(launcher.launch).toHaveBeenCalledWith(
|
|
887
|
+
expect.objectContaining({
|
|
888
|
+
codexInternetAccess: false,
|
|
889
|
+
}),
|
|
890
|
+
);
|
|
891
|
+
|
|
892
|
+
scheduler.destroy();
|
|
893
|
+
});
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
// ===========================================================================
|
|
897
|
+
// Cleanup
|
|
898
|
+
// ===========================================================================
|
|
899
|
+
describe("destroy", () => {
|
|
900
|
+
it("stops all timers and clears state", () => {
|
|
901
|
+
const launcher = createMockLauncher();
|
|
902
|
+
const bridge = createMockBridge();
|
|
903
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
904
|
+
|
|
905
|
+
const job = makeJob();
|
|
906
|
+
scheduler.scheduleJob(job);
|
|
907
|
+
expect(scheduler.getNextRunTime("test-job")).not.toBeNull();
|
|
908
|
+
|
|
909
|
+
scheduler.destroy();
|
|
910
|
+
expect(scheduler.getNextRunTime("test-job")).toBeNull();
|
|
911
|
+
expect(scheduler.getExecutions("test-job")).toEqual([]);
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
it("clears execution history for all jobs", async () => {
|
|
915
|
+
const launcher = createMockLauncher();
|
|
916
|
+
const bridge = createMockBridge();
|
|
917
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
918
|
+
|
|
919
|
+
cronStore.createJob({
|
|
920
|
+
name: "History Clear",
|
|
921
|
+
prompt: "Go",
|
|
922
|
+
schedule: "0 8 * * *",
|
|
923
|
+
recurring: true,
|
|
924
|
+
backendType: "claude",
|
|
925
|
+
model: "claude-sonnet-4-6",
|
|
926
|
+
cwd: "/tmp",
|
|
927
|
+
enabled: true,
|
|
928
|
+
permissionMode: "bypassPermissions",
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
await scheduler.executeJob("history-clear");
|
|
932
|
+
expect(scheduler.getExecutions("history-clear")).toHaveLength(1);
|
|
933
|
+
|
|
934
|
+
scheduler.destroy();
|
|
935
|
+
expect(scheduler.getExecutions("history-clear")).toEqual([]);
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
it("stops multiple timers", () => {
|
|
939
|
+
const launcher = createMockLauncher();
|
|
940
|
+
const bridge = createMockBridge();
|
|
941
|
+
const scheduler = new CronSchedulerClass(launcher as any, bridge as any);
|
|
942
|
+
|
|
943
|
+
scheduler.scheduleJob(makeJob({ id: "job-1", name: "Job 1", schedule: "0 8 * * *" }));
|
|
944
|
+
scheduler.scheduleJob(makeJob({ id: "job-2", name: "Job 2", schedule: "0 12 * * *" }));
|
|
945
|
+
scheduler.scheduleJob(makeJob({ id: "job-3", name: "Job 3", schedule: "0 18 * * *" }));
|
|
946
|
+
|
|
947
|
+
expect(scheduler.getNextRunTime("job-1")).not.toBeNull();
|
|
948
|
+
expect(scheduler.getNextRunTime("job-2")).not.toBeNull();
|
|
949
|
+
expect(scheduler.getNextRunTime("job-3")).not.toBeNull();
|
|
950
|
+
|
|
951
|
+
scheduler.destroy();
|
|
952
|
+
|
|
953
|
+
expect(scheduler.getNextRunTime("job-1")).toBeNull();
|
|
954
|
+
expect(scheduler.getNextRunTime("job-2")).toBeNull();
|
|
955
|
+
expect(scheduler.getNextRunTime("job-3")).toBeNull();
|
|
956
|
+
});
|
|
957
|
+
});
|