Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions locales/en/schedule.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@
},
"enabled": "Enabled",
"disabled": "Disabled",
"activeSchedulesTitle": "Active Schedules",
"activeSchedulesDescription": "In-memory cron state currently registered by the scheduler.",
"noActiveSchedules": "No schedules are currently registered in memory.",
"nextRun": "Next run",
"agentLabel": "Agent",
"activeState": {
"active": "Active",
"inactive": "Inactive",
"executing": "Executing"
},
"lastRun": "Last run",
"cron": "Cron",
"exitCode": "Exit",
Expand Down
10 changes: 10 additions & 0 deletions locales/ja/schedule.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@
},
"enabled": "有効",
"disabled": "無効",
"activeSchedulesTitle": "Active Schedules",
"activeSchedulesDescription": "DB設定ではなく、現在メモリ上で登録されている cron state です。",
"noActiveSchedules": "現在メモリ上で登録されている schedule はありません。",
"nextRun": "次回実行",
"agentLabel": "Agent",
"activeState": {
"active": "稼働中",
"inactive": "停止",
"executing": "実行中"
},
"lastRun": "最終実行",
"cron": "Cron式",
"exitCode": "終了コード",
Expand Down
35 changes: 35 additions & 0 deletions src/app/api/worktrees/[id]/schedules/active/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import { getDbInstance } from '@/lib/db/db-instance';
import { getWorktreeById } from '@/lib/db';
import { isValidWorktreeId } from '@/lib/security/path-validator';
import { getActiveSchedulesForWorktree } from '@/lib/schedule-manager';
import { createLogger } from '@/lib/logger';

const logger = createLogger('api/schedules/active');

export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
void request;

