mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(cron): complete smart scheduling flow from chat + polish cron management UI (#2943)
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
dd1439e2dc
commit
fb8068e92e
@@ -0,0 +1,359 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// ── Mock i18n ───────────────────────────────────────────────────────────
|
||||
// Stable identity so useT-derived useCallback deps don't churn (would
|
||||
// otherwise re-fire loadCoreCronJobsOnly's useEffect every render and
|
||||
// stomp coreError back to null mid-test).
|
||||
const stableI18n = { t: (k: string) => k };
|
||||
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => stableI18n }));
|
||||
|
||||
// ── Mock navigation ─────────────────────────────────────────────────────
|
||||
vi.mock('../hooks/useSettingsNavigation', () => ({
|
||||
useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }),
|
||||
}));
|
||||
|
||||
// ── Mock SettingsHeader ─────────────────────────────────────────────────
|
||||
vi.mock('../components/SettingsHeader', () => ({
|
||||
default: ({ title }: { title: string }) => <div data-testid="settings-header">{title}</div>,
|
||||
}));
|
||||
|
||||
// ── Mock CronJobFormModal ───────────────────────────────────────────────
|
||||
// The modal is independently tested; here we just verify it opens/closes
|
||||
// and the callbacks fire.
|
||||
const mockModalOnCreate = vi.fn();
|
||||
const mockModalOnUpdate = vi.fn();
|
||||
|
||||
vi.mock('./cron/CronJobFormModal', () => ({
|
||||
default: ({
|
||||
open,
|
||||
mode,
|
||||
job,
|
||||
onClose,
|
||||
onCreate,
|
||||
onUpdate,
|
||||
}: {
|
||||
open: boolean;
|
||||
mode: string;
|
||||
job?: { id: string };
|
||||
onClose: () => void;
|
||||
onCreate: (p: unknown) => Promise<void>;
|
||||
onUpdate: (id: string, p: unknown) => Promise<void>;
|
||||
}) => {
|
||||
if (!open) return null;
|
||||
// Capture the callbacks on each render so tests can invoke them
|
||||
mockModalOnCreate.mockImplementation(onCreate);
|
||||
mockModalOnUpdate.mockImplementation(onUpdate);
|
||||
return (
|
||||
<div data-testid={`cron-form-modal-${mode}`}>
|
||||
<span data-testid="modal-job-id">{job?.id ?? ''}</span>
|
||||
<button data-testid="modal-close" onClick={onClose}>
|
||||
close
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Mock tauriCommands ──────────────────────────────────────────────────
|
||||
const cronAddMock = vi.fn();
|
||||
const cronListMock = vi.fn();
|
||||
const cronUpdateMock = vi.fn();
|
||||
const cronRemoveMock = vi.fn();
|
||||
const cronRunMock = vi.fn();
|
||||
const cronRunsMock = vi.fn();
|
||||
|
||||
vi.mock('../../../utils/tauriCommands', () => ({
|
||||
openhumanCronAdd: (...args: unknown[]) => cronAddMock(...args),
|
||||
openhumanCronList: () => cronListMock(),
|
||||
openhumanCronUpdate: (...args: unknown[]) => cronUpdateMock(...args),
|
||||
openhumanCronRemove: (...args: unknown[]) => cronRemoveMock(...args),
|
||||
openhumanCronRun: (...args: unknown[]) => cronRunMock(...args),
|
||||
openhumanCronRuns: (...args: unknown[]) => cronRunsMock(...args),
|
||||
}));
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
const sampleJob = {
|
||||
id: 'job-1',
|
||||
expression: '*/30 * * * *',
|
||||
schedule: { kind: 'cron', expr: '*/30 * * * *' },
|
||||
command: '',
|
||||
name: 'Daily Briefing',
|
||||
job_type: 'agent',
|
||||
session_target: 'isolated',
|
||||
enabled: true,
|
||||
delivery: { mode: 'proactive', best_effort: true },
|
||||
delete_after_run: false,
|
||||
created_at: '2026-05-01T00:00:00.000Z',
|
||||
next_run: '2026-06-01T09:00:00.000Z',
|
||||
prompt: 'Summarise the news',
|
||||
};
|
||||
|
||||
async function importPanel() {
|
||||
vi.resetModules();
|
||||
const mod = await import('./CronJobsPanel');
|
||||
return mod.default;
|
||||
}
|
||||
|
||||
describe('<CronJobsPanel />', () => {
|
||||
beforeEach(() => {
|
||||
[
|
||||
cronListMock,
|
||||
cronAddMock,
|
||||
cronUpdateMock,
|
||||
cronRemoveMock,
|
||||
cronRunMock,
|
||||
cronRunsMock,
|
||||
mockModalOnCreate,
|
||||
mockModalOnUpdate,
|
||||
].forEach(fn => fn.mockReset());
|
||||
cronListMock.mockResolvedValue({ result: [sampleJob] });
|
||||
cronAddMock.mockResolvedValue({ result: { ...sampleJob, id: 'job-new' } });
|
||||
cronUpdateMock.mockResolvedValue({ result: sampleJob });
|
||||
cronRemoveMock.mockResolvedValue({ result: { job_id: 'job-1', removed: true } });
|
||||
cronRunMock.mockResolvedValue({ result: {} });
|
||||
cronRunsMock.mockResolvedValue({ result: [] });
|
||||
});
|
||||
|
||||
it('renders the "+ New Scheduled Job" button', async () => {
|
||||
const Panel = await importPanel();
|
||||
render(<Panel />);
|
||||
await waitFor(() => expect(cronListMock).toHaveBeenCalled());
|
||||
expect(screen.getByTestId('cron-new-job')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking "+ New Scheduled Job" opens create modal', async () => {
|
||||
const Panel = await importPanel();
|
||||
render(<Panel />);
|
||||
await waitFor(() => expect(cronListMock).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByTestId('cron-new-job'));
|
||||
expect(screen.getByTestId('cron-form-modal-create')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('onCreate triggers openhumanCronAdd and refresh', async () => {
|
||||
const Panel = await importPanel();
|
||||
render(<Panel />);
|
||||
await waitFor(() => expect(cronListMock).toHaveBeenCalled());
|
||||
|
||||
// Open create modal
|
||||
fireEvent.click(screen.getByTestId('cron-new-job'));
|
||||
await waitFor(() => expect(screen.getByTestId('cron-form-modal-create')).toBeInTheDocument());
|
||||
|
||||
// Invoke create via captured mock callback
|
||||
const params = {
|
||||
schedule: { kind: 'cron', expr: '0 9 * * *' },
|
||||
job_type: 'agent',
|
||||
prompt: 'hi',
|
||||
};
|
||||
await mockModalOnCreate(params);
|
||||
|
||||
await waitFor(() => expect(cronAddMock).toHaveBeenCalledWith(params));
|
||||
// List should be refreshed (at least 2 calls total: initial + refresh)
|
||||
expect(cronListMock.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('edit button click opens edit modal with the correct job', async () => {
|
||||
const Panel = await importPanel();
|
||||
render(<Panel />);
|
||||
await waitFor(() => expect(cronListMock).toHaveBeenCalled());
|
||||
|
||||
// The mock CoreJobList renders are replaced here — we need to simulate the
|
||||
// onEditCoreJob callback by clicking the edit button rendered by the real CoreJobList.
|
||||
// Since CoreJobList is NOT mocked, it renders actual buttons.
|
||||
const editBtn = await screen.findByTestId('cron-job-edit-job-1');
|
||||
fireEvent.click(editBtn);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('cron-form-modal-edit')).toBeInTheDocument());
|
||||
expect(screen.getByTestId('modal-job-id')).toHaveTextContent('job-1');
|
||||
});
|
||||
|
||||
it('onUpdate triggers openhumanCronUpdate and refresh', async () => {
|
||||
const Panel = await importPanel();
|
||||
render(<Panel />);
|
||||
await waitFor(() => expect(cronListMock).toHaveBeenCalled());
|
||||
|
||||
// Open edit modal via edit button
|
||||
const editBtn = await screen.findByTestId('cron-job-edit-job-1');
|
||||
fireEvent.click(editBtn);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('cron-form-modal-edit')).toBeInTheDocument());
|
||||
|
||||
const patch = { name: 'Updated', schedule: { kind: 'cron', expr: '0 9 * * *' } };
|
||||
await mockModalOnUpdate('job-1', patch);
|
||||
|
||||
await waitFor(() => expect(cronUpdateMock).toHaveBeenCalledWith('job-1', patch));
|
||||
expect(cronListMock.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('surfaces errorLoadList when openhumanCronList rejects', async () => {
|
||||
cronListMock.mockRejectedValueOnce(new Error('boom'));
|
||||
const Panel = await importPanel();
|
||||
render(<Panel />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('settings.cron.jobs.errorLoadList')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces errorToggle when openhumanCronUpdate rejects on toggle', async () => {
|
||||
cronUpdateMock.mockRejectedValueOnce(new Error('nope'));
|
||||
const Panel = await importPanel();
|
||||
render(<Panel />);
|
||||
await waitFor(() => expect(cronListMock).toHaveBeenCalled());
|
||||
|
||||
const toggle = await screen.findByTestId('cron-job-toggle-job-1');
|
||||
fireEvent.click(toggle);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('settings.cron.jobs.errorToggle')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('successful toggle replaces job in state', async () => {
|
||||
cronUpdateMock.mockResolvedValueOnce({ result: { ...sampleJob, enabled: false } });
|
||||
const Panel = await importPanel();
|
||||
render(<Panel />);
|
||||
await waitFor(() => expect(cronListMock).toHaveBeenCalled());
|
||||
|
||||
const toggle = await screen.findByTestId('cron-job-toggle-job-1');
|
||||
fireEvent.click(toggle);
|
||||
|
||||
await waitFor(() => expect(cronUpdateMock).toHaveBeenCalledWith('job-1', { enabled: false }));
|
||||
});
|
||||
|
||||
it('runCoreJob invokes cronRun + cronRuns + cronList; surfaces errorRun on failure', async () => {
|
||||
cronRunsMock.mockResolvedValueOnce({ result: [{ id: 1, status: 'ok' }] });
|
||||
const Panel = await importPanel();
|
||||
render(<Panel />);
|
||||
await waitFor(() => expect(cronListMock).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(await screen.findByTestId('cron-job-run-job-1'));
|
||||
await waitFor(() => expect(cronRunMock).toHaveBeenCalledWith('job-1'));
|
||||
await waitFor(() => expect(cronRunsMock).toHaveBeenCalledWith('job-1', 10));
|
||||
|
||||
// Then trigger a failure
|
||||
cronRunMock.mockRejectedValueOnce(new Error('explode'));
|
||||
fireEvent.click(screen.getByTestId('cron-job-run-job-1'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('settings.cron.jobs.errorRun')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('loadCoreRuns invokes cronRuns; surfaces errorLoadRuns on failure', async () => {
|
||||
const Panel = await importPanel();
|
||||
render(<Panel />);
|
||||
await waitFor(() => expect(cronListMock).toHaveBeenCalled());
|
||||
|
||||
cronRunsMock.mockResolvedValueOnce({ result: [{ id: 9, status: 'ok' }] });
|
||||
fireEvent.click(await screen.findByTestId('cron-job-view-runs-job-1'));
|
||||
await waitFor(() => expect(cronRunsMock).toHaveBeenCalledWith('job-1', 10));
|
||||
|
||||
cronRunsMock.mockRejectedValueOnce(new Error('runs-failed'));
|
||||
fireEvent.click(screen.getByTestId('cron-job-view-runs-job-1'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('settings.cron.jobs.errorLoadRuns')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('removeCoreJob removes from list; surfaces errorRemove on failure', async () => {
|
||||
const Panel = await importPanel();
|
||||
render(<Panel />);
|
||||
await waitFor(() => expect(cronListMock).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(await screen.findByTestId('cron-job-remove-job-1'));
|
||||
await waitFor(() => expect(cronRemoveMock).toHaveBeenCalledWith('job-1'));
|
||||
|
||||
// Re-load and trigger failure path
|
||||
cronListMock.mockResolvedValueOnce({ result: [sampleJob] });
|
||||
cronRemoveMock.mockRejectedValueOnce(new Error('remove-failed'));
|
||||
// Force re-render by refreshing
|
||||
fireEvent.click(screen.getByTestId('cron-refresh'));
|
||||
await waitFor(() => expect(cronListMock).toHaveBeenCalled());
|
||||
const removeBtn = await screen.findByTestId('cron-job-remove-job-1');
|
||||
fireEvent.click(removeBtn);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('settings.cron.jobs.errorRemove')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handleCreate surfaces errorCreate and re-throws when cronAdd rejects', async () => {
|
||||
cronAddMock.mockRejectedValueOnce(new Error('add-failed'));
|
||||
const Panel = await importPanel();
|
||||
render(<Panel />);
|
||||
await waitFor(() => expect(cronListMock).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByTestId('cron-new-job'));
|
||||
await waitFor(() => expect(screen.getByTestId('cron-form-modal-create')).toBeInTheDocument());
|
||||
|
||||
const params = {
|
||||
schedule: { kind: 'cron', expr: '0 9 * * *' },
|
||||
job_type: 'agent',
|
||||
prompt: 'hi',
|
||||
};
|
||||
let caught: unknown = null;
|
||||
try {
|
||||
await mockModalOnCreate(params);
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect((caught as Error).message).toBe('add-failed');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('settings.cron.jobs.errorCreate')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handleUpdate surfaces errorUpdate and re-throws when cronUpdate rejects', async () => {
|
||||
cronUpdateMock.mockRejectedValueOnce(new Error('update-failed'));
|
||||
const Panel = await importPanel();
|
||||
render(<Panel />);
|
||||
await waitFor(() => expect(cronListMock).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(await screen.findByTestId('cron-job-edit-job-1'));
|
||||
await waitFor(() => expect(screen.getByTestId('cron-form-modal-edit')).toBeInTheDocument());
|
||||
|
||||
const patch = {
|
||||
name: 'Updated',
|
||||
schedule: { kind: 'cron', expr: '0 9 * * *' },
|
||||
session_target: 'isolated',
|
||||
delete_after_run: false,
|
||||
};
|
||||
let caught: unknown = null;
|
||||
try {
|
||||
await mockModalOnUpdate('job-1', patch);
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect((caught as Error).message).toBe('update-failed');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('settings.cron.jobs.errorUpdate')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('closing the create modal hides it', async () => {
|
||||
const Panel = await importPanel();
|
||||
render(<Panel />);
|
||||
await waitFor(() => expect(cronListMock).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByTestId('cron-new-job'));
|
||||
await waitFor(() => expect(screen.getByTestId('cron-form-modal-create')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('cron-form-modal-create')).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('closing the edit modal clears editingJob', async () => {
|
||||
const Panel = await importPanel();
|
||||
render(<Panel />);
|
||||
await waitFor(() => expect(cronListMock).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(await screen.findByTestId('cron-job-edit-job-1'));
|
||||
await waitFor(() => expect(screen.getByTestId('cron-form-modal-edit')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('cron-form-modal-edit')).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,8 @@ import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import {
|
||||
type CoreCronJob,
|
||||
type CoreCronRun,
|
||||
type CronAddParams,
|
||||
openhumanCronAdd,
|
||||
openhumanCronList,
|
||||
openhumanCronRemove,
|
||||
openhumanCronRun,
|
||||
@@ -14,12 +16,17 @@ import {
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
import CoreJobList from './cron/CoreJobList';
|
||||
import CronJobFormModal from './cron/CronJobFormModal';
|
||||
|
||||
const loadCronJobsLog = createDebug('app:settings:CronJobsPanel:loadCronSkills');
|
||||
|
||||
const CronJobsPanel = () => {
|
||||
const { t } = useT();
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
const formatCronError = useCallback(
|
||||
(key: string, message: string) => t(key).replace('{message}', message),
|
||||
[t]
|
||||
);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [coreError, setCoreError] = useState<string | null>(null);
|
||||
@@ -28,6 +35,10 @@ const CronJobsPanel = () => {
|
||||
const [coreRunsByJob, setCoreRunsByJob] = useState<Record<string, CoreCronRun[]>>({});
|
||||
const [coreBusyKey, setCoreBusyKey] = useState<string | null>(null);
|
||||
|
||||
// Create / edit modal state
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingJob, setEditingJob] = useState<CoreCronJob | null>(null);
|
||||
|
||||
const loadCoreCronJobs = useCallback(async () => {
|
||||
const response = await openhumanCronList();
|
||||
const sorted = [...response.result].sort((a, b) => {
|
||||
@@ -49,11 +60,11 @@ const CronJobsPanel = () => {
|
||||
} catch (err) {
|
||||
loadCronJobsLog('failure', err);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setCoreError(`Failed to load core cron jobs: ${message}`);
|
||||
setCoreError(formatCronError('settings.cron.jobs.errorLoadList', message));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadCoreCronJobs]);
|
||||
}, [formatCronError, loadCoreCronJobs]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCoreCronJobsOnly();
|
||||
@@ -69,7 +80,7 @@ const CronJobsPanel = () => {
|
||||
setCoreJobs(prev => prev.map(item => (item.id === updated.id ? updated : item)));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setCoreError(`Failed to update core cron job: ${message}`);
|
||||
setCoreError(formatCronError('settings.cron.jobs.errorToggle', message));
|
||||
} finally {
|
||||
setCoreBusyKey(null);
|
||||
}
|
||||
@@ -87,7 +98,7 @@ const CronJobsPanel = () => {
|
||||
await loadCoreCronJobs();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setCoreError(`Failed to run core cron job: ${message}`);
|
||||
setCoreError(formatCronError('settings.cron.jobs.errorRun', message));
|
||||
} finally {
|
||||
setCoreBusyKey(null);
|
||||
}
|
||||
@@ -103,12 +114,55 @@ const CronJobsPanel = () => {
|
||||
setCoreRunsByJob(prev => ({ ...prev, [jobId]: runs.result }));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setCoreError(`Failed to load run history: ${message}`);
|
||||
setCoreError(formatCronError('settings.cron.jobs.errorLoadRuns', message));
|
||||
} finally {
|
||||
setCoreBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async (params: CronAddParams) => {
|
||||
loadCronJobsLog('handleCreate metadata=%o', {
|
||||
jobType: params.job_type,
|
||||
scheduleKind: params.schedule.kind,
|
||||
hasName: Boolean(params.name),
|
||||
hasSessionTarget: Boolean(params.session_target),
|
||||
deleteAfterRun: params.delete_after_run,
|
||||
});
|
||||
try {
|
||||
await openhumanCronAdd(params);
|
||||
await loadCoreCronJobs();
|
||||
setFormOpen(false);
|
||||
loadCronJobsLog('handleCreate success');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
loadCronJobsLog('handleCreate error: %s', message);
|
||||
setCoreError(formatCronError('settings.cron.jobs.errorCreate', message));
|
||||
throw err; // Re-throw so modal can surface inline error
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (jobId: string, patch: Record<string, unknown>) => {
|
||||
const patchSchedule = patch.schedule as { kind?: string } | undefined;
|
||||
loadCronJobsLog('handleUpdate metadata=%o', {
|
||||
jobId,
|
||||
scheduleKind: patchSchedule?.kind ?? 'unknown',
|
||||
hasName: patch.name !== null,
|
||||
hasSessionTarget: 'session_target' in patch,
|
||||
deleteAfterRun: patch.delete_after_run,
|
||||
});
|
||||
try {
|
||||
await openhumanCronUpdate(jobId, patch);
|
||||
await loadCoreCronJobs();
|
||||
setEditingJob(null);
|
||||
loadCronJobsLog('handleUpdate success');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
loadCronJobsLog('handleUpdate error: %s', message);
|
||||
setCoreError(formatCronError('settings.cron.jobs.errorUpdate', message));
|
||||
throw err; // Re-throw so modal can surface inline error
|
||||
}
|
||||
};
|
||||
|
||||
const removeCoreJob = async (jobId: string) => {
|
||||
const key = `core-remove:${jobId}`;
|
||||
setCoreBusyKey(key);
|
||||
@@ -124,7 +178,7 @@ const CronJobsPanel = () => {
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setCoreError(`Failed to remove core cron job: ${message}`);
|
||||
setCoreError(formatCronError('settings.cron.jobs.errorRemove', message));
|
||||
} finally {
|
||||
setCoreBusyKey(null);
|
||||
}
|
||||
@@ -147,6 +201,20 @@ const CronJobsPanel = () => {
|
||||
<p className="text-xs text-stone-400 dark:text-neutral-500">{t('cron.manageCronJobs')}</p>
|
||||
</section>
|
||||
|
||||
{/* "+ New Scheduled Job" button */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="cron-new-job"
|
||||
className="inline-flex items-center rounded-xl border border-primary-700/30 bg-primary-600 px-3.5 py-2 text-sm font-semibold text-white shadow-soft transition-colors hover:bg-primary-700 active:bg-primary-800 focus:outline-none focus:ring-2 focus:ring-primary-500/40"
|
||||
onClick={() => {
|
||||
setEditingJob(null);
|
||||
setFormOpen(true);
|
||||
}}>
|
||||
{t('settings.cron.jobs.createJob')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{coreError && (
|
||||
<div className="rounded-lg border border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
|
||||
{coreError}
|
||||
@@ -162,17 +230,43 @@ const CronJobsPanel = () => {
|
||||
onRunCoreJob={jobId => void runCoreJob(jobId)}
|
||||
onLoadCoreRuns={jobId => void loadCoreRuns(jobId)}
|
||||
onRemoveCoreJob={jobId => void removeCoreJob(jobId)}
|
||||
onEditCoreJob={job => setEditingJob(job)}
|
||||
/>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="cron-refresh"
|
||||
className="btn btn-outline btn-sm"
|
||||
className="inline-flex items-center rounded-xl border border-stone-300 dark:border-stone-700 bg-white dark:bg-stone-900 px-3.5 py-2 text-sm font-medium text-stone-700 dark:text-stone-200 transition-colors hover:bg-stone-100 dark:hover:bg-stone-800 focus:outline-none focus:ring-2 focus:ring-primary-500/30"
|
||||
onClick={() => void loadCoreCronJobsOnly()}>
|
||||
{t('cron.refreshCronJobs')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create modal */}
|
||||
{formOpen && editingJob === null && (
|
||||
<CronJobFormModal
|
||||
key="cron-form-create"
|
||||
mode="create"
|
||||
open={true}
|
||||
onClose={() => setFormOpen(false)}
|
||||
onCreate={params => handleCreate(params)}
|
||||
onUpdate={handleUpdate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit modal */}
|
||||
{editingJob !== null && (
|
||||
<CronJobFormModal
|
||||
key={`cron-form-edit-${editingJob.id}`}
|
||||
mode="edit"
|
||||
job={editingJob}
|
||||
open={true}
|
||||
onClose={() => setEditingJob(null)}
|
||||
onCreate={handleCreate}
|
||||
onUpdate={handleUpdate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import createDebug from 'debug';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { execute as composioExecute, listConnections } from '../../../lib/composio/composioApi';
|
||||
import { SCHEDULE_PRESETS } from '../../../lib/cron/schedulePresets';
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import {
|
||||
CoreCronJob,
|
||||
@@ -42,14 +43,6 @@ interface GhBranch {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const SCHEDULE_PRESETS = [
|
||||
{ labelKey: 'settings.devWorkflow.schedule.every30min' as const, value: '*/30 * * * *' },
|
||||
{ labelKey: 'settings.devWorkflow.schedule.everyHour' as const, value: '0 * * * *' },
|
||||
{ labelKey: 'settings.devWorkflow.schedule.every2hours' as const, value: '0 */2 * * *' },
|
||||
{ labelKey: 'settings.devWorkflow.schedule.every6hours' as const, value: '0 */6 * * *' },
|
||||
{ labelKey: 'settings.devWorkflow.schedule.onceDaily' as const, value: '0 9 * * *' },
|
||||
];
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────
|
||||
|
||||
const DevWorkflowPanel = () => {
|
||||
|
||||
@@ -836,8 +836,8 @@ describe('DevWorkflowPanel', () => {
|
||||
renderWithProviders(<Panel />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Schedule preset matches — should show the label key
|
||||
expect(screen.getByText('settings.devWorkflow.schedule.every30min')).toBeInTheDocument();
|
||||
// Schedule preset matches — should show the shared label key (migrated to cron namespace)
|
||||
expect(screen.getByText('settings.cron.schedule.every30min')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { CoreCronJob, CoreCronRun } from '../../../../utils/tauriCommands';
|
||||
@@ -11,10 +11,12 @@ vi.mock('../../../../lib/i18n/I18nContext', () => ({
|
||||
'common.enabled': 'Enabled',
|
||||
'common.remove': 'Remove',
|
||||
'settings.cron.jobs.desc': 'Manage cron jobs',
|
||||
'settings.cron.jobs.edit': 'Edit',
|
||||
'settings.cron.jobs.lastStatus': 'Last status',
|
||||
'settings.cron.jobs.nextRun': 'Next run',
|
||||
'settings.cron.jobs.pause': 'Pause',
|
||||
'settings.cron.jobs.recentRuns': 'Recent runs',
|
||||
'settings.cron.jobs.saving': 'Saving…',
|
||||
'settings.cron.jobs.schedule': 'Schedule',
|
||||
'settings.cron.jobs.title': 'Scheduled Jobs',
|
||||
'settings.cron.jobs.viewRuns': 'View Runs',
|
||||
@@ -75,4 +77,50 @@ describe('CoreJobList stable test hooks', () => {
|
||||
expect(screen.getByTestId('cron-job-remove-morning_briefing')).toHaveTextContent('Remove');
|
||||
expect(screen.getByTestId('cron-job-runs-morning_briefing')).toHaveTextContent('success');
|
||||
});
|
||||
|
||||
test('edit button absent when onEditCoreJob prop is not provided', () => {
|
||||
renderList();
|
||||
expect(screen.queryByTestId('cron-job-edit-morning_briefing')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('edit button present and invokes callback with job when onEditCoreJob is provided', () => {
|
||||
const onEditCoreJob = vi.fn();
|
||||
render(
|
||||
<CoreJobList
|
||||
loading={false}
|
||||
coreJobs={[job]}
|
||||
coreRunsByJob={{ [job.id]: [run] }}
|
||||
coreBusyKey={null}
|
||||
onToggleCoreJob={vi.fn()}
|
||||
onRunCoreJob={vi.fn()}
|
||||
onLoadCoreRuns={vi.fn()}
|
||||
onRemoveCoreJob={vi.fn()}
|
||||
onEditCoreJob={onEditCoreJob}
|
||||
/>
|
||||
);
|
||||
|
||||
const editBtn = screen.getByTestId('cron-job-edit-morning_briefing');
|
||||
expect(editBtn).toBeInTheDocument();
|
||||
expect(editBtn).toHaveTextContent('Edit');
|
||||
|
||||
fireEvent.click(editBtn);
|
||||
expect(onEditCoreJob).toHaveBeenCalledOnce();
|
||||
expect(onEditCoreJob).toHaveBeenCalledWith(job);
|
||||
});
|
||||
|
||||
test('toggle button shows saving label when coreBusyKey targets the toggle', () => {
|
||||
render(
|
||||
<CoreJobList
|
||||
loading={false}
|
||||
coreJobs={[job]}
|
||||
coreRunsByJob={{}}
|
||||
coreBusyKey={`core-toggle:${job.id}`}
|
||||
onToggleCoreJob={vi.fn()}
|
||||
onRunCoreJob={vi.fn()}
|
||||
onLoadCoreRuns={vi.fn()}
|
||||
onRemoveCoreJob={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId(`cron-job-toggle-${job.id}`)).toHaveTextContent('Saving…');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ interface CoreJobListProps {
|
||||
onRunCoreJob: (jobId: string) => void;
|
||||
onLoadCoreRuns: (jobId: string) => void;
|
||||
onRemoveCoreJob: (jobId: string) => void;
|
||||
/** Optional: when provided, an Edit button is rendered per row. */
|
||||
onEditCoreJob?: (job: CoreCronJob) => void;
|
||||
}
|
||||
|
||||
const CoreJobList = ({
|
||||
@@ -21,8 +23,34 @@ const CoreJobList = ({
|
||||
onRunCoreJob,
|
||||
onLoadCoreRuns,
|
||||
onRemoveCoreJob,
|
||||
onEditCoreJob,
|
||||
}: CoreJobListProps) => {
|
||||
const { t } = useT();
|
||||
const neutralButtonClassName =
|
||||
'inline-flex min-h-9 items-center justify-center whitespace-nowrap rounded-lg border border-stone-300 dark:border-stone-700 bg-white dark:bg-stone-900 px-3 py-1.5 text-[13px] font-medium leading-5 text-stone-700 dark:text-stone-200 shadow-sm transition-colors hover:bg-stone-100 hover:text-stone-900 dark:hover:bg-stone-800 dark:hover:text-stone-100 focus:outline-none focus:ring-2 focus:ring-primary-500/30 disabled:cursor-not-allowed disabled:opacity-70 disabled:text-stone-500 dark:disabled:text-stone-400';
|
||||
const removeButtonClassName =
|
||||
'inline-flex min-h-9 items-center justify-center whitespace-nowrap rounded-lg border border-red-700/40 bg-red-600 px-3 py-1.5 text-[13px] font-medium leading-5 text-white shadow-sm transition-colors hover:bg-red-700 hover:text-white active:bg-red-800 focus:outline-none focus:ring-2 focus:ring-red-500/30 disabled:cursor-not-allowed disabled:opacity-70';
|
||||
|
||||
const toggleButtonLabel = (job: CoreCronJob) => {
|
||||
if (coreBusyKey === `core-toggle:${job.id}`) {
|
||||
return t('settings.cron.jobs.saving');
|
||||
}
|
||||
return job.enabled ? t('settings.cron.jobs.pause') : t('settings.cron.jobs.resume');
|
||||
};
|
||||
|
||||
const runButtonLabel = (jobId: string) =>
|
||||
coreBusyKey === `core-run:${jobId}`
|
||||
? t('settings.cron.jobs.runningNow')
|
||||
: t('subconscious.runNow');
|
||||
|
||||
const viewRunsButtonLabel = (jobId: string) =>
|
||||
coreBusyKey === `core-runs:${jobId}`
|
||||
? t('settings.cron.jobs.loadingRuns')
|
||||
: t('settings.cron.jobs.viewRuns');
|
||||
|
||||
const removeButtonLabel = (jobId: string) =>
|
||||
coreBusyKey === `core-remove:${jobId}` ? t('settings.cron.jobs.removing') : t('common.remove');
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900">
|
||||
<div className="p-4 border-b border-stone-200 dark:border-neutral-800">
|
||||
@@ -102,44 +130,43 @@ const CoreJobList = ({
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`cron-job-toggle-${job.id}`}
|
||||
className="btn btn-sm btn-outline"
|
||||
className={neutralButtonClassName}
|
||||
disabled={coreBusyKey === `core-toggle:${job.id}`}
|
||||
onClick={() => onToggleCoreJob(job)}>
|
||||
{coreBusyKey === `core-toggle:${job.id}`
|
||||
? t('settings.cron.jobs.saving')
|
||||
: job.enabled
|
||||
? t('settings.cron.jobs.pause')
|
||||
: t('settings.cron.jobs.resume')}
|
||||
{toggleButtonLabel(job)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`cron-job-run-${job.id}`}
|
||||
className="btn btn-sm btn-outline"
|
||||
className={neutralButtonClassName}
|
||||
disabled={coreBusyKey === `core-run:${job.id}`}
|
||||
onClick={() => onRunCoreJob(job.id)}>
|
||||
{coreBusyKey === `core-run:${job.id}`
|
||||
? t('settings.cron.jobs.runningNow')
|
||||
: t('subconscious.runNow')}
|
||||
{runButtonLabel(job.id)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`cron-job-view-runs-${job.id}`}
|
||||
className="btn btn-sm btn-outline"
|
||||
className={neutralButtonClassName}
|
||||
disabled={coreBusyKey === `core-runs:${job.id}`}
|
||||
onClick={() => onLoadCoreRuns(job.id)}>
|
||||
{coreBusyKey === `core-runs:${job.id}`
|
||||
? t('settings.cron.jobs.loadingRuns')
|
||||
: t('settings.cron.jobs.viewRuns')}
|
||||
{viewRunsButtonLabel(job.id)}
|
||||
</button>
|
||||
{onEditCoreJob && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`cron-job-edit-${job.id}`}
|
||||
className={neutralButtonClassName}
|
||||
onClick={() => onEditCoreJob(job)}>
|
||||
{t('settings.cron.jobs.edit')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`cron-job-remove-${job.id}`}
|
||||
className="btn btn-sm btn-error"
|
||||
className={removeButtonClassName}
|
||||
disabled={coreBusyKey === `core-remove:${job.id}`}
|
||||
onClick={() => onRemoveCoreJob(job.id)}>
|
||||
{coreBusyKey === `core-remove:${job.id}`
|
||||
? t('settings.cron.jobs.removing')
|
||||
: t('common.remove')}
|
||||
{removeButtonLabel(job.id)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CoreCronJob } from '../../../../utils/tauriCommands';
|
||||
import CronJobFormModal, { type CronJobFormModalProps } from './CronJobFormModal';
|
||||
|
||||
// ── Mock i18n ──────────────────────────────────────────────────────────
|
||||
vi.mock('../../../../lib/i18n/I18nContext', () => ({
|
||||
useT: () => ({
|
||||
t: (key: string, fallback?: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'settings.cron.jobs.createJob': 'New Scheduled Job',
|
||||
'settings.cron.jobs.editJob': 'Edit Scheduled Job',
|
||||
'settings.cron.jobs.formName': 'Job name',
|
||||
'settings.cron.jobs.formNamePlaceholder': 'e.g. daily-report, cleanup-task',
|
||||
'settings.cron.jobs.formJobType': 'Job type',
|
||||
'settings.cron.jobs.formJobTypeAgent': 'Agent (AI prompt)',
|
||||
'settings.cron.jobs.formJobTypeShell': 'Shell command',
|
||||
'settings.cron.jobs.formScheduleType': 'Schedule type',
|
||||
'settings.cron.jobs.formScheduleCron': 'Recurring (cron)',
|
||||
'settings.cron.jobs.formScheduleAt': 'One-time (run at)',
|
||||
'settings.cron.jobs.formScheduleEvery': 'Interval (every N ms)',
|
||||
'settings.cron.jobs.formCronPreset': 'Preset',
|
||||
'settings.cron.jobs.formCronCustom': 'Custom expression',
|
||||
'settings.cron.jobs.formCronCustomPlaceholder': 'e.g. */30 * * * *',
|
||||
'settings.cron.jobs.formCronPreview': 'Runs: {preview}',
|
||||
'settings.cron.jobs.formAtLabel': 'Run at',
|
||||
'settings.cron.jobs.formEveryLabel': 'Interval (milliseconds)',
|
||||
'settings.cron.jobs.formEveryPlaceholder': 'e.g. 3600000',
|
||||
'settings.cron.jobs.formPrompt': 'Agent prompt',
|
||||
'settings.cron.jobs.formPromptPlaceholder': 'What should the agent do each run?',
|
||||
'settings.cron.jobs.formCommand': 'Shell command',
|
||||
'settings.cron.jobs.formCommandPlaceholder': 'e.g. curl https://example.com/health',
|
||||
'settings.cron.jobs.formSessionTarget': 'Session target',
|
||||
'settings.cron.jobs.formSessionIsolated': 'Isolated (recommended)',
|
||||
'settings.cron.jobs.formSessionMain': 'Main session',
|
||||
'settings.cron.jobs.formDelivery': 'Delivery mode',
|
||||
'settings.cron.jobs.formDeliveryNone': 'None (output only)',
|
||||
'settings.cron.jobs.formDeliveryProactive': 'Proactive (push notification)',
|
||||
'settings.cron.jobs.formDeleteAfterRun': 'Delete after first run',
|
||||
'settings.cron.jobs.formCancel': 'Cancel',
|
||||
'settings.cron.jobs.formSave': 'Save',
|
||||
'settings.cron.jobs.formCreate': 'Create',
|
||||
'settings.cron.jobs.formSaving': 'Saving…',
|
||||
'settings.cron.jobs.formError': 'Failed to save job',
|
||||
'settings.cron.jobs.custom': 'Custom',
|
||||
'settings.cron.schedule.every30min': 'Every 30 minutes',
|
||||
'settings.cron.schedule.everyHour': 'Every hour',
|
||||
'settings.cron.schedule.every2hours': 'Every 2 hours',
|
||||
'settings.cron.schedule.every6hours': 'Every 6 hours',
|
||||
'settings.cron.schedule.onceDaily': 'Once daily (9 AM)',
|
||||
};
|
||||
return map[key] ?? fallback ?? key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// ── Mock cronToHuman ────────────────────────────────────────────────────
|
||||
vi.mock('../../../../lib/cron/cronToHuman', () => ({
|
||||
cronToHuman: (expr: string) => `Parsed: ${expr}`,
|
||||
}));
|
||||
|
||||
// ── Sample data ─────────────────────────────────────────────────────────
|
||||
const sampleJob: CoreCronJob = {
|
||||
id: 'job-abc',
|
||||
expression: '*/30 * * * *',
|
||||
schedule: { kind: 'cron', expr: '*/30 * * * *' },
|
||||
command: '',
|
||||
name: 'Test Job',
|
||||
job_type: 'agent',
|
||||
session_target: 'isolated',
|
||||
enabled: true,
|
||||
delivery: { mode: 'proactive', best_effort: true },
|
||||
delete_after_run: false,
|
||||
created_at: '2026-05-01T00:00:00.000Z',
|
||||
next_run: '2026-05-01T01:00:00.000Z',
|
||||
prompt: 'Do something daily',
|
||||
};
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
function makeProps(overrides: Partial<CronJobFormModalProps> = {}): CronJobFormModalProps {
|
||||
return {
|
||||
mode: 'create',
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
onCreate: vi.fn().mockResolvedValue(undefined),
|
||||
onUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('<CronJobFormModal />', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Closed state ────────────────────────────────────────────────────
|
||||
|
||||
it('renders nothing when open is false', () => {
|
||||
const props = makeProps({ open: false });
|
||||
const { container } = render(<CronJobFormModal {...props} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
// ── Create mode defaults ─────────────────────────────────────────────
|
||||
|
||||
it('opens in create mode with agent type and cron schedule by default', () => {
|
||||
render(<CronJobFormModal {...makeProps()} />);
|
||||
|
||||
expect(screen.getByTestId('cron-form-modal')).toBeInTheDocument();
|
||||
expect(screen.getByText('New Scheduled Job')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('cron-form-job-type-agent')).toBeChecked();
|
||||
expect(screen.getByTestId('cron-form-schedule-cron')).toBeChecked();
|
||||
});
|
||||
|
||||
it('submit button is disabled when prompt is empty in create mode', () => {
|
||||
render(<CronJobFormModal {...makeProps()} />);
|
||||
// Prompt textarea should be visible for agent type
|
||||
expect(screen.getByTestId('cron-form-prompt')).toBeInTheDocument();
|
||||
// Submit disabled without prompt
|
||||
expect(screen.getByTestId('cron-form-submit')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables submit after filling prompt + selecting preset, then calls onCreate with correct shape', async () => {
|
||||
const onCreate = vi.fn().mockResolvedValue(undefined);
|
||||
render(<CronJobFormModal {...makeProps({ onCreate })} />);
|
||||
|
||||
// Fill in the prompt
|
||||
fireEvent.change(screen.getByTestId('cron-form-prompt'), {
|
||||
target: { value: 'Send daily report' },
|
||||
});
|
||||
|
||||
// The first preset is already selected so submit should be enabled now
|
||||
const submit = screen.getByTestId('cron-form-submit');
|
||||
expect(submit).not.toBeDisabled();
|
||||
|
||||
fireEvent.click(submit);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onCreate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
const [params] = onCreate.mock.calls[0];
|
||||
expect(params.job_type).toBe('agent');
|
||||
expect(params.prompt).toBe('Send daily report');
|
||||
expect(params.schedule).toMatchObject({ kind: 'cron' });
|
||||
expect(params.session_target).toBe('isolated');
|
||||
expect(params.delivery).toMatchObject({ mode: 'proactive', best_effort: true });
|
||||
});
|
||||
|
||||
// ── Switching to shell job type ───────────────────────────────────────
|
||||
|
||||
it('switching to shell hides prompt, shows command, and requires command for submit', async () => {
|
||||
const onCreate = vi.fn().mockResolvedValue(undefined);
|
||||
render(<CronJobFormModal {...makeProps({ onCreate })} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('cron-form-job-type-shell'));
|
||||
|
||||
// Prompt should not be rendered
|
||||
expect(screen.queryByTestId('cron-form-prompt')).not.toBeInTheDocument();
|
||||
// Command should appear
|
||||
expect(screen.getByTestId('cron-form-command')).toBeInTheDocument();
|
||||
// Submit disabled (command empty)
|
||||
expect(screen.getByTestId('cron-form-submit')).toBeDisabled();
|
||||
|
||||
// Fill command
|
||||
fireEvent.change(screen.getByTestId('cron-form-command'), {
|
||||
target: { value: 'curl https://example.com/health' },
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('cron-form-submit')).not.toBeDisabled();
|
||||
fireEvent.click(screen.getByTestId('cron-form-submit'));
|
||||
|
||||
await waitFor(() => expect(onCreate).toHaveBeenCalledOnce());
|
||||
const [params] = onCreate.mock.calls[0];
|
||||
expect(params.job_type).toBe('shell');
|
||||
expect(params.command).toBe('curl https://example.com/health');
|
||||
expect(params).not.toHaveProperty('prompt');
|
||||
});
|
||||
|
||||
// ── Schedule type: at ─────────────────────────────────────────────────
|
||||
|
||||
it('switching to "at" schedule shows datetime input and sets deleteAfterRun to true', () => {
|
||||
render(<CronJobFormModal {...makeProps()} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('cron-form-schedule-at'));
|
||||
|
||||
expect(screen.getByTestId('cron-form-at')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('cron-form-cron-preset')).not.toBeInTheDocument();
|
||||
// deleteAfterRun checkbox should be checked
|
||||
expect(screen.getByTestId('cron-form-delete-after-run')).toBeChecked();
|
||||
});
|
||||
|
||||
// ── Schedule type: every ──────────────────────────────────────────────
|
||||
|
||||
it('switching to "every" schedule shows ms input', () => {
|
||||
render(<CronJobFormModal {...makeProps()} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('cron-form-schedule-every'));
|
||||
|
||||
expect(screen.getByTestId('cron-form-every')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('cron-form-cron-preset')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Edit mode prefill ──────────────────────────────────────────────────
|
||||
|
||||
it('prefills fields from job prop in edit mode', () => {
|
||||
render(<CronJobFormModal {...makeProps({ mode: 'edit', job: sampleJob })} />);
|
||||
|
||||
expect(screen.getByText('Edit Scheduled Job')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('cron-form-name')).toHaveValue('Test Job');
|
||||
expect(screen.getByTestId('cron-form-prompt')).toHaveValue('Do something daily');
|
||||
});
|
||||
|
||||
it('disables job type radio in edit mode', () => {
|
||||
render(<CronJobFormModal {...makeProps({ mode: 'edit', job: sampleJob })} />);
|
||||
|
||||
expect(screen.getByTestId('cron-form-job-type-agent')).toBeDisabled();
|
||||
expect(screen.getByTestId('cron-form-job-type-shell')).toBeDisabled();
|
||||
});
|
||||
|
||||
// ── Edit submit ────────────────────────────────────────────────────────
|
||||
|
||||
it('calls onUpdate with job.id and patch on edit submit', async () => {
|
||||
const onUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
render(<CronJobFormModal {...makeProps({ mode: 'edit', job: sampleJob, onUpdate })} />);
|
||||
|
||||
// Change the name
|
||||
fireEvent.change(screen.getByTestId('cron-form-name'), { target: { value: 'Updated Name' } });
|
||||
|
||||
fireEvent.click(screen.getByTestId('cron-form-submit'));
|
||||
|
||||
await waitFor(() => expect(onUpdate).toHaveBeenCalledOnce());
|
||||
const [jobId, patch] = onUpdate.mock.calls[0];
|
||||
expect(jobId).toBe('job-abc');
|
||||
expect(patch).toMatchObject({ schedule: { kind: 'cron' } });
|
||||
});
|
||||
|
||||
// ── Cancel ────────────────────────────────────────────────────────────
|
||||
|
||||
it('calls onClose when Cancel button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<CronJobFormModal {...makeProps({ onClose })} />);
|
||||
|
||||
// There are two cancel buttons (header x and footer Cancel)
|
||||
const cancelButtons = screen.getAllByTestId('cron-form-cancel');
|
||||
fireEvent.click(cancelButtons[cancelButtons.length - 1]);
|
||||
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
// ── Error surfacing ────────────────────────────────────────────────────
|
||||
|
||||
it('surfaces error in cron-form-error when onCreate rejects', async () => {
|
||||
const onCreate = vi.fn().mockRejectedValue(new Error('network error'));
|
||||
render(<CronJobFormModal {...makeProps({ onCreate })} />);
|
||||
|
||||
// Fill prompt so submit is enabled
|
||||
fireEvent.change(screen.getByTestId('cron-form-prompt'), { target: { value: 'Some prompt' } });
|
||||
|
||||
fireEvent.click(screen.getByTestId('cron-form-submit'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cron-form-error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('cron-form-error')).toHaveTextContent('Failed to save job');
|
||||
});
|
||||
|
||||
// ── Create: "at" schedule ───────────────────────────────────────────
|
||||
it('submits with at-schedule, isoifies datetime input', async () => {
|
||||
const onCreate = vi.fn().mockResolvedValue(undefined);
|
||||
render(<CronJobFormModal {...makeProps({ onCreate })} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('cron-form-schedule-at'));
|
||||
fireEvent.change(screen.getByTestId('cron-form-at'), { target: { value: '2030-01-01T09:00' } });
|
||||
fireEvent.change(screen.getByTestId('cron-form-prompt'), { target: { value: 'go' } });
|
||||
|
||||
expect(screen.getByTestId('cron-form-submit')).not.toBeDisabled();
|
||||
fireEvent.click(screen.getByTestId('cron-form-submit'));
|
||||
|
||||
await waitFor(() => expect(onCreate).toHaveBeenCalledOnce());
|
||||
const [params] = onCreate.mock.calls[0];
|
||||
expect(params.schedule.kind).toBe('at');
|
||||
expect(params.schedule.at).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
expect(params.delete_after_run).toBe(true);
|
||||
});
|
||||
|
||||
it('submits with every-schedule using parsed ms', async () => {
|
||||
const onCreate = vi.fn().mockResolvedValue(undefined);
|
||||
render(<CronJobFormModal {...makeProps({ onCreate })} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('cron-form-schedule-every'));
|
||||
fireEvent.change(screen.getByTestId('cron-form-every'), { target: { value: '60000' } });
|
||||
fireEvent.change(screen.getByTestId('cron-form-prompt'), { target: { value: 'tick' } });
|
||||
|
||||
expect(screen.getByTestId('cron-form-submit')).not.toBeDisabled();
|
||||
fireEvent.click(screen.getByTestId('cron-form-submit'));
|
||||
|
||||
await waitFor(() => expect(onCreate).toHaveBeenCalledOnce());
|
||||
const [params] = onCreate.mock.calls[0];
|
||||
expect(params.schedule).toEqual({ kind: 'every', every_ms: 60000 });
|
||||
});
|
||||
|
||||
it('submit disabled when every ms is empty or non-positive', () => {
|
||||
render(<CronJobFormModal {...makeProps()} />);
|
||||
fireEvent.click(screen.getByTestId('cron-form-schedule-every'));
|
||||
fireEvent.change(screen.getByTestId('cron-form-prompt'), { target: { value: 'x' } });
|
||||
expect(screen.getByTestId('cron-form-submit')).toBeDisabled();
|
||||
fireEvent.change(screen.getByTestId('cron-form-every'), { target: { value: '0' } });
|
||||
expect(screen.getByTestId('cron-form-submit')).toBeDisabled();
|
||||
});
|
||||
|
||||
// ── Cron custom expression ──────────────────────────────────────────
|
||||
it('typing a custom cron expression clears preset and renders preview', () => {
|
||||
render(<CronJobFormModal {...makeProps()} />);
|
||||
const preset = screen.getByTestId('cron-form-cron-preset') as HTMLSelectElement;
|
||||
// Select empty/custom option
|
||||
fireEvent.change(preset, { target: { value: '' } });
|
||||
const custom = screen.getByTestId('cron-form-cron-custom');
|
||||
fireEvent.change(custom, { target: { value: '*/15 * * * *' } });
|
||||
expect(screen.getByTestId('cron-form-cron-preview')).toHaveTextContent('*/15 * * * *');
|
||||
});
|
||||
|
||||
it('typing a value that matches a preset sets cronPreset', () => {
|
||||
render(<CronJobFormModal {...makeProps()} />);
|
||||
fireEvent.change(screen.getByTestId('cron-form-cron-preset'), { target: { value: '' } });
|
||||
const custom = screen.getByTestId('cron-form-cron-custom');
|
||||
// value matches a preset
|
||||
fireEvent.change(custom, { target: { value: '0 9 * * *' } });
|
||||
// Preview rendered for that expression
|
||||
expect(screen.getByTestId('cron-form-cron-preview')).toHaveTextContent('0 9 * * *');
|
||||
});
|
||||
|
||||
// ── Session target / delivery / deleteAfterRun ──────────────────────
|
||||
it('changes session_target and delivery mode in the submitted params', async () => {
|
||||
const onCreate = vi.fn().mockResolvedValue(undefined);
|
||||
render(<CronJobFormModal {...makeProps({ onCreate })} />);
|
||||
fireEvent.change(screen.getByTestId('cron-form-prompt'), { target: { value: 'p' } });
|
||||
fireEvent.change(screen.getByTestId('cron-form-session-target'), { target: { value: 'main' } });
|
||||
fireEvent.change(screen.getByTestId('cron-form-delivery'), { target: { value: 'none' } });
|
||||
fireEvent.click(screen.getByTestId('cron-form-delete-after-run'));
|
||||
|
||||
fireEvent.click(screen.getByTestId('cron-form-submit'));
|
||||
await waitFor(() => expect(onCreate).toHaveBeenCalledOnce());
|
||||
const [params] = onCreate.mock.calls[0];
|
||||
expect(params.session_target).toBe('main');
|
||||
expect(params.delivery).toMatchObject({ mode: 'none' });
|
||||
expect(params.delete_after_run).toBe(true);
|
||||
});
|
||||
|
||||
// ── Edit mode: at and every prefill ─────────────────────────────────
|
||||
it('edit mode prefills "at" schedule and converts ISO to datetime-local', () => {
|
||||
const atJob: CoreCronJob = {
|
||||
...sampleJob,
|
||||
schedule: { kind: 'at', at: '2030-01-01T09:00:00.000Z' },
|
||||
};
|
||||
render(<CronJobFormModal {...makeProps({ mode: 'edit', job: atJob })} />);
|
||||
const atInput = screen.getByTestId('cron-form-at') as HTMLInputElement;
|
||||
expect(atInput.value).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
it('edit mode prefills "every" schedule with every_ms as string', () => {
|
||||
const everyJob: CoreCronJob = { ...sampleJob, schedule: { kind: 'every', every_ms: 120000 } };
|
||||
render(<CronJobFormModal {...makeProps({ mode: 'edit', job: everyJob })} />);
|
||||
expect(screen.getByTestId('cron-form-every')).toHaveValue(120000);
|
||||
});
|
||||
|
||||
it('edit mode with a non-preset custom cron expression shows the custom input', () => {
|
||||
const job: CoreCronJob = { ...sampleJob, schedule: { kind: 'cron', expr: '*/7 * * * *' } };
|
||||
render(<CronJobFormModal {...makeProps({ mode: 'edit', job })} />);
|
||||
expect(screen.getByTestId('cron-form-cron-custom')).toHaveValue('*/7 * * * *');
|
||||
});
|
||||
|
||||
it('edit mode handleSubmit builds patch with all fields and clears name to null when blank', async () => {
|
||||
const onUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
render(<CronJobFormModal {...makeProps({ mode: 'edit', job: sampleJob, onUpdate })} />);
|
||||
fireEvent.change(screen.getByTestId('cron-form-name'), { target: { value: '' } });
|
||||
fireEvent.click(screen.getByTestId('cron-form-submit'));
|
||||
await waitFor(() => expect(onUpdate).toHaveBeenCalledOnce());
|
||||
const [, patch] = onUpdate.mock.calls[0];
|
||||
expect(patch.name).toBeNull();
|
||||
expect(patch.session_target).toBe('isolated');
|
||||
expect(patch.delivery).toMatchObject({ mode: 'proactive' });
|
||||
});
|
||||
|
||||
it('edit mode with shell job patches command not prompt', async () => {
|
||||
const shellJob: CoreCronJob = {
|
||||
...sampleJob,
|
||||
job_type: 'shell',
|
||||
command: 'echo hi',
|
||||
prompt: '',
|
||||
};
|
||||
const onUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
render(<CronJobFormModal {...makeProps({ mode: 'edit', job: shellJob, onUpdate })} />);
|
||||
fireEvent.click(screen.getByTestId('cron-form-submit'));
|
||||
await waitFor(() => expect(onUpdate).toHaveBeenCalledOnce());
|
||||
const [, patch] = onUpdate.mock.calls[0];
|
||||
expect(patch.command).toBe('echo hi');
|
||||
expect(patch).not.toHaveProperty('prompt');
|
||||
expect(patch).not.toHaveProperty('session_target');
|
||||
});
|
||||
|
||||
// ── Schedule kind switching back to cron resets delete flag ─────────
|
||||
it('switching from "at" back to "cron" in create mode clears deleteAfterRun', () => {
|
||||
render(<CronJobFormModal {...makeProps()} />);
|
||||
fireEvent.click(screen.getByTestId('cron-form-schedule-at'));
|
||||
expect(screen.getByTestId('cron-form-delete-after-run')).toBeChecked();
|
||||
fireEvent.click(screen.getByTestId('cron-form-schedule-cron'));
|
||||
expect(screen.getByTestId('cron-form-delete-after-run')).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('edit mode with an unparseable "at" ISO falls back to empty input', () => {
|
||||
const badAtJob: CoreCronJob = { ...sampleJob, schedule: { kind: 'at', at: 'not-a-real-date' } };
|
||||
render(<CronJobFormModal {...makeProps({ mode: 'edit', job: badAtJob })} />);
|
||||
// The input stays empty when ISO can't be parsed.
|
||||
expect(screen.getByTestId('cron-form-at')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('changing preset dropdown to a different preset updates the expression and clears custom', () => {
|
||||
render(<CronJobFormModal {...makeProps()} />);
|
||||
// Initially first preset is selected. Pick a different preset value.
|
||||
fireEvent.change(screen.getByTestId('cron-form-cron-preset'), {
|
||||
target: { value: '0 9 * * *' },
|
||||
});
|
||||
// Preview should reflect the newly-picked preset
|
||||
expect(screen.getByTestId('cron-form-cron-preview')).toHaveTextContent('0 9 * * *');
|
||||
});
|
||||
|
||||
// ── Toggle job type back to agent ───────────────────────────────────
|
||||
it('toggling back to agent job type restores the prompt field', () => {
|
||||
render(<CronJobFormModal {...makeProps()} />);
|
||||
fireEvent.click(screen.getByTestId('cron-form-job-type-shell'));
|
||||
expect(screen.queryByTestId('cron-form-prompt')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('cron-form-job-type-agent'));
|
||||
expect(screen.getByTestId('cron-form-prompt')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,636 @@
|
||||
/**
|
||||
* CronJobFormModal — Create / Edit cron job form modal.
|
||||
*
|
||||
* Reachable from CronJobsPanel via the "+ New Scheduled Job" button (create)
|
||||
* or the "Edit" button per job row (edit).
|
||||
*/
|
||||
import createDebug from 'debug';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { cronToHuman } from '../../../../lib/cron/cronToHuman';
|
||||
import { SCHEDULE_PRESET_VALUES, SCHEDULE_PRESETS } from '../../../../lib/cron/schedulePresets';
|
||||
import { useT } from '../../../../lib/i18n/I18nContext';
|
||||
import type {
|
||||
CoreCronJob,
|
||||
CoreCronSchedule,
|
||||
CronAddParams,
|
||||
} from '../../../../utils/tauriCommands/cron';
|
||||
|
||||
const log = createDebug('app:settings:CronJobFormModal');
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────
|
||||
|
||||
type JobType = 'agent' | 'shell';
|
||||
type ScheduleKind = 'cron' | 'at' | 'every';
|
||||
type DeliveryMode = 'none' | 'proactive';
|
||||
type SessionTarget = 'isolated' | 'main';
|
||||
|
||||
export interface CronJobFormModalProps {
|
||||
mode: 'create' | 'edit';
|
||||
job?: CoreCronJob;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreate: (params: CronAddParams) => Promise<void>;
|
||||
onUpdate: (jobId: string, patch: Record<string, unknown>) => Promise<void>;
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function buildSchedule(
|
||||
kind: ScheduleKind,
|
||||
cronExpr: string,
|
||||
atValue: string,
|
||||
everyMs: string
|
||||
): CoreCronSchedule | null {
|
||||
if (kind === 'cron') {
|
||||
const expr = cronExpr.trim();
|
||||
if (!expr) return null;
|
||||
return { kind: 'cron', expr, tz: null };
|
||||
}
|
||||
if (kind === 'at') {
|
||||
if (!atValue) return null;
|
||||
return { kind: 'at', at: new Date(atValue).toISOString() };
|
||||
}
|
||||
if (kind === 'every') {
|
||||
const ms = parseInt(everyMs, 10);
|
||||
if (!ms || ms <= 0) return null;
|
||||
return { kind: 'every', every_ms: ms };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getInitialScheduleKind(job: CoreCronJob): ScheduleKind {
|
||||
return job.schedule.kind;
|
||||
}
|
||||
|
||||
function getInitialCronExpr(job: CoreCronJob): string {
|
||||
return job.schedule.kind === 'cron' ? job.schedule.expr : '';
|
||||
}
|
||||
|
||||
function getInitialAtValue(job: CoreCronJob): string {
|
||||
if (job.schedule.kind === 'at') {
|
||||
// Convert ISO to datetime-local format (YYYY-MM-DDTHH:MM)
|
||||
try {
|
||||
const d = new Date(job.schedule.at);
|
||||
const offset = d.getTimezoneOffset();
|
||||
const local = new Date(d.getTime() - offset * 60000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function getInitialEveryMs(job: CoreCronJob): string {
|
||||
return job.schedule.kind === 'every' ? String(job.schedule.every_ms) : '';
|
||||
}
|
||||
|
||||
function getInitialDelivery(job: CoreCronJob): DeliveryMode {
|
||||
return job.delivery.mode === 'proactive' ? 'proactive' : 'none';
|
||||
}
|
||||
|
||||
interface CronJobFormInitialState {
|
||||
name: string;
|
||||
jobType: JobType;
|
||||
scheduleKind: ScheduleKind;
|
||||
cronPreset: string;
|
||||
cronCustom: string;
|
||||
atValue: string;
|
||||
everyMs: string;
|
||||
prompt: string;
|
||||
command: string;
|
||||
sessionTarget: SessionTarget;
|
||||
delivery: DeliveryMode;
|
||||
deleteAfterRun: boolean;
|
||||
}
|
||||
|
||||
function getInitialFormState(mode: 'create' | 'edit', job?: CoreCronJob): CronJobFormInitialState {
|
||||
if (mode === 'edit' && job) {
|
||||
const scheduleKind = getInitialScheduleKind(job);
|
||||
const cronExpr = getInitialCronExpr(job);
|
||||
const hasPresetCron = scheduleKind === 'cron' && SCHEDULE_PRESET_VALUES.has(cronExpr);
|
||||
|
||||
return {
|
||||
name: job.name ?? '',
|
||||
jobType: job.job_type === 'shell' ? 'shell' : 'agent',
|
||||
scheduleKind,
|
||||
cronPreset:
|
||||
scheduleKind === 'cron' ? (hasPresetCron ? cronExpr : '') : SCHEDULE_PRESETS[0].value,
|
||||
cronCustom: scheduleKind === 'cron' && !hasPresetCron ? cronExpr : '',
|
||||
atValue: scheduleKind === 'at' ? getInitialAtValue(job) : '',
|
||||
everyMs: scheduleKind === 'every' ? getInitialEveryMs(job) : '',
|
||||
prompt: job.prompt ?? '',
|
||||
command: job.command ?? '',
|
||||
sessionTarget: job.session_target === 'main' ? 'main' : 'isolated',
|
||||
delivery: getInitialDelivery(job),
|
||||
deleteAfterRun: job.delete_after_run,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: '',
|
||||
jobType: 'agent',
|
||||
scheduleKind: 'cron',
|
||||
cronPreset: SCHEDULE_PRESETS[0].value,
|
||||
cronCustom: '',
|
||||
atValue: '',
|
||||
everyMs: '',
|
||||
prompt: '',
|
||||
command: '',
|
||||
sessionTarget: 'isolated',
|
||||
delivery: 'proactive',
|
||||
deleteAfterRun: false,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────
|
||||
|
||||
const CronJobFormModal = ({
|
||||
mode,
|
||||
job,
|
||||
open,
|
||||
onClose,
|
||||
onCreate,
|
||||
onUpdate,
|
||||
}: CronJobFormModalProps) => {
|
||||
const { t } = useT();
|
||||
const initialState = getInitialFormState(mode, job);
|
||||
|
||||
// ── Form state ─────────────────────────────────────────────────────
|
||||
|
||||
const [name, setName] = useState(initialState.name);
|
||||
const [jobType, setJobType] = useState<JobType>(initialState.jobType);
|
||||
const [scheduleKind, setScheduleKind] = useState<ScheduleKind>(initialState.scheduleKind);
|
||||
const [cronPreset, setCronPreset] = useState<string>(initialState.cronPreset);
|
||||
const [cronCustom, setCronCustom] = useState(initialState.cronCustom);
|
||||
const [atValue, setAtValue] = useState(initialState.atValue);
|
||||
const [everyMs, setEveryMs] = useState(initialState.everyMs);
|
||||
const [prompt, setPrompt] = useState(initialState.prompt);
|
||||
const [command, setCommand] = useState(initialState.command);
|
||||
const [sessionTarget, setSessionTarget] = useState<SessionTarget>(initialState.sessionTarget);
|
||||
const [delivery, setDelivery] = useState<DeliveryMode>(initialState.delivery);
|
||||
const [deleteAfterRun, setDeleteAfterRun] = useState(initialState.deleteAfterRun);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Effective cron expression: if preset is selected use its value, else custom
|
||||
const cronExpr = SCHEDULE_PRESET_VALUES.has(cronPreset) ? cronPreset : cronCustom.trim();
|
||||
|
||||
const handleScheduleKindChange = (nextKind: ScheduleKind) => {
|
||||
setScheduleKind(nextKind);
|
||||
if (nextKind === 'at') {
|
||||
setDeleteAfterRun(true);
|
||||
} else if (mode === 'create') {
|
||||
setDeleteAfterRun(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Validation ──────────────────────────────────────────────────────
|
||||
const schedule = buildSchedule(scheduleKind, cronExpr, atValue, everyMs);
|
||||
const isScheduleValid = schedule !== null;
|
||||
const isPromptValid = jobType !== 'agent' || prompt.trim().length > 0;
|
||||
const isCommandValid = jobType !== 'shell' || command.trim().length > 0;
|
||||
const canSubmit = isScheduleValid && isPromptValid && isCommandValid && !saving;
|
||||
|
||||
// ── Submit ──────────────────────────────────────────────────────────
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit || !schedule) return;
|
||||
setError(null);
|
||||
setSaving(true);
|
||||
|
||||
log(
|
||||
'[CronJobFormModal] submit mode=%s, jobType=%s, scheduleKind=%s',
|
||||
mode,
|
||||
jobType,
|
||||
scheduleKind
|
||||
);
|
||||
|
||||
try {
|
||||
if (mode === 'create') {
|
||||
const params: CronAddParams = {
|
||||
name: name.trim() || undefined,
|
||||
schedule,
|
||||
job_type: jobType,
|
||||
...(jobType === 'agent' ? { prompt: prompt.trim() } : {}),
|
||||
...(jobType === 'shell' ? { command: command.trim() } : {}),
|
||||
...(jobType === 'agent' ? { session_target: sessionTarget } : {}),
|
||||
...(jobType === 'agent'
|
||||
? { delivery: { mode: delivery, best_effort: true } }
|
||||
: { delivery: { mode: 'none', best_effort: false } }),
|
||||
delete_after_run: deleteAfterRun,
|
||||
};
|
||||
log('[CronJobFormModal] calling onCreate metadata=%o', {
|
||||
mode: 'create',
|
||||
jobType: params.job_type,
|
||||
scheduleKind: params.schedule.kind,
|
||||
hasName: Boolean(params.name),
|
||||
hasSessionTarget: Boolean(params.session_target),
|
||||
deleteAfterRun: params.delete_after_run,
|
||||
});
|
||||
await onCreate(params);
|
||||
} else {
|
||||
if (!job) return;
|
||||
const patch: Record<string, unknown> = {
|
||||
name: name.trim() || null,
|
||||
schedule,
|
||||
...(jobType === 'agent' ? { prompt: prompt.trim() } : {}),
|
||||
...(jobType === 'shell' ? { command: command.trim() } : {}),
|
||||
...(jobType === 'agent' ? { session_target: sessionTarget } : {}),
|
||||
...(jobType === 'agent'
|
||||
? { delivery: { mode: delivery, best_effort: true } }
|
||||
: { delivery: { mode: 'none', best_effort: false } }),
|
||||
delete_after_run: deleteAfterRun,
|
||||
};
|
||||
const patchSchedule = patch.schedule as { kind?: string } | undefined;
|
||||
log('[CronJobFormModal] calling onUpdate metadata=%o', {
|
||||
mode: 'edit',
|
||||
jobId: job.id,
|
||||
scheduleKind: patchSchedule?.kind ?? 'unknown',
|
||||
hasName: patch.name !== null,
|
||||
hasSessionTarget: 'session_target' in patch,
|
||||
deleteAfterRun: patch.delete_after_run,
|
||||
});
|
||||
await onUpdate(job.id, patch);
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log('[CronJobFormModal] save error: %s', msg);
|
||||
setError(t('settings.cron.jobs.formError'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────
|
||||
const title =
|
||||
mode === 'create' ? t('settings.cron.jobs.createJob') : t('settings.cron.jobs.editJob');
|
||||
|
||||
const submitLabel = saving
|
||||
? t('settings.cron.jobs.formSaving')
|
||||
: mode === 'create'
|
||||
? t('settings.cron.jobs.formCreate')
|
||||
: t('settings.cron.jobs.formSave');
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="cron-form-modal"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40 dark:bg-black/60"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Card */}
|
||||
<div className="relative z-10 w-full max-w-lg bg-white dark:bg-neutral-900 rounded-2xl shadow-xl border border-stone-200 dark:border-neutral-800 flex flex-col max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-stone-200 dark:border-neutral-800 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-stone-900 dark:text-neutral-100">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="cron-form-cancel"
|
||||
onClick={onClose}
|
||||
className="text-stone-400 hover:text-stone-600 dark:text-neutral-500 dark:hover:text-neutral-300 transition-colors text-xl leading-none">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable body */}
|
||||
<div className="overflow-y-auto px-6 py-4 flex flex-col gap-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.cron.jobs.formName')}
|
||||
</label>
|
||||
<input
|
||||
data-testid="cron-form-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder={t('settings.cron.jobs.formNamePlaceholder')}
|
||||
disabled={saving}
|
||||
className="w-full rounded-md border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Job type */}
|
||||
<div>
|
||||
<div className="text-xs font-medium text-stone-700 dark:text-neutral-300 mb-1.5">
|
||||
{t('settings.cron.jobs.formJobType')}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-stone-700 dark:text-neutral-300 cursor-pointer">
|
||||
<input
|
||||
data-testid="cron-form-job-type-agent"
|
||||
type="radio"
|
||||
name="cron-job-type"
|
||||
value="agent"
|
||||
checked={jobType === 'agent'}
|
||||
onChange={() => setJobType('agent')}
|
||||
disabled={mode === 'edit' || saving}
|
||||
className="accent-primary-600"
|
||||
/>
|
||||
{t('settings.cron.jobs.formJobTypeAgent')}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-stone-700 dark:text-neutral-300 cursor-pointer">
|
||||
<input
|
||||
data-testid="cron-form-job-type-shell"
|
||||
type="radio"
|
||||
name="cron-job-type"
|
||||
value="shell"
|
||||
checked={jobType === 'shell'}
|
||||
onChange={() => setJobType('shell')}
|
||||
disabled={mode === 'edit' || saving}
|
||||
className="accent-primary-600"
|
||||
/>
|
||||
{t('settings.cron.jobs.formJobTypeShell')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schedule type */}
|
||||
<div>
|
||||
<div className="text-xs font-medium text-stone-700 dark:text-neutral-300 mb-1.5">
|
||||
{t('settings.cron.jobs.formScheduleType')}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-stone-700 dark:text-neutral-300 cursor-pointer">
|
||||
<input
|
||||
data-testid="cron-form-schedule-cron"
|
||||
type="radio"
|
||||
name="cron-schedule-kind"
|
||||
value="cron"
|
||||
checked={scheduleKind === 'cron'}
|
||||
onChange={() => handleScheduleKindChange('cron')}
|
||||
disabled={saving}
|
||||
className="accent-primary-600"
|
||||
/>
|
||||
{t('settings.cron.jobs.formScheduleCron')}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-stone-700 dark:text-neutral-300 cursor-pointer">
|
||||
<input
|
||||
data-testid="cron-form-schedule-at"
|
||||
type="radio"
|
||||
name="cron-schedule-kind"
|
||||
value="at"
|
||||
checked={scheduleKind === 'at'}
|
||||
onChange={() => handleScheduleKindChange('at')}
|
||||
disabled={saving}
|
||||
className="accent-primary-600"
|
||||
/>
|
||||
{t('settings.cron.jobs.formScheduleAt')}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-stone-700 dark:text-neutral-300 cursor-pointer">
|
||||
<input
|
||||
data-testid="cron-form-schedule-every"
|
||||
type="radio"
|
||||
name="cron-schedule-kind"
|
||||
value="every"
|
||||
checked={scheduleKind === 'every'}
|
||||
onChange={() => handleScheduleKindChange('every')}
|
||||
disabled={saving}
|
||||
className="accent-primary-600"
|
||||
/>
|
||||
{t('settings.cron.jobs.formScheduleEvery')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cron schedule fields */}
|
||||
{scheduleKind === 'cron' && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Preset dropdown */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.cron.jobs.formCronPreset')}
|
||||
</label>
|
||||
<select
|
||||
data-testid="cron-form-cron-preset"
|
||||
value={SCHEDULE_PRESET_VALUES.has(cronPreset) ? cronPreset : ''}
|
||||
onChange={e => {
|
||||
const val = e.target.value;
|
||||
if (val) {
|
||||
setCronPreset(val);
|
||||
setCronCustom('');
|
||||
} else {
|
||||
setCronPreset('');
|
||||
}
|
||||
}}
|
||||
disabled={saving}
|
||||
className="w-full rounded-md border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50">
|
||||
<option value="">{t('settings.cron.jobs.custom')}</option>
|
||||
{SCHEDULE_PRESETS.map(p => (
|
||||
<option key={p.value} value={p.value}>
|
||||
{t(p.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Custom expression — shown when no preset selected or user typed */}
|
||||
{(!SCHEDULE_PRESET_VALUES.has(cronPreset) || cronCustom) && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.cron.jobs.formCronCustom')}
|
||||
</label>
|
||||
<input
|
||||
data-testid="cron-form-cron-custom"
|
||||
type="text"
|
||||
value={cronCustom}
|
||||
onChange={e => {
|
||||
const val = e.target.value;
|
||||
setCronCustom(val);
|
||||
// Reset preset to custom sentinel
|
||||
if (!SCHEDULE_PRESET_VALUES.has(val.trim())) {
|
||||
setCronPreset('');
|
||||
} else {
|
||||
setCronPreset(val.trim());
|
||||
}
|
||||
}}
|
||||
placeholder={t('settings.cron.jobs.formCronCustomPlaceholder')}
|
||||
disabled={saving}
|
||||
className="w-full rounded-md border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm font-mono text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live preview */}
|
||||
{cronExpr && (
|
||||
<p
|
||||
data-testid="cron-form-cron-preview"
|
||||
className="text-xs text-stone-500 dark:text-neutral-400">
|
||||
{t('settings.cron.jobs.formCronPreview').replace(
|
||||
'{preview}',
|
||||
cronToHuman(cronExpr)
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* At */}
|
||||
{scheduleKind === 'at' && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.cron.jobs.formAtLabel')}
|
||||
</label>
|
||||
<input
|
||||
data-testid="cron-form-at"
|
||||
type="datetime-local"
|
||||
value={atValue}
|
||||
onChange={e => setAtValue(e.target.value)}
|
||||
disabled={saving}
|
||||
className="w-full rounded-md border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Every */}
|
||||
{scheduleKind === 'every' && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.cron.jobs.formEveryLabel')}
|
||||
</label>
|
||||
<input
|
||||
data-testid="cron-form-every"
|
||||
type="number"
|
||||
min="1"
|
||||
value={everyMs}
|
||||
onChange={e => setEveryMs(e.target.value)}
|
||||
disabled={saving}
|
||||
placeholder={t('settings.cron.jobs.formEveryPlaceholder')}
|
||||
className="w-full rounded-md border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prompt (agent only) */}
|
||||
{jobType === 'agent' && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.cron.jobs.formPrompt')}
|
||||
<span className="text-coral-500 ml-0.5">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
data-testid="cron-form-prompt"
|
||||
value={prompt}
|
||||
onChange={e => setPrompt(e.target.value)}
|
||||
placeholder={t('settings.cron.jobs.formPromptPlaceholder')}
|
||||
rows={4}
|
||||
disabled={saving}
|
||||
className="w-full rounded-md border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50 resize-y"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Command (shell only) */}
|
||||
{jobType === 'shell' && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.cron.jobs.formCommand')}
|
||||
<span className="text-coral-500 ml-0.5">*</span>
|
||||
</label>
|
||||
<input
|
||||
data-testid="cron-form-command"
|
||||
type="text"
|
||||
value={command}
|
||||
onChange={e => setCommand(e.target.value)}
|
||||
placeholder={t('settings.cron.jobs.formCommandPlaceholder')}
|
||||
disabled={saving}
|
||||
className="w-full rounded-md border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm font-mono text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session target (agent only) */}
|
||||
{jobType === 'agent' && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.cron.jobs.formSessionTarget')}
|
||||
</label>
|
||||
<select
|
||||
data-testid="cron-form-session-target"
|
||||
value={sessionTarget}
|
||||
onChange={e => setSessionTarget(e.target.value as SessionTarget)}
|
||||
disabled={saving}
|
||||
className="w-full rounded-md border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50">
|
||||
<option value="isolated">{t('settings.cron.jobs.formSessionIsolated')}</option>
|
||||
<option value="main">{t('settings.cron.jobs.formSessionMain')}</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delivery mode (agent only) */}
|
||||
{jobType === 'agent' && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.cron.jobs.formDelivery')}
|
||||
</label>
|
||||
<select
|
||||
data-testid="cron-form-delivery"
|
||||
value={delivery}
|
||||
onChange={e => setDelivery(e.target.value as DeliveryMode)}
|
||||
disabled={saving}
|
||||
className="w-full rounded-md border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50">
|
||||
<option value="proactive">{t('settings.cron.jobs.formDeliveryProactive')}</option>
|
||||
<option value="none">{t('settings.cron.jobs.formDeliveryNone')}</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete after run */}
|
||||
<label className="flex items-center gap-2 text-sm text-stone-700 dark:text-neutral-300 cursor-pointer select-none">
|
||||
<input
|
||||
data-testid="cron-form-delete-after-run"
|
||||
type="checkbox"
|
||||
checked={deleteAfterRun}
|
||||
onChange={e => setDeleteAfterRun(e.target.checked)}
|
||||
disabled={saving}
|
||||
className="accent-primary-600"
|
||||
/>
|
||||
{t('settings.cron.jobs.formDeleteAfterRun')}
|
||||
</label>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div
|
||||
data-testid="cron-form-error"
|
||||
className="px-3 py-2 rounded-md bg-coral-50 dark:bg-coral-500/10 border border-coral-200 dark:border-coral-500/30 text-xs text-coral-700 dark:text-coral-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-stone-200 dark:border-neutral-800 flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="cron-form-cancel"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 rounded-md border border-stone-300 dark:border-neutral-700 text-sm text-stone-700 dark:text-neutral-300 hover:bg-stone-50 dark:hover:bg-neutral-800 transition-colors disabled:opacity-50">
|
||||
{t('settings.cron.jobs.formCancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="cron-form-submit"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={!canSubmit}
|
||||
className="px-4 py-2 rounded-md bg-primary-600 hover:bg-primary-500 text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CronJobFormModal;
|
||||
@@ -112,8 +112,8 @@ export default function ScheduledCronCard({
|
||||
// dashboard's grouped-skill card.
|
||||
const containerClass = `rounded-2xl border shadow-soft transition-colors ${
|
||||
isActive
|
||||
? 'border-sage-200 dark:border-sage-500/30 bg-sage-50 dark:bg-sage-500/10'
|
||||
: 'border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900'
|
||||
? 'border-sage-200 dark:border-sage-500/30 bg-gradient-to-br from-sage-50 via-white to-sage-100 dark:from-sage-500/10 dark:via-neutral-900 dark:to-sage-500/5'
|
||||
: 'border-stone-200 dark:border-neutral-800 bg-gradient-to-br from-white via-stone-50 to-stone-100/60 dark:from-neutral-900 dark:via-neutral-900 dark:to-neutral-800/80'
|
||||
}`;
|
||||
|
||||
const headingRow = (
|
||||
@@ -250,7 +250,7 @@ export default function ScheduledCronCard({
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
className="w-full text-left px-4 py-3 flex items-center justify-between gap-3 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40 rounded-2xl"
|
||||
className="w-full text-left px-4 py-3 flex items-center justify-between gap-3 cursor-pointer rounded-2xl transition-colors hover:bg-stone-100/80 dark:hover:bg-neutral-800/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
||||
>
|
||||
{headingRow}
|
||||
{rightCluster}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { SCHEDULE_PRESETS } from '../../lib/cron/schedulePresets';
|
||||
import {
|
||||
type RunLogSlice,
|
||||
type ScannedRun,
|
||||
@@ -97,16 +98,6 @@ interface RunState {
|
||||
result?: SkillRunStarted;
|
||||
}
|
||||
|
||||
// Cron schedule presets. The exact cron expressions match
|
||||
// DevWorkflowPanel's set so users see the same options across the two
|
||||
// panels. Custom expressions land in a future revision.
|
||||
const SCHEDULE_PRESETS: { labelKey: string; value: string }[] = [
|
||||
{ labelKey: 'settings.skillsRunner.schedule.every30min', value: '*/30 * * * *' },
|
||||
{ labelKey: 'settings.skillsRunner.schedule.everyHour', value: '0 * * * *' },
|
||||
{ labelKey: 'settings.skillsRunner.schedule.every2hours', value: '0 */2 * * *' },
|
||||
{ labelKey: 'settings.skillsRunner.schedule.every6hours', value: '0 */6 * * *' },
|
||||
{ labelKey: 'settings.skillsRunner.schedule.onceDaily', value: '0 9 * * *' },
|
||||
];
|
||||
|
||||
/** Name prefix used to identify cron jobs owned by this panel (per-skill). */
|
||||
const CRON_NAME_PREFIX = 'skill-run-';
|
||||
@@ -1064,9 +1055,9 @@ export const SkillsRunnerBody = ({ headerText, className }: SkillsRunnerBodyProp
|
||||
</div>
|
||||
|
||||
{/* Schedule (cron-driven recurring) */}
|
||||
<div className="pt-4 border-t border-stone-200 dark:border-stone-700 space-y-3">
|
||||
<div className="pt-4 border-t border-stone-200 dark:border-stone-700">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-stone-700 dark:text-stone-300">
|
||||
<h3 className="text-sm font-semibold text-stone-800 dark:text-stone-200">
|
||||
{t('settings.skillsRunner.schedule.heading')}
|
||||
</h3>
|
||||
<p className="text-xs text-stone-500 dark:text-stone-400 mt-1">
|
||||
@@ -1074,62 +1065,64 @@ export const SkillsRunnerBody = ({ headerText, className }: SkillsRunnerBodyProp
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="skills-runner-schedule"
|
||||
className="block text-sm font-medium text-stone-700 dark:text-stone-300 mb-1"
|
||||
<div className="mt-3 rounded-2xl border border-stone-200/90 dark:border-stone-700/80 bg-gradient-to-br from-stone-50 via-white to-stone-100 dark:from-stone-900 dark:via-stone-900 dark:to-stone-800/80 px-3 py-3 shadow-soft">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="skills-runner-schedule"
|
||||
className="block text-xs font-semibold uppercase tracking-wide text-stone-600 dark:text-stone-300 mb-1.5"
|
||||
>
|
||||
{t('settings.skillsRunner.schedule.frequency')}
|
||||
</label>
|
||||
<select
|
||||
id="skills-runner-schedule"
|
||||
value={schedule}
|
||||
onChange={(e) => setSchedule(e.target.value)}
|
||||
className="w-full rounded-xl border border-stone-300 dark:border-stone-600 bg-white dark:bg-stone-800 px-3 py-2 text-sm text-stone-900 dark:text-stone-100 shadow-sm focus:outline-none focus:ring-2 focus:ring-primary-500/40 focus:border-primary-500"
|
||||
>
|
||||
{SCHEDULE_PRESETS.map((p) => (
|
||||
<option key={p.value} value={p.value}>
|
||||
{t(p.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSaveSchedule()}
|
||||
disabled={savingSchedule || missingRequired.length > 0}
|
||||
className="rounded-xl border border-primary-700/30 bg-primary-600 hover:bg-primary-700 active:bg-primary-800 disabled:bg-stone-300 disabled:border-stone-300 dark:disabled:bg-stone-700 dark:disabled:border-stone-700 disabled:text-stone-600 dark:disabled:text-stone-300 px-4 py-2 text-sm font-semibold text-white shadow-soft transition-colors"
|
||||
>
|
||||
{t('settings.skillsRunner.schedule.frequency')}
|
||||
</label>
|
||||
<select
|
||||
id="skills-runner-schedule"
|
||||
value={schedule}
|
||||
onChange={(e) => setSchedule(e.target.value)}
|
||||
className="w-full rounded border border-stone-300 dark:border-stone-600 bg-white dark:bg-stone-800 px-3 py-2 text-sm text-stone-900 dark:text-stone-100"
|
||||
>
|
||||
{SCHEDULE_PRESETS.map((p) => (
|
||||
<option key={p.value} value={p.value}>
|
||||
{t(p.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{savingSchedule
|
||||
? t('settings.skillsRunner.schedule.saving')
|
||||
: t('settings.skillsRunner.schedule.save')}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSaveSchedule()}
|
||||
disabled={savingSchedule || missingRequired.length > 0}
|
||||
className="rounded bg-stone-200 hover:bg-stone-300 dark:bg-stone-700 dark:hover:bg-stone-600 disabled:opacity-50 px-4 py-2 text-sm font-medium text-stone-900 dark:text-stone-100"
|
||||
>
|
||||
{savingSchedule
|
||||
? t('settings.skillsRunner.schedule.saving')
|
||||
: t('settings.skillsRunner.schedule.save')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{scheduleSaved && (
|
||||
<p className="text-xs text-emerald-700 dark:text-emerald-300">
|
||||
<p className="mt-2 inline-flex items-center rounded-full border border-emerald-300 dark:border-emerald-700 bg-emerald-50 dark:bg-emerald-900/40 px-2.5 py-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||
{t('settings.skillsRunner.schedule.saved')}
|
||||
</p>
|
||||
)}
|
||||
{scheduleError && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400">
|
||||
<p className="mt-2 inline-flex items-center rounded-full border border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-900/40 px-2.5 py-1 text-xs font-medium text-red-700 dark:text-red-300">
|
||||
{t('settings.skillsRunner.schedule.error')} {scheduleError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Existing scheduled jobs for this skill */}
|
||||
{scheduledJobsLoading ? (
|
||||
<p className="text-xs text-stone-500 dark:text-stone-400">
|
||||
<p className="mt-3 text-xs text-stone-500 dark:text-stone-400">
|
||||
{t('settings.skillsRunner.schedule.loadingJobs')}
|
||||
</p>
|
||||
) : scheduledJobs.length === 0 ? (
|
||||
<p className="text-xs italic text-stone-500 dark:text-stone-400">
|
||||
<p className="mt-3 text-xs italic text-stone-500 dark:text-stone-400">
|
||||
{t('settings.skillsRunner.schedule.noJobs')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-stone-600 dark:text-stone-400">
|
||||
<div className="mt-3 space-y-2 rounded-2xl border border-stone-200/80 dark:border-stone-800 bg-stone-50/70 dark:bg-stone-900/40 p-2.5">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-stone-600 dark:text-stone-400 px-1">
|
||||
{t('settings.skillsRunner.schedule.existing')}
|
||||
</div>
|
||||
{/* Per-skill saved-schedule list — uses the shared
|
||||
@@ -1159,14 +1152,14 @@ export const SkillsRunnerBody = ({ headerText, className }: SkillsRunnerBodyProp
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRunJobNow(job.id)}
|
||||
className="rounded bg-primary-600 hover:bg-primary-700 px-2 py-1 text-xs font-medium text-white"
|
||||
className="rounded-lg border border-primary-700/30 bg-primary-600 hover:bg-primary-700 active:bg-primary-800 px-2.5 py-1 text-xs font-semibold text-white transition-colors"
|
||||
>
|
||||
{t('settings.skillsRunner.schedule.runNow')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRemoveJob(job.id)}
|
||||
className="rounded bg-red-600 hover:bg-red-700 px-2 py-1 text-xs font-medium text-white"
|
||||
className="rounded-lg border border-red-700/40 bg-red-600 hover:bg-red-700 active:bg-red-800 px-2.5 py-1 text-xs font-semibold text-white transition-colors"
|
||||
>
|
||||
{t('settings.skillsRunner.schedule.remove')}
|
||||
</button>
|
||||
|
||||
@@ -387,6 +387,41 @@ describe('SkillsRunnerBody — per-job history viewer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('SkillsRunnerBody — schedule frequency + save', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(hoisted).forEach((fn) => fn.mockReset());
|
||||
hoisted.listSkills.mockResolvedValue(skillsList);
|
||||
hoisted.describeSkill.mockResolvedValue(skillDescription);
|
||||
hoisted.recentRuns.mockResolvedValue([]);
|
||||
hoisted.cronList.mockResolvedValue({ result: [] });
|
||||
hoisted.cronAdd.mockResolvedValue({ result: makeJob() });
|
||||
hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } });
|
||||
});
|
||||
|
||||
it('changes schedule frequency and calls openhumanCronAdd on save', async () => {
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
|
||||
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: SKILL_ID } });
|
||||
await waitFor(() => expect(hoisted.cronList).toHaveBeenCalled());
|
||||
|
||||
const freq = screen.getByLabelText(
|
||||
'settings.skillsRunner.schedule.frequency'
|
||||
) as HTMLSelectElement;
|
||||
fireEvent.change(freq, { target: { value: '0 9 * * *' } });
|
||||
expect(freq.value).toBe('0 9 * * *');
|
||||
|
||||
fireEvent.click(screen.getByText('settings.skillsRunner.schedule.save'));
|
||||
await waitFor(() => expect(hoisted.cronAdd).toHaveBeenCalled());
|
||||
const [params] = hoisted.cronAdd.mock.calls[0];
|
||||
expect(params).toMatchObject({
|
||||
schedule: { kind: 'cron', expr: '0 9 * * *' },
|
||||
job_type: 'agent',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SkillsRunnerBody — SmartIssuePicker conditional mount', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(hoisted).forEach((fn) => fn.mockReset());
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { SCHEDULE_PRESET_VALUES, SCHEDULE_PRESETS } from './schedulePresets';
|
||||
|
||||
describe('SCHEDULE_PRESETS', () => {
|
||||
it('is non-empty', () => {
|
||||
expect(SCHEDULE_PRESETS.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('all values are valid 5-field cron expressions', () => {
|
||||
for (const preset of SCHEDULE_PRESETS) {
|
||||
const fields = preset.value.trim().split(/\s+/);
|
||||
expect(fields).toHaveLength(5);
|
||||
}
|
||||
});
|
||||
|
||||
it('has no duplicate cron expression values', () => {
|
||||
const values = SCHEDULE_PRESETS.map(p => p.value);
|
||||
const unique = new Set(values);
|
||||
expect(unique.size).toBe(values.length);
|
||||
});
|
||||
|
||||
it('has no duplicate labelKeys', () => {
|
||||
const keys = SCHEDULE_PRESETS.map(p => p.labelKey);
|
||||
const unique = new Set(keys);
|
||||
expect(unique.size).toBe(keys.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SCHEDULE_PRESET_VALUES', () => {
|
||||
it('contains all preset values from SCHEDULE_PRESETS', () => {
|
||||
for (const preset of SCHEDULE_PRESETS) {
|
||||
expect(SCHEDULE_PRESET_VALUES.has(preset.value)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('has same size as SCHEDULE_PRESETS', () => {
|
||||
expect(SCHEDULE_PRESET_VALUES.size).toBe(SCHEDULE_PRESETS.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Shared schedule presets for cron-based scheduling UI.
|
||||
*
|
||||
* Both DevWorkflowPanel and SkillsRunnerBody previously defined their own
|
||||
* local SCHEDULE_PRESETS arrays with identical expressions. This module
|
||||
* consolidates them into a single authoritative source.
|
||||
*
|
||||
* Note: the `labelKey` values are *shared* keys from the `settings.cron.schedule.*`
|
||||
* namespace so every panel renders the same translated labels — this module
|
||||
* provides the canonical cron expressions, their shared labels, and a lookup set.
|
||||
*/
|
||||
|
||||
export interface SchedulePreset {
|
||||
/** i18n key from the shared cron namespace (e.g. 'settings.cron.schedule.every30min'). */
|
||||
labelKey: string;
|
||||
/** 5-field cron expression. */
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical cron schedule presets used across scheduling UI panels.
|
||||
* The `labelKey` values are *generic* keys from the cron namespace so the
|
||||
* modal and any future panel can use them directly.
|
||||
*/
|
||||
export const SCHEDULE_PRESETS: ReadonlyArray<SchedulePreset> = [
|
||||
{ labelKey: 'settings.cron.schedule.every30min', value: '*/30 * * * *' },
|
||||
{ labelKey: 'settings.cron.schedule.everyHour', value: '0 * * * *' },
|
||||
{ labelKey: 'settings.cron.schedule.every2hours', value: '0 */2 * * *' },
|
||||
{ labelKey: 'settings.cron.schedule.every6hours', value: '0 */6 * * *' },
|
||||
{ labelKey: 'settings.cron.schedule.onceDaily', value: '0 9 * * *' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Set of all preset cron expressions for O(1) membership checks.
|
||||
* Use this to determine whether a user-typed expression matches a preset.
|
||||
*/
|
||||
export const SCHEDULE_PRESET_VALUES: ReadonlySet<string> = new Set(
|
||||
SCHEDULE_PRESETS.map(p => p.value)
|
||||
);
|
||||
@@ -2994,8 +2994,55 @@ const messages: TranslationMap = {
|
||||
'settings.companion.sessionTtl': 'جلسة TTL',
|
||||
'settings.companion.screenCapture': 'لقطة الشاشة',
|
||||
'settings.companion.appContext': 'سياق التطبيق',
|
||||
'settings.cron.jobs.commandRequired': 'الأمر مطلوب لمهام shell',
|
||||
'settings.cron.jobs.createJob': 'مهمة مجدولة جديدة',
|
||||
'settings.cron.jobs.created': 'تم إنشاء المهمة',
|
||||
'settings.cron.jobs.custom': 'مخصص',
|
||||
'settings.cron.jobs.desc': 'الوصف',
|
||||
'settings.cron.jobs.edit': 'تعديل',
|
||||
'settings.cron.jobs.editJob': 'تعديل المهمة المجدولة',
|
||||
'settings.cron.jobs.empty': 'لا توجد مهام cron أساسية.',
|
||||
'settings.cron.jobs.errorCreate': 'فشل إنشاء مهمة cron: {message}',
|
||||
'settings.cron.jobs.errorLoadList': 'فشل تحميل مهام cron الأساسية: {message}',
|
||||
'settings.cron.jobs.errorLoadRuns': 'فشل تحميل سجل التشغيل: {message}',
|
||||
'settings.cron.jobs.errorRemove': 'فشل إزالة مهمة cron الأساسية: {message}',
|
||||
'settings.cron.jobs.errorRun': 'فشل تشغيل مهمة cron الأساسية: {message}',
|
||||
'settings.cron.jobs.errorToggle': 'فشل تحديث مهمة cron الأساسية: {message}',
|
||||
'settings.cron.jobs.errorUpdate': 'فشل تحديث مهمة cron: {message}',
|
||||
'settings.cron.jobs.formAtLabel': 'التشغيل في',
|
||||
'settings.cron.jobs.formCancel': 'إلغاء',
|
||||
'settings.cron.jobs.formCommand': 'أمر shell',
|
||||
'settings.cron.jobs.formCommandPlaceholder': 'مثال: curl https://example.com/health',
|
||||
'settings.cron.jobs.formCreate': 'إنشاء',
|
||||
'settings.cron.jobs.formCronCustom': 'تعبير مخصص',
|
||||
'settings.cron.jobs.formCronCustomPlaceholder': 'مثال: */30 * * * *',
|
||||
'settings.cron.jobs.formCronPreset': 'إعداد مسبق',
|
||||
'settings.cron.jobs.formCronPreview': 'يعمل: {preview}',
|
||||
'settings.cron.jobs.formDeleteAfterRun': 'حذف بعد أول تشغيل',
|
||||
'settings.cron.jobs.formDelivery': 'وضع التسليم',
|
||||
'settings.cron.jobs.formDeliveryNone': 'بلا (الإخراج فقط)',
|
||||
'settings.cron.jobs.formDeliveryProactive': 'استباقي (إشعار فوري)',
|
||||
'settings.cron.jobs.formError': 'فشل حفظ المهمة',
|
||||
'settings.cron.jobs.formEveryLabel': 'الفاصل الزمني (ميلي ثانية)',
|
||||
'settings.cron.jobs.formEveryPlaceholder': 'مثال: 3600000',
|
||||
'settings.cron.jobs.formJobType': 'نوع المهمة',
|
||||
'settings.cron.jobs.formJobTypeAgent': 'Agent (موجّه AI)',
|
||||
'settings.cron.jobs.formJobTypeShell': 'أمر shell',
|
||||
'settings.cron.jobs.formName': 'اسم المهمة',
|
||||
'settings.cron.jobs.formNamePlaceholder': 'مثال: daily-report, cleanup-task',
|
||||
'settings.cron.jobs.formPrompt': 'موجّه Agent',
|
||||
'settings.cron.jobs.formPromptPlaceholder': 'ماذا يجب أن يفعل الوكيل في كل تشغيل؟',
|
||||
'settings.cron.jobs.formPromptRequired': 'الموجّه مطلوب لمهام Agent',
|
||||
'settings.cron.jobs.formSave': 'حفظ',
|
||||
'settings.cron.jobs.formSaving': 'جارٍ الحفظ…',
|
||||
'settings.cron.jobs.formScheduleAt': 'مرة واحدة (التشغيل في)',
|
||||
'settings.cron.jobs.formScheduleCron': 'متكرر (cron)',
|
||||
'settings.cron.jobs.formScheduleEvery': 'الفاصل الزمني (كل N مللي ثانية)',
|
||||
'settings.cron.jobs.formScheduleRequired': 'الجدول مطلوب',
|
||||
'settings.cron.jobs.formScheduleType': 'نوع الجدول',
|
||||
'settings.cron.jobs.formSessionIsolated': 'معزول (موصى به)',
|
||||
'settings.cron.jobs.formSessionMain': 'الجلسة الرئيسية',
|
||||
'settings.cron.jobs.formSessionTarget': 'هدف الجلسة',
|
||||
'settings.cron.jobs.lastStatus': 'آخر حالة',
|
||||
'settings.cron.jobs.loading': 'جارٍ تحميل مهام cron...',
|
||||
'settings.cron.jobs.loadingRuns': 'جارٍ تحميل عمليات التشغيل',
|
||||
@@ -3009,7 +3056,13 @@ const messages: TranslationMap = {
|
||||
'settings.cron.jobs.saving': 'جارٍ الحفظ…',
|
||||
'settings.cron.jobs.schedule': 'الجدول',
|
||||
'settings.cron.jobs.title': 'مهام Cron الأساسية',
|
||||
'settings.cron.jobs.updated': 'تم تحديث المهمة',
|
||||
'settings.cron.jobs.viewRuns': 'عرض عمليات التشغيل',
|
||||
'settings.cron.schedule.every2hours': 'كل ساعتين',
|
||||
'settings.cron.schedule.every30min': 'كل 30 دقيقة',
|
||||
'settings.cron.schedule.every6hours': 'كل 6 ساعات',
|
||||
'settings.cron.schedule.everyHour': 'كل ساعة',
|
||||
'settings.cron.schedule.onceDaily': 'مرة يوميًا (9 صباحًا)',
|
||||
'settings.localModel.deviceCapability.active': 'نشط',
|
||||
'settings.localModel.deviceCapability.appliedTier': 'المستوى المطبّق',
|
||||
'settings.localModel.deviceCapability.applying': 'جارٍ التطبيق',
|
||||
|
||||
@@ -3046,8 +3046,55 @@ const messages: TranslationMap = {
|
||||
'settings.companion.sessionTtl': 'সেশন TTL',
|
||||
'settings.companion.screenCapture': 'স্ক্রিন ক্যাপচার',
|
||||
'settings.companion.appContext': 'অ্যাপ প্রসঙ্গ',
|
||||
'settings.cron.jobs.commandRequired': 'শেল জবের জন্য কমান্ড আবশ্যক',
|
||||
'settings.cron.jobs.createJob': 'নতুন নির্ধারিত জব',
|
||||
'settings.cron.jobs.created': 'জব তৈরি হয়েছে',
|
||||
'settings.cron.jobs.custom': 'কাস্টম',
|
||||
'settings.cron.jobs.desc': 'বিবরণ',
|
||||
'settings.cron.jobs.edit': 'সম্পাদনা',
|
||||
'settings.cron.jobs.editJob': 'নির্ধারিত জব সম্পাদনা',
|
||||
'settings.cron.jobs.empty': 'কোনো কোর ক্রন জব পাওয়া যায়নি।',
|
||||
'settings.cron.jobs.errorCreate': 'cron জব তৈরি করা যায়নি: {message}',
|
||||
'settings.cron.jobs.errorLoadList': 'কোর cron জব লোড করা যায়নি: {message}',
|
||||
'settings.cron.jobs.errorLoadRuns': 'রান ইতিহাস লোড করা যায়নি: {message}',
|
||||
'settings.cron.jobs.errorRemove': 'কোর cron জব সরানো যায়নি: {message}',
|
||||
'settings.cron.jobs.errorRun': 'কোর cron জব চালানো যায়নি: {message}',
|
||||
'settings.cron.jobs.errorToggle': 'কোর cron জব আপডেট করা যায়নি: {message}',
|
||||
'settings.cron.jobs.errorUpdate': 'cron জব আপডেট করা যায়নি: {message}',
|
||||
'settings.cron.jobs.formAtLabel': 'যখন চালাবে',
|
||||
'settings.cron.jobs.formCancel': 'বাতিল',
|
||||
'settings.cron.jobs.formCommand': 'শেল কমান্ড',
|
||||
'settings.cron.jobs.formCommandPlaceholder': 'যেমন curl https://example.com/health',
|
||||
'settings.cron.jobs.formCreate': 'তৈরি করুন',
|
||||
'settings.cron.jobs.formCronCustom': 'কাস্টম এক্সপ্রেশন',
|
||||
'settings.cron.jobs.formCronCustomPlaceholder': 'যেমন */30 * * * *',
|
||||
'settings.cron.jobs.formCronPreset': 'প্রিসেট',
|
||||
'settings.cron.jobs.formCronPreview': 'চলে: {preview}',
|
||||
'settings.cron.jobs.formDeleteAfterRun': 'প্রথম রানের পর মুছে ফেলুন',
|
||||
'settings.cron.jobs.formDelivery': 'ডেলিভারি মোড',
|
||||
'settings.cron.jobs.formDeliveryNone': 'কোনোটি নয় (শুধু আউটপুট)',
|
||||
'settings.cron.jobs.formDeliveryProactive': 'সক্রিয় (পুশ নোটিফিকেশন)',
|
||||
'settings.cron.jobs.formError': 'জব সংরক্ষণ করা যায়নি',
|
||||
'settings.cron.jobs.formEveryLabel': 'ব্যবধান (মিলিসেকেন্ড)',
|
||||
'settings.cron.jobs.formEveryPlaceholder': 'যেমন 3600000',
|
||||
'settings.cron.jobs.formJobType': 'জবের ধরন',
|
||||
'settings.cron.jobs.formJobTypeAgent': 'Agent (AI প্রম্পট)',
|
||||
'settings.cron.jobs.formJobTypeShell': 'শেল কমান্ড',
|
||||
'settings.cron.jobs.formName': 'জবের নাম',
|
||||
'settings.cron.jobs.formNamePlaceholder': 'যেমন daily-report, cleanup-task',
|
||||
'settings.cron.jobs.formPrompt': 'Agent প্রম্পট',
|
||||
'settings.cron.jobs.formPromptPlaceholder': 'প্রতিটি রানে এজেন্টের কী করা উচিত?',
|
||||
'settings.cron.jobs.formPromptRequired': 'এজেন্ট জবের জন্য প্রম্পট আবশ্যক',
|
||||
'settings.cron.jobs.formSave': 'সংরক্ষণ',
|
||||
'settings.cron.jobs.formSaving': 'সংরক্ষণ করা হচ্ছে…',
|
||||
'settings.cron.jobs.formScheduleAt': 'এককালীন (যখন চালাবে)',
|
||||
'settings.cron.jobs.formScheduleCron': 'পুনরাবৃত্ত (cron)',
|
||||
'settings.cron.jobs.formScheduleEvery': 'ব্যবধান (প্রতি N ms)',
|
||||
'settings.cron.jobs.formScheduleRequired': 'শিডিউল আবশ্যক',
|
||||
'settings.cron.jobs.formScheduleType': 'শিডিউলের ধরন',
|
||||
'settings.cron.jobs.formSessionIsolated': 'বিচ্ছিন্ন (প্রস্তাবিত)',
|
||||
'settings.cron.jobs.formSessionMain': 'প্রধান সেশন',
|
||||
'settings.cron.jobs.formSessionTarget': 'সেশন টার্গেট',
|
||||
'settings.cron.jobs.lastStatus': 'শেষ স্ট্যাটাস',
|
||||
'settings.cron.jobs.loading': 'ক্রন জব লোড হচ্ছে...',
|
||||
'settings.cron.jobs.loadingRuns': 'রান লোড হচ্ছে',
|
||||
@@ -3061,7 +3108,13 @@ const messages: TranslationMap = {
|
||||
'settings.cron.jobs.saving': 'সংরক্ষণ হচ্ছে…',
|
||||
'settings.cron.jobs.schedule': 'সময়সূচি',
|
||||
'settings.cron.jobs.title': 'কোর ক্রন জবস',
|
||||
'settings.cron.jobs.updated': 'জব আপডেট হয়েছে',
|
||||
'settings.cron.jobs.viewRuns': 'রান দেখুন',
|
||||
'settings.cron.schedule.every2hours': 'প্রতি ২ ঘণ্টায়',
|
||||
'settings.cron.schedule.every30min': 'প্রতি ৩০ মিনিটে',
|
||||
'settings.cron.schedule.every6hours': 'প্রতি ৬ ঘণ্টায়',
|
||||
'settings.cron.schedule.everyHour': 'প্রতি ঘণ্টায়',
|
||||
'settings.cron.schedule.onceDaily': 'দৈনিক একবার (সকাল ৯টা)',
|
||||
'settings.localModel.deviceCapability.active': 'সক্রিয়',
|
||||
'settings.localModel.deviceCapability.appliedTier': 'প্রয়োগকৃত টায়ার',
|
||||
'settings.localModel.deviceCapability.applying': 'প্রয়োগ হচ্ছে',
|
||||
|
||||
@@ -3125,8 +3125,55 @@ const messages: TranslationMap = {
|
||||
'settings.companion.sessionTtl': 'Sitzungs-TTL',
|
||||
'settings.companion.screenCapture': 'Bildschirmaufnahme',
|
||||
'settings.companion.appContext': 'App-Kontext',
|
||||
'settings.cron.jobs.commandRequired': 'Bei Shell-Jobs ist ein Befehl erforderlich',
|
||||
'settings.cron.jobs.createJob': 'Neuer geplanter Job',
|
||||
'settings.cron.jobs.created': 'Job erstellt',
|
||||
'settings.cron.jobs.custom': 'Benutzerdefiniert',
|
||||
'settings.cron.jobs.desc': 'Beschr',
|
||||
'settings.cron.jobs.edit': 'Bearbeiten',
|
||||
'settings.cron.jobs.editJob': 'Geplanten Job bearbeiten',
|
||||
'settings.cron.jobs.empty': 'Keine zentralen Cron-Jobs gefunden.',
|
||||
'settings.cron.jobs.errorCreate': 'Cron-Job konnte nicht erstellt werden: {message}',
|
||||
'settings.cron.jobs.errorLoadList': 'Core-Cron-Jobs konnten nicht geladen werden: {message}',
|
||||
'settings.cron.jobs.errorLoadRuns': 'Ausführungsverlauf konnte nicht geladen werden: {message}',
|
||||
'settings.cron.jobs.errorRemove': 'Core-Cron-Job konnte nicht entfernt werden: {message}',
|
||||
'settings.cron.jobs.errorRun': 'Core-Cron-Job konnte nicht ausgeführt werden: {message}',
|
||||
'settings.cron.jobs.errorToggle': 'Core-Cron-Job konnte nicht aktualisiert werden: {message}',
|
||||
'settings.cron.jobs.errorUpdate': 'Cron-Job konnte nicht aktualisiert werden: {message}',
|
||||
'settings.cron.jobs.formAtLabel': 'Ausführen um',
|
||||
'settings.cron.jobs.formCancel': 'Abbrechen',
|
||||
'settings.cron.jobs.formCommand': 'Shell-Befehl',
|
||||
'settings.cron.jobs.formCommandPlaceholder': 'z. B. curl https://example.com/health',
|
||||
'settings.cron.jobs.formCreate': 'Erstellen',
|
||||
'settings.cron.jobs.formCronCustom': 'Benutzerdefinierter Ausdruck',
|
||||
'settings.cron.jobs.formCronCustomPlaceholder': 'z. B. */30 * * * *',
|
||||
'settings.cron.jobs.formCronPreset': 'Voreinstellung',
|
||||
'settings.cron.jobs.formCronPreview': 'Ausführungen: {preview}',
|
||||
'settings.cron.jobs.formDeleteAfterRun': 'Nach erster Ausführung löschen',
|
||||
'settings.cron.jobs.formDelivery': 'Zustellungsmodus',
|
||||
'settings.cron.jobs.formDeliveryNone': 'Keine (nur Ausgabe)',
|
||||
'settings.cron.jobs.formDeliveryProactive': 'Proaktiv (Push-Benachrichtigung)',
|
||||
'settings.cron.jobs.formError': 'Job konnte nicht gespeichert werden',
|
||||
'settings.cron.jobs.formEveryLabel': 'Intervall (Millisekunden)',
|
||||
'settings.cron.jobs.formEveryPlaceholder': 'z. B. 3600000',
|
||||
'settings.cron.jobs.formJobType': 'Job-Typ',
|
||||
'settings.cron.jobs.formJobTypeAgent': 'Agent (KI-Prompt)',
|
||||
'settings.cron.jobs.formJobTypeShell': 'Shell-Befehl',
|
||||
'settings.cron.jobs.formName': 'Job-Name',
|
||||
'settings.cron.jobs.formNamePlaceholder': 'z. B. daily-report, cleanup-task',
|
||||
'settings.cron.jobs.formPrompt': 'Agent-Prompt',
|
||||
'settings.cron.jobs.formPromptPlaceholder': 'Was soll der Agent bei jeder Ausführung tun?',
|
||||
'settings.cron.jobs.formPromptRequired': 'Bei Agent-Jobs ist ein Prompt erforderlich',
|
||||
'settings.cron.jobs.formSave': 'Speichern',
|
||||
'settings.cron.jobs.formSaving': 'Wird gespeichert…',
|
||||
'settings.cron.jobs.formScheduleAt': 'Einmalig (ausführen um)',
|
||||
'settings.cron.jobs.formScheduleCron': 'Wiederkehrend (Cron)',
|
||||
'settings.cron.jobs.formScheduleEvery': 'Intervall (alle N ms)',
|
||||
'settings.cron.jobs.formScheduleRequired': 'Ein Zeitplan ist erforderlich',
|
||||
'settings.cron.jobs.formScheduleType': 'Zeitplantyp',
|
||||
'settings.cron.jobs.formSessionIsolated': 'Isoliert (empfohlen)',
|
||||
'settings.cron.jobs.formSessionMain': 'Hauptsitzung',
|
||||
'settings.cron.jobs.formSessionTarget': 'Sitzungsziel',
|
||||
'settings.cron.jobs.lastStatus': 'Letzter Stand',
|
||||
'settings.cron.jobs.loading': 'Cron-Jobs werden geladen...',
|
||||
'settings.cron.jobs.loadingRuns': 'Ladeläufe',
|
||||
@@ -3140,7 +3187,13 @@ const messages: TranslationMap = {
|
||||
'settings.cron.jobs.saving': 'Sparen…',
|
||||
'settings.cron.jobs.schedule': 'Zeitplan',
|
||||
'settings.cron.jobs.title': 'Kern-Cron-Jobs',
|
||||
'settings.cron.jobs.updated': 'Job aktualisiert',
|
||||
'settings.cron.jobs.viewRuns': 'Läufe ansehen',
|
||||
'settings.cron.schedule.every2hours': 'Alle 2 Stunden',
|
||||
'settings.cron.schedule.every30min': 'Alle 30 Minuten',
|
||||
'settings.cron.schedule.every6hours': 'Alle 6 Stunden',
|
||||
'settings.cron.schedule.everyHour': 'Jede Stunde',
|
||||
'settings.cron.schedule.onceDaily': 'Einmal täglich (9 Uhr)',
|
||||
'settings.localModel.deviceCapability.active': 'Aktiv',
|
||||
'settings.localModel.deviceCapability.appliedTier': 'Angewandte Stufe',
|
||||
'settings.localModel.deviceCapability.applying': 'Bewerben',
|
||||
|
||||
@@ -3258,8 +3258,55 @@ const en: TranslationMap = {
|
||||
'settings.companion.sessionTtl': 'Session TTL',
|
||||
'settings.companion.screenCapture': 'Screen Capture',
|
||||
'settings.companion.appContext': 'App Context',
|
||||
'settings.cron.jobs.commandRequired': 'Command is required for shell jobs',
|
||||
'settings.cron.jobs.created': 'Job created',
|
||||
'settings.cron.jobs.createJob': 'New Scheduled Job',
|
||||
'settings.cron.jobs.custom': 'Custom',
|
||||
'settings.cron.jobs.desc': 'View and manage scheduled jobs running in the core.',
|
||||
'settings.cron.jobs.edit': 'Edit',
|
||||
'settings.cron.jobs.editJob': 'Edit Scheduled Job',
|
||||
'settings.cron.jobs.empty': 'No core cron jobs found.',
|
||||
'settings.cron.jobs.errorCreate': 'Failed to create cron job: {message}',
|
||||
'settings.cron.jobs.errorLoadList': 'Failed to load core cron jobs: {message}',
|
||||
'settings.cron.jobs.errorLoadRuns': 'Failed to load run history: {message}',
|
||||
'settings.cron.jobs.errorRemove': 'Failed to remove core cron job: {message}',
|
||||
'settings.cron.jobs.errorRun': 'Failed to run core cron job: {message}',
|
||||
'settings.cron.jobs.errorToggle': 'Failed to update core cron job: {message}',
|
||||
'settings.cron.jobs.errorUpdate': 'Failed to update cron job: {message}',
|
||||
'settings.cron.jobs.formAtLabel': 'Run at',
|
||||
'settings.cron.jobs.formCancel': 'Cancel',
|
||||
'settings.cron.jobs.formCommand': 'Shell command',
|
||||
'settings.cron.jobs.formCommandPlaceholder': 'e.g. curl https://example.com/health',
|
||||
'settings.cron.jobs.formCreate': 'Create',
|
||||
'settings.cron.jobs.formCronCustom': 'Custom expression',
|
||||
'settings.cron.jobs.formCronCustomPlaceholder': 'e.g. */30 * * * *',
|
||||
'settings.cron.jobs.formCronPreset': 'Preset',
|
||||
'settings.cron.jobs.formCronPreview': 'Runs: {preview}',
|
||||
'settings.cron.jobs.formDelivery': 'Delivery mode',
|
||||
'settings.cron.jobs.formDeliveryNone': 'None (output only)',
|
||||
'settings.cron.jobs.formDeliveryProactive': 'Proactive (push notification)',
|
||||
'settings.cron.jobs.formDeleteAfterRun': 'Delete after first run',
|
||||
'settings.cron.jobs.formError': 'Failed to save job',
|
||||
'settings.cron.jobs.formEveryLabel': 'Interval (milliseconds)',
|
||||
'settings.cron.jobs.formEveryPlaceholder': 'e.g. 3600000',
|
||||
'settings.cron.jobs.formJobType': 'Job type',
|
||||
'settings.cron.jobs.formJobTypeAgent': 'Agent (AI prompt)',
|
||||
'settings.cron.jobs.formJobTypeShell': 'Shell command',
|
||||
'settings.cron.jobs.formName': 'Job name',
|
||||
'settings.cron.jobs.formNamePlaceholder': 'e.g. daily-report, cleanup-task',
|
||||
'settings.cron.jobs.formPrompt': 'Agent prompt',
|
||||
'settings.cron.jobs.formPromptPlaceholder': 'What should the agent do each run?',
|
||||
'settings.cron.jobs.formPromptRequired': 'Prompt is required for agent jobs',
|
||||
'settings.cron.jobs.formSave': 'Save',
|
||||
'settings.cron.jobs.formSaving': 'Saving…',
|
||||
'settings.cron.jobs.formScheduleAt': 'One-time (run at)',
|
||||
'settings.cron.jobs.formScheduleCron': 'Recurring (cron)',
|
||||
'settings.cron.jobs.formScheduleEvery': 'Interval (every N ms)',
|
||||
'settings.cron.jobs.formScheduleRequired': 'Schedule is required',
|
||||
'settings.cron.jobs.formScheduleType': 'Schedule type',
|
||||
'settings.cron.jobs.formSessionIsolated': 'Isolated (recommended)',
|
||||
'settings.cron.jobs.formSessionMain': 'Main session',
|
||||
'settings.cron.jobs.formSessionTarget': 'Session target',
|
||||
'settings.cron.jobs.lastStatus': 'Last status',
|
||||
'settings.cron.jobs.loading': 'Loading cron jobs...',
|
||||
'settings.cron.jobs.loadingRuns': 'Loading runs…',
|
||||
@@ -3273,7 +3320,13 @@ const en: TranslationMap = {
|
||||
'settings.cron.jobs.saving': 'Saving…',
|
||||
'settings.cron.jobs.schedule': 'Schedule',
|
||||
'settings.cron.jobs.title': 'Core Cron Jobs',
|
||||
'settings.cron.jobs.updated': 'Job updated',
|
||||
'settings.cron.jobs.viewRuns': 'View runs',
|
||||
'settings.cron.schedule.every2hours': 'Every 2 hours',
|
||||
'settings.cron.schedule.every30min': 'Every 30 minutes',
|
||||
'settings.cron.schedule.every6hours': 'Every 6 hours',
|
||||
'settings.cron.schedule.everyHour': 'Every hour',
|
||||
'settings.cron.schedule.onceDaily': 'Once daily (9 AM)',
|
||||
'settings.localModel.deviceCapability.active': 'Active',
|
||||
'settings.localModel.deviceCapability.appliedTier': 'Applied tier',
|
||||
'settings.localModel.deviceCapability.applying': 'Applying',
|
||||
|
||||
@@ -3102,8 +3102,57 @@ const messages: TranslationMap = {
|
||||
'settings.companion.sessionTtl': 'TTL de sesión',
|
||||
'settings.companion.screenCapture': 'Captura de pantalla',
|
||||
'settings.companion.appContext': 'Contexto de la aplicación',
|
||||
'settings.cron.jobs.commandRequired': 'El comando es obligatorio para los trabajos de shell',
|
||||
'settings.cron.jobs.createJob': 'Nuevo trabajo programado',
|
||||
'settings.cron.jobs.created': 'Trabajo creado',
|
||||
'settings.cron.jobs.custom': 'Personalizado',
|
||||
'settings.cron.jobs.desc': 'Descripción',
|
||||
'settings.cron.jobs.edit': 'Editar',
|
||||
'settings.cron.jobs.editJob': 'Editar trabajo programado',
|
||||
'settings.cron.jobs.empty': 'No se encontraron tareas cron del core.',
|
||||
'settings.cron.jobs.errorCreate': 'No se pudo crear el trabajo cron: {message}',
|
||||
'settings.cron.jobs.errorLoadList':
|
||||
'No se pudieron cargar los trabajos cron del núcleo: {message}',
|
||||
'settings.cron.jobs.errorLoadRuns': 'No se pudo cargar el historial de ejecuciones: {message}',
|
||||
'settings.cron.jobs.errorRemove': 'No se pudo eliminar el trabajo cron del núcleo: {message}',
|
||||
'settings.cron.jobs.errorRun': 'No se pudo ejecutar el trabajo cron del núcleo: {message}',
|
||||
'settings.cron.jobs.errorToggle': 'No se pudo actualizar el trabajo cron del núcleo: {message}',
|
||||
'settings.cron.jobs.errorUpdate': 'No se pudo actualizar el trabajo cron: {message}',
|
||||
'settings.cron.jobs.formAtLabel': 'Ejecutar a las',
|
||||
'settings.cron.jobs.formCancel': 'Cancelar',
|
||||
'settings.cron.jobs.formCommand': 'Comando de shell',
|
||||
'settings.cron.jobs.formCommandPlaceholder': 'p. ej. curl https://example.com/health',
|
||||
'settings.cron.jobs.formCreate': 'Crear',
|
||||
'settings.cron.jobs.formCronCustom': 'Expresión personalizada',
|
||||
'settings.cron.jobs.formCronCustomPlaceholder': 'p. ej. */30 * * * *',
|
||||
'settings.cron.jobs.formCronPreset': 'Predefinido',
|
||||
'settings.cron.jobs.formCronPreview': 'Ejecuciones: {preview}',
|
||||
'settings.cron.jobs.formDeleteAfterRun': 'Eliminar tras la primera ejecución',
|
||||
'settings.cron.jobs.formDelivery': 'Modo de entrega',
|
||||
'settings.cron.jobs.formDeliveryNone': 'Ninguno (solo salida)',
|
||||
'settings.cron.jobs.formDeliveryProactive': 'Proactivo (notificación push)',
|
||||
'settings.cron.jobs.formError': 'No se pudo guardar el trabajo',
|
||||
'settings.cron.jobs.formEveryLabel': 'Intervalo (milisegundos)',
|
||||
'settings.cron.jobs.formEveryPlaceholder': 'p. ej. 3600000',
|
||||
'settings.cron.jobs.formJobType': 'Tipo de trabajo',
|
||||
'settings.cron.jobs.formJobTypeAgent': 'Agent (instrucción de IA)',
|
||||
'settings.cron.jobs.formJobTypeShell': 'Comando de shell',
|
||||
'settings.cron.jobs.formName': 'Nombre del trabajo',
|
||||
'settings.cron.jobs.formNamePlaceholder': 'p. ej. daily-report, cleanup-task',
|
||||
'settings.cron.jobs.formPrompt': 'Instrucción del agente',
|
||||
'settings.cron.jobs.formPromptPlaceholder': '¿Qué debe hacer el agente en cada ejecución?',
|
||||
'settings.cron.jobs.formPromptRequired':
|
||||
'La instrucción es obligatoria para los trabajos de agente',
|
||||
'settings.cron.jobs.formSave': 'Guardar',
|
||||
'settings.cron.jobs.formSaving': 'Guardando…',
|
||||
'settings.cron.jobs.formScheduleAt': 'Una sola vez (ejecutar a las)',
|
||||
'settings.cron.jobs.formScheduleCron': 'Recurrente (cron)',
|
||||
'settings.cron.jobs.formScheduleEvery': 'Intervalo (cada N ms)',
|
||||
'settings.cron.jobs.formScheduleRequired': 'La programación es obligatoria',
|
||||
'settings.cron.jobs.formScheduleType': 'Tipo de programación',
|
||||
'settings.cron.jobs.formSessionIsolated': 'Aislada (recomendado)',
|
||||
'settings.cron.jobs.formSessionMain': 'Sesión principal',
|
||||
'settings.cron.jobs.formSessionTarget': 'Sesión de destino',
|
||||
'settings.cron.jobs.lastStatus': 'Último estado',
|
||||
'settings.cron.jobs.loading': 'Cargando tareas cron...',
|
||||
'settings.cron.jobs.loadingRuns': 'Cargando ejecuciones',
|
||||
@@ -3117,7 +3166,13 @@ const messages: TranslationMap = {
|
||||
'settings.cron.jobs.saving': 'Guardando…',
|
||||
'settings.cron.jobs.schedule': 'Programación',
|
||||
'settings.cron.jobs.title': 'Tareas cron del core',
|
||||
'settings.cron.jobs.updated': 'Trabajo actualizado',
|
||||
'settings.cron.jobs.viewRuns': 'Ver ejecuciones',
|
||||
'settings.cron.schedule.every2hours': 'Cada 2 horas',
|
||||
'settings.cron.schedule.every30min': 'Cada 30 minutos',
|
||||
'settings.cron.schedule.every6hours': 'Cada 6 horas',
|
||||
'settings.cron.schedule.everyHour': 'Cada hora',
|
||||
'settings.cron.schedule.onceDaily': 'Una vez al día (9 a. m.)',
|
||||
'settings.localModel.deviceCapability.active': 'Activo',
|
||||
'settings.localModel.deviceCapability.appliedTier': 'Nivel aplicado',
|
||||
'settings.localModel.deviceCapability.applying': 'Aplicando',
|
||||
|
||||
@@ -3114,8 +3114,56 @@ const messages: TranslationMap = {
|
||||
'settings.companion.sessionTtl': 'Durée de vie de la session',
|
||||
'settings.companion.screenCapture': "Capture d'écran",
|
||||
'settings.companion.appContext': "Contexte de l'application",
|
||||
'settings.cron.jobs.commandRequired': 'La commande est requise pour les tâches shell',
|
||||
'settings.cron.jobs.createJob': 'Nouvelle tâche planifiée',
|
||||
'settings.cron.jobs.created': 'Tâche créée',
|
||||
'settings.cron.jobs.custom': 'Personnalisé',
|
||||
'settings.cron.jobs.desc': 'Description',
|
||||
'settings.cron.jobs.edit': 'Modifier',
|
||||
'settings.cron.jobs.editJob': 'Modifier la tâche planifiée',
|
||||
'settings.cron.jobs.empty': 'Aucune tâche cron core trouvée.',
|
||||
'settings.cron.jobs.errorCreate': 'Échec de la création de la tâche cron : {message}',
|
||||
'settings.cron.jobs.errorLoadList': 'Échec du chargement des tâches cron du cœur : {message}',
|
||||
'settings.cron.jobs.errorLoadRuns':
|
||||
"Échec du chargement de l'historique des exécutions : {message}",
|
||||
'settings.cron.jobs.errorRemove': 'Échec de la suppression de la tâche cron du cœur : {message}',
|
||||
'settings.cron.jobs.errorRun': "Échec de l'exécution de la tâche cron du cœur : {message}",
|
||||
'settings.cron.jobs.errorToggle': 'Échec de la mise à jour de la tâche cron du cœur : {message}',
|
||||
'settings.cron.jobs.errorUpdate': 'Échec de la mise à jour de la tâche cron : {message}',
|
||||
'settings.cron.jobs.formAtLabel': 'Exécuter à',
|
||||
'settings.cron.jobs.formCancel': 'Annuler',
|
||||
'settings.cron.jobs.formCommand': 'Commande shell',
|
||||
'settings.cron.jobs.formCommandPlaceholder': 'ex. curl https://example.com/health',
|
||||
'settings.cron.jobs.formCreate': 'Créer',
|
||||
'settings.cron.jobs.formCronCustom': 'Expression personnalisée',
|
||||
'settings.cron.jobs.formCronCustomPlaceholder': 'ex. */30 * * * *',
|
||||
'settings.cron.jobs.formCronPreset': 'Préréglage',
|
||||
'settings.cron.jobs.formCronPreview': 'Exécutions : {preview}',
|
||||
'settings.cron.jobs.formDeleteAfterRun': 'Supprimer après la première exécution',
|
||||
'settings.cron.jobs.formDelivery': 'Mode de distribution',
|
||||
'settings.cron.jobs.formDeliveryNone': 'Aucun (sortie uniquement)',
|
||||
'settings.cron.jobs.formDeliveryProactive': 'Proactif (notification push)',
|
||||
'settings.cron.jobs.formError': "Échec de l'enregistrement de la tâche",
|
||||
'settings.cron.jobs.formEveryLabel': 'Intervalle (millisecondes)',
|
||||
'settings.cron.jobs.formEveryPlaceholder': 'ex. 3600000',
|
||||
'settings.cron.jobs.formJobType': 'Type de tâche',
|
||||
'settings.cron.jobs.formJobTypeAgent': 'Agent (invite IA)',
|
||||
'settings.cron.jobs.formJobTypeShell': 'Commande shell',
|
||||
'settings.cron.jobs.formName': 'Nom de la tâche',
|
||||
'settings.cron.jobs.formNamePlaceholder': 'ex. daily-report, cleanup-task',
|
||||
'settings.cron.jobs.formPrompt': "Invite de l'agent",
|
||||
'settings.cron.jobs.formPromptPlaceholder': "Que doit faire l'agent à chaque exécution ?",
|
||||
'settings.cron.jobs.formPromptRequired': "L'invite est requise pour les tâches d'agent",
|
||||
'settings.cron.jobs.formSave': 'Enregistrer',
|
||||
'settings.cron.jobs.formSaving': 'Enregistrement…',
|
||||
'settings.cron.jobs.formScheduleAt': 'Unique (exécuter à)',
|
||||
'settings.cron.jobs.formScheduleCron': 'Récurrent (cron)',
|
||||
'settings.cron.jobs.formScheduleEvery': 'Intervalle (toutes les N ms)',
|
||||
'settings.cron.jobs.formScheduleRequired': 'La planification est requise',
|
||||
'settings.cron.jobs.formScheduleType': 'Type de planification',
|
||||
'settings.cron.jobs.formSessionIsolated': 'Isolée (recommandé)',
|
||||
'settings.cron.jobs.formSessionMain': 'Session principale',
|
||||
'settings.cron.jobs.formSessionTarget': 'Session cible',
|
||||
'settings.cron.jobs.lastStatus': 'Dernier statut',
|
||||
'settings.cron.jobs.loading': 'Chargement des tâches cron…',
|
||||
'settings.cron.jobs.loadingRuns': 'Chargement des exécutions',
|
||||
@@ -3129,7 +3177,13 @@ const messages: TranslationMap = {
|
||||
'settings.cron.jobs.saving': 'Enregistrement…',
|
||||
'settings.cron.jobs.schedule': 'Planification',
|
||||
'settings.cron.jobs.title': 'Tâches cron du core',
|
||||
'settings.cron.jobs.updated': 'Tâche mise à jour',
|
||||
'settings.cron.jobs.viewRuns': 'Voir les exécutions',
|
||||
'settings.cron.schedule.every2hours': 'Toutes les 2 heures',
|
||||
'settings.cron.schedule.every30min': 'Toutes les 30 minutes',
|
||||
'settings.cron.schedule.every6hours': 'Toutes les 6 heures',
|
||||
'settings.cron.schedule.everyHour': 'Toutes les heures',
|
||||
'settings.cron.schedule.onceDaily': 'Une fois par jour (9 h)',
|
||||
'settings.localModel.deviceCapability.active': 'Actif',
|
||||
'settings.localModel.deviceCapability.appliedTier': 'Niveau appliqué',
|
||||
'settings.localModel.deviceCapability.applying': 'Application en cours',
|
||||
|
||||
@@ -3054,8 +3054,55 @@ const messages: TranslationMap = {
|
||||
'settings.companion.sessionTtl': 'सत्र टीटीएल',
|
||||
'settings.companion.screenCapture': 'स्क्रीन कैप्चर',
|
||||
'settings.companion.appContext': 'ऐप संदर्भ',
|
||||
'settings.cron.jobs.commandRequired': 'शेल जॉब के लिए कमांड आवश्यक है',
|
||||
'settings.cron.jobs.createJob': 'नई शेड्यूल्ड जॉब',
|
||||
'settings.cron.jobs.created': 'जॉब बनाई गई',
|
||||
'settings.cron.jobs.custom': 'कस्टम',
|
||||
'settings.cron.jobs.desc': 'विवरण',
|
||||
'settings.cron.jobs.edit': 'संपादित करें',
|
||||
'settings.cron.jobs.editJob': 'शेड्यूल्ड जॉब संपादित करें',
|
||||
'settings.cron.jobs.empty': 'कोई कोर cron job नहीं मिला।',
|
||||
'settings.cron.jobs.errorCreate': 'cron जॉब बनाने में विफल: {message}',
|
||||
'settings.cron.jobs.errorLoadList': 'कोर cron जॉब लोड करने में विफल: {message}',
|
||||
'settings.cron.jobs.errorLoadRuns': 'रन इतिहास लोड करने में विफल: {message}',
|
||||
'settings.cron.jobs.errorRemove': 'कोर cron जॉब हटाने में विफल: {message}',
|
||||
'settings.cron.jobs.errorRun': 'कोर cron जॉब चलाने में विफल: {message}',
|
||||
'settings.cron.jobs.errorToggle': 'कोर cron जॉब अपडेट करने में विफल: {message}',
|
||||
'settings.cron.jobs.errorUpdate': 'cron जॉब अपडेट करने में विफल: {message}',
|
||||
'settings.cron.jobs.formAtLabel': 'इस समय चलाएँ',
|
||||
'settings.cron.jobs.formCancel': 'रद्द करें',
|
||||
'settings.cron.jobs.formCommand': 'शेल कमांड',
|
||||
'settings.cron.jobs.formCommandPlaceholder': 'उदा. curl https://example.com/health',
|
||||
'settings.cron.jobs.formCreate': 'बनाएँ',
|
||||
'settings.cron.jobs.formCronCustom': 'कस्टम एक्सप्रेशन',
|
||||
'settings.cron.jobs.formCronCustomPlaceholder': 'उदा. */30 * * * *',
|
||||
'settings.cron.jobs.formCronPreset': 'प्रीसेट',
|
||||
'settings.cron.jobs.formCronPreview': 'चलता है: {preview}',
|
||||
'settings.cron.jobs.formDeleteAfterRun': 'पहली बार चलने के बाद हटाएँ',
|
||||
'settings.cron.jobs.formDelivery': 'डिलीवरी मोड',
|
||||
'settings.cron.jobs.formDeliveryNone': 'कोई नहीं (केवल आउटपुट)',
|
||||
'settings.cron.jobs.formDeliveryProactive': 'प्रोएक्टिव (पुश सूचना)',
|
||||
'settings.cron.jobs.formError': 'जॉब सहेजने में विफल',
|
||||
'settings.cron.jobs.formEveryLabel': 'अंतराल (मिलीसेकंड)',
|
||||
'settings.cron.jobs.formEveryPlaceholder': 'उदा. 3600000',
|
||||
'settings.cron.jobs.formJobType': 'जॉब प्रकार',
|
||||
'settings.cron.jobs.formJobTypeAgent': 'Agent (AI प्रॉम्प्ट)',
|
||||
'settings.cron.jobs.formJobTypeShell': 'शेल कमांड',
|
||||
'settings.cron.jobs.formName': 'जॉब नाम',
|
||||
'settings.cron.jobs.formNamePlaceholder': 'उदा. daily-report, cleanup-task',
|
||||
'settings.cron.jobs.formPrompt': 'Agent प्रॉम्प्ट',
|
||||
'settings.cron.jobs.formPromptPlaceholder': 'हर बार चलने पर agent को क्या करना चाहिए?',
|
||||
'settings.cron.jobs.formPromptRequired': 'agent जॉब के लिए प्रॉम्प्ट आवश्यक है',
|
||||
'settings.cron.jobs.formSave': 'सहेजें',
|
||||
'settings.cron.jobs.formSaving': 'सहेजा जा रहा है…',
|
||||
'settings.cron.jobs.formScheduleAt': 'एक-बार (इस समय चलाएँ)',
|
||||
'settings.cron.jobs.formScheduleCron': 'आवर्ती (cron)',
|
||||
'settings.cron.jobs.formScheduleEvery': 'अंतराल (हर N मि.से.)',
|
||||
'settings.cron.jobs.formScheduleRequired': 'शेड्यूल आवश्यक है',
|
||||
'settings.cron.jobs.formScheduleType': 'शेड्यूल प्रकार',
|
||||
'settings.cron.jobs.formSessionIsolated': 'पृथक (अनुशंसित)',
|
||||
'settings.cron.jobs.formSessionMain': 'मुख्य सत्र',
|
||||
'settings.cron.jobs.formSessionTarget': 'सत्र लक्ष्य',
|
||||
'settings.cron.jobs.lastStatus': 'आखिरी स्टेटस',
|
||||
'settings.cron.jobs.loading': 'Cron jobs लोड हो रही हैं...',
|
||||
'settings.cron.jobs.loadingRuns': 'रन लोड हो रहे हैं',
|
||||
@@ -3069,7 +3116,13 @@ const messages: TranslationMap = {
|
||||
'settings.cron.jobs.saving': 'सेव हो रहा है…',
|
||||
'settings.cron.jobs.schedule': 'शेड्यूल',
|
||||
'settings.cron.jobs.title': 'कोर Cron Jobs',
|
||||
'settings.cron.jobs.updated': 'जॉब अपडेट की गई',
|
||||
'settings.cron.jobs.viewRuns': 'रन देखें',
|
||||
'settings.cron.schedule.every2hours': 'हर 2 घंटे',
|
||||
'settings.cron.schedule.every30min': 'हर 30 मिनट',
|
||||
'settings.cron.schedule.every6hours': 'हर 6 घंटे',
|
||||
'settings.cron.schedule.everyHour': 'हर घंटे',
|
||||
'settings.cron.schedule.onceDaily': 'दिन में एक बार (सुबह 9 बजे)',
|
||||
'settings.localModel.deviceCapability.active': 'एक्टिव',
|
||||
'settings.localModel.deviceCapability.appliedTier': 'लागू टियर',
|
||||
'settings.localModel.deviceCapability.applying': 'लागू हो रहा है',
|
||||
|
||||
@@ -3060,8 +3060,55 @@ const messages: TranslationMap = {
|
||||
'settings.companion.sessionTtl': 'Sesi TTL',
|
||||
'settings.companion.screenCapture': 'Tangkapan Layar',
|
||||
'settings.companion.appContext': 'Konteks Aplikasi',
|
||||
'settings.cron.jobs.commandRequired': 'Perintah wajib diisi untuk tugas shell',
|
||||
'settings.cron.jobs.createJob': 'Tugas Terjadwal Baru',
|
||||
'settings.cron.jobs.created': 'Tugas dibuat',
|
||||
'settings.cron.jobs.custom': 'Kustom',
|
||||
'settings.cron.jobs.desc': 'Deskripsi',
|
||||
'settings.cron.jobs.edit': 'Edit',
|
||||
'settings.cron.jobs.editJob': 'Edit Tugas Terjadwal',
|
||||
'settings.cron.jobs.empty': 'Tidak ada cron job core ditemukan.',
|
||||
'settings.cron.jobs.errorCreate': 'Gagal membuat tugas cron: {message}',
|
||||
'settings.cron.jobs.errorLoadList': 'Gagal memuat tugas cron inti: {message}',
|
||||
'settings.cron.jobs.errorLoadRuns': 'Gagal memuat riwayat eksekusi: {message}',
|
||||
'settings.cron.jobs.errorRemove': 'Gagal menghapus tugas cron inti: {message}',
|
||||
'settings.cron.jobs.errorRun': 'Gagal menjalankan tugas cron inti: {message}',
|
||||
'settings.cron.jobs.errorToggle': 'Gagal memperbarui tugas cron inti: {message}',
|
||||
'settings.cron.jobs.errorUpdate': 'Gagal memperbarui tugas cron: {message}',
|
||||
'settings.cron.jobs.formAtLabel': 'Jalankan pada',
|
||||
'settings.cron.jobs.formCancel': 'Batal',
|
||||
'settings.cron.jobs.formCommand': 'Perintah shell',
|
||||
'settings.cron.jobs.formCommandPlaceholder': 'mis. curl https://example.com/health',
|
||||
'settings.cron.jobs.formCreate': 'Buat',
|
||||
'settings.cron.jobs.formCronCustom': 'Ekspresi kustom',
|
||||
'settings.cron.jobs.formCronCustomPlaceholder': 'mis. */30 * * * *',
|
||||
'settings.cron.jobs.formCronPreset': 'Preset',
|
||||
'settings.cron.jobs.formCronPreview': 'Berjalan: {preview}',
|
||||
'settings.cron.jobs.formDeleteAfterRun': 'Hapus setelah eksekusi pertama',
|
||||
'settings.cron.jobs.formDelivery': 'Mode pengiriman',
|
||||
'settings.cron.jobs.formDeliveryNone': 'Tidak ada (hanya output)',
|
||||
'settings.cron.jobs.formDeliveryProactive': 'Proaktif (notifikasi push)',
|
||||
'settings.cron.jobs.formError': 'Gagal menyimpan tugas',
|
||||
'settings.cron.jobs.formEveryLabel': 'Interval (milidetik)',
|
||||
'settings.cron.jobs.formEveryPlaceholder': 'mis. 3600000',
|
||||
'settings.cron.jobs.formJobType': 'Jenis tugas',
|
||||
'settings.cron.jobs.formJobTypeAgent': 'Agent (prompt AI)',
|
||||
'settings.cron.jobs.formJobTypeShell': 'Perintah shell',
|
||||
'settings.cron.jobs.formName': 'Nama tugas',
|
||||
'settings.cron.jobs.formNamePlaceholder': 'mis. daily-report, cleanup-task',
|
||||
'settings.cron.jobs.formPrompt': 'Prompt agent',
|
||||
'settings.cron.jobs.formPromptPlaceholder': 'Apa yang harus dilakukan agent setiap eksekusi?',
|
||||
'settings.cron.jobs.formPromptRequired': 'Prompt wajib diisi untuk tugas agent',
|
||||
'settings.cron.jobs.formSave': 'Simpan',
|
||||
'settings.cron.jobs.formSaving': 'Menyimpan…',
|
||||
'settings.cron.jobs.formScheduleAt': 'Sekali jalan (jalankan pada)',
|
||||
'settings.cron.jobs.formScheduleCron': 'Berulang (cron)',
|
||||
'settings.cron.jobs.formScheduleEvery': 'Interval (setiap N md)',
|
||||
'settings.cron.jobs.formScheduleRequired': 'Jadwal wajib diisi',
|
||||
'settings.cron.jobs.formScheduleType': 'Jenis jadwal',
|
||||
'settings.cron.jobs.formSessionIsolated': 'Terisolasi (disarankan)',
|
||||
'settings.cron.jobs.formSessionMain': 'Sesi utama',
|
||||
'settings.cron.jobs.formSessionTarget': 'Target sesi',
|
||||
'settings.cron.jobs.lastStatus': 'Status terakhir',
|
||||
'settings.cron.jobs.loading': 'Memuat cron job...',
|
||||
'settings.cron.jobs.loadingRuns': 'Memuat run',
|
||||
@@ -3075,7 +3122,13 @@ const messages: TranslationMap = {
|
||||
'settings.cron.jobs.saving': 'Menyimpan...',
|
||||
'settings.cron.jobs.schedule': 'Jadwal',
|
||||
'settings.cron.jobs.title': 'Cron Job Core',
|
||||
'settings.cron.jobs.updated': 'Tugas diperbarui',
|
||||
'settings.cron.jobs.viewRuns': 'Lihat run',
|
||||
'settings.cron.schedule.every2hours': 'Setiap 2 jam',
|
||||
'settings.cron.schedule.every30min': 'Setiap 30 menit',
|
||||
'settings.cron.schedule.every6hours': 'Setiap 6 jam',
|
||||
'settings.cron.schedule.everyHour': 'Setiap jam',
|
||||
'settings.cron.schedule.onceDaily': 'Sekali sehari (9 pagi)',
|
||||
'settings.localModel.deviceCapability.active': 'Aktif',
|
||||
'settings.localModel.deviceCapability.appliedTier': 'Tingkat yang diterapkan',
|
||||
'settings.localModel.deviceCapability.applying': 'Menerapkan',
|
||||
|
||||
@@ -3097,8 +3097,56 @@ const messages: TranslationMap = {
|
||||
'settings.companion.sessionTtl': 'Sessione TTL',
|
||||
'settings.companion.screenCapture': 'Acquisizione schermata',
|
||||
'settings.companion.appContext': 'Contesto app',
|
||||
'settings.cron.jobs.commandRequired': 'Il comando è obbligatorio per i job shell',
|
||||
'settings.cron.jobs.createJob': 'Nuovo job pianificato',
|
||||
'settings.cron.jobs.created': 'Job creato',
|
||||
'settings.cron.jobs.custom': 'Personalizzato',
|
||||
'settings.cron.jobs.desc': 'Descrizione',
|
||||
'settings.cron.jobs.edit': 'Modifica',
|
||||
'settings.cron.jobs.editJob': 'Modifica job pianificato',
|
||||
'settings.cron.jobs.empty': 'Nessun cron job core trovato.',
|
||||
'settings.cron.jobs.errorCreate': 'Impossibile creare il job cron: {message}',
|
||||
'settings.cron.jobs.errorLoadList': 'Impossibile caricare i job cron del core: {message}',
|
||||
'settings.cron.jobs.errorLoadRuns':
|
||||
'Impossibile caricare la cronologia delle esecuzioni: {message}',
|
||||
'settings.cron.jobs.errorRemove': 'Impossibile rimuovere il job cron del core: {message}',
|
||||
'settings.cron.jobs.errorRun': 'Impossibile eseguire il job cron del core: {message}',
|
||||
'settings.cron.jobs.errorToggle': 'Impossibile aggiornare il job cron del core: {message}',
|
||||
'settings.cron.jobs.errorUpdate': 'Impossibile aggiornare il job cron: {message}',
|
||||
'settings.cron.jobs.formAtLabel': 'Esegui alle',
|
||||
'settings.cron.jobs.formCancel': 'Annulla',
|
||||
'settings.cron.jobs.formCommand': 'Comando shell',
|
||||
'settings.cron.jobs.formCommandPlaceholder': 'es. curl https://example.com/health',
|
||||
'settings.cron.jobs.formCreate': 'Crea',
|
||||
'settings.cron.jobs.formCronCustom': 'Espressione personalizzata',
|
||||
'settings.cron.jobs.formCronCustomPlaceholder': 'es. */30 * * * *',
|
||||
'settings.cron.jobs.formCronPreset': 'Preimpostazione',
|
||||
'settings.cron.jobs.formCronPreview': 'Esecuzioni: {preview}',
|
||||
'settings.cron.jobs.formDeleteAfterRun': 'Elimina dopo la prima esecuzione',
|
||||
'settings.cron.jobs.formDelivery': 'Modalità di consegna',
|
||||
'settings.cron.jobs.formDeliveryNone': 'Nessuna (solo output)',
|
||||
'settings.cron.jobs.formDeliveryProactive': 'Proattiva (notifica push)',
|
||||
'settings.cron.jobs.formError': 'Impossibile salvare il job',
|
||||
'settings.cron.jobs.formEveryLabel': 'Intervallo (millisecondi)',
|
||||
'settings.cron.jobs.formEveryPlaceholder': 'es. 3600000',
|
||||
'settings.cron.jobs.formJobType': 'Tipo di job',
|
||||
'settings.cron.jobs.formJobTypeAgent': 'Agent (prompt AI)',
|
||||
'settings.cron.jobs.formJobTypeShell': 'Comando shell',
|
||||
'settings.cron.jobs.formName': 'Nome del job',
|
||||
'settings.cron.jobs.formNamePlaceholder': 'es. daily-report, cleanup-task',
|
||||
'settings.cron.jobs.formPrompt': "Prompt dell'agent",
|
||||
'settings.cron.jobs.formPromptPlaceholder': "Cosa deve fare l'agent a ogni esecuzione?",
|
||||
'settings.cron.jobs.formPromptRequired': 'Il prompt è obbligatorio per i job agent',
|
||||
'settings.cron.jobs.formSave': 'Salva',
|
||||
'settings.cron.jobs.formSaving': 'Salvataggio…',
|
||||
'settings.cron.jobs.formScheduleAt': 'Una tantum (esegui alle)',
|
||||
'settings.cron.jobs.formScheduleCron': 'Ricorrente (cron)',
|
||||
'settings.cron.jobs.formScheduleEvery': 'Intervallo (ogni N ms)',
|
||||
'settings.cron.jobs.formScheduleRequired': 'La pianificazione è obbligatoria',
|
||||
'settings.cron.jobs.formScheduleType': 'Tipo di pianificazione',
|
||||
'settings.cron.jobs.formSessionIsolated': 'Isolata (consigliata)',
|
||||
'settings.cron.jobs.formSessionMain': 'Sessione principale',
|
||||
'settings.cron.jobs.formSessionTarget': 'Sessione di destinazione',
|
||||
'settings.cron.jobs.lastStatus': 'Ultimo stato',
|
||||
'settings.cron.jobs.loading': 'Caricamento cron job...',
|
||||
'settings.cron.jobs.loadingRuns': 'Caricamento esecuzioni',
|
||||
@@ -3112,7 +3160,13 @@ const messages: TranslationMap = {
|
||||
'settings.cron.jobs.saving': 'Salvataggio…',
|
||||
'settings.cron.jobs.schedule': 'Pianificazione',
|
||||
'settings.cron.jobs.title': 'Cron Job core',
|
||||
'settings.cron.jobs.updated': 'Job aggiornato',
|
||||
'settings.cron.jobs.viewRuns': 'Visualizza esecuzioni',
|
||||
'settings.cron.schedule.every2hours': 'Ogni 2 ore',
|
||||
'settings.cron.schedule.every30min': 'Ogni 30 minuti',
|
||||
'settings.cron.schedule.every6hours': 'Ogni 6 ore',
|
||||
'settings.cron.schedule.everyHour': 'Ogni ora',
|
||||
'settings.cron.schedule.onceDaily': 'Una volta al giorno (9:00)',
|
||||
'settings.localModel.deviceCapability.active': 'Attivo',
|
||||
'settings.localModel.deviceCapability.appliedTier': 'Tier applicato',
|
||||
'settings.localModel.deviceCapability.applying': 'Applicazione',
|
||||
|
||||
@@ -3026,8 +3026,55 @@ const messages: TranslationMap = {
|
||||
'settings.companion.sessionTtl': '세션 TTL',
|
||||
'settings.companion.screenCapture': '화면 캡처',
|
||||
'settings.companion.appContext': '앱 컨텍스트',
|
||||
'settings.cron.jobs.commandRequired': '셸 작업에는 명령어가 필요합니다',
|
||||
'settings.cron.jobs.createJob': '새 예약 작업',
|
||||
'settings.cron.jobs.created': '작업이 생성되었습니다',
|
||||
'settings.cron.jobs.custom': '사용자 지정',
|
||||
'settings.cron.jobs.desc': '설명',
|
||||
'settings.cron.jobs.edit': '편집',
|
||||
'settings.cron.jobs.editJob': '예약 작업 편집',
|
||||
'settings.cron.jobs.empty': '코어 cron 작업을 찾을 수 없습니다.',
|
||||
'settings.cron.jobs.errorCreate': 'cron 작업 생성에 실패했습니다: {message}',
|
||||
'settings.cron.jobs.errorLoadList': '코어 cron 작업을 불러오지 못했습니다: {message}',
|
||||
'settings.cron.jobs.errorLoadRuns': '실행 기록을 불러오지 못했습니다: {message}',
|
||||
'settings.cron.jobs.errorRemove': '코어 cron 작업을 삭제하지 못했습니다: {message}',
|
||||
'settings.cron.jobs.errorRun': '코어 cron 작업을 실행하지 못했습니다: {message}',
|
||||
'settings.cron.jobs.errorToggle': '코어 cron 작업을 업데이트하지 못했습니다: {message}',
|
||||
'settings.cron.jobs.errorUpdate': 'cron 작업 업데이트에 실패했습니다: {message}',
|
||||
'settings.cron.jobs.formAtLabel': '실행 시각',
|
||||
'settings.cron.jobs.formCancel': '취소',
|
||||
'settings.cron.jobs.formCommand': '셸 명령어',
|
||||
'settings.cron.jobs.formCommandPlaceholder': '예: curl https://example.com/health',
|
||||
'settings.cron.jobs.formCreate': '생성',
|
||||
'settings.cron.jobs.formCronCustom': '사용자 지정 표현식',
|
||||
'settings.cron.jobs.formCronCustomPlaceholder': '예: */30 * * * *',
|
||||
'settings.cron.jobs.formCronPreset': '프리셋',
|
||||
'settings.cron.jobs.formCronPreview': '실행: {preview}',
|
||||
'settings.cron.jobs.formDeleteAfterRun': '첫 실행 후 삭제',
|
||||
'settings.cron.jobs.formDelivery': '전달 방식',
|
||||
'settings.cron.jobs.formDeliveryNone': '없음 (출력만)',
|
||||
'settings.cron.jobs.formDeliveryProactive': '선제적 (푸시 알림)',
|
||||
'settings.cron.jobs.formError': '작업 저장에 실패했습니다',
|
||||
'settings.cron.jobs.formEveryLabel': '간격 (밀리초)',
|
||||
'settings.cron.jobs.formEveryPlaceholder': '예: 3600000',
|
||||
'settings.cron.jobs.formJobType': '작업 유형',
|
||||
'settings.cron.jobs.formJobTypeAgent': 'Agent (AI 프롬프트)',
|
||||
'settings.cron.jobs.formJobTypeShell': '셸 명령어',
|
||||
'settings.cron.jobs.formName': '작업 이름',
|
||||
'settings.cron.jobs.formNamePlaceholder': '예: daily-report, cleanup-task',
|
||||
'settings.cron.jobs.formPrompt': 'Agent 프롬프트',
|
||||
'settings.cron.jobs.formPromptPlaceholder': '매 실행마다 에이전트가 무엇을 해야 하나요?',
|
||||
'settings.cron.jobs.formPromptRequired': '에이전트 작업에는 프롬프트가 필요합니다',
|
||||
'settings.cron.jobs.formSave': '저장',
|
||||
'settings.cron.jobs.formSaving': '저장 중…',
|
||||
'settings.cron.jobs.formScheduleAt': '일회성 (실행 시각)',
|
||||
'settings.cron.jobs.formScheduleCron': '반복 (cron)',
|
||||
'settings.cron.jobs.formScheduleEvery': '간격 (N밀리초마다)',
|
||||
'settings.cron.jobs.formScheduleRequired': '일정이 필요합니다',
|
||||
'settings.cron.jobs.formScheduleType': '일정 유형',
|
||||
'settings.cron.jobs.formSessionIsolated': '격리됨 (권장)',
|
||||
'settings.cron.jobs.formSessionMain': '메인 세션',
|
||||
'settings.cron.jobs.formSessionTarget': '세션 대상',
|
||||
'settings.cron.jobs.lastStatus': '마지막 상태',
|
||||
'settings.cron.jobs.loading': 'cron 작업 불러오는 중...',
|
||||
'settings.cron.jobs.loadingRuns': '실행 기록 불러오는 중',
|
||||
@@ -3041,7 +3088,13 @@ const messages: TranslationMap = {
|
||||
'settings.cron.jobs.saving': '저장 중…',
|
||||
'settings.cron.jobs.schedule': '일정',
|
||||
'settings.cron.jobs.title': '코어 Cron 작업',
|
||||
'settings.cron.jobs.updated': '작업이 업데이트되었습니다',
|
||||
'settings.cron.jobs.viewRuns': '실행 기록 보기',
|
||||
'settings.cron.schedule.every2hours': '2시간마다',
|
||||
'settings.cron.schedule.every30min': '30분마다',
|
||||
'settings.cron.schedule.every6hours': '6시간마다',
|
||||
'settings.cron.schedule.everyHour': '매시간',
|
||||
'settings.cron.schedule.onceDaily': '매일 1회 (오전 9시)',
|
||||
'settings.localModel.deviceCapability.active': '활성',
|
||||
'settings.localModel.deviceCapability.appliedTier': '적용된 티어',
|
||||
'settings.localModel.deviceCapability.applying': '적용 중',
|
||||
|
||||
@@ -3104,8 +3104,55 @@ const messages: TranslationMap = {
|
||||
'settings.companion.sessionTtl': 'TTL sesji',
|
||||
'settings.companion.screenCapture': 'Przechwytywanie ekranu',
|
||||
'settings.companion.appContext': 'Kontekst aplikacji',
|
||||
'settings.cron.jobs.commandRequired': 'Polecenie jest wymagane dla zadań typu shell',
|
||||
'settings.cron.jobs.createJob': 'Nowe zaplanowane zadanie',
|
||||
'settings.cron.jobs.created': 'Utworzono zadanie',
|
||||
'settings.cron.jobs.custom': 'Niestandardowe',
|
||||
'settings.cron.jobs.desc': 'Zadania cykliczne uruchamiane przez harmonogram rdzenia.',
|
||||
'settings.cron.jobs.edit': 'Edytuj',
|
||||
'settings.cron.jobs.editJob': 'Edytuj zaplanowane zadanie',
|
||||
'settings.cron.jobs.empty': 'Nie znaleziono zadań cron rdzenia.',
|
||||
'settings.cron.jobs.errorCreate': 'Nie udało się utworzyć zadania cron: {message}',
|
||||
'settings.cron.jobs.errorLoadList': 'Nie udało się załadować zadań cron rdzenia: {message}',
|
||||
'settings.cron.jobs.errorLoadRuns': 'Nie udało się załadować historii uruchomień: {message}',
|
||||
'settings.cron.jobs.errorRemove': 'Nie udało się usunąć zadania cron rdzenia: {message}',
|
||||
'settings.cron.jobs.errorRun': 'Nie udało się uruchomić zadania cron rdzenia: {message}',
|
||||
'settings.cron.jobs.errorToggle': 'Nie udało się zaktualizować zadania cron rdzenia: {message}',
|
||||
'settings.cron.jobs.errorUpdate': 'Nie udało się zaktualizować zadania cron: {message}',
|
||||
'settings.cron.jobs.formAtLabel': 'Uruchom o',
|
||||
'settings.cron.jobs.formCancel': 'Anuluj',
|
||||
'settings.cron.jobs.formCommand': 'Polecenie shell',
|
||||
'settings.cron.jobs.formCommandPlaceholder': 'np. curl https://example.com/health',
|
||||
'settings.cron.jobs.formCreate': 'Utwórz',
|
||||
'settings.cron.jobs.formCronCustom': 'Wyrażenie niestandardowe',
|
||||
'settings.cron.jobs.formCronCustomPlaceholder': 'np. */30 * * * *',
|
||||
'settings.cron.jobs.formCronPreset': 'Ustawienie wstępne',
|
||||
'settings.cron.jobs.formCronPreview': 'Uruchomienia: {preview}',
|
||||
'settings.cron.jobs.formDeleteAfterRun': 'Usuń po pierwszym uruchomieniu',
|
||||
'settings.cron.jobs.formDelivery': 'Tryb dostarczania',
|
||||
'settings.cron.jobs.formDeliveryNone': 'Brak (tylko wynik)',
|
||||
'settings.cron.jobs.formDeliveryProactive': 'Proaktywny (powiadomienie push)',
|
||||
'settings.cron.jobs.formError': 'Nie udało się zapisać zadania',
|
||||
'settings.cron.jobs.formEveryLabel': 'Interwał (milisekundy)',
|
||||
'settings.cron.jobs.formEveryPlaceholder': 'np. 3600000',
|
||||
'settings.cron.jobs.formJobType': 'Typ zadania',
|
||||
'settings.cron.jobs.formJobTypeAgent': 'Agent (zapytanie AI)',
|
||||
'settings.cron.jobs.formJobTypeShell': 'Polecenie shell',
|
||||
'settings.cron.jobs.formName': 'Nazwa zadania',
|
||||
'settings.cron.jobs.formNamePlaceholder': 'np. daily-report, cleanup-task',
|
||||
'settings.cron.jobs.formPrompt': 'Zapytanie agenta',
|
||||
'settings.cron.jobs.formPromptPlaceholder': 'Co agent ma robić przy każdym uruchomieniu?',
|
||||
'settings.cron.jobs.formPromptRequired': 'Zapytanie jest wymagane dla zadań agenta',
|
||||
'settings.cron.jobs.formSave': 'Zapisz',
|
||||
'settings.cron.jobs.formSaving': 'Zapisywanie…',
|
||||
'settings.cron.jobs.formScheduleAt': 'Jednorazowe (uruchom o)',
|
||||
'settings.cron.jobs.formScheduleCron': 'Cykliczne (cron)',
|
||||
'settings.cron.jobs.formScheduleEvery': 'Interwał (co N ms)',
|
||||
'settings.cron.jobs.formScheduleRequired': 'Harmonogram jest wymagany',
|
||||
'settings.cron.jobs.formScheduleType': 'Typ harmonogramu',
|
||||
'settings.cron.jobs.formSessionIsolated': 'Izolowana (zalecane)',
|
||||
'settings.cron.jobs.formSessionMain': 'Sesja główna',
|
||||
'settings.cron.jobs.formSessionTarget': 'Docelowa sesja',
|
||||
'settings.cron.jobs.lastStatus': 'Ostatni status',
|
||||
'settings.cron.jobs.loading': 'Wczytywanie zadań cron...',
|
||||
'settings.cron.jobs.loadingRuns': 'Wczytywanie uruchomień',
|
||||
@@ -3119,7 +3166,13 @@ const messages: TranslationMap = {
|
||||
'settings.cron.jobs.saving': 'Zapisywanie…',
|
||||
'settings.cron.jobs.schedule': 'Harmonogram',
|
||||
'settings.cron.jobs.title': 'Zadania cron rdzenia',
|
||||
'settings.cron.jobs.updated': 'Zaktualizowano zadanie',
|
||||
'settings.cron.jobs.viewRuns': 'Zobacz uruchomienia',
|
||||
'settings.cron.schedule.every2hours': 'Co 2 godziny',
|
||||
'settings.cron.schedule.every30min': 'Co 30 minut',
|
||||
'settings.cron.schedule.every6hours': 'Co 6 godzin',
|
||||
'settings.cron.schedule.everyHour': 'Co godzinę',
|
||||
'settings.cron.schedule.onceDaily': 'Raz dziennie (9:00)',
|
||||
'settings.localModel.deviceCapability.active': 'Aktywne',
|
||||
'settings.localModel.deviceCapability.appliedTier': 'Zastosowany poziom',
|
||||
'settings.localModel.deviceCapability.applying': 'Stosowanie',
|
||||
|
||||
@@ -3100,8 +3100,55 @@ const messages: TranslationMap = {
|
||||
'settings.companion.sessionTtl': 'Sessão TTL',
|
||||
'settings.companion.screenCapture': 'Captura de tela',
|
||||
'settings.companion.appContext': 'Contexto do aplicativo',
|
||||
'settings.cron.jobs.commandRequired': 'O comando é obrigatório para tarefas shell',
|
||||
'settings.cron.jobs.createJob': 'Nova tarefa agendada',
|
||||
'settings.cron.jobs.created': 'Tarefa criada',
|
||||
'settings.cron.jobs.custom': 'Personalizado',
|
||||
'settings.cron.jobs.desc': 'Descrição',
|
||||
'settings.cron.jobs.edit': 'Editar',
|
||||
'settings.cron.jobs.editJob': 'Editar tarefa agendada',
|
||||
'settings.cron.jobs.empty': 'Nenhuma tarefa cron do core encontrada.',
|
||||
'settings.cron.jobs.errorCreate': 'Falha ao criar tarefa cron: {message}',
|
||||
'settings.cron.jobs.errorLoadList': 'Falha ao carregar tarefas cron do núcleo: {message}',
|
||||
'settings.cron.jobs.errorLoadRuns': 'Falha ao carregar o histórico de execuções: {message}',
|
||||
'settings.cron.jobs.errorRemove': 'Falha ao remover tarefa cron do núcleo: {message}',
|
||||
'settings.cron.jobs.errorRun': 'Falha ao executar tarefa cron do núcleo: {message}',
|
||||
'settings.cron.jobs.errorToggle': 'Falha ao atualizar tarefa cron do núcleo: {message}',
|
||||
'settings.cron.jobs.errorUpdate': 'Falha ao atualizar tarefa cron: {message}',
|
||||
'settings.cron.jobs.formAtLabel': 'Executar às',
|
||||
'settings.cron.jobs.formCancel': 'Cancelar',
|
||||
'settings.cron.jobs.formCommand': 'Comando shell',
|
||||
'settings.cron.jobs.formCommandPlaceholder': 'ex.: curl https://example.com/health',
|
||||
'settings.cron.jobs.formCreate': 'Criar',
|
||||
'settings.cron.jobs.formCronCustom': 'Expressão personalizada',
|
||||
'settings.cron.jobs.formCronCustomPlaceholder': 'ex.: */30 * * * *',
|
||||
'settings.cron.jobs.formCronPreset': 'Predefinição',
|
||||
'settings.cron.jobs.formCronPreview': 'Execuções: {preview}',
|
||||
'settings.cron.jobs.formDeleteAfterRun': 'Excluir após a primeira execução',
|
||||
'settings.cron.jobs.formDelivery': 'Modo de entrega',
|
||||
'settings.cron.jobs.formDeliveryNone': 'Nenhum (apenas saída)',
|
||||
'settings.cron.jobs.formDeliveryProactive': 'Proativo (notificação push)',
|
||||
'settings.cron.jobs.formError': 'Falha ao salvar a tarefa',
|
||||
'settings.cron.jobs.formEveryLabel': 'Intervalo (milissegundos)',
|
||||
'settings.cron.jobs.formEveryPlaceholder': 'ex.: 3600000',
|
||||
'settings.cron.jobs.formJobType': 'Tipo de tarefa',
|
||||
'settings.cron.jobs.formJobTypeAgent': 'Agent (prompt de IA)',
|
||||
'settings.cron.jobs.formJobTypeShell': 'Comando shell',
|
||||
'settings.cron.jobs.formName': 'Nome da tarefa',
|
||||
'settings.cron.jobs.formNamePlaceholder': 'ex.: daily-report, cleanup-task',
|
||||
'settings.cron.jobs.formPrompt': 'Prompt do Agent',
|
||||
'settings.cron.jobs.formPromptPlaceholder': 'O que o agent deve fazer em cada execução?',
|
||||
'settings.cron.jobs.formPromptRequired': 'O prompt é obrigatório para tarefas de agent',
|
||||
'settings.cron.jobs.formSave': 'Salvar',
|
||||
'settings.cron.jobs.formSaving': 'Salvando…',
|
||||
'settings.cron.jobs.formScheduleAt': 'Única (executar às)',
|
||||
'settings.cron.jobs.formScheduleCron': 'Recorrente (cron)',
|
||||
'settings.cron.jobs.formScheduleEvery': 'Intervalo (a cada N ms)',
|
||||
'settings.cron.jobs.formScheduleRequired': 'O agendamento é obrigatório',
|
||||
'settings.cron.jobs.formScheduleType': 'Tipo de agendamento',
|
||||
'settings.cron.jobs.formSessionIsolated': 'Isolada (recomendada)',
|
||||
'settings.cron.jobs.formSessionMain': 'Sessão principal',
|
||||
'settings.cron.jobs.formSessionTarget': 'Sessão de destino',
|
||||
'settings.cron.jobs.lastStatus': 'Último status',
|
||||
'settings.cron.jobs.loading': 'Carregando tarefas cron...',
|
||||
'settings.cron.jobs.loadingRuns': 'Carregando execuções',
|
||||
@@ -3115,7 +3162,13 @@ const messages: TranslationMap = {
|
||||
'settings.cron.jobs.saving': 'Salvando…',
|
||||
'settings.cron.jobs.schedule': 'Agendamento',
|
||||
'settings.cron.jobs.title': 'Tarefas Cron do Core',
|
||||
'settings.cron.jobs.updated': 'Tarefa atualizada',
|
||||
'settings.cron.jobs.viewRuns': 'Ver execuções',
|
||||
'settings.cron.schedule.every2hours': 'A cada 2 horas',
|
||||
'settings.cron.schedule.every30min': 'A cada 30 minutos',
|
||||
'settings.cron.schedule.every6hours': 'A cada 6 horas',
|
||||
'settings.cron.schedule.everyHour': 'A cada hora',
|
||||
'settings.cron.schedule.onceDaily': 'Uma vez por dia (9h)',
|
||||
'settings.localModel.deviceCapability.active': 'Ativo',
|
||||
'settings.localModel.deviceCapability.appliedTier': 'Nível aplicado',
|
||||
'settings.localModel.deviceCapability.applying': 'Aplicando',
|
||||
|
||||
@@ -3071,8 +3071,55 @@ const messages: TranslationMap = {
|
||||
'settings.companion.sessionTtl': 'TTL сеанса',
|
||||
'settings.companion.screenCapture': 'Снимок экрана',
|
||||
'settings.companion.appContext': 'Контекст приложения',
|
||||
'settings.cron.jobs.commandRequired': 'Для shell-задач требуется команда',
|
||||
'settings.cron.jobs.createJob': 'Новая запланированная задача',
|
||||
'settings.cron.jobs.created': 'Задача создана',
|
||||
'settings.cron.jobs.custom': 'Свой вариант',
|
||||
'settings.cron.jobs.desc': 'Описание',
|
||||
'settings.cron.jobs.edit': 'Изменить',
|
||||
'settings.cron.jobs.editJob': 'Изменить запланированную задачу',
|
||||
'settings.cron.jobs.empty': 'Заданий по расписанию не найдено.',
|
||||
'settings.cron.jobs.errorCreate': 'Не удалось создать cron-задачу: {message}',
|
||||
'settings.cron.jobs.errorLoadList': 'Не удалось загрузить cron-задачи ядра: {message}',
|
||||
'settings.cron.jobs.errorLoadRuns': 'Не удалось загрузить историю запусков: {message}',
|
||||
'settings.cron.jobs.errorRemove': 'Не удалось удалить cron-задачу ядра: {message}',
|
||||
'settings.cron.jobs.errorRun': 'Не удалось запустить cron-задачу ядра: {message}',
|
||||
'settings.cron.jobs.errorToggle': 'Не удалось обновить cron-задачу ядра: {message}',
|
||||
'settings.cron.jobs.errorUpdate': 'Не удалось обновить cron-задачу: {message}',
|
||||
'settings.cron.jobs.formAtLabel': 'Запустить в',
|
||||
'settings.cron.jobs.formCancel': 'Отмена',
|
||||
'settings.cron.jobs.formCommand': 'Shell-команда',
|
||||
'settings.cron.jobs.formCommandPlaceholder': 'напр. curl https://example.com/health',
|
||||
'settings.cron.jobs.formCreate': 'Создать',
|
||||
'settings.cron.jobs.formCronCustom': 'Своё выражение',
|
||||
'settings.cron.jobs.formCronCustomPlaceholder': 'напр. */30 * * * *',
|
||||
'settings.cron.jobs.formCronPreset': 'Шаблон',
|
||||
'settings.cron.jobs.formCronPreview': 'Запуски: {preview}',
|
||||
'settings.cron.jobs.formDeleteAfterRun': 'Удалить после первого запуска',
|
||||
'settings.cron.jobs.formDelivery': 'Режим доставки',
|
||||
'settings.cron.jobs.formDeliveryNone': 'Нет (только вывод)',
|
||||
'settings.cron.jobs.formDeliveryProactive': 'Проактивный (push-уведомление)',
|
||||
'settings.cron.jobs.formError': 'Не удалось сохранить задачу',
|
||||
'settings.cron.jobs.formEveryLabel': 'Интервал (миллисекунды)',
|
||||
'settings.cron.jobs.formEveryPlaceholder': 'напр. 3600000',
|
||||
'settings.cron.jobs.formJobType': 'Тип задачи',
|
||||
'settings.cron.jobs.formJobTypeAgent': 'Agent (запрос к ИИ)',
|
||||
'settings.cron.jobs.formJobTypeShell': 'Shell-команда',
|
||||
'settings.cron.jobs.formName': 'Название задачи',
|
||||
'settings.cron.jobs.formNamePlaceholder': 'напр. daily-report, cleanup-task',
|
||||
'settings.cron.jobs.formPrompt': 'Запрос для агента',
|
||||
'settings.cron.jobs.formPromptPlaceholder': 'Что агент должен делать при каждом запуске?',
|
||||
'settings.cron.jobs.formPromptRequired': 'Для задач агента требуется запрос',
|
||||
'settings.cron.jobs.formSave': 'Сохранить',
|
||||
'settings.cron.jobs.formSaving': 'Сохранение…',
|
||||
'settings.cron.jobs.formScheduleAt': 'Однократно (запуск в)',
|
||||
'settings.cron.jobs.formScheduleCron': 'Повторяющаяся (cron)',
|
||||
'settings.cron.jobs.formScheduleEvery': 'Интервал (каждые N мс)',
|
||||
'settings.cron.jobs.formScheduleRequired': 'Требуется расписание',
|
||||
'settings.cron.jobs.formScheduleType': 'Тип расписания',
|
||||
'settings.cron.jobs.formSessionIsolated': 'Изолированная (рекомендуется)',
|
||||
'settings.cron.jobs.formSessionMain': 'Основная сессия',
|
||||
'settings.cron.jobs.formSessionTarget': 'Целевая сессия',
|
||||
'settings.cron.jobs.lastStatus': 'Последний статус',
|
||||
'settings.cron.jobs.loading': 'Загрузка заданий...',
|
||||
'settings.cron.jobs.loadingRuns': 'Загрузка запусков',
|
||||
@@ -3086,7 +3133,13 @@ const messages: TranslationMap = {
|
||||
'settings.cron.jobs.saving': 'Сохранение…',
|
||||
'settings.cron.jobs.schedule': 'Расписание',
|
||||
'settings.cron.jobs.title': 'Задания ядра по расписанию',
|
||||
'settings.cron.jobs.updated': 'Задача обновлена',
|
||||
'settings.cron.jobs.viewRuns': 'Посмотреть запуски',
|
||||
'settings.cron.schedule.every2hours': 'Каждые 2 часа',
|
||||
'settings.cron.schedule.every30min': 'Каждые 30 минут',
|
||||
'settings.cron.schedule.every6hours': 'Каждые 6 часов',
|
||||
'settings.cron.schedule.everyHour': 'Каждый час',
|
||||
'settings.cron.schedule.onceDaily': 'Раз в день (9:00)',
|
||||
'settings.localModel.deviceCapability.active': 'Активно',
|
||||
'settings.localModel.deviceCapability.appliedTier': 'Применённый уровень',
|
||||
'settings.localModel.deviceCapability.applying': 'Применение',
|
||||
|
||||
@@ -2908,8 +2908,55 @@ const messages: TranslationMap = {
|
||||
'settings.companion.sessionTtl': '会话生存时间',
|
||||
'settings.companion.screenCapture': '屏幕截图',
|
||||
'settings.companion.appContext': '应用程序上下文',
|
||||
'settings.cron.jobs.commandRequired': 'Shell 任务必须填写命令',
|
||||
'settings.cron.jobs.createJob': '新建定时任务',
|
||||
'settings.cron.jobs.created': '任务已创建',
|
||||
'settings.cron.jobs.custom': '自定义',
|
||||
'settings.cron.jobs.desc': '管理核心调度器中的定时任务',
|
||||
'settings.cron.jobs.edit': '编辑',
|
||||
'settings.cron.jobs.editJob': '编辑定时任务',
|
||||
'settings.cron.jobs.empty': '未找到核心定时任务。',
|
||||
'settings.cron.jobs.errorCreate': '创建 cron 任务失败:{message}',
|
||||
'settings.cron.jobs.errorLoadList': '加载核心 cron 任务失败:{message}',
|
||||
'settings.cron.jobs.errorLoadRuns': '加载运行历史失败:{message}',
|
||||
'settings.cron.jobs.errorRemove': '删除核心 cron 任务失败:{message}',
|
||||
'settings.cron.jobs.errorRun': '运行核心 cron 任务失败:{message}',
|
||||
'settings.cron.jobs.errorToggle': '更新核心 cron 任务失败:{message}',
|
||||
'settings.cron.jobs.errorUpdate': '更新 cron 任务失败:{message}',
|
||||
'settings.cron.jobs.formAtLabel': '运行时间',
|
||||
'settings.cron.jobs.formCancel': '取消',
|
||||
'settings.cron.jobs.formCommand': 'Shell 命令',
|
||||
'settings.cron.jobs.formCommandPlaceholder': '例如 curl https://example.com/health',
|
||||
'settings.cron.jobs.formCreate': '创建',
|
||||
'settings.cron.jobs.formCronCustom': '自定义表达式',
|
||||
'settings.cron.jobs.formCronCustomPlaceholder': '例如 */30 * * * *',
|
||||
'settings.cron.jobs.formCronPreset': '预设',
|
||||
'settings.cron.jobs.formCronPreview': '运行时间:{preview}',
|
||||
'settings.cron.jobs.formDeleteAfterRun': '首次运行后删除',
|
||||
'settings.cron.jobs.formDelivery': '投递方式',
|
||||
'settings.cron.jobs.formDeliveryNone': '无(仅输出)',
|
||||
'settings.cron.jobs.formDeliveryProactive': '主动(推送通知)',
|
||||
'settings.cron.jobs.formError': '保存任务失败',
|
||||
'settings.cron.jobs.formEveryLabel': '间隔(毫秒)',
|
||||
'settings.cron.jobs.formEveryPlaceholder': '例如 3600000',
|
||||
'settings.cron.jobs.formJobType': '任务类型',
|
||||
'settings.cron.jobs.formJobTypeAgent': 'Agent(AI 提示词)',
|
||||
'settings.cron.jobs.formJobTypeShell': 'Shell 命令',
|
||||
'settings.cron.jobs.formName': '任务名称',
|
||||
'settings.cron.jobs.formNamePlaceholder': '例如 daily-report, cleanup-task',
|
||||
'settings.cron.jobs.formPrompt': 'Agent 提示词',
|
||||
'settings.cron.jobs.formPromptPlaceholder': '每次运行时 Agent 应执行什么?',
|
||||
'settings.cron.jobs.formPromptRequired': 'Agent 任务必须填写提示词',
|
||||
'settings.cron.jobs.formSave': '保存',
|
||||
'settings.cron.jobs.formSaving': '保存中…',
|
||||
'settings.cron.jobs.formScheduleAt': '一次性(在指定时间运行)',
|
||||
'settings.cron.jobs.formScheduleCron': '周期性(cron)',
|
||||
'settings.cron.jobs.formScheduleEvery': '间隔(每 N 毫秒)',
|
||||
'settings.cron.jobs.formScheduleRequired': '必须设置计划',
|
||||
'settings.cron.jobs.formScheduleType': '计划类型',
|
||||
'settings.cron.jobs.formSessionIsolated': '隔离(推荐)',
|
||||
'settings.cron.jobs.formSessionMain': '主会话',
|
||||
'settings.cron.jobs.formSessionTarget': '会话目标',
|
||||
'settings.cron.jobs.lastStatus': '上次状态',
|
||||
'settings.cron.jobs.loading': '正在加载定时任务...',
|
||||
'settings.cron.jobs.loadingRuns': '加载运行记录中',
|
||||
@@ -2923,7 +2970,13 @@ const messages: TranslationMap = {
|
||||
'settings.cron.jobs.saving': '保存中…',
|
||||
'settings.cron.jobs.schedule': '计划',
|
||||
'settings.cron.jobs.title': '核心定时任务',
|
||||
'settings.cron.jobs.updated': '任务已更新',
|
||||
'settings.cron.jobs.viewRuns': '查看运行记录',
|
||||
'settings.cron.schedule.every2hours': '每 2 小时',
|
||||
'settings.cron.schedule.every30min': '每 30 分钟',
|
||||
'settings.cron.schedule.every6hours': '每 6 小时',
|
||||
'settings.cron.schedule.everyHour': '每小时',
|
||||
'settings.cron.schedule.onceDaily': '每天一次(上午 9 点)',
|
||||
'settings.localModel.deviceCapability.active': '活跃',
|
||||
'settings.localModel.deviceCapability.appliedTier': '已应用档位',
|
||||
'settings.localModel.deviceCapability.applying': '应用中',
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Outlet, useNavigate } from 'react-router-dom';
|
||||
import { setWalkthroughPending } from '../../components/walkthrough/AppWalkthrough';
|
||||
import { useCoreState } from '../../providers/CoreStateProvider';
|
||||
import { trackEvent } from '../../services/analytics';
|
||||
import { getDefaultEnabledTools } from '../../utils/toolDefinitions';
|
||||
import { getDefaultEnabledTools, getEnabledRustToolNames } from '../../utils/toolDefinitions';
|
||||
import BetaBanner from './components/BetaBanner';
|
||||
import { OnboardingContext, type OnboardingDraft } from './OnboardingContext';
|
||||
|
||||
@@ -34,7 +34,7 @@ const OnboardingLayout = () => {
|
||||
snapshot.localState.onboardingTasks?.accessibilityPermissionGranted ?? false,
|
||||
localModelConsentGiven: false,
|
||||
localModelDownloadStarted: false,
|
||||
enabledTools: getDefaultEnabledTools(),
|
||||
enabledTools: getEnabledRustToolNames(getDefaultEnabledTools()),
|
||||
connectedSources: draft.connectedSources,
|
||||
updatedAtMs: Date.now(),
|
||||
});
|
||||
|
||||
@@ -40,7 +40,10 @@ vi.mock('../../../services/chatService', () => ({
|
||||
chatSend: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/toolDefinitions', () => ({ getDefaultEnabledTools: vi.fn(() => []) }));
|
||||
vi.mock('../../../utils/toolDefinitions', () => ({
|
||||
getDefaultEnabledTools: vi.fn(() => []),
|
||||
getEnabledRustToolNames: vi.fn((ids: string[]) => ids),
|
||||
}));
|
||||
|
||||
vi.mock('../components/BetaBanner', () => ({ default: () => <div data-testid="beta-banner" /> }));
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ use crate::openhuman::tools::Tool;
|
||||
use crate::openhuman::util::truncate_with_ellipsis;
|
||||
|
||||
use anyhow::Result;
|
||||
use std::borrow::Cow;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -55,6 +56,48 @@ use std::sync::Arc;
|
||||
/// failure mode this guards against.
|
||||
use super::turn_checkpoint::{assistant_message_has_tool_calls, MAX_ITER_CHECKPOINT_INSTRUCTION};
|
||||
|
||||
/// Built-in direct tools that the orchestrator should call by name, not via
|
||||
/// `run_skill`.
|
||||
const DIRECT_TOOL_NAMES: &[&str] = &[
|
||||
"cron_add",
|
||||
"cron_list",
|
||||
"cron_remove",
|
||||
"cron_update",
|
||||
"cron_run",
|
||||
"cron_runs",
|
||||
"current_time",
|
||||
];
|
||||
|
||||
/// Recovery shim for legacy/wrong-model calls of the form:
|
||||
/// `run_skill({skill_id: "<built-in tool>", inputs: {...}})`.
|
||||
///
|
||||
/// When this pattern appears, rewrite it into a direct tool call so the turn
|
||||
/// can proceed without a manual retry.
|
||||
fn normalize_tool_call<'a>(call: &'a ParsedToolCall) -> Cow<'a, ParsedToolCall> {
|
||||
if call.name != "run_skill" {
|
||||
return Cow::Borrowed(call);
|
||||
}
|
||||
let Some(skill_id) = call.arguments.get("skill_id").and_then(|v| v.as_str()) else {
|
||||
return Cow::Borrowed(call);
|
||||
};
|
||||
if !DIRECT_TOOL_NAMES.contains(&skill_id) {
|
||||
return Cow::Borrowed(call);
|
||||
}
|
||||
let Some(inputs) = call.arguments.get("inputs").and_then(|v| v.as_object()) else {
|
||||
return Cow::Borrowed(call);
|
||||
};
|
||||
|
||||
log::warn!(
|
||||
"[agent_loop] rewrote legacy run_skill->{} call into direct tool invocation",
|
||||
skill_id
|
||||
);
|
||||
Cow::Owned(ParsedToolCall {
|
||||
name: skill_id.to_string(),
|
||||
arguments: serde_json::Value::Object(inputs.clone()),
|
||||
tool_call_id: call.tool_call_id.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
/// Executes a single interaction "turn" with the agent.
|
||||
///
|
||||
@@ -697,11 +740,14 @@ impl Agent {
|
||||
call: &ParsedToolCall,
|
||||
iteration: usize,
|
||||
) -> (ToolExecutionResult, ToolCallRecord) {
|
||||
let normalized_call = normalize_tool_call(call);
|
||||
let call: &ParsedToolCall = &normalized_call;
|
||||
// The per-call execution path lives in the shared
|
||||
// [`super::agent_tool_exec::run_agent_tool_call`] so `Agent::turn`
|
||||
// (when migrated to the turn engine, via `AgentToolSource`) and any
|
||||
// direct caller run the identical logic. Progress is emitted through a
|
||||
// `TurnProgress` over this agent's sink.
|
||||
// `TurnProgress` over this agent's sink. Legacy `run_skill`-wrapped
|
||||
// built-in cron tool calls are normalized to direct calls first.
|
||||
let progress = super::super::engine::TurnProgress::new(self.on_progress.clone());
|
||||
let ctx = super::agent_tool_exec::AgentToolExecCtx {
|
||||
tools: &self.tools,
|
||||
|
||||
@@ -112,6 +112,27 @@ impl Tool for EchoTool {
|
||||
}
|
||||
}
|
||||
|
||||
struct CronAddProbeTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for CronAddProbeTool {
|
||||
fn name(&self) -> &str {
|
||||
"cron_add"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"cron add probe"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({"type":"object"})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
|
||||
Ok(ToolResult::success(format!("cron_add_args={args}")))
|
||||
}
|
||||
}
|
||||
|
||||
struct CountingTool {
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
@@ -606,6 +627,40 @@ async fn execute_tool_call_reports_unknown_tool() {
|
||||
assert!(!record.success);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_tool_call_rewrites_legacy_run_skill_for_builtin_cron_tools() {
|
||||
let provider: Arc<dyn Provider> = Arc::new(DummyProvider);
|
||||
let agent = make_agent_with_builder(
|
||||
provider,
|
||||
vec![Box::new(CronAddProbeTool)],
|
||||
Box::new(FixedMemoryLoader {
|
||||
context: String::new(),
|
||||
}),
|
||||
vec![],
|
||||
crate::openhuman::config::AgentConfig::default(),
|
||||
crate::openhuman::config::ContextConfig::default(),
|
||||
);
|
||||
let call = ParsedToolCall {
|
||||
name: "run_skill".into(),
|
||||
arguments: serde_json::json!({
|
||||
"skill_id": "cron_add",
|
||||
"inputs": {
|
||||
"name": "water-reminder",
|
||||
"schedule": { "kind": "every", "every_ms": 60000 },
|
||||
"job_type": "agent",
|
||||
"prompt": "remind me to drink water"
|
||||
}
|
||||
}),
|
||||
tool_call_id: Some("tc-run-skill-1".into()),
|
||||
};
|
||||
|
||||
let (result, record) = agent.execute_tool_call(&call, 0).await;
|
||||
assert!(result.success, "{}", result.output);
|
||||
assert_eq!(result.name, "cron_add");
|
||||
assert_eq!(record.name, "cron_add");
|
||||
assert!(result.output.contains("\"every_ms\":60000"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_tool_call_denies_tool_above_channel_permission() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
@@ -48,14 +48,53 @@ Default bias: **do not spawn a sub-agent when a direct response or direct tool c
|
||||
- **Fail gracefully** — If a sub-agent fails after retries, explain what happened clearly.
|
||||
- **Escalate when appropriate** — If orchestration is the wrong mode or a specialist cannot make progress, hand control back to OpenHuman Core with a concise explanation and let Core handle general interactions.
|
||||
|
||||
**Scheduling rule of thumb.** To "remind me in 10 minutes", call `current_time`
|
||||
first. If `cron_add` is available and enabled for this runtime, then call
|
||||
`cron_add` with `schedule = {kind:"at", at:"<iso-time>"}`, `job_type:"agent"`,
|
||||
and a `prompt` that tells a future agent what to deliver (e.g. "Send pushover:
|
||||
'stand up and stretch'"). If `cron_add` is disabled by config, absent from your
|
||||
tool list, or returns an error, do not promise the reminder: tell the user you
|
||||
can't schedule it in this environment and, if helpful, provide the computed time
|
||||
or a manual fallback.
|
||||
**Scheduling rule of thumb.**
|
||||
|
||||
- **`cron_add`, `cron_list`, `cron_remove`, `current_time` are direct named tools.**
|
||||
Call them by their tool name — never via `run_skill`. `run_skill` is for
|
||||
user-installed skills only and will return "skill not found" for any built-in tool name.
|
||||
|
||||
- **Never call `run_skill` with `skill_id="cron_add"` (or `"cron_list"`, `"cron_remove"`,
|
||||
`"current_time"`, or any other built-in tool name).** This path always errors.
|
||||
|
||||
- **One-shot / reminders** (e.g. "remind me in 10 minutes"): call `current_time`
|
||||
first, propose the exact reminder timing, ask the user to confirm, then call
|
||||
`cron_add` with `schedule = {kind:"at", at:"<iso-time>"}`,
|
||||
`job_type:"agent"`, and a `prompt` that tells a future agent what to deliver
|
||||
(e.g. "Send pushover: 'stand up and stretch'").
|
||||
|
||||
- **Recurring tasks** (e.g. "run this every day", "check my email every hour"):
|
||||
propose a specific schedule (e.g. "I'll run this daily at 09:00 — shall I set
|
||||
that up?"), ask the user to confirm, then call `cron_add` directly with
|
||||
`schedule = {kind:"cron", expr:"<5-field-cron>", tz:null}`, `job_type:"agent"`,
|
||||
and a detailed `prompt` for the recurring agent. Common expressions:
|
||||
`"0 9 * * *"` (daily 9 AM), `"0 * * * *"` (hourly), `"*/30 * * * *"` (every 30 min),
|
||||
`"* * * * *"` (every minute).
|
||||
|
||||
- **Finite repetitions** (e.g. "send X every minute for 10 times"): use a recurring
|
||||
cron schedule with `delete_after_run:false`. The user can pause or remove the job
|
||||
after N deliveries, or you can note the job id and remove it after the Nth run if
|
||||
you have a way to track count. Do not refuse or stall — set up the schedule.
|
||||
|
||||
- **Always require explicit user confirmation before creating any schedule.**
|
||||
This applies to both one-shot and recurring jobs. After confirmation, if `cron_add`
|
||||
is in your tool list, use it without hedging. Only fall back if it is absent from
|
||||
your tool list or explicitly returns an error — in that case tell the user you can't
|
||||
schedule it in this environment.
|
||||
|
||||
**Worked example.** User: "send me a cricketer name every minute".
|
||||
|
||||
1. Reply with one short bubble: "got it — i'll send a name every minute via cron. ok?"
|
||||
2. After confirmation, call `cron_add` directly (NOT `run_skill`):
|
||||
```json
|
||||
{
|
||||
"schedule": {"kind": "cron", "expr": "* * * * *", "tz": null},
|
||||
"job_type": "agent",
|
||||
"prompt": "Send the user one random cricketer name, just the name.",
|
||||
"delivery": {"mode": "proactive", "best_effort": true}
|
||||
}
|
||||
```
|
||||
3. Reply with the new job id and a hint that it's listed under Settings → Cron Jobs.
|
||||
|
||||
## Dedicated worker threads
|
||||
|
||||
|
||||
@@ -47,9 +47,36 @@ fn all_filterable_tool_names() -> HashSet<&'static str> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Given the list of enabled Rust tool names (already expanded from UI IDs by
|
||||
/// the frontend), retain only tools that are either infrastructure (not
|
||||
/// filterable) or explicitly enabled.
|
||||
/// Expand persisted tool-preference entries into Rust tool `name()` values.
|
||||
///
|
||||
/// Accepts both formats we may find in app state:
|
||||
/// - Rust tool names (new format)
|
||||
/// - UI toggle IDs (legacy / partial-rollout format)
|
||||
///
|
||||
/// Unknown entries are ignored.
|
||||
fn expand_enabled_tool_names(enabled_tool_names: &[String]) -> HashSet<String> {
|
||||
let mut expanded = HashSet::new();
|
||||
for entry in enabled_tool_names {
|
||||
if let Some((_, rust_names)) = TOOL_ID_TO_RUST_NAMES.iter().find(|(id, _)| id == &entry) {
|
||||
for name in *rust_names {
|
||||
expanded.insert((*name).to_string());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if TOOL_ID_TO_RUST_NAMES
|
||||
.iter()
|
||||
.flat_map(|(_, names)| names.iter().copied())
|
||||
.any(|name| name == entry)
|
||||
{
|
||||
expanded.insert(entry.clone());
|
||||
}
|
||||
}
|
||||
expanded
|
||||
}
|
||||
|
||||
/// Given the list of enabled tools from app state, retain only tools that are
|
||||
/// either infrastructure (not filterable) or explicitly enabled.
|
||||
///
|
||||
/// An empty `enabled_tool_names` list means "all enabled" (default / not yet
|
||||
/// configured) — the filter is a no-op in that case.
|
||||
@@ -64,7 +91,13 @@ pub(crate) fn filter_tools_by_user_preference(
|
||||
|
||||
let filterable = all_filterable_tool_names();
|
||||
|
||||
let allowed: HashSet<&str> = enabled_tool_names.iter().map(String::as_str).collect();
|
||||
let allowed = expand_enabled_tool_names(enabled_tool_names);
|
||||
if allowed.is_empty() {
|
||||
log::warn!(
|
||||
"[tool-filter] enabled_tools was non-empty but none matched known UI IDs or tool names; leaving tools unfiltered for safety"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let before = tools.len();
|
||||
tools.retain(|tool| {
|
||||
@@ -86,3 +119,30 @@ pub(crate) fn filter_tools_by_user_preference(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::expand_enabled_tool_names;
|
||||
|
||||
#[test]
|
||||
fn expands_legacy_ui_toggle_ids_to_rust_tool_names() {
|
||||
let allowed = expand_enabled_tool_names(&["cron".to_string(), "web_search".to_string()]);
|
||||
assert!(allowed.contains("cron_add"));
|
||||
assert!(allowed.contains("cron_list"));
|
||||
assert!(allowed.contains("web_search_tool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_direct_rust_tool_names() {
|
||||
let allowed =
|
||||
expand_enabled_tool_names(&["cron_add".to_string(), "memory_store".to_string()]);
|
||||
assert!(allowed.contains("cron_add"));
|
||||
assert!(allowed.contains("memory_store"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_unknown_entries() {
|
||||
let allowed = expand_enabled_tool_names(&["totally_unknown".to_string()]);
|
||||
assert!(allowed.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user