diff --git a/src/server/__tests__/cron-scheduler-launcher.test.ts b/src/server/__tests__/cron-scheduler-launcher.test.ts new file mode 100644 index 00000000..2840b14d --- /dev/null +++ b/src/server/__tests__/cron-scheduler-launcher.test.ts @@ -0,0 +1,201 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import * as fs from 'fs/promises' +import * as os from 'os' +import * as path from 'path' + +import { + buildCronCliArgs, + CronScheduler, + resolveCronProjectRoot, +} from '../services/cronScheduler.js' +import { CronService } from '../services/cronService.js' + +const originalConfigDir = process.env.CLAUDE_CONFIG_DIR +const originalPath = process.env.PATH +const originalClaudeCliPath = process.env.CLAUDE_CLI_PATH +const originalClaudeAppRoot = process.env.CLAUDE_APP_ROOT + +const isWindows = process.platform === 'win32' +const unixOnly = isWindows ? it.skip : it + +async function createTmpDir(): Promise { + const dir = path.join( + os.tmpdir(), + `claude-cron-launcher-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ) + await fs.mkdir(dir, { recursive: true }) + return dir +} + +async function cleanupTmpDir(dir: string): Promise { + await fs.rm(dir, { recursive: true, force: true }).catch(() => {}) +} + +async function createSourceRoot(root: string): Promise { + await fs.mkdir(path.join(root, 'src', 'entrypoints'), { recursive: true }) + await fs.writeFile(path.join(root, 'preload.ts'), '', 'utf-8') + await fs.writeFile( + path.join(root, 'src', 'entrypoints', 'cli.tsx'), + '', + 'utf-8', + ) +} + +function restoreEnv(): void { + if (originalConfigDir) { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir + } else { + delete process.env.CLAUDE_CONFIG_DIR + } + if (originalPath) { + process.env.PATH = originalPath + } else { + delete process.env.PATH + } + if (originalClaudeCliPath) { + process.env.CLAUDE_CLI_PATH = originalClaudeCliPath + } else { + delete process.env.CLAUDE_CLI_PATH + } + if (originalClaudeAppRoot) { + process.env.CLAUDE_APP_ROOT = originalClaudeAppRoot + } else { + delete process.env.CLAUDE_APP_ROOT + } +} + +describe('cron scheduler launcher resolution', () => { + let tmpDir: string + + beforeEach(async () => { + tmpDir = await createTmpDir() + process.env.CLAUDE_CONFIG_DIR = path.join(tmpDir, 'config') + }) + + afterEach(async () => { + restoreEnv() + await cleanupTmpDir(tmpDir) + }) + + it('uses the bundled sidecar launcher when one is configured', () => { + const sidecarPath = path.join(tmpDir, 'claude-sidecar') + const appRoot = path.join(tmpDir, 'app-root') + + const args = buildCronCliArgs(['--print'], { + cliPath: sidecarPath, + appRoot, + execPath: path.join(tmpDir, 'bun'), + cwd: path.join(tmpDir, 'missing-cwd'), + moduleDir: path.join(tmpDir, 'missing-module'), + env: {}, + }) + + expect(args).toEqual([ + sidecarPath, + 'cli', + '--app-root', + appRoot, + '--print', + ]) + }) + + it('prefers an explicit CC_HAHA_ROOT when it points at a source checkout', async () => { + const sourceRoot = path.join(tmpDir, 'source') + await createSourceRoot(sourceRoot) + + expect( + resolveCronProjectRoot({ + cwd: path.join(tmpDir, 'other'), + moduleDir: path.join(tmpDir, 'broken', 'src', 'server', 'services'), + env: { CC_HAHA_ROOT: sourceRoot }, + }), + ).toBe(sourceRoot) + }) + + it('falls back to the nearest source checkout from cwd before module dir', async () => { + const sourceRoot = path.join(tmpDir, 'source') + const nestedCwd = path.join(sourceRoot, 'nested', 'workdir') + await createSourceRoot(sourceRoot) + await fs.mkdir(nestedCwd, { recursive: true }) + + expect( + resolveCronProjectRoot({ + cwd: nestedCwd, + moduleDir: path.join(tmpDir, 'wrong', 'src', 'server', 'services'), + env: {}, + }), + ).toBe(sourceRoot) + }) + + unixOnly('executeTask launches the configured desktop sidecar instead of source bun', async () => { + const binDir = path.join(tmpDir, 'bin') + const appRoot = path.join(tmpDir, 'app-root') + const sidecarPath = path.join(tmpDir, 'claude-sidecar') + const sidecarArgsPath = path.join(tmpDir, 'sidecar.args') + const bunArgsPath = path.join(tmpDir, 'bun.args') + + await fs.mkdir(binDir, { recursive: true }) + await fs.mkdir(appRoot, { recursive: true }) + await fs.writeFile( + path.join(binDir, 'bun'), + [ + '#!/bin/sh', + `printf '%s\\n' "$@" > "${bunArgsPath}"`, + 'echo "error: Module not found \\"B:\\\\src\\\\entrypoints\\\\cli.tsx\\"" >&2', + 'exit 1', + '', + ].join('\n'), + 'utf-8', + ) + await fs.chmod(path.join(binDir, 'bun'), 0o755) + await fs.writeFile( + sidecarPath, + [ + '#!/bin/sh', + `printf '%s\\n' "$@" > "${sidecarArgsPath}"`, + '/bin/cat >/dev/null', + 'printf \'%s\\n\' \'{"type":"result","result":"sidecar ok"}\'', + 'exit 0', + '', + ].join('\n'), + 'utf-8', + ) + await fs.chmod(sidecarPath, 0o755) + + process.env.PATH = binDir + process.env.CLAUDE_CLI_PATH = sidecarPath + process.env.CLAUDE_APP_ROOT = appRoot + + const cronService = new CronService() + const scheduler = new CronScheduler(cronService) + const task = await cronService.createTask({ + cron: '* * * * *', + prompt: 'cron sidecar test', + name: 'Sidecar Task', + recurring: true, + folderPath: tmpDir, + }) + + const run = await scheduler.executeTask(task) + + expect(run.status).toBe('completed') + expect(run.output).toBe('sidecar ok') + + const sidecarArgs = (await fs.readFile(sidecarArgsPath, 'utf-8')) + .trim() + .split('\n') + expect(sidecarArgs.slice(0, 4)).toEqual([ + 'cli', + '--app-root', + appRoot, + '--print', + ]) + expect(sidecarArgs).not.toContain(path.join('src', 'entrypoints', 'cli.tsx')) + + const bunWasCalled = await fs + .stat(bunArgsPath) + .then(() => true) + .catch(() => false) + expect(bunWasCalled).toBe(false) + }) +}) diff --git a/src/server/services/cronScheduler.ts b/src/server/services/cronScheduler.ts index 332d0c04..b05df2fd 100644 --- a/src/server/services/cronScheduler.ts +++ b/src/server/services/cronScheduler.ts @@ -15,6 +15,10 @@ import * as crypto from 'crypto' import { CronService, type CronTask } from './cronService.js' import { SessionService } from './sessionService.js' import { sendTaskNotification } from './notificationService.js' +import { + buildClaudeCliArgs, + resolveClaudeCliLauncher, +} from '../../utils/desktopBundledCli.js' // ─── Types ───────────────────────────────────────────────────────────────────── @@ -242,6 +246,87 @@ function trimRuns(data: RunsFile): void { const TASK_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes +type CronCliResolutionOptions = { + cliPath?: string | null + execPath?: string + appRoot?: string + cwd?: string + moduleDir?: string + env?: NodeJS.ProcessEnv +} + +function isSourceProjectRoot(root: string): boolean { + return ( + existsSync(path.join(root, 'preload.ts')) && + existsSync(path.join(root, 'src', 'entrypoints', 'cli.tsx')) + ) +} + +function findSourceProjectRoot(startDir: string): string | null { + let current = path.resolve(startDir) + + while (true) { + if (isSourceProjectRoot(current)) { + return current + } + + const parent = path.dirname(current) + if (parent === current) { + return null + } + current = parent + } +} + +export function resolveCronProjectRoot( + options: CronCliResolutionOptions = {}, +): string { + const env = options.env ?? process.env + const explicitRoot = env.CC_HAHA_ROOT?.trim() + if (explicitRoot && isSourceProjectRoot(path.resolve(explicitRoot))) { + return path.resolve(explicitRoot) + } + + const cwdRoot = findSourceProjectRoot(options.cwd ?? process.cwd()) + if (cwdRoot) { + return cwdRoot + } + + const moduleRoot = findSourceProjectRoot(options.moduleDir ?? import.meta.dir) + if (moduleRoot) { + return moduleRoot + } + + return path.resolve(options.moduleDir ?? import.meta.dir, '../../..') +} + +export function buildCronCliArgs( + baseArgs: string[], + options: CronCliResolutionOptions = {}, +): string[] { + const launcher = resolveClaudeCliLauncher({ + cliPath: options.cliPath ?? process.env.CLAUDE_CLI_PATH, + execPath: options.execPath ?? process.execPath, + }) + + if (launcher) { + return buildClaudeCliArgs( + launcher, + baseArgs, + options.appRoot ?? process.env.CLAUDE_APP_ROOT, + ) + } + + const projectRoot = resolveCronProjectRoot(options) + return [ + 'bun', + '--preload', + path.join(projectRoot, 'preload.ts'), + path.join(projectRoot, 'src', 'entrypoints', 'cli.tsx'), + ...baseArgs, + ] +} + export class CronScheduler { private intervalId: Timer | null = null private runningTasks = new Map< @@ -396,11 +481,6 @@ export class CronScheduler { // Persist the "running" state await appendRun(run) - // Resolve paths relative to project root - const projectRoot = path.resolve(import.meta.dir, '../../..') - const cliPath = path.join(projectRoot, 'src/entrypoints/cli.tsx') - const preloadPath = path.join(projectRoot, 'preload.ts') - const inputPayload = JSON.stringify({ type: 'user', message: { @@ -411,25 +491,28 @@ export class CronScheduler { session_id: sessionId || '', }) + '\n' + const cliArgs = buildCronCliArgs([ + '--print', + '--verbose', + '--input-format', + 'stream-json', + '--output-format', + 'stream-json', + ...(sessionId ? ['--session-id', sessionId] : []), + ]) + const proc = Bun.spawn( - [ - 'bun', - '--preload', - preloadPath, - cliPath, - '--print', - '--verbose', - '--input-format', - 'stream-json', - '--output-format', - 'stream-json', - ...(sessionId ? ['--session-id', sessionId] : []), - ], + cliArgs, { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', cwd: workDir, + env: { + ...process.env, + CALLER_DIR: workDir, + PWD: workDir, + }, }, )