try {
if (!isValidWorktreeId(params.id)) {
return NextResponse.json({ error: 'Invalid worktree ID format' }, { status: 400 });
}

const db = getDbInstance();
const worktree = getWorktreeById(db, params.id);
if (!worktree) {
return NextResponse.json({ error: `Worktree '${params.id}' not found` }, { status: 404 });
}

const schedules = getActiveSchedulesForWorktree(params.id);
return NextResponse.json({ schedules }, { status: 200 });
} catch (error) {
logger.error('error-fetching-active-schedules:', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json({ error: 'Failed to fetch active schedules' }, { status: 500 });
}
}
13 changes: 10 additions & 3 deletions src/app/api/worktrees/[id]/slash-commands/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getDbInstance } from '@/lib/db/db-instance';
import { getWorktreeById } from '@/lib/db';
import { getSlashCommandGroups, loadCodexSkills, loadCodexPrompts } from '@/lib/slash-commands';
import { getSlashCommandGroups, loadCodexSkills, loadCodexPrompts, getCopilotBuiltinCommands } from '@/lib/slash-commands';
import { getStandardCommandGroups } from '@/lib/standard-commands';
import { mergeCommandGroups, filterCommandsByCliTool } from '@/lib/command-merger';
import { mergeCommandGroups, filterCommandsByCliTool, groupByCategory } from '@/lib/command-merger';
import { isValidWorktreePath } from '@/lib/security/worktree-path-validator';
import { CLI_TOOL_IDS, type CLIToolType } from '@/lib/cli-tools/types';
import type { SlashCommandGroup } from '@/types/slash-commands';
Expand Down Expand Up @@ -111,7 +111,14 @@ export async function GET(
const globalCodexGroups: SlashCommandGroup[] = allGlobalCodex.length > 0
? [{ category: 'skill' as const, label: 'Skills', commands: allGlobalCodex }]
: [];
const mergedGroups = mergeCommandGroups(standardGroups, [...worktreeGroups, ...globalCodexGroups]);

// Issue #586: Copilot builtins are only injected when cliTool is 'copilot'
// to prevent overriding Claude standard commands with same names (clear, model, etc.)
const copilotBuiltinGroups: SlashCommandGroup[] = cliTool === 'copilot'
? groupByCategory(getCopilotBuiltinCommands())
: [];

const mergedGroups = mergeCommandGroups(standardGroups, [...worktreeGroups, ...globalCodexGroups, ...copilotBuiltinGroups]);

// Issue #4: Filter by CLI tool
const filteredGroups = filterCommandsByCliTool(mergedGroups, cliTool);
Expand Down
63 changes: 62 additions & 1 deletion src/components/worktree/ExecutionLogPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ interface Schedule {
updated_at: number;
}

interface ActiveSchedule {
scheduleId: string;
worktreeId: string;
name: string;
cronExpression: string;
cliToolId: string;
enabled: boolean;
isExecuting: boolean;
isCronActive: boolean;
nextRunAt: number | null;
}

export interface ExecutionLogPaneProps {
worktreeId: string;
className?: string;
Expand Down Expand Up @@ -104,6 +116,7 @@ export const ExecutionLogPane = memo(function ExecutionLogPane({
const t = useTranslations('schedule');
const [logs, setLogs] = useState<ExecutionLog[]>([]);
const [schedules, setSchedules] = useState<Schedule[]>([]);
const [activeSchedules, setActiveSchedules] = useState<ActiveSchedule[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedLogId, setExpandedLogId] = useState<string | null>(null);
Expand All @@ -114,9 +127,10 @@ export const ExecutionLogPane = memo(function ExecutionLogPane({
setError(null);

try {
const [logsRes, schedulesRes] = await Promise.all([
const [logsRes, schedulesRes, activeRes] = await Promise.all([
fetch(`/api/worktrees/${worktreeId}/execution-logs`),
fetch(`/api/worktrees/${worktreeId}/schedules`),
fetch(`/api/worktrees/${worktreeId}/schedules/active`),
]);

if (logsRes.ok) {
Expand All @@ -128,6 +142,13 @@ export const ExecutionLogPane = memo(function ExecutionLogPane({
const schedulesData = await schedulesRes.json();
setSchedules(schedulesData.schedules || []);
}

if (activeRes.ok) {
const activeData = await activeRes.json();
setActiveSchedules(activeData.schedules || []);
} else {
setActiveSchedules([]);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch data');
} finally {
Expand Down Expand Up @@ -191,6 +212,46 @@ export const ExecutionLogPane = memo(function ExecutionLogPane({
{/* Schedules Section */}
<div>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-200 mb-2">{t('title')} ({schedules.length})</h3>
<div className="mb-3 rounded border border-cyan-100 bg-cyan-50/60 p-3 dark:border-cyan-900/40 dark:bg-cyan-950/20">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-semibold text-cyan-900 dark:text-cyan-200">
{t('activeSchedulesTitle')} ({activeSchedules.length})
</span>
<span className="text-xs text-cyan-700 dark:text-cyan-300">
{t('activeSchedulesDescription')}
</span>
</div>
{activeSchedules.length === 0 ? (
<p className="text-xs text-cyan-800 dark:text-cyan-300">{t('noActiveSchedules')}</p>
) : (
<div className="space-y-2">
{activeSchedules.map((schedule) => (
<div key={schedule.scheduleId} className="rounded border border-cyan-200/80 bg-white/80 p-3 dark:border-cyan-900/40 dark:bg-gray-900/60">
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-sm text-gray-900 dark:text-gray-100">{schedule.name}</span>
<div className="flex items-center gap-2">
<span className={`text-xs px-2 py-0.5 rounded ${schedule.isCronActive ? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300' : 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-300'}`}>
{schedule.isCronActive ? t('activeState.active') : t('activeState.inactive')}
</span>
{schedule.isExecuting && (
<span className="text-xs px-2 py-0.5 rounded bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300">
{t('activeState.executing')}
</span>
)}
</div>
</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-400">
<span>{t('cron')}: {schedule.cronExpression || 'N/A'}</span>
<span className="ml-3">{t('agentLabel')}: {schedule.cliToolId}</span>
{schedule.nextRunAt && (
<span className="ml-3">{t('nextRun')}: {formatTimestamp(schedule.nextRunAt)}</span>
)}
</div>
</div>
))}
</div>
)}
</div>
{schedules.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<p className="font-medium text-gray-600 dark:text-gray-300 mb-3">{t('noSchedulesTitle')}</p>
Expand Down
140 changes: 118 additions & 22 deletions src/lib/schedule-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,58 @@ interface ManagerState {
cmateFileCache: Map<string, number>;
}

export interface ActiveScheduleInfo {
scheduleId: string;
worktreeId: string;
name: string;
cronExpression: string;
cliToolId: string;
enabled: boolean;
isExecuting: boolean;
isCronActive: boolean;
nextRunAt: number | null;
}

function isCronJobActive(cronJob: import('croner').Cron): boolean {
try {
if (typeof cronJob.isStopped === 'function') {
return !cronJob.isStopped();
}
if (typeof cronJob.isRunning === 'function') {
return cronJob.isRunning();
}
} catch {
return false;
}

return true;
}

function createScheduleState(
worktreeId: string,
scheduleId: string,
entry: ScheduleState['entry']
): ScheduleState {
const cronJob = new Cron(entry.cronExpression, {
paused: false,
protect: true,
});

const state: ScheduleState = {
scheduleId,
worktreeId,
cronJob,
isExecuting: false,
entry,
};

cronJob.schedule(() => {
void executeSchedule(state);
});

return state;
}

// =============================================================================
// Global State (hot reload persistence)
// =============================================================================
Expand Down Expand Up @@ -168,15 +220,27 @@ async function syncSchedules(): Promise<void> {
continue;
}

const worktreeScheduleIds: string[] = [];
let hasInactiveState = false;
for (const [scheduleId, state] of manager.schedules) {
if (state.worktreeId !== worktree.id) continue;
worktreeScheduleIds.push(scheduleId);
if (!isCronJobActive(state.cronJob)) {
hasInactiveState = true;
}
}

// If mtime matches cached value, skip DB operations for this worktree
if (cachedMtime !== undefined && cachedMtime === mtime) {
// File unchanged - re-add existing schedule IDs to keep them active
for (const [scheduleId, state] of manager.schedules) {
if (state.worktreeId === worktree.id) {
if (!hasInactiveState) {
// File unchanged - re-add existing schedule IDs to keep them active
for (const scheduleId of worktreeScheduleIds) {
activeScheduleIds.add(scheduleId);
}
continue;
}
continue;

logger.warn('schedule:inactive-state-detected', { worktreeId: worktree.id });
}

// Update mtime cache
Expand Down Expand Up @@ -219,31 +283,27 @@ async function syncSchedules(): Promise<void> {
// Check if this schedule already has a running cron job
const existingState = manager.schedules.get(scheduleId);
if (existingState) {
if (!isCronJobActive(existingState.cronJob)) {
try {
existingState.cronJob.stop();
} catch {
// Ignore cleanup errors for inactive cron jobs
}

const recoveredState = createScheduleState(worktree.id, scheduleId, entry);
manager.schedules.set(scheduleId, recoveredState);
logger.warn('schedule:recreated-inactive', { name: entry.name, cron: entry.cronExpression });
continue;
}

// Update entry if changed
existingState.entry = entry;
continue;
}

// Create new cron job
try {
const cronJob = new Cron(entry.cronExpression, {
paused: false,
protect: true, // Prevent overlapping
});

const state: ScheduleState = {
scheduleId,
worktreeId: worktree.id,
cronJob,
isExecuting: false,
entry,
};

// Schedule execution
cronJob.schedule(() => {
void executeSchedule(state);
});

const state = createScheduleState(worktree.id, scheduleId, entry);
manager.schedules.set(scheduleId, state);
logger.info('schedule:created', { name: entry.name, cron: entry.cronExpression });
} catch (cronError) {
Expand Down Expand Up @@ -425,3 +485,39 @@ export function getScheduleWorktreeIds(): string[] {
}
return Array.from(worktreeIds);
}

export function getActiveSchedulesForWorktree(worktreeId: string): ActiveScheduleInfo[] {
const manager = getManagerState();
const schedules: ActiveScheduleInfo[] = [];

for (const [scheduleId, state] of manager.schedules) {
if (state.worktreeId !== worktreeId) continue;

let nextRunAt: number | null = null;
try {
const nextRun = typeof state.cronJob.nextRun === 'function'
? state.cronJob.nextRun()
: null;
if (nextRun instanceof Date) {
nextRunAt = nextRun.getTime();
}
} catch {
nextRunAt = null;
}

schedules.push({
scheduleId,
worktreeId,
name: state.entry.name,
cronExpression: state.entry.cronExpression,
cliToolId: state.entry.cliToolId,
enabled: state.entry.enabled,
isExecuting: state.isExecuting,
isCronActive: isCronJobActive(state.cronJob),
nextRunAt,
});
}

schedules.sort((a, b) => a.name.localeCompare(b.name));
return schedules;
}
4 changes: 2 additions & 2 deletions src/lib/slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,7 @@ export async function getSlashCommandGroups(basePath?: string): Promise<SlashCom
const skills = await loadSkills(basePath);
const codexLocalSkills = await loadCodexSkills(basePath);
const codexLocalPrompts = await loadCodexPrompts(basePath);
const deduplicated = deduplicateByName([...skills, ...codexLocalSkills, ...codexLocalPrompts, ...getCopilotBuiltinCommands()], commands);
const deduplicated = deduplicateByName([...skills, ...codexLocalSkills, ...codexLocalPrompts], commands);
return groupByCategory(deduplicated);
}

Expand All @@ -537,7 +537,7 @@ export async function getSlashCommandGroups(basePath?: string): Promise<SlashCom
// Intentional: skillsCache is populated here; loadSkills does not manage its own cache
skillsCache = await loadSkills().catch(() => []);
}
const deduplicated = deduplicateByName([...skillsCache, ...getCopilotBuiltinCommands()], commandsCache);
const deduplicated = deduplicateByName([...skillsCache], commandsCache);
return groupByCategory(deduplicated);
}

Expand Down
Loading
Loading