mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
fix: launch scheduled tasks through desktop sidecar
Scheduled task Run Now was still constructing a source checkout Bun command, so packaged desktop builds could try to execute src/entrypoints/cli.tsx from a missing or wrong root. Resolve the same launcher used by the desktop runtime first, and keep the source checkout fallback only for development. Constraint: Desktop Release tasks must run through the configured sidecar and app root instead of assuming source files exist on disk Rejected: Retain import.meta.dir path math with broader fallback | still bypasses packaged desktop launcher and repeats the issue root cause Confidence: high Scope-risk: narrow Directive: Do not change scheduled task execution back to direct source CLI invocation without repeating desktop Run Now E2E Tested: bun test src/server/__tests__/cron-scheduler-launcher.test.ts src/server/__tests__/cron-scheduler.test.ts Tested: bun run check:server Tested: bun run check:native Tested: Desktop UI E2E via scheduled task page create task and Run Now with fake sidecar Not-tested: Full packaged macOS .app click test
This commit is contained in:
parent
8b07c16f45
commit
165d914d5a
201
src/server/__tests__/cron-scheduler-launcher.test.ts
Normal file
201
src/server/__tests__/cron-scheduler-launcher.test.ts
Normal file
@ -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<string> {
|
||||
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<void> {
|
||||
await fs.rm(dir, { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
|
||||
async function createSourceRoot(root: string): Promise<void> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user