mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +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 { CronService, type CronTask } from './cronService.js'
|
||||||
import { SessionService } from './sessionService.js'
|
import { SessionService } from './sessionService.js'
|
||||||
import { sendTaskNotification } from './notificationService.js'
|
import { sendTaskNotification } from './notificationService.js'
|
||||||
|
import {
|
||||||
|
buildClaudeCliArgs,
|
||||||
|
resolveClaudeCliLauncher,
|
||||||
|
} from '../../utils/desktopBundledCli.js'
|
||||||
|
|
||||||
// ─── Types ─────────────────────────────────────────────────────────────────────
|
// ─── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -242,6 +246,87 @@ function trimRuns(data: RunsFile): void {
|
|||||||
|
|
||||||
const TASK_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes
|
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 {
|
export class CronScheduler {
|
||||||
private intervalId: Timer | null = null
|
private intervalId: Timer | null = null
|
||||||
private runningTasks = new Map<
|
private runningTasks = new Map<
|
||||||
@ -396,11 +481,6 @@ export class CronScheduler {
|
|||||||
// Persist the "running" state
|
// Persist the "running" state
|
||||||
await appendRun(run)
|
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({
|
const inputPayload = JSON.stringify({
|
||||||
type: 'user',
|
type: 'user',
|
||||||
message: {
|
message: {
|
||||||
@ -411,25 +491,28 @@ export class CronScheduler {
|
|||||||
session_id: sessionId || '',
|
session_id: sessionId || '',
|
||||||
}) + '\n'
|
}) + '\n'
|
||||||
|
|
||||||
|
const cliArgs = buildCronCliArgs([
|
||||||
|
'--print',
|
||||||
|
'--verbose',
|
||||||
|
'--input-format',
|
||||||
|
'stream-json',
|
||||||
|
'--output-format',
|
||||||
|
'stream-json',
|
||||||
|
...(sessionId ? ['--session-id', sessionId] : []),
|
||||||
|
])
|
||||||
|
|
||||||
const proc = Bun.spawn(
|
const proc = Bun.spawn(
|
||||||
[
|
cliArgs,
|
||||||
'bun',
|
|
||||||
'--preload',
|
|
||||||
preloadPath,
|
|
||||||
cliPath,
|
|
||||||
'--print',
|
|
||||||
'--verbose',
|
|
||||||
'--input-format',
|
|
||||||
'stream-json',
|
|
||||||
'--output-format',
|
|
||||||
'stream-json',
|
|
||||||
...(sessionId ? ['--session-id', sessionId] : []),
|
|
||||||
],
|
|
||||||
{
|
{
|
||||||
stdin: 'pipe',
|
stdin: 'pipe',
|
||||||
stdout: 'pipe',
|
stdout: 'pipe',
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
cwd: workDir,
|
cwd: workDir,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
CALLER_DIR: workDir,
|
||||||
|
PWD: workDir,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user