- Cron-based task scheduling with SchedulerService (create, pause, resume, delete) - One-shot executor using `claude -p` with stream-json output for rich log display - CLAUDECODE env var stripped to prevent nested session detection - JsonScheduleRepository for persistent schedule/run/log storage - Full IPC pipeline: handlers, preload bridge, API types, HttpClient stubs - ScheduleSection UI with create/edit dialog, run history, status badges - ScheduleRunLogDialog with CliLogsRichView (thinking blocks, tool cards, markdown) - Real-time run status updates via store subscription - Retry logic with configurable max retries and auto-pause on consecutive failures - CronScheduleInput with human-readable descriptions via cronstrue - 3 test suites: SchedulerService, ScheduledTaskExecutor, JsonScheduleRepository
224 lines
6.4 KiB
TypeScript
224 lines
6.4 KiB
TypeScript
/**
|
|
* One-shot executor for scheduled tasks.
|
|
*
|
|
* Spawns `claude -p <prompt>` as a child process with stream-json output,
|
|
* captures stdout/stderr, and returns the result when the process exits.
|
|
*
|
|
* Uses `--output-format stream-json` so the renderer can display rich logs
|
|
* (thinking blocks, tool cards, markdown) via CliLogsRichView.
|
|
*/
|
|
|
|
import { killProcessTree, spawnCli } from '@main/utils/childProcess';
|
|
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
|
import { createLogger } from '@shared/utils/logger';
|
|
|
|
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
|
|
|
|
import type { ScheduleLaunchConfig, ScheduleRun } from '@shared/types';
|
|
import type { ChildProcess } from 'child_process';
|
|
|
|
const logger = createLogger('Service:ScheduledTaskExecutor');
|
|
|
|
const STDOUT_MAX_BYTES = 512 * 1024; // 512KB — stream-json is verbose (JSON wrappers, thinking, tool_use)
|
|
const STDERR_MAX_BYTES = 16 * 1024; // 16KB
|
|
const SUMMARY_MAX_CHARS = 500;
|
|
|
|
/**
|
|
* Extracts a human-readable summary from stream-json stdout.
|
|
* Finds the last assistant message's text content blocks.
|
|
* Falls back to raw stdout slice if parsing yields nothing.
|
|
*/
|
|
function extractSummaryFromStreamJson(stdout: string): string {
|
|
const lines = stdout.split('\n');
|
|
let lastText = '';
|
|
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
const trimmed = lines[i].trim();
|
|
if (!trimmed) continue;
|
|
try {
|
|
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
|
if (parsed.type !== 'assistant') continue;
|
|
|
|
const content = (parsed.content ??
|
|
(parsed.message as Record<string, unknown> | undefined)?.content) as
|
|
| Array<{ type?: string; text?: string }>
|
|
| undefined;
|
|
if (!Array.isArray(content)) continue;
|
|
|
|
for (const block of content) {
|
|
if (block?.type === 'text' && typeof block.text === 'string' && block.text.trim()) {
|
|
lastText = block.text.trim();
|
|
}
|
|
}
|
|
if (lastText) break;
|
|
} catch {
|
|
// skip non-JSON lines
|
|
}
|
|
}
|
|
|
|
return (lastText || stdout).slice(0, SUMMARY_MAX_CHARS);
|
|
}
|
|
|
|
export interface ScheduledTaskResult {
|
|
exitCode: number | null;
|
|
stdout: string;
|
|
stderr: string;
|
|
summary: string;
|
|
durationMs: number;
|
|
}
|
|
|
|
export interface ExecutionRequest {
|
|
runId: string;
|
|
config: ScheduleLaunchConfig;
|
|
maxTurns: number;
|
|
maxBudgetUsd?: number;
|
|
}
|
|
|
|
/**
|
|
* Internal extension of ScheduleRun with pinned storage path.
|
|
* Used by SchedulerService to ensure writes go to the correct path
|
|
* even if claudeRootPath changes mid-run.
|
|
*/
|
|
export interface InternalScheduleRun extends ScheduleRun {
|
|
storageBasePath: string;
|
|
}
|
|
|
|
export class ScheduledTaskExecutor {
|
|
private activeProcesses = new Map<string, ChildProcess>();
|
|
|
|
async execute(request: ExecutionRequest): Promise<ScheduledTaskResult> {
|
|
const startTime = Date.now();
|
|
|
|
const binaryPath = await ClaudeBinaryResolver.resolve();
|
|
if (!binaryPath) {
|
|
throw new Error('Claude CLI binary not found');
|
|
}
|
|
|
|
const shellEnv = await resolveInteractiveShellEnv();
|
|
|
|
const args = this.buildArgs(request);
|
|
|
|
logger.info(`[${request.runId}] Spawning: ${binaryPath} ${args.join(' ')}`);
|
|
|
|
const child = spawnCli(binaryPath, args, {
|
|
cwd: request.config.cwd,
|
|
env: { ...process.env, ...shellEnv, CLAUDECODE: undefined },
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
this.activeProcesses.set(request.runId, child);
|
|
|
|
try {
|
|
const result = await this.waitForExit(child, request.runId);
|
|
const durationMs = Date.now() - startTime;
|
|
|
|
return {
|
|
exitCode: result.exitCode,
|
|
stdout: result.stdout,
|
|
stderr: result.stderr,
|
|
summary: extractSummaryFromStreamJson(result.stdout),
|
|
durationMs,
|
|
};
|
|
} finally {
|
|
this.activeProcesses.delete(request.runId);
|
|
}
|
|
}
|
|
|
|
cancel(runId: string): boolean {
|
|
const child = this.activeProcesses.get(runId);
|
|
if (!child) {
|
|
return false;
|
|
}
|
|
logger.info(`[${runId}] Cancelling active run`);
|
|
killProcessTree(child, 'SIGTERM');
|
|
this.activeProcesses.delete(runId);
|
|
return true;
|
|
}
|
|
|
|
cancelAll(): void {
|
|
for (const [runId, child] of this.activeProcesses) {
|
|
logger.info(`[${runId}] Cancelling (shutdown)`);
|
|
killProcessTree(child, 'SIGTERM');
|
|
}
|
|
this.activeProcesses.clear();
|
|
}
|
|
|
|
get activeCount(): number {
|
|
return this.activeProcesses.size;
|
|
}
|
|
|
|
private buildArgs(request: ExecutionRequest): string[] {
|
|
const { config, maxTurns, maxBudgetUsd } = request;
|
|
const args: string[] = [
|
|
'-p',
|
|
config.prompt,
|
|
'--output-format',
|
|
'stream-json',
|
|
'--verbose',
|
|
'--max-turns',
|
|
String(maxTurns),
|
|
'--no-session-persistence',
|
|
];
|
|
|
|
if (maxBudgetUsd != null) {
|
|
args.push('--max-budget-usd', String(maxBudgetUsd));
|
|
}
|
|
|
|
if (config.model) {
|
|
args.push('--model', config.model);
|
|
}
|
|
|
|
if (config.skipPermissions !== false) {
|
|
args.push('--dangerously-skip-permissions');
|
|
}
|
|
|
|
if (config.allowedTools?.length) {
|
|
args.push('--allowed-tools', config.allowedTools.join(','));
|
|
}
|
|
|
|
if (config.disallowedTools?.length) {
|
|
args.push('--disallowed-tools', config.disallowedTools.join(','));
|
|
}
|
|
|
|
return args;
|
|
}
|
|
|
|
private waitForExit(
|
|
child: ChildProcess,
|
|
runId: string
|
|
): Promise<{ exitCode: number | null; stdout: string; stderr: string }> {
|
|
return new Promise((resolve, reject) => {
|
|
const stdoutChunks: Buffer[] = [];
|
|
const stderrChunks: Buffer[] = [];
|
|
let stdoutBytes = 0;
|
|
let stderrBytes = 0;
|
|
|
|
child.stdout?.on('data', (chunk: Buffer) => {
|
|
if (stdoutBytes < STDOUT_MAX_BYTES) {
|
|
stdoutChunks.push(chunk);
|
|
stdoutBytes += chunk.length;
|
|
}
|
|
});
|
|
|
|
child.stderr?.on('data', (chunk: Buffer) => {
|
|
if (stderrBytes < STDERR_MAX_BYTES) {
|
|
stderrChunks.push(chunk);
|
|
stderrBytes += chunk.length;
|
|
}
|
|
});
|
|
|
|
child.once('error', (error) => {
|
|
logger.error(`[${runId}] Process error: ${error.message}`);
|
|
reject(error);
|
|
});
|
|
|
|
child.once('close', (code) => {
|
|
const stdout = Buffer.concat(stdoutChunks).toString('utf8').slice(0, STDOUT_MAX_BYTES);
|
|
const stderr = Buffer.concat(stderrChunks).toString('utf8').slice(0, STDERR_MAX_BYTES);
|
|
|
|
logger.info(`[${runId}] Process exited with code ${code}`);
|
|
resolve({ exitCode: code, stdout, stderr });
|
|
});
|
|
});
|
|
}
|
|
}
|