From 6654e1ec536d25df704cc4a6aa033f7ffb99eb82 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Thu, 2 Jul 2026 21:51:32 +0800 Subject: [PATCH 01/36] docs(cron-jobs): plan staged rollout --- .../cron-agent-jobs-phase-1-scheduler/plan.md | 158 ++++++++++++++++++ .../cron-agent-jobs-phase-1-scheduler/spec.md | 91 ++++++++++ .../tasks.md | 30 ++++ .../plan.md | 95 +++++++++++ .../spec.md | 110 ++++++++++++ .../tasks.md | 29 ++++ .../plan.md | 92 ++++++++++ .../spec.md | 102 +++++++++++ .../tasks.md | 28 ++++ .../plan.md | 119 +++++++++++++ .../spec.md | 107 ++++++++++++ .../tasks.md | 30 ++++ .../plan.md | 115 +++++++++++++ .../spec.md | 93 +++++++++++ .../tasks.md | 30 ++++ .../plan.md | 92 ++++++++++ .../spec.md | 109 ++++++++++++ .../tasks.md | 27 +++ 18 files changed, 1457 insertions(+) create mode 100644 docs/features/cron-agent-jobs-phase-1-scheduler/plan.md create mode 100644 docs/features/cron-agent-jobs-phase-1-scheduler/spec.md create mode 100644 docs/features/cron-agent-jobs-phase-1-scheduler/tasks.md create mode 100644 docs/features/cron-agent-jobs-phase-2-cron-trigger-engine/plan.md create mode 100644 docs/features/cron-agent-jobs-phase-2-cron-trigger-engine/spec.md create mode 100644 docs/features/cron-agent-jobs-phase-2-cron-trigger-engine/tasks.md create mode 100644 docs/features/cron-agent-jobs-phase-3-agent-binding/plan.md create mode 100644 docs/features/cron-agent-jobs-phase-3-agent-binding/spec.md create mode 100644 docs/features/cron-agent-jobs-phase-3-agent-binding/tasks.md create mode 100644 docs/features/cron-agent-jobs-phase-4-fresh-session-runs/plan.md create mode 100644 docs/features/cron-agent-jobs-phase-4-fresh-session-runs/spec.md create mode 100644 docs/features/cron-agent-jobs-phase-4-fresh-session-runs/tasks.md create mode 100644 docs/features/cron-agent-jobs-phase-5-delivery-continuation/plan.md create mode 100644 docs/features/cron-agent-jobs-phase-5-delivery-continuation/spec.md create mode 100644 docs/features/cron-agent-jobs-phase-5-delivery-continuation/tasks.md create mode 100644 docs/features/cron-agent-jobs-phase-6-cronjob-tool/plan.md create mode 100644 docs/features/cron-agent-jobs-phase-6-cronjob-tool/spec.md create mode 100644 docs/features/cron-agent-jobs-phase-6-cronjob-tool/tasks.md diff --git a/docs/features/cron-agent-jobs-phase-1-scheduler/plan.md b/docs/features/cron-agent-jobs-phase-1-scheduler/plan.md new file mode 100644 index 000000000..3e4e4a15d --- /dev/null +++ b/docs/features/cron-agent-jobs-phase-1-scheduler/plan.md @@ -0,0 +1,158 @@ +# Implementation Plan + +## Phase Boundary + +This phase creates the scheduler substrate only. It can merge before cron parsing or agent runtime +integration because due work is represented as persisted `next_run_at` timestamps. + +## Module Layout + +Add new code under these ownership boundaries: + +- `src/shared/cronJobs.ts` for shared domain types. +- `src/shared/contracts/routes/cronJobs.routes.ts` for typed route contracts. +- `src/renderer/api/CronJobsClient.ts` for renderer access. +- `src/main/presenter/cronJobs/` for service, repository, and scheduler manager. +- `src/main/presenter/cronJobs/schedulerHost.ts` for the utility-process entry. +- `src/renderer/settings/components/CronJobsSettings.vue` for the status-first UI. +- `test/main/presenter/cronJobs/` and `test/renderer/api/clients.test.ts` coverage. + +Keep existing `src/main/presenter/scheduledTasks/` unchanged except for future migration notes. + +## Data Model + +Create new SQLite tables through the sqlite presenter table catalog and migrations: + +```sql +CREATE TABLE cron_jobs ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + enabled INTEGER NOT NULL, + cron_expr TEXT NOT NULL, + timezone TEXT NOT NULL, + agent_id TEXT, + next_run_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE cron_job_runs ( + id TEXT PRIMARY KEY, + job_id TEXT NOT NULL, + scheduled_at INTEGER NOT NULL, + queued_at INTEGER NOT NULL, + started_at INTEGER, + completed_at INTEGER, + status TEXT NOT NULL, + reason TEXT NOT NULL, + session_id TEXT, + error TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE INDEX idx_cron_jobs_enabled_next_run +ON cron_jobs(enabled, next_run_at); + +CREATE INDEX idx_cron_job_runs_job_id_created +ON cron_job_runs(job_id, created_at); + +CREATE UNIQUE INDEX idx_cron_job_runs_job_slot +ON cron_job_runs(job_id, scheduled_at); +``` + +The unique slot index is required for crash/reconcile idempotency. + +## Scheduler Protocol + +Use a serializable message protocol: + +```ts +type SchedulerCommand = + | { type: 'START' } + | { type: 'STOP'; reason: 'idle' | 'app_quit' | 'restart' } + | { type: 'RECONCILE'; reason: 'startup' | 'resume' | 'manual' | 'job_changed' } + | { type: 'JOB_CHANGED'; jobId: string } + | { type: 'RUN_NOW'; jobId: string } + +type SchedulerEvent = + | { type: 'READY'; pid: number } + | { type: 'HEARTBEAT'; pid: number; nextRunAt: number | null; enabledJobCount: number } + | { type: 'RUN_DUE'; jobId: string; runId: string } + | { type: 'IDLE' } + | { type: 'ERROR'; error: string; stack?: string } +``` + +Main owns restart policy, status cache, and mock execution. The utility process owns scans and run +row insertion. + +## Lifecycle + +1. On app startup, main constructs `CronJobsService` and `SchedulerProcessManager`. +2. `CronJobsService.start()` counts enabled jobs. +3. If enabled count is greater than zero, `SchedulerProcessManager` forks the utility process. +4. `READY` updates status and sends `RECONCILE`. +5. The utility process scans due jobs, inserts queued runs, and emits `RUN_DUE`. +6. Main marks the queued run as mock-completed or mock-failed through the service. +7. When enabled count becomes zero, main schedules a 30 second idle stop. +8. `before-quit` sends `STOP`. +9. `powerMonitor.resume` sends `RECONCILE`. + +## Typed Routes + +Initial route surface: + +- `cronJobs.list` +- `cronJobs.upsert` +- `cronJobs.delete` +- `cronJobs.toggle` +- `cronJobs.runNow` +- `cronJobs.getSchedulerStatus` +- `cronJobs.reconcileScheduler` +- `cronJobs.restartScheduler` + +Phase 1 `upsert` accepts `cronExpr`, `timezone`, `enabled`, and optional `nextRunAt`. +Phase 2 replaces permissive schedule handling with parser-backed validation. + +## UI Plan + +Replace the Scheduled Tasks settings entry only after the Cron Jobs page is route-backed and stable. +If product risk is high, keep both entries for one release and label the old page as compatibility. + +The first UI is intentionally operational: + +```text ++---------------------------------------------------------+ +| Cron Jobs | +| Scheduler: Running | pid 18421 | +| Enabled: 2 | Next run: 2026-07-03 09:00 | +| Last heartbeat: 3s ago | +| | +| [Reconcile] [Restart] | ++---------------------------------------------------------+ +``` + +No nested cards. Use existing settings shell, shadcn controls, lucide icons, and i18n keys. + +## Compatibility + +- Existing `scheduledTasks.*` routes keep working. +- New routes use `cronJobs.*`; do not overload `scheduledTasks.*`. +- No automatic migration in phase 1. +- Documentation and UI copy must avoid promising agent execution until phase 4. + +## Test Strategy + +- Unit test repository CRUD and due-run idempotency. +- Unit test `SchedulerProcessManager` start, idle stop, crash restart, and status transitions with + a mocked utility process. +- Route dispatcher tests for every new route contract. +- Renderer client tests for route names and response parsing. +- Minimal renderer test for running and stopped status states. + +## Validation Commands + +- `pnpm run format` +- `pnpm run i18n` +- `pnpm run lint` +- Targeted tests: `pnpm test -- test/main/presenter/cronJobs` diff --git a/docs/features/cron-agent-jobs-phase-1-scheduler/spec.md b/docs/features/cron-agent-jobs-phase-1-scheduler/spec.md new file mode 100644 index 000000000..9aee0b5c0 --- /dev/null +++ b/docs/features/cron-agent-jobs-phase-1-scheduler/spec.md @@ -0,0 +1,91 @@ +# Cron Agent Jobs Phase 1: Scheduler Process + +## User Need + +Users need a reliable Cron Jobs foundation that can discover due jobs without tying scheduling +accuracy to renderer lifetime, settings UI state, or one main-process timeout per task. + +## Goal + +Introduce the first independently mergeable slice of the Cron Jobs / Agent Jobs module: + +- A SQLite-backed `cron_jobs` and `cron_job_runs` store. +- A main-process `SchedulerProcessManager`. +- An Electron `utilityProcess` named `deepchat-scheduler`. +- A typed scheduler protocol where the utility process only discovers due jobs and queues runs. +- A minimal Cron Jobs status UI that shows scheduler state. + +The main process owns job execution. In this phase execution remains a mock action after receiving +`RUN_DUE`; no agent runtime work is performed yet. + +## Current State + +The existing `ScheduledTasksService` persists tasks in ConfigPresenter under `scheduledTasks`. +It supports `once`, `daily`, and `weekly` triggers and directly dispatches `notify` or `prompt` +actions from main-process timers. That service is a compatibility boundary, not the new Cron Jobs +core. + +## Acceptance Criteria + +- With zero enabled cron jobs, `deepchat-scheduler` is stopped. +- Creating or enabling the first cron job starts `deepchat-scheduler` after the app is ready. +- Disabling or deleting the last enabled cron job stops the scheduler within 30 seconds. +- The scheduler scans only `next_run_at <= Date.now()` jobs and creates `queued` runs. +- Queued run creation is idempotent for the same `(job_id, scheduled_at)` slot. +- Main receives `RUN_DUE` with `jobId` and `runId`. +- App startup and OS resume trigger `RECONCILE`. +- Utility-process crash is detected and restarted with bounded backoff while enabled jobs remain. +- Scheduler status is available through a typed route and rendered in the Cron Jobs page. + +## UX Shape + +Running state: + +```text ++---------------------------------------------------------+ +| Cron Jobs | +| Scheduler: Running | utilityProcess | pid 18421 | +| Enabled jobs: 2 | Next run: 2026-07-03 09:00 | +| | +| [New Job] [Reconcile Now] [Restart Scheduler] | ++---------------------------------------------------------+ +``` + +Stopped state: + +```text ++---------------------------------------------------------+ +| Cron Jobs | +| Scheduler: Stopped | +| Create or enable a cron job to start the scheduler. | +| | +| [New Job] | ++---------------------------------------------------------+ +``` + +## Non-Goals + +- No cron expression parser. +- No schedule editor beyond minimal data needed to exercise scheduler state. +- No agent binding, permission resolution, or real session execution. +- No delivery, remote continuation, or `cronjob` agent tool. +- No cleanup of the legacy `ScheduledTasksService`. + +## Constraints + +- Use typed routes, typed contracts, and renderer `api/*Client`. +- Do not introduce legacy IPC or `useLegacyPresenter()` paths. +- The scheduler utility process must not execute model, tool, notification, or remote delivery logic. +- If the database is encrypted, the utility process must open its own connection through a + narrowly scoped scheduler DB adapter and must not log database credentials. +- `utilityProcess.fork()` must be invoked only after Electron app `ready`. + +## References + +- Electron `utilityProcess` creates a Node-enabled child process and supports message passing: + https://www.electronjs.org/docs/latest/api/utility-process + +## Open Questions + +None. Phase 1 deliberately keeps execution mocked so later phases can replace the mock without +reworking the scheduler lifecycle. diff --git a/docs/features/cron-agent-jobs-phase-1-scheduler/tasks.md b/docs/features/cron-agent-jobs-phase-1-scheduler/tasks.md new file mode 100644 index 000000000..063a6099e --- /dev/null +++ b/docs/features/cron-agent-jobs-phase-1-scheduler/tasks.md @@ -0,0 +1,30 @@ +# Tasks + +## Preparation + +- [ ] Add shared Cron Jobs domain types and route contracts. +- [ ] Add SQLite table classes and migrations for `cron_jobs` and `cron_job_runs`. +- [ ] Add repository methods for job CRUD, enabled counts, due scans, queued run insertion, and + status updates. + +## Scheduler Process + +- [ ] Implement `SchedulerProcessManager` with start, stop, restart, reconcile, heartbeat, and + crash backoff. +- [ ] Implement `schedulerHost` utility-process entry with direct scheduler DB adapter access. +- [ ] Add scheduler protocol validation and defensive error serialization. +- [ ] Wire startup, resume, before-quit, and job-change lifecycle hooks. + +## Routes And UI + +- [ ] Register `cronJobs.*` routes in shared contracts and main route dispatcher. +- [ ] Add `CronJobsClient`. +- [ ] Add minimal Cron Jobs settings page with scheduler status, reconcile, and restart actions. +- [ ] Add i18n keys for all visible strings. + +## Tests And Validation + +- [ ] Cover due-run idempotency and `next_run_at <= now` scan behavior. +- [ ] Cover scheduler start/idle-stop/crash-restart transitions. +- [ ] Cover route dispatcher and renderer client behavior. +- [ ] Run `pnpm run format`, `pnpm run i18n`, and `pnpm run lint`. diff --git a/docs/features/cron-agent-jobs-phase-2-cron-trigger-engine/plan.md b/docs/features/cron-agent-jobs-phase-2-cron-trigger-engine/plan.md new file mode 100644 index 000000000..1a1a17938 --- /dev/null +++ b/docs/features/cron-agent-jobs-phase-2-cron-trigger-engine/plan.md @@ -0,0 +1,95 @@ +# Implementation Plan + +## Phase Boundary + +This phase depends on phase 1 tables, routes, repository, and scheduler status. It makes schedules +real, but still leaves job execution mocked. + +## Data Model Changes + +Alter `cron_jobs`: + +```sql +ALTER TABLE cron_jobs ADD COLUMN misfire_policy TEXT NOT NULL DEFAULT 'skip'; +ALTER TABLE cron_jobs ADD COLUMN max_catch_up_runs INTEGER; +ALTER TABLE cron_jobs ADD COLUMN schedule_error TEXT; +``` + +Keep `cron_expr`, `timezone`, and `next_run_at` as first-class columns for scan efficiency. + +## Core Services + +Add `CronExpressionService`: + +- `validate(cronExpr, timezone)` +- `preview(cronExpr, timezone, count, from)` +- `computeNextRunAt(schedule, from)` +- `reconcileMisfire(job, now)` +- `presetToCron(preset)` + +This service is pure and has no renderer or SQLite dependency. + +## Route Additions + +Add or extend: + +- `cronJobs.previewSchedule` +- `cronJobs.validateSchedule` +- `cronJobs.upsert` schedule fields + +Every write route must recompute `next_run_at` on the main side. Renderer-supplied previews are not +trusted. + +## Scheduler Reconcile + +The utility process should keep the scan rule simple: + +```text +SELECT enabled jobs WHERE next_run_at IS NOT NULL AND next_run_at <= now +``` + +After queuing a run, it asks the schedule service to advance `next_run_at` based on the stored +schedule and misfire policy. This keeps phase 1's scheduler lifecycle stable. + +## UI Plan + +Upgrade the job editor schedule area: + +```text ++---------------------------------------------------------+ +| Schedule | +| [Preset] [Cron] | +| | +| Preset: [Weekdays v] [09:00] | +| Timezone: [Asia/Tokyo v] | +| | +| Cron: 0 9 * * 1-5 | +| Next runs | +| 2026-07-03 09:00 | 2026-07-06 09:00 | ... | ++---------------------------------------------------------+ +``` + +Use inline validation near the cron input. Do not use visible instructional paragraphs to explain +cron syntax; rely on compact labels, preview, and error messages. + +## Compatibility + +- Legacy `ScheduledTasksService` remains unchanged. +- New Cron Jobs never stores `kind: 'daily'` or `kind: 'weekly'`. +- If legacy migration is attempted later, map old triggers into cron expressions at migration time. + +## Test Strategy + +- Pure unit tests for parser-backed preview and validation. +- Timezone tests with at least `UTC`, `Asia/Tokyo`, and a DST-observing zone. +- Misfire policy tests around app downtime and resume. +- Repository tests proving `next_run_at` updates are transactional with queued run creation. +- Renderer tests for preset mode, raw cron mode, preview loading, and invalid expression state. + +## Validation Commands + +- `pnpm run format` +- `pnpm run i18n` +- `pnpm run lint` +- `pnpm test -- test/main/presenter/cronJobs` +- `pnpm test -- test/renderer` diff --git a/docs/features/cron-agent-jobs-phase-2-cron-trigger-engine/spec.md b/docs/features/cron-agent-jobs-phase-2-cron-trigger-engine/spec.md new file mode 100644 index 000000000..befb50fe4 --- /dev/null +++ b/docs/features/cron-agent-jobs-phase-2-cron-trigger-engine/spec.md @@ -0,0 +1,110 @@ +# Cron Agent Jobs Phase 2: Cron Trigger Engine + +## User Need + +Users need one expressive schedule model instead of separate once/daily/weekly trigger kinds. +The schedule must preview upcoming runs, respect timezones, and handle missed runs predictably. + +## Goal + +Replace phase 1's timestamp-only scheduling input with a cron-expression trigger engine: + +- Store every schedule as `cronExpr + timezone`. +- Validate expressions through a dedicated parser. +- Compute and persist `next_run_at`. +- Preview the next N runs in the UI and tool-facing service layer. +- Support misfire policy for missed runs. +- Provide preset controls that only generate cron expressions. + +## Parser Choice + +Use `cron-parser` unless implementation discovers a blocker in the locked package version. +The package is not currently in `package.json`, so this phase adds the dependency and tests the +exact syntax DeepChat exposes. + +The exposed syntax must be limited to parser-backed behavior verified by tests. Do not market a +cron feature in UI copy without a passing unit test for that feature. + +## Schedule Model + +```ts +type CronJobSchedule = { + cronExpr: string + timezone: string + misfirePolicy: 'skip' | 'run_once' + maxCatchUpRuns?: number +} +``` + +Presets are UI helpers only: + +```ts +type SchedulePreset = + | { type: 'every_n_minutes'; n: number } + | { type: 'hourly'; minute: number } + | { type: 'daily'; time: string } + | { type: 'weekdays'; time: string } + | { type: 'weekly'; days: number[]; time: string } + | { type: 'monthly'; day: number | 'last'; time: string } + | { type: 'custom'; cronExpr: string } +``` + +## Acceptance Criteria + +- `*/5 * * * *` previews the next 5 runs from a controlled clock. +- `0 9 * * 1-5` correctly represents weekdays at 09:00. +- `0 0 9 L * *` correctly represents the last day of each month at 09:00 when supported by the + locked parser version. +- `0 0 9 * * 1#1` correctly represents the first Monday of each month when supported by the locked + parser version. +- Timezone changes immediately update preview rows and persisted `next_run_at`. +- Invalid cron input shows a parser error and cannot enable the job. +- Scheduler due scans remain based only on `next_run_at <= now`. +- `misfirePolicy: 'skip'` advances to the next future run after downtime. +- `misfirePolicy: 'run_once'` creates at most one catch-up run per reconcile unless + `maxCatchUpRuns` is explicitly set. + +## UX Shape + +```text ++---------------------------------------------------------+ +| Schedule | +| Mode | +| (o) Preset | +| Every [day v] at [09:00] | +| | +| ( ) Cron expression | +| [0 0 9 * * * ] | +| Timezone [Asia/Tokyo v] | +| | +| Next 5 runs | +| - 2026-07-03 09:00 | +| - 2026-07-04 09:00 | +| - 2026-07-05 09:00 | ++---------------------------------------------------------+ +``` + +## Non-Goals + +- No agent binding or real execution. +- No run detail UI beyond schedule preview. +- No remote delivery or `cronjob` agent tool. +- No broad legacy task migration unless the migration can be completed without changing execution + semantics. + +## Constraints + +- Do not reintroduce once/daily/weekly as persisted model variants. +- Presets must write only `cronExpr` and `timezone`. +- Store timestamps as epoch milliseconds. +- Use deterministic tests with fixed reference dates and timezones. + +## References + +- `cron-parser` README documents timezone handling and extended syntax including `L`, `#`, and `H`: + https://github.com/harrisiirak/cron-parser/blob/master/README.md + +## Open Questions + +None. Unsupported parser syntax should be removed from UI copy instead of becoming a product +promise. diff --git a/docs/features/cron-agent-jobs-phase-2-cron-trigger-engine/tasks.md b/docs/features/cron-agent-jobs-phase-2-cron-trigger-engine/tasks.md new file mode 100644 index 000000000..86ceb17c2 --- /dev/null +++ b/docs/features/cron-agent-jobs-phase-2-cron-trigger-engine/tasks.md @@ -0,0 +1,29 @@ +# Tasks + +## Parser Integration + +- [ ] Add and lock the cron parser dependency. +- [ ] Implement `CronExpressionService` with validate, preview, next-run, misfire, and preset + conversion methods. +- [ ] Add tests for minute interval, weekdays, monthly last day, nth weekday, timezone, DST, and + invalid expressions. + +## Persistence And Scheduler + +- [ ] Add schedule-related migrations to `cron_jobs`. +- [ ] Recompute `next_run_at` on every create, update, toggle, run completion, and reconcile. +- [ ] Implement `skip` and `run_once` misfire behavior. +- [ ] Keep utility-process scans based on `next_run_at <= now`. + +## Routes And UI + +- [ ] Add `cronJobs.previewSchedule` and `cronJobs.validateSchedule` route contracts. +- [ ] Update `CronJobsClient`. +- [ ] Build the schedule editor with preset and raw cron modes. +- [ ] Add next-runs preview, loading, empty, and parser-error states. +- [ ] Add i18n keys. + +## Validation + +- [ ] Run targeted main and renderer tests. +- [ ] Run `pnpm run format`, `pnpm run i18n`, and `pnpm run lint`. diff --git a/docs/features/cron-agent-jobs-phase-3-agent-binding/plan.md b/docs/features/cron-agent-jobs-phase-3-agent-binding/plan.md new file mode 100644 index 000000000..65efbb564 --- /dev/null +++ b/docs/features/cron-agent-jobs-phase-3-agent-binding/plan.md @@ -0,0 +1,92 @@ +# Implementation Plan + +## Phase Boundary + +This phase depends on phase 1 and phase 2. It updates job definitions and validation, but still does +not run the agent loop. Main can continue to mock execution after resolving runtime. + +## Data Model Changes + +Extend `cron_jobs`: + +```sql +ALTER TABLE cron_jobs ADD COLUMN description TEXT; +ALTER TABLE cron_jobs ADD COLUMN status TEXT NOT NULL DEFAULT 'ready'; +ALTER TABLE cron_jobs ADD COLUMN task_prompt TEXT NOT NULL DEFAULT ''; +ALTER TABLE cron_jobs ADD COLUMN task_system_instruction TEXT; +ALTER TABLE cron_jobs ADD COLUMN task_output_mode TEXT NOT NULL DEFAULT 'final_message'; +ALTER TABLE cron_jobs ADD COLUMN model_policy TEXT NOT NULL DEFAULT 'follow_agent'; +ALTER TABLE cron_jobs ADD COLUMN tool_policy TEXT NOT NULL DEFAULT 'follow_agent'; +ALTER TABLE cron_jobs ADD COLUMN permission_policy TEXT NOT NULL DEFAULT 'follow_agent'; +ALTER TABLE cron_jobs ADD COLUMN runtime_json TEXT NOT NULL DEFAULT '{}'; +ALTER TABLE cron_jobs ADD COLUMN agent_snapshot_json TEXT; +``` + +`agent_id` remains nullable at the database level for upgrade compatibility, but service validation +rejects enabling jobs without a valid agent. + +## Runtime Resolver + +Add `CronJobRuntimeResolver`: + +1. Load job. +2. Load `AgentRepository.getAgent(job.agentId)`. +3. Reject missing or disabled agent with `invalid_agent`. +4. Resolve current config through `resolveDeepChatAgentConfig()` or ACP config paths. +5. Apply job policies: + - `follow_agent`: use current agent config. + - `pin_current` / `snapshot`: use `agent_snapshot_json`. +6. Return a sanitized runtime plan for later phase 4 execution. + +## Agent Change Handling + +When agent CRUD changes occur: + +- Revalidate affected jobs. +- Mark jobs `invalid_agent` if their agent no longer exists or is disabled. +- Reconcile scheduler if runnable enabled counts changed. + +Use typed events only if renderer needs immediate refresh. + +## UI Plan + +Add an Agent section to the job editor: + +```text ++---------------------------------------------------------+ +| Agent | +| [Issue Triage Agent v] | +| Runtime: follows agent | +| Model: follows | Tools: follows | Permissions: follows | +| | +| [Advanced] Pin current runtime snapshot | ++---------------------------------------------------------+ +``` + +Invalid jobs in the list: + +```text +! Daily Issue Triage + Agent missing or disabled + [Choose Agent] [Disable] +``` + +## Compatibility + +- Jobs created in phases 1 and 2 without an agent are disabled or marked `invalid_agent`. +- Legacy scheduled prompt tasks are not migrated here because phase 4 owns real session creation. + +## Test Strategy + +- Resolver tests for valid agent, missing agent, disabled agent, follow mode, and snapshot mode. +- Repository tests for status transitions and migration defaults. +- UI tests for agent selector, invalid agent state, and snapshot toggles. +- Scheduler tests proving invalid jobs are not counted as runnable. + +## Validation Commands + +- `pnpm run format` +- `pnpm run i18n` +- `pnpm run lint` +- `pnpm test -- test/main/presenter/cronJobs` +- `pnpm test -- test/renderer` diff --git a/docs/features/cron-agent-jobs-phase-3-agent-binding/spec.md b/docs/features/cron-agent-jobs-phase-3-agent-binding/spec.md new file mode 100644 index 000000000..efd4338f7 --- /dev/null +++ b/docs/features/cron-agent-jobs-phase-3-agent-binding/spec.md @@ -0,0 +1,102 @@ +# Cron Agent Jobs Phase 3: Agent Binding + +## User Need + +Users need a scheduled job to run as a specific DeepChat agent, with model, tools, MCP servers, +skills, permission mode, and workspace behavior resolved from that agent. + +## Goal + +Turn a scheduled cron job into an agent-bound job definition: + +- Every enabled job must reference a valid `agentId`. +- Runtime settings default to following the current agent config. +- Snapshot policies preserve a stable runtime when requested. +- Deleted or disabled agents make affected jobs invalid instead of silently running a fallback. + +## Job Model + +```ts +type AgentCronJob = { + id: string + name: string + description?: string + enabled: boolean + status: 'ready' | 'disabled' | 'invalid_agent' + + schedule: { + cronExpr: string + timezone: string + misfirePolicy: 'skip' | 'run_once' + } + + agent: { + agentId: string + agentVersion?: string + modelPolicy: 'follow_agent' | 'pin_current' + toolPolicy: 'follow_agent' | 'snapshot' + permissionPolicy: 'follow_agent' | 'snapshot' + } + + task: { + prompt: string + systemInstruction?: string + outputMode: 'final_message' | 'structured_json' | 'artifact' + } + + runtime: { + maxDurationMs: number + maxTurns: number + maxToolCalls: number + concurrencyPolicy: 'skip' | 'queue' + } + + nextRunAt: number | null +} +``` + +## Acceptance Criteria + +- Creating or enabling a job requires a valid enabled agent. +- Job execution planning resolves model/tools/MCP/skills/permissions from the bound agent. +- In `follow_agent` mode, agent config updates affect the next job run. +- In snapshot mode, the job uses the captured runtime snapshot. +- Deleting or disabling an agent moves related jobs to `invalid_agent` and prevents enqueueing. +- The job editor clearly shows which runtime parts follow the agent and which are pinned. +- Scheduler status excludes invalid jobs from enabled runnable counts. + +## UX Shape + +```text ++---------------------------------------------------------+ +| Agent Binding | +| Agent: [DeepChat Issue Triage v] | +| | +| Runtime follows agent | +| [x] Model | +| [x] Tools / MCP / Skills | +| [x] Permission mode | +| | +| Advanced | +| [ ] Pin current model/tools/permissions | ++---------------------------------------------------------+ +``` + +## Non-Goals + +- No fresh session creation yet. +- No remote delivery. +- No `cronjob` agent tool. +- No support for running without an agent. + +## Constraints + +- Use `AgentRepository` and existing agent config normalization. +- Keep `deepchat` as an explicit agent selection, not an invisible fallback. +- Snapshot JSON must be versioned so later migrations can identify the captured shape. +- Do not expose raw provider credentials in snapshots. + +## Open Questions + +None. Snapshot support is required for model/tools/permissions at the level available through the +current agent config; unavailable runtime internals should remain follow-mode only. diff --git a/docs/features/cron-agent-jobs-phase-3-agent-binding/tasks.md b/docs/features/cron-agent-jobs-phase-3-agent-binding/tasks.md new file mode 100644 index 000000000..5a7662fb6 --- /dev/null +++ b/docs/features/cron-agent-jobs-phase-3-agent-binding/tasks.md @@ -0,0 +1,28 @@ +# Tasks + +## Domain And Persistence + +- [ ] Extend shared Cron Jobs types with agent, task, runtime, and status fields. +- [ ] Add migrations for task, policy, runtime, and snapshot columns. +- [ ] Normalize existing rows without agents into disabled or `invalid_agent` state. + +## Runtime Resolution + +- [ ] Implement `CronJobRuntimeResolver`. +- [ ] Resolve current agent config in follow mode. +- [ ] Capture and use sanitized runtime snapshots. +- [ ] Revalidate jobs on agent update, disable, and delete paths. + +## Routes And UI + +- [ ] Update create/update/toggle validation to require a valid agent for enabled jobs. +- [ ] Add agent selector and runtime policy controls. +- [ ] Add invalid-agent list and editor states. +- [ ] Add i18n keys. + +## Tests And Validation + +- [ ] Cover resolver success and failure paths. +- [ ] Cover follow vs snapshot policy behavior. +- [ ] Cover invalid jobs being excluded from scheduler runnable counts. +- [ ] Run `pnpm run format`, `pnpm run i18n`, and `pnpm run lint`. diff --git a/docs/features/cron-agent-jobs-phase-4-fresh-session-runs/plan.md b/docs/features/cron-agent-jobs-phase-4-fresh-session-runs/plan.md new file mode 100644 index 000000000..c1397ed2c --- /dev/null +++ b/docs/features/cron-agent-jobs-phase-4-fresh-session-runs/plan.md @@ -0,0 +1,119 @@ +# Implementation Plan + +## Phase Boundary + +This phase depends on agent-bound jobs from phase 3. It is the first phase where Cron Jobs produce +real DeepChat sessions. + +## Execution Service + +Add `CronJobRunExecutor` in main: + +1. Claim a queued run atomically. +2. Load job and resolve runtime through `CronJobRuntimeResolver`. +3. Enforce concurrency policy: + - `skip`: fail or cancel the new run if another run is active for the same job. + - `queue`: leave queued until the active run completes. +4. Create a fresh session through `SessionService.createSession()`. +5. Store `sessionId` on the run. +6. Send the job prompt through `ChatService.sendMessage()`. +7. Capture final assistant message id and preview. +8. Mark run success, failed, cancelled, or waiting permission. +9. Advance `next_run_at`. + +## Data Model Changes + +Extend `cron_job_runs`: + +```sql +ALTER TABLE cron_job_runs ADD COLUMN output_message_id TEXT; +ALTER TABLE cron_job_runs ADD COLUMN output_preview TEXT; +ALTER TABLE cron_job_runs ADD COLUMN parent_continuation_session_id TEXT; +ALTER TABLE cron_job_runs ADD COLUMN claimed_at INTEGER; +ALTER TABLE cron_job_runs ADD COLUMN claim_owner TEXT; +``` + +Add session metadata storage if no generic session metadata exists: + +```sql +CREATE TABLE deepchat_session_metadata ( + session_id TEXT PRIMARY KEY, + source TEXT NOT NULL, + metadata_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +``` + +## Run Claiming + +Use a transactional claim: + +```text +UPDATE cron_job_runs +SET status = 'running', started_at = now, claimed_at = now, claim_owner = owner +WHERE id = runId AND status = 'queued' +``` + +Only the process that changes one row proceeds. + +## Run History Routes + +Add: + +- `cronJobs.listRuns` +- `cronJobs.getRun` +- `cronJobs.openRunSession` +- `cronJobs.continueRun` + +`continueRun` activates the existing session and should not create a new one. + +## UI Plan + +Add run history and run detail: + +```text ++---------------------------------------------------------+ +| Runs | +| success Daily Issue Triage 2026-07-03 09:00 2m 14s | +| failed Daily Issue Triage 2026-07-02 09:00 error | +| queued Weekly Release 2026-07-03 18:00 pending | ++---------------------------------------------------------+ +``` + +Detail view: + +```text ++---------------------------------------------------------+ +| Cron Run | +| Job: Daily Issue Triage | +| Status: success | Duration: 2m 14s | +| Session: Open | +| | +| Output | +| 3 issues need attention... | +| | +| [Continue Session] [Run Again] | ++---------------------------------------------------------+ +``` + +## Compatibility + +- Legacy auto-send scheduled tasks still use their old session creator until explicitly migrated. +- Cron Job sessions are regular sessions with metadata, not subagent sessions. + +## Test Strategy + +- Executor tests for queued claim, duplicate run protection, success, failure, and permission wait. +- Integration-style tests with mocked `SessionService` and `ChatService`. +- Repository tests for run history pagination and metadata persistence. +- Renderer tests for history list and run detail actions. + +## Validation Commands + +- `pnpm run format` +- `pnpm run i18n` +- `pnpm run lint` +- `pnpm test -- test/main/presenter/cronJobs` +- `pnpm test -- test/main/routes` +- `pnpm test -- test/renderer` diff --git a/docs/features/cron-agent-jobs-phase-4-fresh-session-runs/spec.md b/docs/features/cron-agent-jobs-phase-4-fresh-session-runs/spec.md new file mode 100644 index 000000000..11948cfad --- /dev/null +++ b/docs/features/cron-agent-jobs-phase-4-fresh-session-runs/spec.md @@ -0,0 +1,107 @@ +# Cron Agent Jobs Phase 4: Fresh Session Runs + +## User Need + +Users need every scheduled run to produce an inspectable DeepChat session with the job prompt, +agent response, tool calls, permissions, and final output tied back to the job run. + +## Goal + +Replace phase 1's mock execution with real agent execution: + +- Each `RUN_DUE` creates a fresh session. +- The first user message is the job prompt. +- The bound agent runs through the normal DeepChat runtime. +- The run row records lifecycle status, `sessionId`, output preview, and failure details. +- Manual `Run Now` uses the same execution path. + +## Run Model + +```ts +type CronJobRun = { + id: string + jobId: string + sessionId: string | null + parentContinuationSessionId?: string + + scheduledAt: number + startedAt: number | null + completedAt: number | null + + status: + | 'queued' + | 'running' + | 'success' + | 'failed' + | 'cancelled' + | 'waiting_permission' + + outputMessageId?: string + outputPreview?: string + error?: string +} +``` + +## Session Metadata + +Add a maintained metadata shape for sessions created by Cron Jobs: + +```ts +type SessionMetadata = { + source: 'cron_job' + cronJobId: string + cronJobRunId: string + scheduledAt: number +} +``` + +If the current `new_sessions` table cannot store this cleanly, add a generic session metadata column +or side table rather than overloading `subagent_meta_json`. + +## Acceptance Criteria + +- Every due run creates a new session before sending the job prompt. +- `cron_job_runs.session_id` is set as soon as session creation succeeds. +- The session contains the job prompt, assistant response, and tool calls. +- Multiple runs of the same job create multiple sessions. +- Run status transitions are persisted: `queued -> running -> success|failed|waiting_permission`. +- A failed run keeps `sessionId` if session creation already happened. +- Duplicate `RUN_DUE` events for the same run do not start duplicate sessions. +- Manual `Run Now` follows the same queued-run execution path. +- Run detail UI can open the session and continue it. + +## UX Shape + +```text ++---------------------------------------------------------+ +| Job Run | +| Daily Issue Triage | +| Run: 2026-07-03 09:00 | success | 2m 14s | +| Session: #cron-run-8f31 | +| | +| Result | +| - 3 new issues need triage | +| - 1 regression likely related to provider routing | +| | +| [Open Session] [Continue] [Run Again] | ++---------------------------------------------------------+ +``` + +## Non-Goals + +- No remote delivery. +- No inbound remote continuation. +- No `cronjob` agent tool. +- No multi-run catch-up UI beyond history list. + +## Constraints + +- Reuse `SessionService` and `ChatService`; do not create a second agent runtime path. +- Respect agent permission behavior. Jobs must not bypass user approval flows. +- Enforce `runtime.maxDurationMs`, `maxTurns`, `maxToolCalls`, and `concurrencyPolicy`. +- Use run-level locking to prevent duplicate starts. + +## Open Questions + +None. If a provider requires UI interaction, the run enters `waiting_permission` rather than being +silently skipped. diff --git a/docs/features/cron-agent-jobs-phase-4-fresh-session-runs/tasks.md b/docs/features/cron-agent-jobs-phase-4-fresh-session-runs/tasks.md new file mode 100644 index 000000000..a5a26add1 --- /dev/null +++ b/docs/features/cron-agent-jobs-phase-4-fresh-session-runs/tasks.md @@ -0,0 +1,30 @@ +# Tasks + +## Execution Path + +- [ ] Add `CronJobRunExecutor`. +- [ ] Implement transactional queued-run claiming. +- [ ] Create fresh sessions through existing session service. +- [ ] Send job prompt through existing chat service. +- [ ] Persist run status, session id, output message id, preview, and errors. +- [ ] Enforce max duration, max turns, max tool calls, and concurrency policy. + +## Persistence And Routes + +- [ ] Add run claiming and output columns. +- [ ] Add session metadata storage for `source='cron_job'`. +- [ ] Add run history, run detail, open session, continue, and run-again routes. + +## UI + +- [ ] Add run history list. +- [ ] Add run detail view. +- [ ] Add Open Session, Continue Session, and Run Again actions. +- [ ] Add session-list source indicators for cron-job sessions where appropriate. +- [ ] Add i18n keys. + +## Tests And Validation + +- [ ] Cover success, failed, cancelled, waiting-permission, and duplicate `RUN_DUE` cases. +- [ ] Cover manual `Run Now` using the same execution path. +- [ ] Run `pnpm run format`, `pnpm run i18n`, and `pnpm run lint`. diff --git a/docs/features/cron-agent-jobs-phase-5-delivery-continuation/plan.md b/docs/features/cron-agent-jobs-phase-5-delivery-continuation/plan.md new file mode 100644 index 000000000..3a6896a5e --- /dev/null +++ b/docs/features/cron-agent-jobs-phase-5-delivery-continuation/plan.md @@ -0,0 +1,115 @@ +# Implementation Plan + +## Phase Boundary + +This phase depends on real runs and sessions from phase 4. It should merge before the `cronjob` +agent tool because it expands the job domain and routes the tool will later call. + +## Data Model + +Add delivery receipts: + +```sql +CREATE TABLE cron_job_deliveries ( + id TEXT PRIMARY KEY, + job_id TEXT NOT NULL, + run_id TEXT NOT NULL, + target_type TEXT NOT NULL, + target_json TEXT NOT NULL, + status TEXT NOT NULL, + remote_message_id TEXT, + error TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE INDEX idx_cron_job_deliveries_run +ON cron_job_deliveries(run_id, created_at); + +CREATE INDEX idx_cron_job_deliveries_remote_message +ON cron_job_deliveries(remote_message_id); +``` + +Extend `cron_jobs`: + +```sql +ALTER TABLE cron_jobs ADD COLUMN delivery_json TEXT NOT NULL DEFAULT '{}'; +``` + +## Delivery Router + +Add `CronJobDeliveryRouter`: + +1. Load run output and job delivery config. +2. Resolve applicable targets for success or failure. +3. Render payload once as structured content plus channel-safe summaries. +4. Dispatch through target adapters: + - DeepChat Inbox adapter. + - Desktop notification adapter. + - Origin session adapter. + - Remote control adapter. +5. Persist receipt status per target. + +## Remote Boundary + +Add a narrow remote presenter port: + +```ts +type CronJobRemoteDeliveryPort = { + deliverCronJobResult(input: RemoteCronJobDeliveryInput): Promise + resolveCronJobContinuation(input: RemoteInboundMessage): Promise +} +``` + +Remote channels decide whether their message model supports thread continuation. Cron Jobs only +stores receipts and continuation target mapping. + +## Continuation Flow + +```text +remote message + -> inbound reply/action + -> RemoteControlPresenter resolves remote_message_id + -> cron_job_deliveries -> run_id -> session_id + -> ChatService.sendMessage(sessionId, reply) + -> response delivered to same remote thread +``` + +DeepChat UI continuation simply activates the stored session. + +## UI Plan + +Add a Delivery section in the job editor and delivery logs in run detail: + +```text ++---------------------------------------------------------+ +| Delivery Logs | +| Inbox success 2026-07-03 09:02 | +| Desktop success 2026-07-03 09:02 | +| Feishu failed missing channel binding | ++---------------------------------------------------------+ +``` + +Keep delivery controls compact and use checkboxes for target enablement. + +## Compatibility + +- Jobs without `delivery_json` default to DeepChat Inbox plus failure notification only if product + chooses that default; otherwise default to no delivery. +- Existing remote bindings are reused, not migrated. + +## Test Strategy + +- Router tests for success, failure, partial failure, and multiple targets. +- Remote port tests for mapping delivered message to run/session. +- Authorization tests proving unauthorized remote replies do not continue sessions. +- Renderer tests for delivery config and receipt display. + +## Validation Commands + +- `pnpm run format` +- `pnpm run i18n` +- `pnpm run lint` +- `pnpm test -- test/main/presenter/cronJobs` +- `pnpm test -- test/main/presenter/remoteControlPresenter` +- `pnpm test -- test/renderer` diff --git a/docs/features/cron-agent-jobs-phase-5-delivery-continuation/spec.md b/docs/features/cron-agent-jobs-phase-5-delivery-continuation/spec.md new file mode 100644 index 000000000..a04258637 --- /dev/null +++ b/docs/features/cron-agent-jobs-phase-5-delivery-continuation/spec.md @@ -0,0 +1,93 @@ +# Cron Agent Jobs Phase 5: Delivery And Continuation + +## User Need + +Users need scheduled run results to reach the place where they work, and they need to continue the +same run context from DeepChat or from a supported remote thread. + +## Goal + +Add delivery targets and continuation mapping: + +- Deliver run results to DeepChat Inbox, desktop notification, origin session, and supported remote + channels. +- Persist one delivery receipt per target. +- Link delivered remote messages back to the cron run and session. +- Continue the original session from DeepChat UI or inbound remote replies. + +## Delivery Model + +```ts +type JobDelivery = { + targets: DeliveryTarget[] + createContinuableThread: boolean + suppressSuccessNotification: boolean + notifyOnFailure: boolean +} + +type DeliveryTarget = + | { type: 'deepchat_inbox' } + | { type: 'desktop_notification' } + | { type: 'remote'; remoteId: string; channelId?: string; mode: 'summary' | 'full' } + | { type: 'origin_session'; sessionId: string } +``` + +## Acceptance Criteria + +- A job can configure zero or more delivery targets. +- Success and failure delivery behavior can be configured separately. +- Every delivery attempt writes a receipt. +- Delivery failure records an error without changing the run result. +- Remote deliveries persist `runId` and `sessionId` mapping. +- A supported remote thread reply resolves to the original run session and continues that session. +- DeepChat UI Continue enters the same session. +- Multiple remote targets each receive independent receipts. + +## UX Shape + +```text ++---------------------------------------------------------+ +| Delivery | +| Send result to | +| [x] DeepChat Inbox | +| [x] Desktop Notification | +| [ ] Current Session | +| [x] Remote | +| Remote: [Feishu Bot v] | +| Channel: [DeepChat Alerts v] | +| Content: [Summary v] | +| | +| [x] Allow continuing this run from delivery thread | ++---------------------------------------------------------+ +``` + +Run detail: + +```text ++---------------------------------------------------------+ +| Cron Run | +| Delivery: Inbox OK | Desktop OK | Feishu failed | +| | +| [Continue Session] [View Delivery Logs] | ++---------------------------------------------------------+ +``` + +## Non-Goals + +- No new remote channel protocol. +- No best-effort continuation for channels that cannot correlate replies to delivered messages. +- No `cronjob` agent tool yet. +- No retry scheduler beyond explicit delivery retry action unless already supported by remote + infrastructure. + +## Constraints + +- Use `RemoteControlPresenter` channel boundaries; do not put channel-specific formatting in Cron + Jobs service. +- Remote continuation must enforce existing remote authorization and session binding rules. +- Delivery receipts must not store provider secrets. +- Output rendering must use existing remote block rendering where practical. + +## Open Questions + +None. Unsupported remote channels must show delivery-only behavior without continuation. diff --git a/docs/features/cron-agent-jobs-phase-5-delivery-continuation/tasks.md b/docs/features/cron-agent-jobs-phase-5-delivery-continuation/tasks.md new file mode 100644 index 000000000..aee4bb5fb --- /dev/null +++ b/docs/features/cron-agent-jobs-phase-5-delivery-continuation/tasks.md @@ -0,0 +1,30 @@ +# Tasks + +## Persistence + +- [ ] Add `cron_job_deliveries` table and indexes. +- [ ] Add `delivery_json` to `cron_jobs`. +- [ ] Add delivery receipt repository methods. + +## Delivery + +- [ ] Implement `CronJobDeliveryRouter`. +- [ ] Add DeepChat Inbox, desktop notification, origin session, and remote target adapters. +- [ ] Persist receipt success and failure per target. +- [ ] Add delivery retry route only if needed for failed receipts. + +## Continuation + +- [ ] Add remote delivery/continuation port to `RemoteControlPresenter`. +- [ ] Store remote message to `runId/sessionId` mapping. +- [ ] Continue original session from supported remote replies. +- [ ] Continue original session from DeepChat UI. +- [ ] Enforce remote authorization before continuation. + +## UI And Validation + +- [ ] Add delivery configuration controls. +- [ ] Add delivery status and logs to run detail. +- [ ] Add i18n keys. +- [ ] Cover router, remote mapping, unauthorized reply, and UI states. +- [ ] Run `pnpm run format`, `pnpm run i18n`, and `pnpm run lint`. diff --git a/docs/features/cron-agent-jobs-phase-6-cronjob-tool/plan.md b/docs/features/cron-agent-jobs-phase-6-cronjob-tool/plan.md new file mode 100644 index 000000000..7fb286aed --- /dev/null +++ b/docs/features/cron-agent-jobs-phase-6-cronjob-tool/plan.md @@ -0,0 +1,92 @@ +# Implementation Plan + +## Phase Boundary + +This phase depends on phases 1 through 5. It should be the last slice because it exposes the full +domain surface to agents. + +## Tool Placement + +Add: + +- `src/main/presenter/toolPresenter/agentTools/cronJobTool.ts` +- Shared tool schemas near the Cron Jobs domain or tool handler. +- Tests under `test/main/presenter/toolPresenter/agentTools/cronJobTool.test.ts`. + +Register the tool in `AgentToolManager` alongside other local agent tools. + +## Action Routing + +Read actions: + +- `preview_schedule` -> `CronExpressionService.preview` +- `list` -> Cron Jobs service list +- `show` -> Cron Jobs service get +- `history` -> Cron Jobs run repository + +Write actions: + +- Build a confirmation card payload. +- Return `confirmationRequired: true`. +- On confirmed execution, call Cron Jobs service methods. + +Write actions must never mutate state during preview. + +## Confirmation Flow + +Reuse the existing agent tool confirmation infrastructure used by other local tools. The card should +carry enough structured data for the renderer to display a safe confirmation and enough opaque data +for the tool handler to execute after confirmation without trusting model text. + +## Schema And Validation + +Use zod schemas for: + +- Input discriminated by `action`. +- Create input. +- Update patch. +- Filters. +- Confirmation payload. +- Structured result. + +The schema should intentionally exclude scheduler internals. + +## UI/Card Plan + +Cards must be compact and action-oriented: + +```text ++---------------------------------------------------------+ +| Update Cron Job | +| Schedule: 0 0 9 * * * -> 0 0 10 * * * | +| Agent: Issue Triage Agent | +| Next runs after change | +| 2026-07-03 10:00 | 2026-07-04 10:00 | 2026-07-05 10:00 | +| | +| [Apply] [Edit] [Cancel] | ++---------------------------------------------------------+ +``` + +## Compatibility + +- The UI and tool call the same service layer. +- The tool result shape must remain stable enough for model prompts and tests. +- If a job was created before phase 6, it is manageable by the tool as long as it passes current + validation. + +## Test Strategy + +- Unit tests for every action. +- Confirmation tests proving write actions do not mutate before confirmation. +- Permission tests for denied confirmation or invalid payloads. +- Integration tests with mocked Cron Jobs service. +- Tool registry test proving `cronjob` appears exactly once and can be disabled like other local + agent tools. + +## Validation Commands + +- `pnpm run format` +- `pnpm run i18n` +- `pnpm run lint` +- `pnpm test -- test/main/presenter/toolPresenter/agentTools/cronJobTool.test.ts` +- `pnpm test -- test/main/presenter/cronJobs` diff --git a/docs/features/cron-agent-jobs-phase-6-cronjob-tool/spec.md b/docs/features/cron-agent-jobs-phase-6-cronjob-tool/spec.md new file mode 100644 index 000000000..951cc540e --- /dev/null +++ b/docs/features/cron-agent-jobs-phase-6-cronjob-tool/spec.md @@ -0,0 +1,109 @@ +# Cron Agent Jobs Phase 6: cronjob Agent Tool + +## User Need + +Users need agents to create, inspect, update, pause, resume, run, and review Cron Jobs through one +tool without exposing scheduler internals or bypassing user confirmation. + +## Goal + +Add one local agent tool named `cronjob` that manages the Cron Jobs module: + +- Read actions execute directly. +- Write actions return confirmation cards before applying changes. +- The tool calls the same Cron Jobs service routes used by the UI. +- Tool results are structured and safe for model consumption. + +## Tool Input + +```ts +type CronJobToolInput = + | { action: 'create'; job: CronJobCreateInput } + | { action: 'list'; filter?: CronJobFilter } + | { action: 'show'; jobId: string } + | { action: 'update'; jobId: string; patch: CronJobPatch } + | { action: 'pause'; jobId: string } + | { action: 'resume'; jobId: string } + | { action: 'delete'; jobId: string } + | { action: 'run_now'; jobId: string } + | { action: 'history'; jobId: string; limit?: number } + | { action: 'preview_schedule'; cronExpr: string; timezone: string; count?: number } +``` + +## Create Input + +```ts +type CronJobCreateInput = { + name: string + description?: string + cronExpr: string + timezone: string + agentId: string + prompt: string + + delivery?: JobDelivery + runtime?: Partial +} +``` + +## Tool Result + +```ts +type CronJobToolResult = { + ok: boolean + job?: AgentCronJob + jobs?: AgentCronJob[] + runs?: CronJobRun[] + previewRuns?: string[] + confirmationRequired?: boolean + confirmationCard?: unknown + error?: string +} +``` + +## Acceptance Criteria + +- `preview_schedule` returns parser-backed upcoming runs. +- `list`, `show`, and `history` execute without confirmation. +- `create`, `update`, `delete`, `pause`, `resume`, and `run_now` return confirmation cards first. +- Confirming a write executes through the Cron Jobs service and returns the updated result. +- Confirmation cards show schedule, agent, prompt summary, delivery targets, and next runs. +- The tool never exposes utility-process pid, DB paths, scheduler protocol, or low-level locks. +- Tool schemas reject unknown actions and invalid payloads. +- Tool tests cover every action. + +## Confirmation Card Shape + +```text ++---------------------------------------------------------+ +| Create Cron Job | +| Name: Daily DeepChat Issue Triage | +| Schedule: 0 0 9 * * * | Asia/Tokyo | +| Agent: Issue Triage Agent | +| Delivery: DeepChat Inbox + Feishu | +| Next runs: | +| - 2026-07-03 09:00 | +| - 2026-07-04 09:00 | +| - 2026-07-05 09:00 | +| | +| [Create Job] [Edit] [Cancel] | ++---------------------------------------------------------+ +``` + +## Non-Goals + +- No second scheduling API. +- No direct utility-process controls. +- No write action without confirmation. +- No remote delivery implementation; phase 5 owns delivery. + +## Constraints + +- Register the tool through `AgentToolManager`. +- Use existing local agent tool permission and confirmation patterns. +- Keep user-facing card strings in i18n. +- Redact long prompts and delivery payload internals in model-facing summaries. + +## Open Questions + +None. Tool write confirmation is mandatory even when the current permission mode is permissive. diff --git a/docs/features/cron-agent-jobs-phase-6-cronjob-tool/tasks.md b/docs/features/cron-agent-jobs-phase-6-cronjob-tool/tasks.md new file mode 100644 index 000000000..45f7e772b --- /dev/null +++ b/docs/features/cron-agent-jobs-phase-6-cronjob-tool/tasks.md @@ -0,0 +1,27 @@ +# Tasks + +## Tool Schema + +- [ ] Define zod schemas for all `cronjob` actions. +- [ ] Define stable structured result types. +- [ ] Add parser-backed validation for schedule preview inputs. + +## Tool Handler + +- [ ] Implement read actions: list, show, history, and preview schedule. +- [ ] Implement confirmation card creation for create, update, delete, pause, resume, and run now. +- [ ] Implement confirmed write execution through Cron Jobs service methods. +- [ ] Redact long prompt and delivery details in model-facing summaries. + +## Registry And UI + +- [ ] Register `cronjob` in `AgentToolManager`. +- [ ] Add confirmation card renderer and i18n strings. +- [ ] Ensure the tool can be disabled through existing local tool controls. + +## Tests And Validation + +- [ ] Cover every action. +- [ ] Prove write previews do not mutate state before confirmation. +- [ ] Cover denied confirmation and invalid payload errors. +- [ ] Run `pnpm run format`, `pnpm run i18n`, and `pnpm run lint`. From 8114a9b035ef78cea16b2d9bf68571a5406883fb Mon Sep 17 00:00:00 2001 From: zerob13 Date: Thu, 2 Jul 2026 22:17:09 +0800 Subject: [PATCH 02/36] feat(cron-jobs): add phase one scheduler --- .../tasks.md | 30 +- electron.vite.config.ts | 3 +- src/main/presenter/cronJobs/index.ts | 177 +++++++ src/main/presenter/cronJobs/repository.ts | 136 ++++++ .../cronJobs/schedulerProcessManager.ts | 397 ++++++++++++++++ .../cronJobs/schedulerUtilityHost.ts | 414 +++++++++++++++++ src/main/presenter/index.ts | 14 +- .../hooks/after-start/cronJobsStartHook.ts | 19 + .../hooks/beforeQuit/cronJobsStopHook.ts | 16 + .../lifecyclePresenter/hooks/index.ts | 2 + src/main/presenter/sqlitePresenter/index.ts | 12 +- .../sqlitePresenter/schemaCatalog.ts | 10 + .../sqlitePresenter/tables/cronJobRuns.ts | 187 ++++++++ .../sqlitePresenter/tables/cronJobs.ts | 186 ++++++++ src/main/routes/index.ts | 62 ++- src/main/schedulerUtilityHostEntry.ts | 5 + src/renderer/api/CronJobsClient.ts | 126 +++++ src/renderer/api/index.ts | 1 + .../settings/components/CronJobsSettings.vue | 431 ++++++++++++++++++ src/renderer/settings/main.ts | 2 +- src/renderer/src/i18n/da-DK/routes.json | 2 +- src/renderer/src/i18n/da-DK/settings.json | 33 ++ src/renderer/src/i18n/de-DE/routes.json | 2 +- src/renderer/src/i18n/de-DE/settings.json | 33 ++ src/renderer/src/i18n/en-US/routes.json | 2 +- src/renderer/src/i18n/en-US/settings.json | 33 ++ src/renderer/src/i18n/es-ES/routes.json | 2 +- src/renderer/src/i18n/es-ES/settings.json | 33 ++ src/renderer/src/i18n/fa-IR/routes.json | 2 +- src/renderer/src/i18n/fa-IR/settings.json | 33 ++ src/renderer/src/i18n/fr-FR/routes.json | 2 +- src/renderer/src/i18n/fr-FR/settings.json | 33 ++ src/renderer/src/i18n/he-IL/routes.json | 2 +- src/renderer/src/i18n/he-IL/settings.json | 33 ++ src/renderer/src/i18n/id-ID/routes.json | 2 +- src/renderer/src/i18n/id-ID/settings.json | 33 ++ src/renderer/src/i18n/it-IT/routes.json | 2 +- src/renderer/src/i18n/it-IT/settings.json | 33 ++ src/renderer/src/i18n/ja-JP/routes.json | 2 +- src/renderer/src/i18n/ja-JP/settings.json | 33 ++ src/renderer/src/i18n/ko-KR/routes.json | 2 +- src/renderer/src/i18n/ko-KR/settings.json | 33 ++ src/renderer/src/i18n/ms-MY/routes.json | 2 +- src/renderer/src/i18n/ms-MY/settings.json | 33 ++ src/renderer/src/i18n/pl-PL/routes.json | 2 +- src/renderer/src/i18n/pl-PL/settings.json | 33 ++ src/renderer/src/i18n/pt-BR/routes.json | 2 +- src/renderer/src/i18n/pt-BR/settings.json | 33 ++ src/renderer/src/i18n/ru-RU/routes.json | 2 +- src/renderer/src/i18n/ru-RU/settings.json | 33 ++ src/renderer/src/i18n/tr-TR/routes.json | 2 +- src/renderer/src/i18n/tr-TR/settings.json | 33 ++ src/renderer/src/i18n/vi-VN/routes.json | 2 +- src/renderer/src/i18n/vi-VN/settings.json | 33 ++ src/renderer/src/i18n/zh-CN/routes.json | 2 +- src/renderer/src/i18n/zh-CN/settings.json | 33 ++ src/renderer/src/i18n/zh-HK/routes.json | 2 +- src/renderer/src/i18n/zh-HK/settings.json | 33 ++ src/renderer/src/i18n/zh-TW/routes.json | 2 +- src/renderer/src/i18n/zh-TW/settings.json | 33 ++ src/shared/contracts/routes.ts | 19 + .../contracts/routes/cronJobs.routes.ts | 135 ++++++ src/shared/cronJobs.ts | 114 +++++ src/shared/settingsNavigation.ts | 7 +- test/main/presenter/cronJobs.test.ts | 315 +++++++++++++ test/main/routes/dispatcher.test.ts | 153 ++++++- test/renderer/api/cronJobsClient.test.ts | 109 +++++ 67 files changed, 3738 insertions(+), 44 deletions(-) create mode 100644 src/main/presenter/cronJobs/index.ts create mode 100644 src/main/presenter/cronJobs/repository.ts create mode 100644 src/main/presenter/cronJobs/schedulerProcessManager.ts create mode 100644 src/main/presenter/cronJobs/schedulerUtilityHost.ts create mode 100644 src/main/presenter/lifecyclePresenter/hooks/after-start/cronJobsStartHook.ts create mode 100644 src/main/presenter/lifecyclePresenter/hooks/beforeQuit/cronJobsStopHook.ts create mode 100644 src/main/presenter/sqlitePresenter/tables/cronJobRuns.ts create mode 100644 src/main/presenter/sqlitePresenter/tables/cronJobs.ts create mode 100644 src/main/schedulerUtilityHostEntry.ts create mode 100644 src/renderer/api/CronJobsClient.ts create mode 100644 src/renderer/settings/components/CronJobsSettings.vue create mode 100644 src/shared/contracts/routes/cronJobs.routes.ts create mode 100644 src/shared/cronJobs.ts create mode 100644 test/main/presenter/cronJobs.test.ts create mode 100644 test/renderer/api/cronJobsClient.test.ts diff --git a/docs/features/cron-agent-jobs-phase-1-scheduler/tasks.md b/docs/features/cron-agent-jobs-phase-1-scheduler/tasks.md index 063a6099e..ab81c5d4c 100644 --- a/docs/features/cron-agent-jobs-phase-1-scheduler/tasks.md +++ b/docs/features/cron-agent-jobs-phase-1-scheduler/tasks.md @@ -2,29 +2,29 @@ ## Preparation -- [ ] Add shared Cron Jobs domain types and route contracts. -- [ ] Add SQLite table classes and migrations for `cron_jobs` and `cron_job_runs`. -- [ ] Add repository methods for job CRUD, enabled counts, due scans, queued run insertion, and +- [x] Add shared Cron Jobs domain types and route contracts. +- [x] Add SQLite table classes and migrations for `cron_jobs` and `cron_job_runs`. +- [x] Add repository methods for job CRUD, enabled counts, due scans, queued run insertion, and status updates. ## Scheduler Process -- [ ] Implement `SchedulerProcessManager` with start, stop, restart, reconcile, heartbeat, and +- [x] Implement `SchedulerProcessManager` with start, stop, restart, reconcile, heartbeat, and crash backoff. -- [ ] Implement `schedulerHost` utility-process entry with direct scheduler DB adapter access. -- [ ] Add scheduler protocol validation and defensive error serialization. -- [ ] Wire startup, resume, before-quit, and job-change lifecycle hooks. +- [x] Implement `schedulerHost` utility-process entry with direct scheduler DB adapter access. +- [x] Add scheduler protocol validation and defensive error serialization. +- [x] Wire startup, resume, before-quit, and job-change lifecycle hooks. ## Routes And UI -- [ ] Register `cronJobs.*` routes in shared contracts and main route dispatcher. -- [ ] Add `CronJobsClient`. -- [ ] Add minimal Cron Jobs settings page with scheduler status, reconcile, and restart actions. -- [ ] Add i18n keys for all visible strings. +- [x] Register `cronJobs.*` routes in shared contracts and main route dispatcher. +- [x] Add `CronJobsClient`. +- [x] Add minimal Cron Jobs settings page with scheduler status, reconcile, and restart actions. +- [x] Add i18n keys for all visible strings. ## Tests And Validation -- [ ] Cover due-run idempotency and `next_run_at <= now` scan behavior. -- [ ] Cover scheduler start/idle-stop/crash-restart transitions. -- [ ] Cover route dispatcher and renderer client behavior. -- [ ] Run `pnpm run format`, `pnpm run i18n`, and `pnpm run lint`. +- [x] Cover due-run idempotency and `next_run_at <= now` scan behavior. +- [x] Cover scheduler start/idle-stop/crash-restart transitions. +- [x] Cover route dispatcher and renderer client behavior. +- [x] Run `pnpm run format`, `pnpm run i18n`, and `pnpm run lint`. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index e175736bc..dfca39839 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -27,7 +27,8 @@ export default defineConfig({ input: { index: resolve('src/main/index.ts'), backgroundExecUtilityHost: resolve('src/main/backgroundExecUtilityHostEntry.ts'), - fileWatcherUtilityHost: resolve('src/main/fileWatcherUtilityHostEntry.ts') + fileWatcherUtilityHost: resolve('src/main/fileWatcherUtilityHostEntry.ts'), + schedulerUtilityHost: resolve('src/main/schedulerUtilityHostEntry.ts') }, external: ['sharp', '@duckdb/node-api'], output: { diff --git a/src/main/presenter/cronJobs/index.ts b/src/main/presenter/cronJobs/index.ts new file mode 100644 index 000000000..f79ec9582 --- /dev/null +++ b/src/main/presenter/cronJobs/index.ts @@ -0,0 +1,177 @@ +import type { PowerMonitor } from 'electron' +import type { CronJob, CronJobRun, CronJobsSchedulerStatus } from '@shared/cronJobs' +import type { cronJobsUpsertInputSchema } from '@shared/contracts/routes/cronJobs.routes' +import type { z } from 'zod' +import type { SQLitePresenter } from '../sqlitePresenter' +import { CronJobsRepository } from './repository' +import { + SchedulerProcessManager, + type SchedulerProcessManagerDeps, + type SchedulerRunDueEvent +} from './schedulerProcessManager' + +export type CronJobsUpsertInput = z.input + +export interface CronJobsServiceDeps { + sqlitePresenter: SQLitePresenter + schedulerManager?: SchedulerProcessManager + createSchedulerManager?: ( + deps: Omit + ) => SchedulerProcessManager + powerMonitor?: Pick +} + +export class CronJobsService { + private readonly repository: CronJobsRepository + private readonly schedulerManager: SchedulerProcessManager + private started = false + private powerMonitor: Pick | null = null + private readonly resumeHandler = () => { + void this.reconcileScheduler('system-resume') + } + + constructor(deps: CronJobsServiceDeps) { + this.repository = new CronJobsRepository(deps.sqlitePresenter) + const managerDeps: Omit = { + dbPath: deps.sqlitePresenter.getDatabasePath(), + dbPassword: deps.sqlitePresenter.getDatabasePassword(), + getSnapshot: () => this.repository.getSchedulerSnapshot(), + onRunDue: async (event) => { + await this.processDueRun(event) + } + } + this.schedulerManager = + deps.schedulerManager ?? + deps.createSchedulerManager?.(managerDeps) ?? + new SchedulerProcessManager(managerDeps) + this.powerMonitor = deps.powerMonitor ?? null + } + + start(): void { + if (this.started) { + return + } + this.started = true + void this.attachPowerMonitor() + void this.reconcileScheduler('startup') + } + + async stop(): Promise { + this.started = false + if (this.powerMonitor) { + this.powerMonitor.off('resume', this.resumeHandler) + this.powerMonitor = null + } + await this.schedulerManager.stop('app-quit') + } + + async list(): Promise<{ + jobs: CronJob[] + schedulerStatus: CronJobsSchedulerStatus + }> { + await this.reconcileScheduler('list') + return { + jobs: this.repository.listJobs(), + schedulerStatus: this.schedulerManager.getStatus() + } + } + + async upsert(input: CronJobsUpsertInput): Promise<{ + job: CronJob + schedulerStatus: CronJobsSchedulerStatus + }> { + const job = this.repository.upsertJob(input) + const schedulerStatus = await this.reconcileScheduler('job-upsert') + return { job, schedulerStatus } + } + + async delete(id: string): Promise { + this.repository.deleteJob(id) + return await this.reconcileScheduler('job-delete') + } + + async toggle( + id: string, + enabled: boolean + ): Promise<{ + job: CronJob + schedulerStatus: CronJobsSchedulerStatus + }> { + const job = this.repository.setJobEnabled(id, enabled) + const schedulerStatus = await this.reconcileScheduler('job-toggle') + return { job, schedulerStatus } + } + + async runNow(id: string): Promise<{ + job: CronJob + run: CronJobRun + schedulerStatus: CronJobsSchedulerStatus + }> { + const job = this.repository.requireJob(id) + const run = this.repository.queueRun({ + jobId: id, + scheduledAt: Date.now(), + reason: 'manual' + }) + await this.processDueRun({ + jobId: id, + runId: run.id, + scheduledAt: run.scheduledAt, + reason: run.reason + }) + const completed = this.repository.getRun(run.id) ?? run + const schedulerStatus = await this.reconcileScheduler('manual-run') + return { job, run: completed, schedulerStatus } + } + + getSchedulerStatus(): CronJobsSchedulerStatus { + return this.schedulerManager.getStatus() + } + + async reconcileScheduler(reason = 'manual'): Promise { + return await this.schedulerManager.reconcile(reason) + } + + async restartScheduler(): Promise { + return await this.schedulerManager.restart() + } + + private async attachPowerMonitor(): Promise { + if (this.powerMonitor) { + this.powerMonitor.off('resume', this.resumeHandler) + this.powerMonitor.on('resume', this.resumeHandler) + return + } + + try { + const { powerMonitor } = await import('electron') + this.powerMonitor = powerMonitor + powerMonitor.off('resume', this.resumeHandler) + powerMonitor.on('resume', this.resumeHandler) + } catch (error) { + console.warn('[CronJobs] Failed to attach power monitor resume handler:', error) + } + } + + private async processDueRun(event: SchedulerRunDueEvent): Promise { + const run = this.repository.getRun(event.runId) + if (!run) { + console.warn('[CronJobs] Ignoring unknown run from scheduler:', event.runId) + return + } + + try { + this.repository.markRunRunning(event.runId) + this.repository.markRunCompleted(event.runId) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + try { + this.repository.markRunFailed(event.runId, message) + } catch (markError) { + console.error('[CronJobs] Failed to mark run as failed:', markError) + } + } + } +} + +export { CronJobsRepository } diff --git a/src/main/presenter/cronJobs/repository.ts b/src/main/presenter/cronJobs/repository.ts new file mode 100644 index 000000000..d1b95b076 --- /dev/null +++ b/src/main/presenter/cronJobs/repository.ts @@ -0,0 +1,136 @@ +import type { CronJob, CronJobRun, CronJobRunReason } from '@shared/cronJobs' +import type { cronJobsUpsertInputSchema } from '@shared/contracts/routes/cronJobs.routes' +import type { z } from 'zod' +import type { SQLitePresenter } from '../sqlitePresenter' +import type { CronJobRow } from '../sqlitePresenter/tables/cronJobs' +import type { CronJobRunRow } from '../sqlitePresenter/tables/cronJobRuns' + +export type CronJobUpsertInput = z.input + +export interface CronJobsSchedulerSnapshot { + enabledJobCount: number + nextRunAt: number | null +} + +export class CronJobsRepository { + constructor(private readonly sqlitePresenter: SQLitePresenter) {} + + listJobs(): CronJob[] { + return this.sqlitePresenter.cronJobsTable.list().map(toCronJob) + } + + getJob(id: string): CronJob | null { + const row = this.sqlitePresenter.cronJobsTable.get(id) + return row ? toCronJob(row) : null + } + + requireJob(id: string): CronJob { + const job = this.getJob(id) + if (!job) { + throw new Error(`Unknown cron job: ${id}`) + } + return job + } + + upsertJob(input: CronJobUpsertInput): CronJob { + const row = this.sqlitePresenter.cronJobsTable.upsert({ + id: input.id, + name: input.name, + enabled: input.enabled, + cronExpr: input.cronExpr, + timezone: input.timezone, + agentId: input.agentId, + nextRunAt: input.nextRunAt + }) + return toCronJob(row) + } + + deleteJob(id: string): void { + this.sqlitePresenter.getDatabase().transaction(() => { + this.sqlitePresenter.cronJobRunsTable.deleteByJob(id) + this.sqlitePresenter.cronJobsTable.delete(id) + })() + } + + setJobEnabled(id: string, enabled: boolean): CronJob { + return toCronJob(this.sqlitePresenter.cronJobsTable.setEnabled(id, enabled)) + } + + updateJobNextRunAt(id: string, nextRunAt: number | null): CronJob { + return toCronJob(this.sqlitePresenter.cronJobsTable.updateNextRunAt(id, nextRunAt)) + } + + getSchedulerSnapshot(): CronJobsSchedulerSnapshot { + return { + enabledJobCount: this.sqlitePresenter.cronJobsTable.countEnabled(), + nextRunAt: this.sqlitePresenter.cronJobsTable.getNextEnabledRunAt() + } + } + + queueRun(input: { + jobId: string + scheduledAt: number + reason: CronJobRunReason + now?: number + }): CronJobRun { + return toCronJobRun( + this.sqlitePresenter.cronJobRunsTable.insertQueued({ + jobId: input.jobId, + scheduledAt: input.scheduledAt, + reason: input.reason, + now: input.now + }) + ) + } + + getRun(id: string): CronJobRun | null { + const row = this.sqlitePresenter.cronJobRunsTable.get(id) + return row ? toCronJobRun(row) : null + } + + markRunRunning(id: string): CronJobRun { + return toCronJobRun(this.sqlitePresenter.cronJobRunsTable.markRunning(id)) + } + + markRunCompleted(id: string): CronJobRun { + return toCronJobRun(this.sqlitePresenter.cronJobRunsTable.markCompleted(id)) + } + + markRunFailed(id: string, error: string): CronJobRun { + return toCronJobRun(this.sqlitePresenter.cronJobRunsTable.markFailed(id, error)) + } + + listRunsByJob(jobId: string, limit?: number): CronJobRun[] { + return this.sqlitePresenter.cronJobRunsTable.listByJob(jobId, limit).map(toCronJobRun) + } +} + +export function toCronJob(row: CronJobRow): CronJob { + return { + id: row.id, + name: row.name, + enabled: row.enabled === 1, + cronExpr: row.cron_expr, + timezone: row.timezone, + agentId: row.agent_id, + nextRunAt: row.next_run_at, + createdAt: row.created_at, + updatedAt: row.updated_at + } +} + +export function toCronJobRun(row: CronJobRunRow): CronJobRun { + return { + id: row.id, + jobId: row.job_id, + scheduledAt: row.scheduled_at, + queuedAt: row.queued_at, + startedAt: row.started_at, + completedAt: row.completed_at, + status: row.status, + reason: row.reason, + error: row.error, + createdAt: row.created_at, + updatedAt: row.updated_at + } +} diff --git a/src/main/presenter/cronJobs/schedulerProcessManager.ts b/src/main/presenter/cronJobs/schedulerProcessManager.ts new file mode 100644 index 000000000..c780aa737 --- /dev/null +++ b/src/main/presenter/cronJobs/schedulerProcessManager.ts @@ -0,0 +1,397 @@ +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' +import type { UtilityProcess } from 'electron' +import type { CronJobsSchedulerStatus, SchedulerCommand, SchedulerEvent } from '@shared/cronJobs' +import type { CronJobsSchedulerSnapshot } from './repository' + +type SchedulerUtilityProcess = Pick & { + on(event: 'message', listener: (message: unknown) => void): SchedulerUtilityProcess + on(event: 'exit', listener: (code: number) => void): SchedulerUtilityProcess + on(event: 'error', listener: (type: string, location: string) => void): SchedulerUtilityProcess + once(event: 'spawn', listener: () => void): SchedulerUtilityProcess + once(event: 'exit', listener: (code: number) => void): SchedulerUtilityProcess + off(event: 'spawn', listener: () => void): SchedulerUtilityProcess + off(event: 'exit', listener: (code: number) => void): SchedulerUtilityProcess +} + +export interface SchedulerRunDueEvent { + jobId: string + runId: string + scheduledAt: number + reason: 'scheduled' | 'manual' +} + +export interface SchedulerProcessManagerDeps { + dbPath: string + dbPassword?: string + getSnapshot: () => CronJobsSchedulerSnapshot + onRunDue: (event: SchedulerRunDueEvent) => Promise | void + idleShutdownMs?: number + maxRestartAttempts?: number + restartDelayMs?: number + spawnHost?: () => Promise +} + +const DEFAULT_IDLE_SHUTDOWN_MS = 30_000 +const DEFAULT_RESTART_DELAY_MS = 1_000 +const DEFAULT_MAX_RESTART_ATTEMPTS = 5 + +export class SchedulerProcessManager { + private host: SchedulerUtilityProcess | null = null + private hostReady: Promise | null = null + private readonly idleShutdownMs: number + private readonly restartDelayMs: number + private readonly maxRestartAttempts: number + private idleStopTimer: NodeJS.Timeout | null = null + private restartTimer: NodeJS.Timeout | null = null + private shuttingDown = false + private status: CronJobsSchedulerStatus = { + state: 'stopped', + pid: null, + enabledJobCount: 0, + nextRunAt: null, + lastHeartbeatAt: null, + lastError: null, + restartAttempts: 0, + updatedAt: Date.now() + } + + constructor(private readonly deps: SchedulerProcessManagerDeps) { + this.idleShutdownMs = deps.idleShutdownMs ?? DEFAULT_IDLE_SHUTDOWN_MS + this.restartDelayMs = deps.restartDelayMs ?? DEFAULT_RESTART_DELAY_MS + this.maxRestartAttempts = deps.maxRestartAttempts ?? DEFAULT_MAX_RESTART_ATTEMPTS + } + + getStatus(): CronJobsSchedulerStatus { + return { ...this.status } + } + + async start(reason = 'manual'): Promise { + return await this.reconcile(reason) + } + + async reconcile(reason = 'manual'): Promise { + const snapshot = this.deps.getSnapshot() + this.updateStatus({ + enabledJobCount: snapshot.enabledJobCount, + nextRunAt: snapshot.nextRunAt + }) + + if (snapshot.enabledJobCount === 0) { + this.scheduleIdleStop() + if (!this.host) { + this.updateStatus({ state: 'idle' }) + } else { + this.postCommand({ + type: 'RECONCILE', + reason, + now: Date.now() + }) + } + return this.getStatus() + } + + this.cancelIdleStop() + const host = await this.ensureHost() + host.postMessage({ + type: 'RECONCILE', + reason, + now: Date.now() + } satisfies SchedulerCommand) + return this.getStatus() + } + + async restart(): Promise { + await this.stop('restart') + this.shuttingDown = false + this.updateStatus({ restartAttempts: 0, lastError: null }) + return await this.reconcile('restart') + } + + async stop(reason = 'manual'): Promise { + this.shuttingDown = true + this.cancelIdleStop() + this.cancelRestart() + + const host = this.host + this.host = null + this.hostReady = null + if (host) { + try { + host.postMessage({ + type: 'STOP', + reason, + now: Date.now() + } satisfies SchedulerCommand) + } catch {} + + try { + host.kill() + } catch {} + } + + this.updateStatus({ + state: 'stopped', + pid: null + }) + return this.getStatus() + } + + private async ensureHost(): Promise { + if (this.host) { + return this.host + } + if (this.hostReady) { + return await this.hostReady + } + + this.shuttingDown = false + this.updateStatus({ state: 'starting', lastError: null }) + this.hostReady = this.spawnHost() + try { + return await this.hostReady + } finally { + this.hostReady = null + } + } + + private async spawnHost(): Promise { + const host = this.deps.spawnHost ? await this.deps.spawnHost() : await this.spawnDefaultHost() + host.on('message', (message) => this.handleHostMessage(message)) + host.on('exit', (code) => this.handleHostExit(code)) + host.on('error', (type, location) => { + const message = `Scheduler utility process error: ${type} at ${location}` + console.error('[CronJobs] Scheduler utility process error:', { type, location }) + this.updateStatus({ state: 'error', lastError: message }) + }) + + this.host = host + this.updateStatus({ + state: 'running', + pid: host.pid ?? null, + restartAttempts: 0 + }) + host.postMessage({ + type: 'START', + now: Date.now() + } satisfies SchedulerCommand) + return host + } + + private async spawnDefaultHost(): Promise { + const { app, utilityProcess } = await import('electron') + const modulePath = this.resolveUtilityHostEntryPoint(app.getAppPath()) + const host = utilityProcess.fork(modulePath, ['--deepchat-cron-scheduler-host'], { + serviceName: 'DeepChat Cron Jobs Scheduler', + stdio: 'ignore', + env: { + ...process.env, + DEEPCHAT_CRON_SCHEDULER_HOST: '1', + DEEPCHAT_CRON_SCHEDULER_DB_PATH: this.deps.dbPath, + ...(this.deps.dbPassword + ? { DEEPCHAT_CRON_SCHEDULER_DB_PASSWORD: this.deps.dbPassword } + : {}) + } + }) as SchedulerUtilityProcess + + return await new Promise((resolve, reject) => { + let settled = false + const settle = (callback: () => void) => { + if (settled) { + return + } + settled = true + host.off('spawn', onSpawn) + host.off('exit', onExit) + callback() + } + const onSpawn = () => { + settle(() => resolve(host)) + } + const onExit = (code: number) => { + settle(() => reject(new Error(`Cron scheduler utility exited before spawn: ${code}`))) + } + + host.once('spawn', onSpawn) + host.once('exit', onExit) + }) + } + + private resolveUtilityHostEntryPoint(appPath?: string): string { + const modulePath = fileURLToPath(import.meta.url) + const candidates = [ + ...(appPath + ? [ + path.join(appPath, 'out/main/schedulerUtilityHost.js'), + path.join(appPath, 'schedulerUtilityHost.js') + ] + : []), + path.resolve(path.dirname(modulePath), 'schedulerUtilityHost.js'), + path.resolve(path.dirname(modulePath), '../schedulerUtilityHost.js'), + path.resolve(process.cwd(), 'out/main/schedulerUtilityHost.js') + ] + + return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates[0] + } + + private handleHostMessage(message: unknown): void { + const event = unwrapSchedulerEvent(message) + if (!event) { + return + } + + switch (event.type) { + case 'READY': + this.updateStatus({ + state: 'running', + pid: event.pid, + lastHeartbeatAt: event.now, + lastError: null + }) + return + case 'HEARTBEAT': + this.updateStatus({ + state: 'running', + enabledJobCount: event.enabledJobCount, + nextRunAt: event.nextRunAt, + lastHeartbeatAt: event.now, + lastError: null + }) + if (event.enabledJobCount === 0) { + this.scheduleIdleStop() + } + return + case 'IDLE': + this.updateStatus({ + state: 'idle', + enabledJobCount: event.enabledJobCount, + nextRunAt: event.nextRunAt, + lastHeartbeatAt: event.now + }) + this.scheduleIdleStop() + return + case 'RUN_DUE': + void Promise.resolve( + this.deps.onRunDue({ + jobId: event.jobId, + runId: event.runId, + scheduledAt: event.scheduledAt, + reason: event.reason + }) + ).catch((error) => { + console.error('[CronJobs] Failed to process due run:', error) + }) + return + case 'ERROR': + console.error('[CronJobs] Scheduler utility error:', event.message) + this.updateStatus({ + state: 'error', + lastError: event.message, + lastHeartbeatAt: event.now + }) + return + } + } + + private handleHostExit(code: number): void { + const message = `Cron scheduler utility exited with code ${code}.` + this.host = null + this.hostReady = null + this.updateStatus({ + state: this.shuttingDown ? 'stopped' : 'error', + pid: null, + lastError: this.shuttingDown ? null : message + }) + + if (this.shuttingDown || this.deps.getSnapshot().enabledJobCount === 0) { + return + } + this.scheduleRestart() + } + + private postCommand(command: SchedulerCommand): void { + try { + this.host?.postMessage(command) + } catch (error) { + console.error('[CronJobs] Failed to post scheduler command:', error) + } + } + + private scheduleIdleStop(): void { + if (this.idleStopTimer || !this.host) { + return + } + this.idleStopTimer = setTimeout(() => { + this.idleStopTimer = null + if (this.deps.getSnapshot().enabledJobCount === 0) { + void this.stop('idle') + } + }, this.idleShutdownMs) + } + + private cancelIdleStop(): void { + if (!this.idleStopTimer) { + return + } + clearTimeout(this.idleStopTimer) + this.idleStopTimer = null + } + + private scheduleRestart(): void { + if (this.restartTimer || this.status.restartAttempts >= this.maxRestartAttempts) { + return + } + + const nextAttempts = this.status.restartAttempts + 1 + this.updateStatus({ restartAttempts: nextAttempts }) + this.restartTimer = setTimeout(() => { + this.restartTimer = null + void this.reconcile('restart-after-exit').catch((error) => { + const message = error instanceof Error ? error.message : String(error) + this.updateStatus({ state: 'error', lastError: message }) + this.scheduleRestart() + }) + }, this.restartDelayMs) + } + + private cancelRestart(): void { + if (!this.restartTimer) { + return + } + clearTimeout(this.restartTimer) + this.restartTimer = null + } + + private updateStatus(patch: Partial): void { + this.status = { + ...this.status, + ...patch, + updatedAt: Date.now() + } + } +} + +function unwrapSchedulerEvent(message: unknown): SchedulerEvent | null { + const payload = + message && typeof message === 'object' && 'data' in message + ? (message as { data?: unknown }).data + : message + + if (!payload || typeof payload !== 'object' || !('type' in payload)) { + return null + } + + const type = (payload as { type?: unknown }).type + if ( + type === 'READY' || + type === 'HEARTBEAT' || + type === 'RUN_DUE' || + type === 'IDLE' || + type === 'ERROR' + ) { + return payload as SchedulerEvent + } + + return null +} diff --git a/src/main/presenter/cronJobs/schedulerUtilityHost.ts b/src/main/presenter/cronJobs/schedulerUtilityHost.ts new file mode 100644 index 000000000..5e92242b7 --- /dev/null +++ b/src/main/presenter/cronJobs/schedulerUtilityHost.ts @@ -0,0 +1,414 @@ +import { randomUUID } from 'node:crypto' +import type Database from 'better-sqlite3-multiple-ciphers' +import { openSQLiteDatabase } from '../sqlitePresenter' +import type { CronJobRunReason, SchedulerCommand, SchedulerEvent } from '@shared/cronJobs' + +const SCHEDULER_HOST_ARG = '--deepchat-cron-scheduler-host' +const HEARTBEAT_INTERVAL_MS = 5_000 +const MAX_SCAN_DELAY_MS = 30_000 +const DUE_SCAN_LIMIT = 100 + +type ParentPort = { + postMessage(message: unknown): void + on(event: 'message', listener: (message: unknown) => void): void + start?(): void +} + +type ParentPortMessageEvent = { + data?: unknown +} + +interface DueCronJobRow { + id: string + next_run_at: number +} + +interface SchedulerSnapshot { + enabledJobCount: number + nextRunAt: number | null +} + +function getParentPort(): ParentPort | null { + const maybeProcess = process as NodeJS.Process & { + parentPort?: ParentPort + } + return maybeProcess.parentPort ?? null +} + +function isSchedulerHostRequest(): boolean { + return ( + process.env.DEEPCHAT_CRON_SCHEDULER_HOST === '1' || process.argv.includes(SCHEDULER_HOST_ARG) + ) +} + +function getParentPortMessagePayload(message: unknown): unknown { + if (isSchedulerCommand(message)) { + return message + } + + if (message && typeof message === 'object' && 'data' in message) { + return (message as ParentPortMessageEvent).data + } + + return message +} + +function isSchedulerCommand(message: unknown): message is SchedulerCommand { + if (!message || typeof message !== 'object') { + return false + } + + const type = (message as { type?: unknown }).type + return type === 'START' || type === 'RECONCILE' || type === 'RUN_NOW' || type === 'STOP' +} + +function serializeError(error: unknown): { message: string; stack?: string } { + if (error instanceof Error) { + return { + message: error.message, + stack: error.stack + } + } + return { message: String(error) } +} + +export class CronJobsSchedulerUtilityHost { + private db: Database.Database | null = null + private heartbeatTimer: NodeJS.Timeout | null = null + private scanTimer: NodeJS.Timeout | null = null + private shuttingDown = false + + constructor( + private readonly options: { + dbPath: string + dbPassword?: string + postMessage: (message: SchedulerEvent) => void + } + ) {} + + start(): void { + if (this.shuttingDown) { + return + } + this.openDatabase() + this.send({ + type: 'READY', + pid: process.pid || null, + now: Date.now() + }) + this.startHeartbeat() + this.scanDueRuns() + } + + reconcile(): void { + this.openDatabase() + this.scanDueRuns() + } + + runNow(jobId: string, now = Date.now()): void { + this.openDatabase() + const db = this.requireDatabase() + const job = db.prepare('SELECT id FROM cron_jobs WHERE id = ?').get(jobId) as + | { id: string } + | undefined + if (!job) { + return + } + + const run = this.queueRun(job.id, now, 'manual') + if (!run) { + return + } + this.send({ + type: 'RUN_DUE', + jobId: job.id, + runId: run.runId, + scheduledAt: now, + reason: 'manual', + now: Date.now() + }) + this.scheduleNextScan() + } + + shutdown(): void { + this.shuttingDown = true + this.clearHeartbeat() + this.clearScanTimer() + if (this.db) { + try { + this.db.close() + } catch {} + this.db = null + } + } + + private openDatabase(): void { + if (this.db) { + return + } + this.db = openSQLiteDatabase(this.options.dbPath, this.options.dbPassword) + } + + private requireDatabase(): Database.Database { + if (!this.db) { + throw new Error('Cron scheduler database is not open.') + } + return this.db + } + + private startHeartbeat(): void { + this.clearHeartbeat() + this.sendHeartbeat() + this.heartbeatTimer = setInterval(() => { + this.sendHeartbeat() + }, HEARTBEAT_INTERVAL_MS) + } + + private clearHeartbeat(): void { + if (!this.heartbeatTimer) { + return + } + clearInterval(this.heartbeatTimer) + this.heartbeatTimer = null + } + + private scanDueRuns(): void { + if (this.shuttingDown) { + return + } + + try { + const db = this.requireDatabase() + const now = Date.now() + const dueRows = db + .prepare( + `SELECT id, next_run_at + FROM cron_jobs + WHERE enabled = 1 + AND next_run_at IS NOT NULL + AND next_run_at <= ? + ORDER BY next_run_at ASC, id ASC + LIMIT ?` + ) + .all(now, DUE_SCAN_LIMIT) as DueCronJobRow[] + + const dueEvents = db.transaction((rows: DueCronJobRow[]) => + rows.flatMap((row) => { + const run = this.queueRun(row.id, row.next_run_at, 'scheduled', true) + db.prepare( + `UPDATE cron_jobs + SET next_run_at = NULL, + updated_at = ? + WHERE id = ? + AND next_run_at = ?` + ).run(now, row.id, row.next_run_at) + + return run + ? [ + { + type: 'RUN_DUE' as const, + jobId: row.id, + runId: run.runId, + scheduledAt: row.next_run_at, + reason: 'scheduled' as const, + now: Date.now() + } + ] + : [] + }) + )(dueRows) + + for (const event of dueEvents) { + this.send(event) + } + this.sendHeartbeat() + } catch (error) { + const serialized = serializeError(error) + this.send({ + type: 'ERROR', + message: serialized.message, + stack: serialized.stack, + now: Date.now() + }) + } finally { + this.scheduleNextScan() + } + } + + private queueRun( + jobId: string, + scheduledAt: number, + reason: CronJobRunReason, + ignoreDuplicate = false + ): { runId: string } | null { + const db = this.requireDatabase() + const now = Date.now() + const runId = randomUUID() + const statement = ignoreDuplicate + ? `INSERT OR IGNORE INTO cron_job_runs ( + id, + job_id, + scheduled_at, + queued_at, + started_at, + completed_at, + status, + reason, + error, + created_at, + updated_at + ) + VALUES (?, ?, ?, ?, NULL, NULL, 'queued', ?, NULL, ?, ?)` + : `INSERT INTO cron_job_runs ( + id, + job_id, + scheduled_at, + queued_at, + started_at, + completed_at, + status, + reason, + error, + created_at, + updated_at + ) + VALUES (?, ?, ?, ?, NULL, NULL, 'queued', ?, NULL, ?, ?)` + + const result = db.prepare(statement).run(runId, jobId, scheduledAt, now, reason, now, now) + + return result.changes > 0 ? { runId } : null + } + + private scheduleNextScan(): void { + if (this.shuttingDown) { + return + } + + this.clearScanTimer() + const snapshot = this.readSnapshot() + if (snapshot.enabledJobCount === 0) { + this.send({ + type: 'IDLE', + enabledJobCount: snapshot.enabledJobCount, + nextRunAt: snapshot.nextRunAt, + now: Date.now() + }) + return + } + + const delay = + snapshot.nextRunAt === null + ? MAX_SCAN_DELAY_MS + : Math.min(Math.max(snapshot.nextRunAt - Date.now(), 0), MAX_SCAN_DELAY_MS) + this.scanTimer = setTimeout(() => { + this.scanTimer = null + this.scanDueRuns() + }, delay) + } + + private clearScanTimer(): void { + if (!this.scanTimer) { + return + } + clearTimeout(this.scanTimer) + this.scanTimer = null + } + + private sendHeartbeat(): void { + try { + const snapshot = this.readSnapshot() + this.send({ + type: 'HEARTBEAT', + enabledJobCount: snapshot.enabledJobCount, + nextRunAt: snapshot.nextRunAt, + now: Date.now() + }) + } catch (error) { + const serialized = serializeError(error) + this.send({ + type: 'ERROR', + message: serialized.message, + stack: serialized.stack, + now: Date.now() + }) + } + } + + private readSnapshot(): SchedulerSnapshot { + const db = this.requireDatabase() + const countRow = db + .prepare('SELECT COUNT(*) AS count FROM cron_jobs WHERE enabled = 1') + .get() as { count: number } | undefined + const nextRow = db + .prepare( + `SELECT MIN(next_run_at) AS next_run_at + FROM cron_jobs + WHERE enabled = 1 + AND next_run_at IS NOT NULL` + ) + .get() as { next_run_at: number | null } | undefined + + return { + enabledJobCount: countRow?.count ?? 0, + nextRunAt: nextRow?.next_run_at ?? null + } + } + + private send(message: SchedulerEvent): void { + this.options.postMessage(message) + } +} + +export function runSchedulerUtilityHostIfRequested(): boolean { + if (!isSchedulerHostRequest()) { + return false + } + + const parentPort = getParentPort() + if (!parentPort) { + throw new Error('Cron scheduler utility host started without a parent port.') + } + + const dbPath = process.env.DEEPCHAT_CRON_SCHEDULER_DB_PATH + if (!dbPath) { + throw new Error('Cron scheduler utility host started without a database path.') + } + + const host = new CronJobsSchedulerUtilityHost({ + dbPath, + dbPassword: process.env.DEEPCHAT_CRON_SCHEDULER_DB_PASSWORD, + postMessage: (message) => parentPort.postMessage(message) + }) + const keepAliveIntervalId = setInterval(() => {}, 2 ** 31 - 1) + parentPort.start?.() + + parentPort.on('message', (message) => { + const command = getParentPortMessagePayload(message) + if (!isSchedulerCommand(command)) { + return + } + + switch (command.type) { + case 'START': + host.start() + return + case 'RECONCILE': + host.reconcile() + return + case 'RUN_NOW': + host.runNow(command.jobId, command.now) + return + case 'STOP': + clearInterval(keepAliveIntervalId) + host.shutdown() + process.exit(0) + } + }) + + process.once('beforeExit', () => { + clearInterval(keepAliveIntervalId) + host.shutdown() + }) + + return true +} diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index c01e1fc1c..ac2fc52eb 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -66,6 +66,7 @@ import { SkillSyncPresenter } from './skillSyncPresenter' import { HooksNotificationsService } from './hooksNotifications' import { NewSessionHooksBridge } from './hooksNotifications/newSessionBridge' import { ScheduledTasksService } from './scheduledTasks' +import { CronJobsService } from './cronJobs' import { AgentSessionPresenter } from './agentSessionPresenter' import { AgentRuntimePresenter } from './agentRuntimePresenter' import { MemoryPresenter, isSafeAgentId } from './memoryPresenter' @@ -147,6 +148,7 @@ export class Presenter implements IPresenter { databaseSecurityPresenter: DatabaseSecurityPresenter hooksNotifications: HooksNotificationsService scheduledTasks: ScheduledTasksService + cronJobs: CronJobsService commandPermissionService: CommandPermissionService filePermissionService: FilePermissionService settingsPermissionService: SettingsPermissionService @@ -478,6 +480,9 @@ export class Presenter implements IPresenter { notificationPresenter: this.notificationPresenter, windowPresenter: this.windowPresenter }) + this.cronJobs = new CronJobsService({ + sqlitePresenter: this.sqlitePresenter as unknown as SQLitePresenter + }) const newSessionHooksBridge = new NewSessionHooksBridge(this.hooksNotifications) const providerCatalogPort: ProviderCatalogPort = { getProviderModels: (providerId) => this.configPresenter.getProviderModels?.(providerId) ?? [], @@ -952,6 +957,12 @@ export class Presenter implements IPresenter { } async destroy(): Promise { + try { + await this.cronJobs.stop() + } catch (error) { + console.error('CronJobsService.stop failed during presenter destroy:', error) + } + try { await this.pluginPresenter.shutdown() } catch (error) { @@ -1024,7 +1035,8 @@ const buildMainKernelRouteRuntime = () => pluginPresenter: presenter.pluginPresenter, databaseSecurityPresenter: presenter.databaseSecurityPresenter, memoryPresenter: presenter.memoryPresenter, - scheduledTasks: presenter.scheduledTasks + scheduledTasks: presenter.scheduledTasks, + cronJobs: presenter.cronJobs }) export function getMainKernelRouteRuntime(): ReturnType { diff --git a/src/main/presenter/lifecyclePresenter/hooks/after-start/cronJobsStartHook.ts b/src/main/presenter/lifecyclePresenter/hooks/after-start/cronJobsStartHook.ts new file mode 100644 index 000000000..9610ec9dc --- /dev/null +++ b/src/main/presenter/lifecyclePresenter/hooks/after-start/cronJobsStartHook.ts @@ -0,0 +1,19 @@ +import logger from '@shared/logger' +import { LifecycleHook, LifecycleContext } from '@shared/presenter' +import { presenter } from '@/presenter' +import { LifecyclePhase } from '@shared/lifecycle' + +export const cronJobsStartHook: LifecycleHook = { + name: 'cron-jobs-start', + phase: LifecyclePhase.AFTER_START, + priority: 21, + critical: false, + execute: async (_context: LifecycleContext) => { + if (!presenter) { + throw new Error('cronJobsStartHook: Presenter not initialized') + } + + presenter.cronJobs.start() + logger.info('cronJobsStartHook: Scheduler reconciled') + } +} diff --git a/src/main/presenter/lifecyclePresenter/hooks/beforeQuit/cronJobsStopHook.ts b/src/main/presenter/lifecyclePresenter/hooks/beforeQuit/cronJobsStopHook.ts new file mode 100644 index 000000000..0e0ab51ca --- /dev/null +++ b/src/main/presenter/lifecyclePresenter/hooks/beforeQuit/cronJobsStopHook.ts @@ -0,0 +1,16 @@ +import { LifecycleHook, LifecycleContext } from '@shared/presenter' +import { presenter } from '@/presenter' +import { LifecyclePhase } from '@shared/lifecycle' + +export const cronJobsStopHook: LifecycleHook = { + name: 'cron-jobs-stop', + phase: LifecyclePhase.BEFORE_QUIT, + priority: 29, + critical: false, + execute: async (_context: LifecycleContext) => { + if (!presenter) { + return + } + await presenter.cronJobs.stop() + } +} diff --git a/src/main/presenter/lifecyclePresenter/hooks/index.ts b/src/main/presenter/lifecyclePresenter/hooks/index.ts index 95296d94f..528036a9d 100644 --- a/src/main/presenter/lifecyclePresenter/hooks/index.ts +++ b/src/main/presenter/lifecyclePresenter/hooks/index.ts @@ -17,6 +17,7 @@ export { usageStatsBackfillHook } from './after-start/usageStatsBackfillHook' export { sqliteMainlineNormalizationHook } from './after-start/sqliteMainlineNormalizationHook' export { disabledSearchToolCleanupHook } from './after-start/disabledSearchToolCleanupHook' export { scheduledTasksStartHook } from './after-start/scheduledTasksStartHook' +export { cronJobsStartHook } from './after-start/cronJobsStartHook' export { memoryMaintenanceStartHook } from './after-start/memoryMaintenanceStartHook' export { mcpShutdownHook } from './beforeQuit/mcpShutdownHook' export { trayDestroyHook } from './beforeQuit/trayDestroyHook' @@ -25,4 +26,5 @@ export { presenterDestroyHook } from './beforeQuit/presenterDestroyHook' export { builtinKnowledgeDestroyHook } from './beforeQuit/builtinKnowledgeDestroyHook' export { windowQuittingHook } from './beforeQuit/windowQuittingHook' export { acpCleanupHook } from './beforeQuit/acpCleanupHook' +export { cronJobsStopHook } from './beforeQuit/cronJobsStopHook' export { scheduledTasksStopHook } from './beforeQuit/scheduledTasksStopHook' diff --git a/src/main/presenter/sqlitePresenter/index.ts b/src/main/presenter/sqlitePresenter/index.ts index d1c859ccf..2e8ed4584 100644 --- a/src/main/presenter/sqlitePresenter/index.ts +++ b/src/main/presenter/sqlitePresenter/index.ts @@ -42,6 +42,8 @@ import { ConfigTables } from './tables/configTables' import { NewSessionActiveSkillsTable } from './tables/newSessionActiveSkills' import { NewSessionDisabledAgentToolsTable } from './tables/newSessionDisabledAgentTools' import { SettingsActivityTable } from './tables/settingsActivity' +import { CronJobsTable } from './tables/cronJobs' +import { CronJobRunsTable } from './tables/cronJobRuns' import type { BaseTable } from './tables/baseTable' import { DatabaseRepairService, SchemaInspector } from './schemaRepair' import type { SchemaTableSpec } from './schemaTypes' @@ -246,6 +248,8 @@ export class SQLitePresenter implements ISQLitePresenter { public newSessionActiveSkillsTable!: NewSessionActiveSkillsTable public newSessionDisabledAgentToolsTable!: NewSessionDisabledAgentToolsTable public settingsActivityTable!: SettingsActivityTable + public cronJobsTable!: CronJobsTable + public cronJobRunsTable!: CronJobRunsTable private currentVersion: number = 0 private dbPath: string private password?: string @@ -434,6 +438,8 @@ export class SQLitePresenter implements ISQLitePresenter { this.newSessionActiveSkillsTable = new NewSessionActiveSkillsTable(this.db) this.newSessionDisabledAgentToolsTable = new NewSessionDisabledAgentToolsTable(this.db) this.settingsActivityTable = new SettingsActivityTable(this.db) + this.cronJobsTable = new CronJobsTable(this.db) + this.cronJobRunsTable = new CronJobRunsTable(this.db) // Create only active tables for the new stack. this.acpSessionsTable.createTable() @@ -463,6 +469,8 @@ export class SQLitePresenter implements ISQLitePresenter { this.newSessionActiveSkillsTable.createTable() this.newSessionDisabledAgentToolsTable.createTable() this.settingsActivityTable.createTable() + this.cronJobsTable.createTable() + this.cronJobRunsTable.createTable() } private initVersionTable() { @@ -507,7 +515,9 @@ export class SQLitePresenter implements ISQLitePresenter { this.configTables, this.newSessionActiveSkillsTable, this.newSessionDisabledAgentToolsTable, - this.settingsActivityTable + this.settingsActivityTable, + this.cronJobsTable, + this.cronJobRunsTable ] } diff --git a/src/main/presenter/sqlitePresenter/schemaCatalog.ts b/src/main/presenter/sqlitePresenter/schemaCatalog.ts index 1deaa4ee1..f9c9ef0c7 100644 --- a/src/main/presenter/sqlitePresenter/schemaCatalog.ts +++ b/src/main/presenter/sqlitePresenter/schemaCatalog.ts @@ -28,6 +28,8 @@ import { AgentMemoryAuditTable } from './tables/agentMemoryAudit' import { NewSessionActiveSkillsTable } from './tables/newSessionActiveSkills' import { NewSessionDisabledAgentToolsTable } from './tables/newSessionDisabledAgentTools' import { SettingsActivityTable } from './tables/settingsActivity' +import { CronJobsTable } from './tables/cronJobs' +import { CronJobRunsTable } from './tables/cronJobRuns' import type { BaseTable } from './tables/baseTable' import type { SchemaTableSpec } from './schemaTypes' import { isSchemaTableCreatedOnFreshInstall } from './schemaCatalogMetadata' @@ -254,6 +256,14 @@ const CATALOG_DEFINITIONS: CatalogDefinition[] = [ { name: 'settings_activity', createTable: (db) => new SettingsActivityTable(db) + }, + { + name: 'cron_jobs', + createTable: (db) => new CronJobsTable(db) + }, + { + name: 'cron_job_runs', + createTable: (db) => new CronJobRunsTable(db) } ] diff --git a/src/main/presenter/sqlitePresenter/tables/cronJobRuns.ts b/src/main/presenter/sqlitePresenter/tables/cronJobRuns.ts new file mode 100644 index 000000000..0ed10f42a --- /dev/null +++ b/src/main/presenter/sqlitePresenter/tables/cronJobRuns.ts @@ -0,0 +1,187 @@ +import { randomUUID } from 'node:crypto' +import Database from 'better-sqlite3-multiple-ciphers' +import type { CronJobRunReason, CronJobRunStatus } from '@shared/cronJobs' +import { BaseTable } from './baseTable' + +export interface CronJobRunRow { + id: string + job_id: string + scheduled_at: number + queued_at: number + started_at: number | null + completed_at: number | null + status: CronJobRunStatus + reason: CronJobRunReason + error: string | null + created_at: number + updated_at: number +} + +export interface CronJobRunInsertInput { + id?: string + jobId: string + scheduledAt: number + queuedAt?: number + reason: CronJobRunReason + now?: number +} + +const CRON_JOB_RUNS_INDEX_SQL = ` + CREATE INDEX IF NOT EXISTS idx_cron_job_runs_job_created + ON cron_job_runs(job_id, created_at DESC, id DESC); + CREATE INDEX IF NOT EXISTS idx_cron_job_runs_status_queued + ON cron_job_runs(status, queued_at ASC, id ASC); + CREATE UNIQUE INDEX IF NOT EXISTS idx_cron_job_runs_scheduled_dedupe + ON cron_job_runs(job_id, scheduled_at) + WHERE reason = 'scheduled'; +` + +export class CronJobRunsTable extends BaseTable { + constructor(db: Database.Database) { + super(db, 'cron_job_runs') + } + + getCreateTableSQL(): string { + return ` + CREATE TABLE IF NOT EXISTS cron_job_runs ( + id TEXT PRIMARY KEY, + job_id TEXT NOT NULL, + scheduled_at INTEGER NOT NULL, + queued_at INTEGER NOT NULL, + started_at INTEGER, + completed_at INTEGER, + status TEXT NOT NULL CHECK(status IN ('queued', 'running', 'completed', 'failed', 'cancelled')), + reason TEXT NOT NULL CHECK(reason IN ('scheduled', 'manual')), + error TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + ${CRON_JOB_RUNS_INDEX_SQL} + ` + } + + override createTable(): void { + super.createTable() + this.db.exec(CRON_JOB_RUNS_INDEX_SQL) + } + + getMigrationSQL(): string | null { + return null + } + + getLatestVersion(): number { + return 0 + } + + get(id: string): CronJobRunRow | undefined { + return this.db.prepare('SELECT * FROM cron_job_runs WHERE id = ?').get(id) as + | CronJobRunRow + | undefined + } + + insertQueued(input: CronJobRunInsertInput): CronJobRunRow { + const now = input.now ?? Date.now() + const id = input.id ?? randomUUID() + const queuedAt = input.queuedAt ?? now + + this.db + .prepare( + `INSERT INTO cron_job_runs ( + id, + job_id, + scheduled_at, + queued_at, + started_at, + completed_at, + status, + reason, + error, + created_at, + updated_at + ) + VALUES (?, ?, ?, ?, NULL, NULL, 'queued', ?, NULL, ?, ?)` + ) + .run(id, input.jobId, input.scheduledAt, queuedAt, input.reason, now, now) + + const row = this.get(id) + if (!row) { + throw new Error(`Failed to queue cron job run: ${id}`) + } + return row + } + + markRunning(id: string, startedAt = Date.now()): CronJobRunRow { + const result = this.db + .prepare( + `UPDATE cron_job_runs + SET status = 'running', + started_at = COALESCE(started_at, ?), + updated_at = ? + WHERE id = ?` + ) + .run(startedAt, startedAt, id) + if (result.changes === 0) { + throw new Error(`Unknown cron job run: ${id}`) + } + return this.requireRun(id) + } + + markCompleted(id: string, completedAt = Date.now()): CronJobRunRow { + const result = this.db + .prepare( + `UPDATE cron_job_runs + SET status = 'completed', + completed_at = ?, + error = NULL, + updated_at = ? + WHERE id = ?` + ) + .run(completedAt, completedAt, id) + if (result.changes === 0) { + throw new Error(`Unknown cron job run: ${id}`) + } + return this.requireRun(id) + } + + markFailed(id: string, error: string, completedAt = Date.now()): CronJobRunRow { + const result = this.db + .prepare( + `UPDATE cron_job_runs + SET status = 'failed', + completed_at = ?, + error = ?, + updated_at = ? + WHERE id = ?` + ) + .run(completedAt, error, completedAt, id) + if (result.changes === 0) { + throw new Error(`Unknown cron job run: ${id}`) + } + return this.requireRun(id) + } + + listByJob(jobId: string, limit = 50): CronJobRunRow[] { + const safeLimit = Math.min(Math.max(Math.trunc(limit), 1), 200) + return this.db + .prepare( + `SELECT * + FROM cron_job_runs + WHERE job_id = ? + ORDER BY created_at DESC, id DESC + LIMIT ?` + ) + .all(jobId, safeLimit) as CronJobRunRow[] + } + + deleteByJob(jobId: string): number { + return this.db.prepare('DELETE FROM cron_job_runs WHERE job_id = ?').run(jobId).changes + } + + private requireRun(id: string): CronJobRunRow { + const row = this.get(id) + if (!row) { + throw new Error(`Failed to reload cron job run: ${id}`) + } + return row + } +} diff --git a/src/main/presenter/sqlitePresenter/tables/cronJobs.ts b/src/main/presenter/sqlitePresenter/tables/cronJobs.ts new file mode 100644 index 000000000..140ec00bd --- /dev/null +++ b/src/main/presenter/sqlitePresenter/tables/cronJobs.ts @@ -0,0 +1,186 @@ +import { randomUUID } from 'node:crypto' +import Database from 'better-sqlite3-multiple-ciphers' +import { BaseTable } from './baseTable' + +export interface CronJobRow { + id: string + name: string + enabled: number + cron_expr: string + timezone: string + agent_id: string | null + next_run_at: number | null + created_at: number + updated_at: number +} + +export interface CronJobTableUpsertInput { + id?: string + name: string + enabled: boolean + cronExpr: string + timezone: string + agentId?: string | null + nextRunAt?: number | null + now?: number +} + +const CRON_JOBS_INDEX_SQL = ` + CREATE INDEX IF NOT EXISTS idx_cron_jobs_enabled_next_run + ON cron_jobs(enabled, next_run_at); + CREATE INDEX IF NOT EXISTS idx_cron_jobs_updated_at + ON cron_jobs(updated_at DESC, id DESC); +` + +export class CronJobsTable extends BaseTable { + constructor(db: Database.Database) { + super(db, 'cron_jobs') + } + + getCreateTableSQL(): string { + return ` + CREATE TABLE IF NOT EXISTS cron_jobs ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 0 CHECK(enabled IN (0, 1)), + cron_expr TEXT NOT NULL, + timezone TEXT NOT NULL, + agent_id TEXT, + next_run_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + ${CRON_JOBS_INDEX_SQL} + ` + } + + override createTable(): void { + super.createTable() + this.db.exec(CRON_JOBS_INDEX_SQL) + } + + getMigrationSQL(): string | null { + return null + } + + getLatestVersion(): number { + return 0 + } + + list(): CronJobRow[] { + return this.db + .prepare( + `SELECT * + FROM cron_jobs + ORDER BY updated_at DESC, id DESC` + ) + .all() as CronJobRow[] + } + + get(id: string): CronJobRow | undefined { + return this.db.prepare('SELECT * FROM cron_jobs WHERE id = ?').get(id) as CronJobRow | undefined + } + + upsert(input: CronJobTableUpsertInput): CronJobRow { + const now = input.now ?? Date.now() + const existing = input.id ? this.get(input.id) : undefined + const id = existing?.id ?? input.id ?? randomUUID() + const createdAt = existing?.created_at ?? now + const nextRunAt = + input.nextRunAt === undefined ? (existing?.next_run_at ?? null) : input.nextRunAt + + this.db + .prepare( + `INSERT INTO cron_jobs ( + id, + name, + enabled, + cron_expr, + timezone, + agent_id, + next_run_at, + created_at, + updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + enabled = excluded.enabled, + cron_expr = excluded.cron_expr, + timezone = excluded.timezone, + agent_id = excluded.agent_id, + next_run_at = excluded.next_run_at, + updated_at = excluded.updated_at` + ) + .run( + id, + input.name, + input.enabled ? 1 : 0, + input.cronExpr, + input.timezone, + input.agentId ?? null, + nextRunAt, + createdAt, + now + ) + + const row = this.get(id) + if (!row) { + throw new Error(`Failed to persist cron job: ${id}`) + } + return row + } + + delete(id: string): number { + return this.db.prepare('DELETE FROM cron_jobs WHERE id = ?').run(id).changes + } + + setEnabled(id: string, enabled: boolean, now = Date.now()): CronJobRow { + const result = this.db + .prepare('UPDATE cron_jobs SET enabled = ?, updated_at = ? WHERE id = ?') + .run(enabled ? 1 : 0, now, id) + if (result.changes === 0) { + throw new Error(`Unknown cron job: ${id}`) + } + + const row = this.get(id) + if (!row) { + throw new Error(`Failed to reload cron job: ${id}`) + } + return row + } + + updateNextRunAt(id: string, nextRunAt: number | null, now = Date.now()): CronJobRow { + const result = this.db + .prepare('UPDATE cron_jobs SET next_run_at = ?, updated_at = ? WHERE id = ?') + .run(nextRunAt, now, id) + if (result.changes === 0) { + throw new Error(`Unknown cron job: ${id}`) + } + + const row = this.get(id) + if (!row) { + throw new Error(`Failed to reload cron job: ${id}`) + } + return row + } + + countEnabled(): number { + const row = this.db + .prepare('SELECT COUNT(*) AS count FROM cron_jobs WHERE enabled = 1') + .get() as { count: number } | undefined + return row?.count ?? 0 + } + + getNextEnabledRunAt(): number | null { + const row = this.db + .prepare( + `SELECT MIN(next_run_at) AS next_run_at + FROM cron_jobs + WHERE enabled = 1 + AND next_run_at IS NOT NULL` + ) + .get() as { next_run_at: number | null } | undefined + return row?.next_run_at ?? null + } +} diff --git a/src/main/routes/index.ts b/src/main/routes/index.ts index a93b27c9f..fe9cf3985 100644 --- a/src/main/routes/index.ts +++ b/src/main/routes/index.ts @@ -61,6 +61,14 @@ import { configSetSystemPromptsRoute, configUpdateCustomPromptRoute, configUpdateSystemPromptRoute, + cronJobsDeleteRoute, + cronJobsGetSchedulerStatusRoute, + cronJobsListRoute, + cronJobsReconcileSchedulerRoute, + cronJobsRestartSchedulerRoute, + cronJobsRunNowRoute, + cronJobsToggleRoute, + cronJobsUpsertRoute, databaseSecurityChangePasswordRoute, databaseSecurityDisableRoute, databaseSecurityEnableRoute, @@ -400,6 +408,7 @@ import type { AgentMemoryAuditRow } from '@/presenter/sqlitePresenter/tables/age import type { DeepChatTapeEntryRow } from '@/presenter/sqlitePresenter/tables/deepchatTapeEntries' import type { SQLitePresenter } from '@/presenter/sqlitePresenter' import type { ScheduledTasksService } from '@/presenter/scheduledTasks' +import type { CronJobsService } from '@/presenter/cronJobs' import { killTerminal, writeToTerminal } from '@/presenter/configPresenter/acpInitHelper' import { scheduledTasksDeleteRoute, @@ -447,6 +456,7 @@ export type MainKernelRouteRuntime = { databaseSecurityPresenter: DatabaseSecurityPresenter memoryPresenter: MemoryPresenter scheduledTasks: ScheduledTasksService + cronJobs: CronJobsService } function parseSourceEntryIds(raw: string | null): number[] | null { @@ -707,6 +717,7 @@ export function createMainKernelRouteRuntime(deps: { databaseSecurityPresenter: DatabaseSecurityPresenter memoryPresenter: MemoryPresenter scheduledTasks: ScheduledTasksService + cronJobs: CronJobsService }): MainKernelRouteRuntime { const scheduler = createNodeScheduler() const hotPathPorts = createPresenterHotPathPorts({ @@ -812,7 +823,8 @@ export function createMainKernelRouteRuntime(deps: { pluginPresenter: deps.pluginPresenter, databaseSecurityPresenter: deps.databaseSecurityPresenter, memoryPresenter: deps.memoryPresenter, - scheduledTasks: deps.scheduledTasks + scheduledTasks: deps.scheduledTasks, + cronJobs: deps.cronJobs } } @@ -2597,6 +2609,54 @@ export async function dispatchDeepchatRoute( return scheduledTasksFireNowRoute.output.parse({ task, settings }) } + case cronJobsListRoute.name: { + cronJobsListRoute.input.parse(rawInput) + const { jobs, schedulerStatus } = await runtime.cronJobs.list() + return cronJobsListRoute.output.parse({ jobs, schedulerStatus }) + } + + case cronJobsUpsertRoute.name: { + const input = cronJobsUpsertRoute.input.parse(rawInput) + const { job, schedulerStatus } = await runtime.cronJobs.upsert(input) + return cronJobsUpsertRoute.output.parse({ job, schedulerStatus }) + } + + case cronJobsDeleteRoute.name: { + const input = cronJobsDeleteRoute.input.parse(rawInput) + const schedulerStatus = await runtime.cronJobs.delete(input.id) + return cronJobsDeleteRoute.output.parse({ schedulerStatus }) + } + + case cronJobsToggleRoute.name: { + const input = cronJobsToggleRoute.input.parse(rawInput) + const { job, schedulerStatus } = await runtime.cronJobs.toggle(input.id, input.enabled) + return cronJobsToggleRoute.output.parse({ job, schedulerStatus }) + } + + case cronJobsRunNowRoute.name: { + const input = cronJobsRunNowRoute.input.parse(rawInput) + const { job, run, schedulerStatus } = await runtime.cronJobs.runNow(input.id) + return cronJobsRunNowRoute.output.parse({ job, run, schedulerStatus }) + } + + case cronJobsGetSchedulerStatusRoute.name: { + cronJobsGetSchedulerStatusRoute.input.parse(rawInput) + const schedulerStatus = runtime.cronJobs.getSchedulerStatus() + return cronJobsGetSchedulerStatusRoute.output.parse({ schedulerStatus }) + } + + case cronJobsReconcileSchedulerRoute.name: { + const input = cronJobsReconcileSchedulerRoute.input.parse(rawInput) + const schedulerStatus = await runtime.cronJobs.reconcileScheduler(input.reason) + return cronJobsReconcileSchedulerRoute.output.parse({ schedulerStatus }) + } + + case cronJobsRestartSchedulerRoute.name: { + cronJobsRestartSchedulerRoute.input.parse(rawInput) + const schedulerStatus = await runtime.cronJobs.restartScheduler() + return cronJobsRestartSchedulerRoute.output.parse({ schedulerStatus }) + } + case startupGetBootstrapRoute.name: { startupGetBootstrapRoute.input.parse(rawInput) const coordinator = (runtime as Partial).startupWorkloadCoordinator diff --git a/src/main/schedulerUtilityHostEntry.ts b/src/main/schedulerUtilityHostEntry.ts new file mode 100644 index 000000000..628394f2e --- /dev/null +++ b/src/main/schedulerUtilityHostEntry.ts @@ -0,0 +1,5 @@ +import { runSchedulerUtilityHostIfRequested } from './presenter/cronJobs/schedulerUtilityHost' + +if (!runSchedulerUtilityHostIfRequested()) { + throw new Error('Cron scheduler utility host entrypoint started outside a utility process.') +} diff --git a/src/renderer/api/CronJobsClient.ts b/src/renderer/api/CronJobsClient.ts new file mode 100644 index 000000000..403d52afb --- /dev/null +++ b/src/renderer/api/CronJobsClient.ts @@ -0,0 +1,126 @@ +import type { DeepchatBridge } from '@shared/contracts/bridge' +import { + cronJobRunSchema, + cronJobSchema, + cronJobsDeleteRoute, + cronJobsGetSchedulerStatusRoute, + cronJobsListRoute, + cronJobsReconcileSchedulerRoute, + cronJobsRestartSchedulerRoute, + cronJobsRunNowRoute, + cronJobsSchedulerStatusSchema, + cronJobsToggleRoute, + cronJobsUpsertRoute, + type cronJobsUpsertInputSchema +} from '@shared/contracts/routes/cronJobs.routes' +import type { z } from 'zod' +import { getDeepchatBridge } from './core' + +export type CronJobsUpsertInput = z.input + +const parseJobResponse = (routeName: string, result: unknown) => { + if (typeof result !== 'object' || result === null) { + throw new Error(`[CronJobsClient] Invalid response shape from ${routeName}`) + } + const parsed = cronJobSchema.safeParse((result as { job?: unknown }).job) + if (!parsed.success) { + throw new Error(`[CronJobsClient] Invalid job response from ${routeName}`) + } + return parsed.data +} + +const parseRunResponse = (routeName: string, result: unknown) => { + if (typeof result !== 'object' || result === null) { + throw new Error(`[CronJobsClient] Invalid response shape from ${routeName}`) + } + const parsed = cronJobRunSchema.safeParse((result as { run?: unknown }).run) + if (!parsed.success) { + throw new Error(`[CronJobsClient] Invalid run response from ${routeName}`) + } + return parsed.data +} + +const parseSchedulerStatusResponse = (routeName: string, result: unknown) => { + if (typeof result !== 'object' || result === null) { + throw new Error(`[CronJobsClient] Invalid response shape from ${routeName}`) + } + const parsed = cronJobsSchedulerStatusSchema.safeParse( + (result as { schedulerStatus?: unknown }).schedulerStatus + ) + if (!parsed.success) { + throw new Error(`[CronJobsClient] Invalid scheduler status response from ${routeName}`) + } + return parsed.data +} + +export function createCronJobsClient(bridge: DeepchatBridge = getDeepchatBridge()) { + async function list() { + const result = await bridge.invoke(cronJobsListRoute.name, {}) + if (typeof result !== 'object' || result === null) { + throw new Error('[CronJobsClient] Invalid response shape from cronJobs.list') + } + const jobs = cronJobSchema.array().parse((result as { jobs?: unknown }).jobs) + return { + jobs, + schedulerStatus: parseSchedulerStatusResponse(cronJobsListRoute.name, result) + } + } + + async function upsert(input: CronJobsUpsertInput) { + const result = await bridge.invoke(cronJobsUpsertRoute.name, input) + return { + job: parseJobResponse(cronJobsUpsertRoute.name, result), + schedulerStatus: parseSchedulerStatusResponse(cronJobsUpsertRoute.name, result) + } + } + + async function remove(id: string) { + const result = await bridge.invoke(cronJobsDeleteRoute.name, { id }) + return parseSchedulerStatusResponse(cronJobsDeleteRoute.name, result) + } + + async function toggle(id: string, enabled: boolean) { + const result = await bridge.invoke(cronJobsToggleRoute.name, { id, enabled }) + return { + job: parseJobResponse(cronJobsToggleRoute.name, result), + schedulerStatus: parseSchedulerStatusResponse(cronJobsToggleRoute.name, result) + } + } + + async function runNow(id: string) { + const result = await bridge.invoke(cronJobsRunNowRoute.name, { id }) + return { + job: parseJobResponse(cronJobsRunNowRoute.name, result), + run: parseRunResponse(cronJobsRunNowRoute.name, result), + schedulerStatus: parseSchedulerStatusResponse(cronJobsRunNowRoute.name, result) + } + } + + async function getSchedulerStatus() { + const result = await bridge.invoke(cronJobsGetSchedulerStatusRoute.name, {}) + return parseSchedulerStatusResponse(cronJobsGetSchedulerStatusRoute.name, result) + } + + async function reconcileScheduler(reason?: string) { + const result = await bridge.invoke(cronJobsReconcileSchedulerRoute.name, { reason }) + return parseSchedulerStatusResponse(cronJobsReconcileSchedulerRoute.name, result) + } + + async function restartScheduler() { + const result = await bridge.invoke(cronJobsRestartSchedulerRoute.name, {}) + return parseSchedulerStatusResponse(cronJobsRestartSchedulerRoute.name, result) + } + + return { + list, + upsert, + remove, + toggle, + runNow, + getSchedulerStatus, + reconcileScheduler, + restartScheduler + } +} + +export type CronJobsClient = ReturnType diff --git a/src/renderer/api/index.ts b/src/renderer/api/index.ts index a231230dd..13bb86d2f 100644 --- a/src/renderer/api/index.ts +++ b/src/renderer/api/index.ts @@ -4,6 +4,7 @@ export * from './BrowserClient' export * from './ConfigClient' export * from './ChatClient' export * from './ContextMenuClient' +export * from './CronJobsClient' export * from './DeviceClient' export * from './DebugClient' export * from './DialogClient' diff --git a/src/renderer/settings/components/CronJobsSettings.vue b/src/renderer/settings/components/CronJobsSettings.vue new file mode 100644 index 000000000..5992e7408 --- /dev/null +++ b/src/renderer/settings/components/CronJobsSettings.vue @@ -0,0 +1,431 @@ + + + diff --git a/src/renderer/settings/main.ts b/src/renderer/settings/main.ts index 651547e8f..791280d67 100644 --- a/src/renderer/settings/main.ts +++ b/src/renderer/settings/main.ts @@ -27,7 +27,7 @@ const settingsRouteComponents = { 'settings-acp': () => import('./components/AcpSettings.vue'), 'settings-remote': () => import('./components/RemoteSettings.vue'), 'settings-notifications-hooks': () => import('./components/NotificationsHooksSettings.vue'), - 'settings-scheduled-tasks': () => import('./components/ScheduledTasksSettings.vue'), + 'settings-scheduled-tasks': () => import('./components/CronJobsSettings.vue'), 'settings-plugins': () => import('./components/PluginsSettings.vue'), 'settings-skills': () => import('./components/skills/SkillsSettings.vue'), 'settings-prompt': () => import('./components/PromptSetting.vue'), diff --git a/src/renderer/src/i18n/da-DK/routes.json b/src/renderer/src/i18n/da-DK/routes.json index 5dea0a8fb..6905153d0 100644 --- a/src/renderer/src/i18n/da-DK/routes.json +++ b/src/renderer/src/i18n/da-DK/routes.json @@ -15,7 +15,7 @@ "settings-acp": "ACP-agenter", "settings-skills": "Skills", "settings-notifications-hooks": "Hooks", - "settings-scheduled-tasks": "Planlagte opgaver", + "settings-scheduled-tasks": "Cron Jobs", "settings-dashboard": "Dashboard", "settings-environments": "Miljøer", "settings-remote": "Fjernstyring", diff --git a/src/renderer/src/i18n/da-DK/settings.json b/src/renderer/src/i18n/da-DK/settings.json index 58b4546fd..89f62b7f0 100644 --- a/src/renderer/src/i18n/da-DK/settings.json +++ b/src/renderer/src/i18n/da-DK/settings.json @@ -1383,6 +1383,39 @@ } } }, + "cronJobs": { + "title": "Cron Jobs", + "description": "Manage persisted Cron Jobs and the scheduler process.", + "empty": "No Cron Jobs yet.", + "none": "None", + "nextRunAt": "Next run", + "defaults": { + "name": "Morning cron job" + }, + "actions": { + "newJob": "New job", + "runNow": "Run now", + "clearNextRun": "Clear next run", + "reconcile": "Reconcile", + "restart": "Restart" + }, + "fields": { + "name": "Name", + "cronExpr": "Cron expression", + "timezone": "Timezone" + }, + "status": { + "state": "State", + "enabled": "Enabled jobs", + "heartbeat": "Heartbeat", + "stopped": "Stopped", + "starting": "Starting", + "running": "Running", + "idle": "Idle", + "error": "Error" + }, + "runNowSuccess": "Cron job run completed" + }, "scheduledTasks": { "title": "Planlagte opgaver", "description": "Vis en systemnotifikation, eller send en foruddefineret prompt til en agent på et valgt tidspunkt.", diff --git a/src/renderer/src/i18n/de-DE/routes.json b/src/renderer/src/i18n/de-DE/routes.json index c0ab9b878..d6f3fa7de 100644 --- a/src/renderer/src/i18n/de-DE/routes.json +++ b/src/renderer/src/i18n/de-DE/routes.json @@ -21,7 +21,7 @@ "settings-remote": "Remote", "settings-plugins": "Plugins", "settings-overview": "Einstellungsübersicht", - "settings-scheduled-tasks": "Geplante Aufgaben", + "settings-scheduled-tasks": "Cron Jobs", "settings-memory": "Gedächtnis", "plugins": "Plugins" } diff --git a/src/renderer/src/i18n/de-DE/settings.json b/src/renderer/src/i18n/de-DE/settings.json index 6c99c5594..d6ea11e9b 100644 --- a/src/renderer/src/i18n/de-DE/settings.json +++ b/src/renderer/src/i18n/de-DE/settings.json @@ -2764,6 +2764,39 @@ "databaseRepaired": "Datenbankreparatur abgeschlossen: {status}" } }, + "cronJobs": { + "title": "Cron Jobs", + "description": "Manage persisted Cron Jobs and the scheduler process.", + "empty": "No Cron Jobs yet.", + "none": "None", + "nextRunAt": "Next run", + "defaults": { + "name": "Morning cron job" + }, + "actions": { + "newJob": "New job", + "runNow": "Run now", + "clearNextRun": "Clear next run", + "reconcile": "Reconcile", + "restart": "Restart" + }, + "fields": { + "name": "Name", + "cronExpr": "Cron expression", + "timezone": "Timezone" + }, + "status": { + "state": "State", + "enabled": "Enabled jobs", + "heartbeat": "Heartbeat", + "stopped": "Stopped", + "starting": "Starting", + "running": "Running", + "idle": "Idle", + "error": "Error" + }, + "runNowSuccess": "Cron job run completed" + }, "scheduledTasks": { "title": "Geplante Aufgaben", "description": "Lösen Sie zu einer gewählten Zeit eine Systembenachrichtigung aus oder senden Sie einen vordefinierten Prompt an einen Agenten.", diff --git a/src/renderer/src/i18n/en-US/routes.json b/src/renderer/src/i18n/en-US/routes.json index 4b928d57c..c9f3f4c2b 100644 --- a/src/renderer/src/i18n/en-US/routes.json +++ b/src/renderer/src/i18n/en-US/routes.json @@ -17,7 +17,7 @@ "settings-acp": "ACP Agents", "settings-skills": "Skills", "settings-notifications-hooks": "Hooks", - "settings-scheduled-tasks": "Scheduled Tasks", + "settings-scheduled-tasks": "Cron Jobs", "settings-dashboard": "Dashboard", "settings-environments": "Environments", "settings-remote": "Remote", diff --git a/src/renderer/src/i18n/en-US/settings.json b/src/renderer/src/i18n/en-US/settings.json index 89e9286d7..6a769fdd6 100644 --- a/src/renderer/src/i18n/en-US/settings.json +++ b/src/renderer/src/i18n/en-US/settings.json @@ -1707,6 +1707,39 @@ } } }, + "cronJobs": { + "title": "Cron Jobs", + "description": "Manage persisted Cron Jobs and the scheduler process.", + "empty": "No Cron Jobs yet.", + "none": "None", + "nextRunAt": "Next run", + "defaults": { + "name": "Morning cron job" + }, + "actions": { + "newJob": "New job", + "runNow": "Run now", + "clearNextRun": "Clear next run", + "reconcile": "Reconcile", + "restart": "Restart" + }, + "fields": { + "name": "Name", + "cronExpr": "Cron expression", + "timezone": "Timezone" + }, + "status": { + "state": "State", + "enabled": "Enabled jobs", + "heartbeat": "Heartbeat", + "stopped": "Stopped", + "starting": "Starting", + "running": "Running", + "idle": "Idle", + "error": "Error" + }, + "runNowSuccess": "Cron job run completed" + }, "scheduledTasks": { "title": "Scheduled Tasks", "description": "Fire a system notification or send a preset prompt to an agent at a chosen time.", diff --git a/src/renderer/src/i18n/es-ES/routes.json b/src/renderer/src/i18n/es-ES/routes.json index 17bfc43c0..31b1f549e 100644 --- a/src/renderer/src/i18n/es-ES/routes.json +++ b/src/renderer/src/i18n/es-ES/routes.json @@ -21,7 +21,7 @@ "settings-remote": "Remoto", "settings-plugins": "Complementos", "settings-overview": "Descripción general de la configuración", - "settings-scheduled-tasks": "Tareas programadas", + "settings-scheduled-tasks": "Cron Jobs", "settings-memory": "Memoria", "plugins": "Plugins" } diff --git a/src/renderer/src/i18n/es-ES/settings.json b/src/renderer/src/i18n/es-ES/settings.json index dc4c09b89..bd9a6061e 100644 --- a/src/renderer/src/i18n/es-ES/settings.json +++ b/src/renderer/src/i18n/es-ES/settings.json @@ -2764,6 +2764,39 @@ "databaseRepaired": "Reparación de base de datos finalizada: {status}" } }, + "cronJobs": { + "title": "Cron Jobs", + "description": "Manage persisted Cron Jobs and the scheduler process.", + "empty": "No Cron Jobs yet.", + "none": "None", + "nextRunAt": "Next run", + "defaults": { + "name": "Morning cron job" + }, + "actions": { + "newJob": "New job", + "runNow": "Run now", + "clearNextRun": "Clear next run", + "reconcile": "Reconcile", + "restart": "Restart" + }, + "fields": { + "name": "Name", + "cronExpr": "Cron expression", + "timezone": "Timezone" + }, + "status": { + "state": "State", + "enabled": "Enabled jobs", + "heartbeat": "Heartbeat", + "stopped": "Stopped", + "starting": "Starting", + "running": "Running", + "idle": "Idle", + "error": "Error" + }, + "runNowSuccess": "Cron job run completed" + }, "scheduledTasks": { "title": "Tareas programadas", "description": "Dispara una notificación del sistema o envía un prompt predefinido a un agente a la hora elegida.", diff --git a/src/renderer/src/i18n/fa-IR/routes.json b/src/renderer/src/i18n/fa-IR/routes.json index 0f507b78c..dfa382d7b 100644 --- a/src/renderer/src/i18n/fa-IR/routes.json +++ b/src/renderer/src/i18n/fa-IR/routes.json @@ -15,7 +15,7 @@ "settings-acp": "نماینده ACP", "settings-skills": "Skills", "settings-notifications-hooks": "هوک‌ها", - "settings-scheduled-tasks": "وظایف زمان‌بندی‌شده", + "settings-scheduled-tasks": "Cron Jobs", "settings-dashboard": "داشبورد", "settings-environments": "محیط‌ها", "settings-remote": "کنترل از راه دور", diff --git a/src/renderer/src/i18n/fa-IR/settings.json b/src/renderer/src/i18n/fa-IR/settings.json index 30323c42f..9d6de800f 100644 --- a/src/renderer/src/i18n/fa-IR/settings.json +++ b/src/renderer/src/i18n/fa-IR/settings.json @@ -1450,6 +1450,39 @@ } } }, + "cronJobs": { + "title": "Cron Jobs", + "description": "Manage persisted Cron Jobs and the scheduler process.", + "empty": "No Cron Jobs yet.", + "none": "None", + "nextRunAt": "Next run", + "defaults": { + "name": "Morning cron job" + }, + "actions": { + "newJob": "New job", + "runNow": "Run now", + "clearNextRun": "Clear next run", + "reconcile": "Reconcile", + "restart": "Restart" + }, + "fields": { + "name": "Name", + "cronExpr": "Cron expression", + "timezone": "Timezone" + }, + "status": { + "state": "State", + "enabled": "Enabled jobs", + "heartbeat": "Heartbeat", + "stopped": "Stopped", + "starting": "Starting", + "running": "Running", + "idle": "Idle", + "error": "Error" + }, + "runNowSuccess": "Cron job run completed" + }, "scheduledTasks": { "title": "کارهای زمان‌بندی‌شده", "description": "در زمان انتخاب‌شده یک اعلان سیستم نمایش دهید یا یک پرامپت آماده را به یک عامل ارسال کنید.", diff --git a/src/renderer/src/i18n/fr-FR/routes.json b/src/renderer/src/i18n/fr-FR/routes.json index 1a84b4281..4b31d3335 100644 --- a/src/renderer/src/i18n/fr-FR/routes.json +++ b/src/renderer/src/i18n/fr-FR/routes.json @@ -15,7 +15,7 @@ "settings-acp": "Agent ACP", "settings-skills": "Skills", "settings-notifications-hooks": "Hooks", - "settings-scheduled-tasks": "Tâches planifiées", + "settings-scheduled-tasks": "Cron Jobs", "settings-dashboard": "Tableau de bord", "settings-environments": "Environnements", "settings-remote": "Accès distant", diff --git a/src/renderer/src/i18n/fr-FR/settings.json b/src/renderer/src/i18n/fr-FR/settings.json index 591fe4f8f..84537e980 100644 --- a/src/renderer/src/i18n/fr-FR/settings.json +++ b/src/renderer/src/i18n/fr-FR/settings.json @@ -1450,6 +1450,39 @@ } } }, + "cronJobs": { + "title": "Cron Jobs", + "description": "Manage persisted Cron Jobs and the scheduler process.", + "empty": "No Cron Jobs yet.", + "none": "None", + "nextRunAt": "Next run", + "defaults": { + "name": "Morning cron job" + }, + "actions": { + "newJob": "New job", + "runNow": "Run now", + "clearNextRun": "Clear next run", + "reconcile": "Reconcile", + "restart": "Restart" + }, + "fields": { + "name": "Name", + "cronExpr": "Cron expression", + "timezone": "Timezone" + }, + "status": { + "state": "State", + "enabled": "Enabled jobs", + "heartbeat": "Heartbeat", + "stopped": "Stopped", + "starting": "Starting", + "running": "Running", + "idle": "Idle", + "error": "Error" + }, + "runNowSuccess": "Cron job run completed" + }, "scheduledTasks": { "title": "Tâches planifiées", "description": "Déclenchez une notification système ou envoyez une invite prédéfinie à un agent à l’heure choisie.", diff --git a/src/renderer/src/i18n/he-IL/routes.json b/src/renderer/src/i18n/he-IL/routes.json index 2e3bf9510..390c6348c 100644 --- a/src/renderer/src/i18n/he-IL/routes.json +++ b/src/renderer/src/i18n/he-IL/routes.json @@ -15,7 +15,7 @@ "settings-acp": "סוכני ACP", "settings-skills": "Skills", "settings-notifications-hooks": "Hooks", - "settings-scheduled-tasks": "משימות מתוזמנות", + "settings-scheduled-tasks": "Cron Jobs", "settings-dashboard": "Dashboard", "settings-environments": "סביבות", "settings-remote": "שליטה מרחוק", diff --git a/src/renderer/src/i18n/he-IL/settings.json b/src/renderer/src/i18n/he-IL/settings.json index 47a0a08b8..6451d9870 100644 --- a/src/renderer/src/i18n/he-IL/settings.json +++ b/src/renderer/src/i18n/he-IL/settings.json @@ -1450,6 +1450,39 @@ } } }, + "cronJobs": { + "title": "Cron Jobs", + "description": "Manage persisted Cron Jobs and the scheduler process.", + "empty": "No Cron Jobs yet.", + "none": "None", + "nextRunAt": "Next run", + "defaults": { + "name": "Morning cron job" + }, + "actions": { + "newJob": "New job", + "runNow": "Run now", + "clearNextRun": "Clear next run", + "reconcile": "Reconcile", + "restart": "Restart" + }, + "fields": { + "name": "Name", + "cronExpr": "Cron expression", + "timezone": "Timezone" + }, + "status": { + "state": "State", + "enabled": "Enabled jobs", + "heartbeat": "Heartbeat", + "stopped": "Stopped", + "starting": "Starting", + "running": "Running", + "idle": "Idle", + "error": "Error" + }, + "runNowSuccess": "Cron job run completed" + }, "scheduledTasks": { "title": "משימות מתוזמנות", "description": "הצג התראת מערכת או שלח פרומפט מוכן לסוכן בזמן שנבחר.", diff --git a/src/renderer/src/i18n/id-ID/routes.json b/src/renderer/src/i18n/id-ID/routes.json index 91e1a3adb..53368cbc5 100644 --- a/src/renderer/src/i18n/id-ID/routes.json +++ b/src/renderer/src/i18n/id-ID/routes.json @@ -21,7 +21,7 @@ "settings-remote": "terpencil", "settings-plugins": "plugin", "settings-overview": "Ikhtisar pengaturan", - "settings-scheduled-tasks": "Tugas Terjadwal", + "settings-scheduled-tasks": "Cron Jobs", "settings-memory": "Memori", "plugins": "Plugins" } diff --git a/src/renderer/src/i18n/id-ID/settings.json b/src/renderer/src/i18n/id-ID/settings.json index bce667fc9..795b61b7a 100644 --- a/src/renderer/src/i18n/id-ID/settings.json +++ b/src/renderer/src/i18n/id-ID/settings.json @@ -2764,6 +2764,39 @@ "databaseRepaired": "Perbaikan basis data selesai: {status}" } }, + "cronJobs": { + "title": "Cron Jobs", + "description": "Manage persisted Cron Jobs and the scheduler process.", + "empty": "No Cron Jobs yet.", + "none": "None", + "nextRunAt": "Next run", + "defaults": { + "name": "Morning cron job" + }, + "actions": { + "newJob": "New job", + "runNow": "Run now", + "clearNextRun": "Clear next run", + "reconcile": "Reconcile", + "restart": "Restart" + }, + "fields": { + "name": "Name", + "cronExpr": "Cron expression", + "timezone": "Timezone" + }, + "status": { + "state": "State", + "enabled": "Enabled jobs", + "heartbeat": "Heartbeat", + "stopped": "Stopped", + "starting": "Starting", + "running": "Running", + "idle": "Idle", + "error": "Error" + }, + "runNowSuccess": "Cron job run completed" + }, "scheduledTasks": { "title": "Tugas Terjadwal", "description": "Picu notifikasi sistem atau kirim prompt preset ke agen pada waktu yang dipilih.", diff --git a/src/renderer/src/i18n/it-IT/routes.json b/src/renderer/src/i18n/it-IT/routes.json index 40679e7a0..fbc13ea1c 100644 --- a/src/renderer/src/i18n/it-IT/routes.json +++ b/src/renderer/src/i18n/it-IT/routes.json @@ -21,7 +21,7 @@ "settings-remote": "Remoto", "settings-plugins": "Plugin", "settings-overview": "Panoramica impostazioni", - "settings-scheduled-tasks": "Attività pianificate", + "settings-scheduled-tasks": "Cron Jobs", "settings-memory": "Memoria", "plugins": "Plugins" } diff --git a/src/renderer/src/i18n/it-IT/settings.json b/src/renderer/src/i18n/it-IT/settings.json index d46572fc2..d540c5797 100644 --- a/src/renderer/src/i18n/it-IT/settings.json +++ b/src/renderer/src/i18n/it-IT/settings.json @@ -2764,6 +2764,39 @@ "databaseRepaired": "Riparazione database completata: {status}" } }, + "cronJobs": { + "title": "Cron Jobs", + "description": "Manage persisted Cron Jobs and the scheduler process.", + "empty": "No Cron Jobs yet.", + "none": "None", + "nextRunAt": "Next run", + "defaults": { + "name": "Morning cron job" + }, + "actions": { + "newJob": "New job", + "runNow": "Run now", + "clearNextRun": "Clear next run", + "reconcile": "Reconcile", + "restart": "Restart" + }, + "fields": { + "name": "Name", + "cronExpr": "Cron expression", + "timezone": "Timezone" + }, + "status": { + "state": "State", + "enabled": "Enabled jobs", + "heartbeat": "Heartbeat", + "stopped": "Stopped", + "starting": "Starting", + "running": "Running", + "idle": "Idle", + "error": "Error" + }, + "runNowSuccess": "Cron job run completed" + }, "scheduledTasks": { "title": "Attività pianificate", "description": "Attiva una notifica di sistema o invia un prompt predefinito a un agente all’orario scelto.", diff --git a/src/renderer/src/i18n/ja-JP/routes.json b/src/renderer/src/i18n/ja-JP/routes.json index b6746d230..bf5539ebe 100644 --- a/src/renderer/src/i18n/ja-JP/routes.json +++ b/src/renderer/src/i18n/ja-JP/routes.json @@ -15,7 +15,7 @@ "settings-acp": "ACPエージェント", "settings-skills": "Skills", "settings-notifications-hooks": "フック", - "settings-scheduled-tasks": "スケジュールタスク", + "settings-scheduled-tasks": "Cron Jobs", "settings-dashboard": "Dashboard", "settings-environments": "環境", "settings-remote": "リモート", diff --git a/src/renderer/src/i18n/ja-JP/settings.json b/src/renderer/src/i18n/ja-JP/settings.json index bd17f6d5b..6a49d2a65 100644 --- a/src/renderer/src/i18n/ja-JP/settings.json +++ b/src/renderer/src/i18n/ja-JP/settings.json @@ -1450,6 +1450,39 @@ } } }, + "cronJobs": { + "title": "Cron Jobs", + "description": "Manage persisted Cron Jobs and the scheduler process.", + "empty": "No Cron Jobs yet.", + "none": "None", + "nextRunAt": "Next run", + "defaults": { + "name": "Morning cron job" + }, + "actions": { + "newJob": "New job", + "runNow": "Run now", + "clearNextRun": "Clear next run", + "reconcile": "Reconcile", + "restart": "Restart" + }, + "fields": { + "name": "Name", + "cronExpr": "Cron expression", + "timezone": "Timezone" + }, + "status": { + "state": "State", + "enabled": "Enabled jobs", + "heartbeat": "Heartbeat", + "stopped": "Stopped", + "starting": "Starting", + "running": "Running", + "idle": "Idle", + "error": "Error" + }, + "runNowSuccess": "Cron job run completed" + }, "scheduledTasks": { "title": "スケジュールタスク", "description": "指定した時刻にシステム通知を出すか、エージェントに自動でメッセージを送信します。", diff --git a/src/renderer/src/i18n/ko-KR/routes.json b/src/renderer/src/i18n/ko-KR/routes.json index 6dc2ccee7..81aa7f628 100644 --- a/src/renderer/src/i18n/ko-KR/routes.json +++ b/src/renderer/src/i18n/ko-KR/routes.json @@ -15,7 +15,7 @@ "settings-acp": "ACP 프록시", "settings-skills": "Skills", "settings-notifications-hooks": "훅", - "settings-scheduled-tasks": "예약된 작업", + "settings-scheduled-tasks": "Cron Jobs", "settings-dashboard": "Dashboard", "settings-environments": "환경", "settings-remote": "원격 제어", diff --git a/src/renderer/src/i18n/ko-KR/settings.json b/src/renderer/src/i18n/ko-KR/settings.json index 75897ec0d..ae086dc38 100644 --- a/src/renderer/src/i18n/ko-KR/settings.json +++ b/src/renderer/src/i18n/ko-KR/settings.json @@ -1450,6 +1450,39 @@ } } }, + "cronJobs": { + "title": "Cron Jobs", + "description": "Manage persisted Cron Jobs and the scheduler process.", + "empty": "No Cron Jobs yet.", + "none": "None", + "nextRunAt": "Next run", + "defaults": { + "name": "Morning cron job" + }, + "actions": { + "newJob": "New job", + "runNow": "Run now", + "clearNextRun": "Clear next run", + "reconcile": "Reconcile", + "restart": "Restart" + }, + "fields": { + "name": "Name", + "cronExpr": "Cron expression", + "timezone": "Timezone" + }, + "status": { + "state": "State", + "enabled": "Enabled jobs", + "heartbeat": "Heartbeat", + "stopped": "Stopped", + "starting": "Starting", + "running": "Running", + "idle": "Idle", + "error": "Error" + }, + "runNowSuccess": "Cron job run completed" + }, "scheduledTasks": { "title": "예약된 작업", "description": "지정된 시간에 시스템 알림을 표시하거나 에이전트에게 자동으로 메시지를 보냅니다.", diff --git a/src/renderer/src/i18n/ms-MY/routes.json b/src/renderer/src/i18n/ms-MY/routes.json index d5e636dd4..52512f72a 100644 --- a/src/renderer/src/i18n/ms-MY/routes.json +++ b/src/renderer/src/i18n/ms-MY/routes.json @@ -21,7 +21,7 @@ "settings-remote": "jauh", "settings-plugins": "pemalam", "settings-overview": "Gambaran keseluruhan tetapan", - "settings-scheduled-tasks": "Tugasan Berjadual", + "settings-scheduled-tasks": "Cron Jobs", "settings-memory": "Memori", "plugins": "Plugins" } diff --git a/src/renderer/src/i18n/ms-MY/settings.json b/src/renderer/src/i18n/ms-MY/settings.json index 417d37d88..73d964139 100644 --- a/src/renderer/src/i18n/ms-MY/settings.json +++ b/src/renderer/src/i18n/ms-MY/settings.json @@ -2764,6 +2764,39 @@ "databaseRepaired": "Pembaikan pangkalan data selesai: {status}" } }, + "cronJobs": { + "title": "Cron Jobs", + "description": "Manage persisted Cron Jobs and the scheduler process.", + "empty": "No Cron Jobs yet.", + "none": "None", + "nextRunAt": "Next run", + "defaults": { + "name": "Morning cron job" + }, + "actions": { + "newJob": "New job", + "runNow": "Run now", + "clearNextRun": "Clear next run", + "reconcile": "Reconcile", + "restart": "Restart" + }, + "fields": { + "name": "Name", + "cronExpr": "Cron expression", + "timezone": "Timezone" + }, + "status": { + "state": "State", + "enabled": "Enabled jobs", + "heartbeat": "Heartbeat", + "stopped": "Stopped", + "starting": "Starting", + "running": "Running", + "idle": "Idle", + "error": "Error" + }, + "runNowSuccess": "Cron job run completed" + }, "scheduledTasks": { "title": "Tugasan Berjadual", "description": "Cetuskkan pemberitahuan sistem atau hantar prompt pratetap kepada ejen pada masa yang dipilih.", diff --git a/src/renderer/src/i18n/pl-PL/routes.json b/src/renderer/src/i18n/pl-PL/routes.json index 7c3e2f3f1..34eb6f659 100644 --- a/src/renderer/src/i18n/pl-PL/routes.json +++ b/src/renderer/src/i18n/pl-PL/routes.json @@ -21,7 +21,7 @@ "settings-remote": "Zdalny", "settings-plugins": "Wtyczki", "settings-overview": "Przegląd ustawień", - "settings-scheduled-tasks": "Zaplanowane zadania", + "settings-scheduled-tasks": "Cron Jobs", "settings-memory": "Pamięć", "plugins": "Plugins" } diff --git a/src/renderer/src/i18n/pl-PL/settings.json b/src/renderer/src/i18n/pl-PL/settings.json index a55d1448b..034f5d27a 100644 --- a/src/renderer/src/i18n/pl-PL/settings.json +++ b/src/renderer/src/i18n/pl-PL/settings.json @@ -2764,6 +2764,39 @@ "databaseRepaired": "Zakończono naprawę bazy danych: {status}" } }, + "cronJobs": { + "title": "Cron Jobs", + "description": "Manage persisted Cron Jobs and the scheduler process.", + "empty": "No Cron Jobs yet.", + "none": "None", + "nextRunAt": "Next run", + "defaults": { + "name": "Morning cron job" + }, + "actions": { + "newJob": "New job", + "runNow": "Run now", + "clearNextRun": "Clear next run", + "reconcile": "Reconcile", + "restart": "Restart" + }, + "fields": { + "name": "Name", + "cronExpr": "Cron expression", + "timezone": "Timezone" + }, + "status": { + "state": "State", + "enabled": "Enabled jobs", + "heartbeat": "Heartbeat", + "stopped": "Stopped", + "starting": "Starting", + "running": "Running", + "idle": "Idle", + "error": "Error" + }, + "runNowSuccess": "Cron job run completed" + }, "scheduledTasks": { "title": "Zaplanowane zadania", "description": "Wyświetl powiadomienie systemowe lub wyślij gotowy prompt do agenta o wybranej godzinie.", diff --git a/src/renderer/src/i18n/pt-BR/routes.json b/src/renderer/src/i18n/pt-BR/routes.json index 14414a60a..ccc834784 100644 --- a/src/renderer/src/i18n/pt-BR/routes.json +++ b/src/renderer/src/i18n/pt-BR/routes.json @@ -15,7 +15,7 @@ "settings-acp": "Proxy ACP", "settings-skills": "Skills", "settings-notifications-hooks": "Hooks", - "settings-scheduled-tasks": "Tarefas agendadas", + "settings-scheduled-tasks": "Cron Jobs", "settings-dashboard": "Dashboard", "settings-environments": "Ambientes", "settings-remote": "Remoto", diff --git a/src/renderer/src/i18n/pt-BR/settings.json b/src/renderer/src/i18n/pt-BR/settings.json index 0881af031..d9cd679e0 100644 --- a/src/renderer/src/i18n/pt-BR/settings.json +++ b/src/renderer/src/i18n/pt-BR/settings.json @@ -1450,6 +1450,39 @@ } } }, + "cronJobs": { + "title": "Cron Jobs", + "description": "Manage persisted Cron Jobs and the scheduler process.", + "empty": "No Cron Jobs yet.", + "none": "None", + "nextRunAt": "Next run", + "defaults": { + "name": "Morning cron job" + }, + "actions": { + "newJob": "New job", + "runNow": "Run now", + "clearNextRun": "Clear next run", + "reconcile": "Reconcile", + "restart": "Restart" + }, + "fields": { + "name": "Name", + "cronExpr": "Cron expression", + "timezone": "Timezone" + }, + "status": { + "state": "State", + "enabled": "Enabled jobs", + "heartbeat": "Heartbeat", + "stopped": "Stopped", + "starting": "Starting", + "running": "Running", + "idle": "Idle", + "error": "Error" + }, + "runNowSuccess": "Cron job run completed" + }, "scheduledTasks": { "title": "Tarefas agendadas", "description": "Dispare uma notificação do sistema ou envie um prompt predefinido para um agente no horário escolhido.", diff --git a/src/renderer/src/i18n/ru-RU/routes.json b/src/renderer/src/i18n/ru-RU/routes.json index 615b5c1f8..48b27787a 100644 --- a/src/renderer/src/i18n/ru-RU/routes.json +++ b/src/renderer/src/i18n/ru-RU/routes.json @@ -15,7 +15,7 @@ "settings-acp": "ACP-агент", "settings-skills": "Skills", "settings-notifications-hooks": "Хуки", - "settings-scheduled-tasks": "Запланированные задачи", + "settings-scheduled-tasks": "Cron Jobs", "settings-dashboard": "Панель", "settings-environments": "Окружения", "settings-remote": "Удалённое управление", diff --git a/src/renderer/src/i18n/ru-RU/settings.json b/src/renderer/src/i18n/ru-RU/settings.json index faa4e4c3a..5fc8a6a5c 100644 --- a/src/renderer/src/i18n/ru-RU/settings.json +++ b/src/renderer/src/i18n/ru-RU/settings.json @@ -1450,6 +1450,39 @@ } } }, + "cronJobs": { + "title": "Cron Jobs", + "description": "Manage persisted Cron Jobs and the scheduler process.", + "empty": "No Cron Jobs yet.", + "none": "None", + "nextRunAt": "Next run", + "defaults": { + "name": "Morning cron job" + }, + "actions": { + "newJob": "New job", + "runNow": "Run now", + "clearNextRun": "Clear next run", + "reconcile": "Reconcile", + "restart": "Restart" + }, + "fields": { + "name": "Name", + "cronExpr": "Cron expression", + "timezone": "Timezone" + }, + "status": { + "state": "State", + "enabled": "Enabled jobs", + "heartbeat": "Heartbeat", + "stopped": "Stopped", + "starting": "Starting", + "running": "Running", + "idle": "Idle", + "error": "Error" + }, + "runNowSuccess": "Cron job run completed" + }, "scheduledTasks": { "title": "Запланированные задачи", "description": "Покажите системное уведомление или отправьте готовый промпт агенту в выбранное время.", diff --git a/src/renderer/src/i18n/tr-TR/routes.json b/src/renderer/src/i18n/tr-TR/routes.json index f74f67fa3..6ffe8f463 100644 --- a/src/renderer/src/i18n/tr-TR/routes.json +++ b/src/renderer/src/i18n/tr-TR/routes.json @@ -21,7 +21,7 @@ "settings-remote": "Uzak", "settings-plugins": "Eklentiler", "settings-overview": "Ayarlara Genel Bakış", - "settings-scheduled-tasks": "Zamanlanmış Görevler", + "settings-scheduled-tasks": "Cron Jobs", "settings-memory": "Bellek", "plugins": "Plugins" } diff --git a/src/renderer/src/i18n/tr-TR/settings.json b/src/renderer/src/i18n/tr-TR/settings.json index 898c21448..e2a6f5a67 100644 --- a/src/renderer/src/i18n/tr-TR/settings.json +++ b/src/renderer/src/i18n/tr-TR/settings.json @@ -2764,6 +2764,39 @@ "databaseRepaired": "Veritabanı onarımı tamamlandı: {status}" } }, + "cronJobs": { + "title": "Cron Jobs", + "description": "Manage persisted Cron Jobs and the scheduler process.", + "empty": "No Cron Jobs yet.", + "none": "None", + "nextRunAt": "Next run", + "defaults": { + "name": "Morning cron job" + }, + "actions": { + "newJob": "New job", + "runNow": "Run now", + "clearNextRun": "Clear next run", + "reconcile": "Reconcile", + "restart": "Restart" + }, + "fields": { + "name": "Name", + "cronExpr": "Cron expression", + "timezone": "Timezone" + }, + "status": { + "state": "State", + "enabled": "Enabled jobs", + "heartbeat": "Heartbeat", + "stopped": "Stopped", + "starting": "Starting", + "running": "Running", + "idle": "Idle", + "error": "Error" + }, + "runNowSuccess": "Cron job run completed" + }, "scheduledTasks": { "title": "Zamanlanmış Görevler", "description": "Seçilen zamanda bir sistem bildirimi tetikleyin veya bir ajana hazır istem gönderin.", diff --git a/src/renderer/src/i18n/vi-VN/routes.json b/src/renderer/src/i18n/vi-VN/routes.json index b06108283..3ee549192 100644 --- a/src/renderer/src/i18n/vi-VN/routes.json +++ b/src/renderer/src/i18n/vi-VN/routes.json @@ -21,7 +21,7 @@ "settings-remote": "Từ xa", "settings-plugins": "Plugin", "settings-overview": "Tổng quan về cài đặt", - "settings-scheduled-tasks": "Tác vụ đã lên lịch", + "settings-scheduled-tasks": "Cron Jobs", "settings-memory": "Bộ nhớ", "plugins": "Plugins" } diff --git a/src/renderer/src/i18n/vi-VN/settings.json b/src/renderer/src/i18n/vi-VN/settings.json index 8744c6329..50f321464 100644 --- a/src/renderer/src/i18n/vi-VN/settings.json +++ b/src/renderer/src/i18n/vi-VN/settings.json @@ -2764,6 +2764,39 @@ "databaseRepaired": "Sửa chữa cơ sở dữ liệu đã hoàn tất: {status}" } }, + "cronJobs": { + "title": "Cron Jobs", + "description": "Manage persisted Cron Jobs and the scheduler process.", + "empty": "No Cron Jobs yet.", + "none": "None", + "nextRunAt": "Next run", + "defaults": { + "name": "Morning cron job" + }, + "actions": { + "newJob": "New job", + "runNow": "Run now", + "clearNextRun": "Clear next run", + "reconcile": "Reconcile", + "restart": "Restart" + }, + "fields": { + "name": "Name", + "cronExpr": "Cron expression", + "timezone": "Timezone" + }, + "status": { + "state": "State", + "enabled": "Enabled jobs", + "heartbeat": "Heartbeat", + "stopped": "Stopped", + "starting": "Starting", + "running": "Running", + "idle": "Idle", + "error": "Error" + }, + "runNowSuccess": "Cron job run completed" + }, "scheduledTasks": { "title": "Tác vụ đã lên lịch", "description": "Kích hoạt thông báo hệ thống hoặc gửi prompt đặt sẵn cho tác nhân vào thời điểm đã chọn.", diff --git a/src/renderer/src/i18n/zh-CN/routes.json b/src/renderer/src/i18n/zh-CN/routes.json index dadead66d..9fdb7496c 100644 --- a/src/renderer/src/i18n/zh-CN/routes.json +++ b/src/renderer/src/i18n/zh-CN/routes.json @@ -17,7 +17,7 @@ "settings-acp": "ACP Agent", "settings-skills": "Skills设置", "settings-notifications-hooks": "Hooks", - "settings-scheduled-tasks": "定时任务", + "settings-scheduled-tasks": "Cron Jobs", "settings-dashboard": "数据看板", "settings-environments": "目录环境", "settings-remote": "远程", diff --git a/src/renderer/src/i18n/zh-CN/settings.json b/src/renderer/src/i18n/zh-CN/settings.json index b9091cc5b..dd11e4f14 100644 --- a/src/renderer/src/i18n/zh-CN/settings.json +++ b/src/renderer/src/i18n/zh-CN/settings.json @@ -1707,6 +1707,39 @@ } } }, + "cronJobs": { + "title": "Cron Jobs", + "description": "管理持久化的 Cron Jobs 和调度进程。", + "empty": "暂无 Cron Jobs。", + "none": "无", + "nextRunAt": "下次运行", + "defaults": { + "name": "Morning cron job" + }, + "actions": { + "newJob": "新建任务", + "runNow": "立即运行", + "clearNextRun": "清空下次运行", + "reconcile": "重新对账", + "restart": "重启调度" + }, + "fields": { + "name": "名称", + "cronExpr": "Cron 表达式", + "timezone": "时区" + }, + "status": { + "state": "状态", + "enabled": "启用任务", + "heartbeat": "心跳", + "stopped": "已停止", + "starting": "启动中", + "running": "运行中", + "idle": "空闲", + "error": "错误" + }, + "runNowSuccess": "Cron Job 已完成" + }, "scheduledTasks": { "title": "定时任务", "description": "在设定的时间触发系统通知或自动向 Agent 发送消息。", diff --git a/src/renderer/src/i18n/zh-HK/routes.json b/src/renderer/src/i18n/zh-HK/routes.json index e2be0b112..eb391b814 100644 --- a/src/renderer/src/i18n/zh-HK/routes.json +++ b/src/renderer/src/i18n/zh-HK/routes.json @@ -15,7 +15,7 @@ "settings-acp": "ACP Agent", "settings-skills": "Skills設置", "settings-notifications-hooks": "Hooks", - "settings-scheduled-tasks": "定時任務", + "settings-scheduled-tasks": "Cron Jobs", "settings-dashboard": "資料看板", "settings-environments": "目錄環境", "settings-remote": "遠端", diff --git a/src/renderer/src/i18n/zh-HK/settings.json b/src/renderer/src/i18n/zh-HK/settings.json index 6d324773d..ce4bd5a7b 100644 --- a/src/renderer/src/i18n/zh-HK/settings.json +++ b/src/renderer/src/i18n/zh-HK/settings.json @@ -1450,6 +1450,39 @@ } } }, + "cronJobs": { + "title": "Cron Jobs", + "description": "管理持久化的 Cron Jobs 和调度进程。", + "empty": "暂无 Cron Jobs。", + "none": "无", + "nextRunAt": "下次运行", + "defaults": { + "name": "Morning cron job" + }, + "actions": { + "newJob": "新建任务", + "runNow": "立即运行", + "clearNextRun": "清空下次运行", + "reconcile": "重新对账", + "restart": "重启调度" + }, + "fields": { + "name": "名称", + "cronExpr": "Cron 表达式", + "timezone": "时区" + }, + "status": { + "state": "状态", + "enabled": "启用任务", + "heartbeat": "心跳", + "stopped": "已停止", + "starting": "启动中", + "running": "运行中", + "idle": "空闲", + "error": "错误" + }, + "runNowSuccess": "Cron Job 已完成" + }, "scheduledTasks": { "title": "定時任務", "description": "在設定的時間觸發系統通知或自動向 Agent 發送消息。", diff --git a/src/renderer/src/i18n/zh-TW/routes.json b/src/renderer/src/i18n/zh-TW/routes.json index 119c4b904..279aea273 100644 --- a/src/renderer/src/i18n/zh-TW/routes.json +++ b/src/renderer/src/i18n/zh-TW/routes.json @@ -15,7 +15,7 @@ "settings-acp": "ACP Agent", "settings-skills": "Skills設定", "settings-notifications-hooks": "Hooks", - "settings-scheduled-tasks": "定時任務", + "settings-scheduled-tasks": "Cron Jobs", "settings-dashboard": "資料看板", "settings-environments": "目錄環境", "settings-remote": "遠端", diff --git a/src/renderer/src/i18n/zh-TW/settings.json b/src/renderer/src/i18n/zh-TW/settings.json index a1882fef6..b507074cf 100644 --- a/src/renderer/src/i18n/zh-TW/settings.json +++ b/src/renderer/src/i18n/zh-TW/settings.json @@ -1450,6 +1450,39 @@ } } }, + "cronJobs": { + "title": "Cron Jobs", + "description": "管理持久化的 Cron Jobs 和调度进程。", + "empty": "暂无 Cron Jobs。", + "none": "无", + "nextRunAt": "下次运行", + "defaults": { + "name": "Morning cron job" + }, + "actions": { + "newJob": "新建任务", + "runNow": "立即运行", + "clearNextRun": "清空下次运行", + "reconcile": "重新对账", + "restart": "重启调度" + }, + "fields": { + "name": "名称", + "cronExpr": "Cron 表达式", + "timezone": "时区" + }, + "status": { + "state": "状态", + "enabled": "启用任务", + "heartbeat": "心跳", + "stopped": "已停止", + "starting": "启动中", + "running": "运行中", + "idle": "空闲", + "error": "错误" + }, + "runNowSuccess": "Cron Job 已完成" + }, "scheduledTasks": { "title": "定時任務", "description": "在設定的時間觸發系統通知或自動向 Agent 發送訊息。", diff --git a/src/shared/contracts/routes.ts b/src/shared/contracts/routes.ts index 1927e98f1..2fa2a6d7c 100644 --- a/src/shared/contracts/routes.ts +++ b/src/shared/contracts/routes.ts @@ -264,6 +264,16 @@ import { scheduledTasksToggleRoute, scheduledTasksUpsertRoute } from './routes/scheduledTasks.routes' +import { + cronJobsDeleteRoute, + cronJobsGetSchedulerStatusRoute, + cronJobsListRoute, + cronJobsReconcileSchedulerRoute, + cronJobsRestartSchedulerRoute, + cronJobsRunNowRoute, + cronJobsToggleRoute, + cronJobsUpsertRoute +} from './routes/cronJobs.routes' import { providersAddRoute, providersGetAcpProcessConfigOptionsRoute, @@ -504,6 +514,7 @@ export * from './routes/providers.routes' export * from './routes/project.routes' export * from './routes/remote-control.routes' export * from './routes/scheduledTasks.routes' +export * from './routes/cronJobs.routes' export * from './routes/settings.routes' export * from './routes/shortcut.routes' export * from './routes/startup.routes' @@ -599,6 +610,14 @@ const DEEPCHAT_ROUTE_CATALOG_PART_1 = { [scheduledTasksDeleteRoute.name]: scheduledTasksDeleteRoute, [scheduledTasksToggleRoute.name]: scheduledTasksToggleRoute, [scheduledTasksFireNowRoute.name]: scheduledTasksFireNowRoute, + [cronJobsListRoute.name]: cronJobsListRoute, + [cronJobsUpsertRoute.name]: cronJobsUpsertRoute, + [cronJobsDeleteRoute.name]: cronJobsDeleteRoute, + [cronJobsToggleRoute.name]: cronJobsToggleRoute, + [cronJobsRunNowRoute.name]: cronJobsRunNowRoute, + [cronJobsGetSchedulerStatusRoute.name]: cronJobsGetSchedulerStatusRoute, + [cronJobsReconcileSchedulerRoute.name]: cronJobsReconcileSchedulerRoute, + [cronJobsRestartSchedulerRoute.name]: cronJobsRestartSchedulerRoute, [pluginsListRoute.name]: pluginsListRoute, [pluginsGetRoute.name]: pluginsGetRoute, [pluginsEnableRoute.name]: pluginsEnableRoute, diff --git a/src/shared/contracts/routes/cronJobs.routes.ts b/src/shared/contracts/routes/cronJobs.routes.ts new file mode 100644 index 000000000..38e09fae6 --- /dev/null +++ b/src/shared/contracts/routes/cronJobs.routes.ts @@ -0,0 +1,135 @@ +import { z } from 'zod' +import { defineRouteContract } from '../common' +import { + CRON_JOB_RUN_REASONS, + CRON_JOB_RUN_STATUSES, + CRON_JOBS_SCHEDULER_STATES +} from '../../cronJobs' + +const timestampMsSchema = z.number().int().nonnegative() + +export const cronJobRunStatusSchema = z.enum(CRON_JOB_RUN_STATUSES) +export const cronJobRunReasonSchema = z.enum(CRON_JOB_RUN_REASONS) +export const cronJobsSchedulerStateSchema = z.enum(CRON_JOBS_SCHEDULER_STATES) + +export const cronJobSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1).max(200), + enabled: z.boolean(), + cronExpr: z.string().min(1).max(200), + timezone: z.string().min(1).max(128), + agentId: z.string().min(1).nullable(), + nextRunAt: timestampMsSchema.nullable(), + createdAt: timestampMsSchema, + updatedAt: timestampMsSchema +}) + +export const cronJobRunSchema = z.object({ + id: z.string().min(1), + jobId: z.string().min(1), + scheduledAt: timestampMsSchema, + queuedAt: timestampMsSchema, + startedAt: timestampMsSchema.nullable(), + completedAt: timestampMsSchema.nullable(), + status: cronJobRunStatusSchema, + reason: cronJobRunReasonSchema, + error: z.string().nullable(), + createdAt: timestampMsSchema, + updatedAt: timestampMsSchema +}) + +export const cronJobsSchedulerStatusSchema = z.object({ + state: cronJobsSchedulerStateSchema, + pid: z.number().int().positive().nullable(), + enabledJobCount: z.number().int().nonnegative(), + nextRunAt: timestampMsSchema.nullable(), + lastHeartbeatAt: timestampMsSchema.nullable(), + lastError: z.string().nullable(), + restartAttempts: z.number().int().nonnegative(), + updatedAt: timestampMsSchema +}) + +export const cronJobsListRoute = defineRouteContract({ + name: 'cronJobs.list', + input: z.object({}), + output: z.object({ + jobs: z.array(cronJobSchema), + schedulerStatus: cronJobsSchedulerStatusSchema + }) +}) + +export const cronJobsUpsertInputSchema = cronJobSchema + .omit({ id: true, createdAt: true, updatedAt: true }) + .extend({ + id: z.string().min(1).optional(), + nextRunAt: timestampMsSchema.nullable().optional() + }) + +export const cronJobsUpsertRoute = defineRouteContract({ + name: 'cronJobs.upsert', + input: cronJobsUpsertInputSchema, + output: z.object({ + job: cronJobSchema, + schedulerStatus: cronJobsSchedulerStatusSchema + }) +}) + +export const cronJobsDeleteRoute = defineRouteContract({ + name: 'cronJobs.delete', + input: z.object({ + id: z.string().min(1) + }), + output: z.object({ + schedulerStatus: cronJobsSchedulerStatusSchema + }) +}) + +export const cronJobsToggleRoute = defineRouteContract({ + name: 'cronJobs.toggle', + input: z.object({ + id: z.string().min(1), + enabled: z.boolean() + }), + output: z.object({ + job: cronJobSchema, + schedulerStatus: cronJobsSchedulerStatusSchema + }) +}) + +export const cronJobsRunNowRoute = defineRouteContract({ + name: 'cronJobs.runNow', + input: z.object({ + id: z.string().min(1) + }), + output: z.object({ + job: cronJobSchema, + run: cronJobRunSchema, + schedulerStatus: cronJobsSchedulerStatusSchema + }) +}) + +export const cronJobsGetSchedulerStatusRoute = defineRouteContract({ + name: 'cronJobs.getSchedulerStatus', + input: z.object({}), + output: z.object({ + schedulerStatus: cronJobsSchedulerStatusSchema + }) +}) + +export const cronJobsReconcileSchedulerRoute = defineRouteContract({ + name: 'cronJobs.reconcileScheduler', + input: z.object({ + reason: z.string().max(100).optional() + }), + output: z.object({ + schedulerStatus: cronJobsSchedulerStatusSchema + }) +}) + +export const cronJobsRestartSchedulerRoute = defineRouteContract({ + name: 'cronJobs.restartScheduler', + input: z.object({}), + output: z.object({ + schedulerStatus: cronJobsSchedulerStatusSchema + }) +}) diff --git a/src/shared/cronJobs.ts b/src/shared/cronJobs.ts new file mode 100644 index 000000000..1773d0cda --- /dev/null +++ b/src/shared/cronJobs.ts @@ -0,0 +1,114 @@ +export const CRON_JOBS_DEFAULT_CRON_EXPR = '0 9 * * *' +export const CRON_JOBS_DEFAULT_TIMEZONE = 'UTC' + +export const CRON_JOB_RUN_STATUSES = [ + 'queued', + 'running', + 'completed', + 'failed', + 'cancelled' +] as const +export type CronJobRunStatus = (typeof CRON_JOB_RUN_STATUSES)[number] + +export const CRON_JOB_RUN_REASONS = ['scheduled', 'manual'] as const +export type CronJobRunReason = (typeof CRON_JOB_RUN_REASONS)[number] + +export const CRON_JOBS_SCHEDULER_STATES = [ + 'stopped', + 'starting', + 'running', + 'idle', + 'error' +] as const +export type CronJobsSchedulerState = (typeof CRON_JOBS_SCHEDULER_STATES)[number] + +export interface CronJob { + id: string + name: string + enabled: boolean + cronExpr: string + timezone: string + agentId: string | null + nextRunAt: number | null + createdAt: number + updatedAt: number +} + +export interface CronJobRun { + id: string + jobId: string + scheduledAt: number + queuedAt: number + startedAt: number | null + completedAt: number | null + status: CronJobRunStatus + reason: CronJobRunReason + error: string | null + createdAt: number + updatedAt: number +} + +export interface CronJobsSchedulerStatus { + state: CronJobsSchedulerState + pid: number | null + enabledJobCount: number + nextRunAt: number | null + lastHeartbeatAt: number | null + lastError: string | null + restartAttempts: number + updatedAt: number +} + +export type SchedulerCommand = + | { + type: 'START' + now: number + } + | { + type: 'RECONCILE' + reason: string + now: number + } + | { + type: 'RUN_NOW' + jobId: string + now: number + } + | { + type: 'STOP' + reason: string + now: number + } + +export type SchedulerEvent = + | { + type: 'READY' + pid: number | null + now: number + } + | { + type: 'HEARTBEAT' + enabledJobCount: number + nextRunAt: number | null + now: number + } + | { + type: 'RUN_DUE' + jobId: string + runId: string + scheduledAt: number + reason: CronJobRunReason + now: number + } + | { + type: 'IDLE' + enabledJobCount: number + nextRunAt: number | null + now: number + } + | { + type: 'ERROR' + message: string + stack?: string + now: number + } diff --git a/src/shared/settingsNavigation.ts b/src/shared/settingsNavigation.ts index ba0f55743..7ed8f0417 100644 --- a/src/shared/settingsNavigation.ts +++ b/src/shared/settingsNavigation.ts @@ -192,7 +192,7 @@ export const SETTINGS_NAVIGATION_ITEMS: SettingsNavigationItem[] = [ routeName: 'settings-scheduled-tasks', path: '/scheduled-tasks', titleKey: 'routes.settings-scheduled-tasks', - icon: 'lucide:clock-9', + icon: 'lucide:calendar-clock', position: 5.6, groupKey: 'tools', keywords: [ @@ -201,10 +201,13 @@ export const SETTINGS_NAVIGATION_ITEMS: SettingsNavigationItem[] = [ 'reminder', 'timer', 'cron', + 'cron jobs', + 'agent jobs', '定时', '提醒', '计划', - '定时任务' + '定时任务', + '任务调度' ] }, { diff --git a/test/main/presenter/cronJobs.test.ts b/test/main/presenter/cronJobs.test.ts new file mode 100644 index 000000000..c57756c70 --- /dev/null +++ b/test/main/presenter/cronJobs.test.ts @@ -0,0 +1,315 @@ +import { EventEmitter } from 'node:events' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import type { CronJobsSchedulerStatus } from '@shared/cronJobs' + +const sqliteModule = await import('better-sqlite3-multiple-ciphers').catch(() => null) +const cronJobsTableModule = sqliteModule + ? await import('@/presenter/sqlitePresenter/tables/cronJobs').catch(() => null) + : null +const cronJobRunsTableModule = sqliteModule + ? await import('@/presenter/sqlitePresenter/tables/cronJobRuns').catch(() => null) + : null +const repositoryModule = + sqliteModule && cronJobsTableModule && cronJobRunsTableModule + ? await import('@/presenter/cronJobs/repository').catch(() => null) + : null +const serviceModule = repositoryModule + ? await import('@/presenter/cronJobs').catch(() => null) + : null +const schedulerManagerModule = repositoryModule + ? await import('@/presenter/cronJobs/schedulerProcessManager').catch(() => null) + : null +const schedulerUtilityHostModule = repositoryModule + ? await import('@/presenter/cronJobs/schedulerUtilityHost').catch(() => null) + : null + +const Database = sqliteModule?.default +const CronJobsTable = cronJobsTableModule?.CronJobsTable +const CronJobRunsTable = cronJobRunsTableModule?.CronJobRunsTable +const CronJobsRepository = repositoryModule?.CronJobsRepository +const CronJobsService = serviceModule?.CronJobsService +const SchedulerProcessManager = schedulerManagerModule?.SchedulerProcessManager +const CronJobsSchedulerUtilityHost = schedulerUtilityHostModule?.CronJobsSchedulerUtilityHost +const DatabaseCtor = Database! +const CronJobsTableCtor = CronJobsTable! +const CronJobRunsTableCtor = CronJobRunsTable! +const CronJobsRepositoryCtor = CronJobsRepository! +const CronJobsServiceCtor = CronJobsService! +const SchedulerProcessManagerCtor = SchedulerProcessManager! +const CronJobsSchedulerUtilityHostCtor = CronJobsSchedulerUtilityHost! + +let sqliteAvailable = false +if (Database) { + try { + const smokeDb = new Database(':memory:') + smokeDb.close() + sqliteAvailable = true + } catch { + sqliteAvailable = false + } +} + +const describeIfSqlite = + sqliteAvailable && + CronJobsTable && + CronJobRunsTable && + CronJobsRepository && + CronJobsService && + SchedulerProcessManager && + CronJobsSchedulerUtilityHost + ? describe + : describe.skip + +const createHarness = () => { + const db = new DatabaseCtor(':memory:') + const cronJobsTable = new CronJobsTableCtor(db) + const cronJobRunsTable = new CronJobRunsTableCtor(db) + cronJobsTable.createTable() + cronJobRunsTable.createTable() + + const sqlitePresenter = { + cronJobsTable, + cronJobRunsTable, + getDatabase: () => db, + getDatabasePath: () => ':memory:', + getDatabasePassword: () => undefined + } + + return { db, sqlitePresenter } +} + +const baseStatus = (): CronJobsSchedulerStatus => ({ + state: 'idle', + pid: null, + enabledJobCount: 0, + nextRunAt: null, + lastHeartbeatAt: null, + lastError: null, + restartAttempts: 0, + updatedAt: 1 +}) + +describeIfSqlite('Cron Jobs persistence and service', () => { + it('persists jobs, snapshots enabled rows, and cascades run deletion', () => { + const { db, sqlitePresenter } = createHarness() + try { + const repository = new CronJobsRepositoryCtor(sqlitePresenter as never) + const nextRunAt = 1_800_000_000_000 + const job = repository.upsertJob({ + name: 'Daily sync', + enabled: false, + cronExpr: '0 9 * * *', + timezone: 'UTC', + agentId: null, + nextRunAt + }) + + expect(repository.listJobs()).toEqual([ + expect.objectContaining({ + id: job.id, + name: 'Daily sync', + enabled: false, + nextRunAt + }) + ]) + expect(repository.getSchedulerSnapshot()).toEqual({ + enabledJobCount: 0, + nextRunAt: null + }) + + const enabled = repository.setJobEnabled(job.id, true) + expect(enabled.enabled).toBe(true) + expect(repository.getSchedulerSnapshot()).toEqual({ + enabledJobCount: 1, + nextRunAt + }) + + const run = repository.queueRun({ + jobId: job.id, + scheduledAt: nextRunAt, + reason: 'scheduled' + }) + repository.markRunRunning(run.id) + const completed = repository.markRunCompleted(run.id) + expect(completed.status).toBe('completed') + expect(repository.listRunsByJob(job.id)).toHaveLength(1) + + repository.deleteJob(job.id) + expect(repository.listJobs()).toHaveLength(0) + expect(db.prepare('SELECT COUNT(*) AS count FROM cron_job_runs').get()).toEqual({ + count: 0 + }) + } finally { + db.close() + } + }) + + it('queues and completes manual runs through the service without starting a real process', async () => { + const { db, sqlitePresenter } = createHarness() + try { + const status = baseStatus() + const schedulerManager = { + reconcile: vi.fn().mockResolvedValue(status), + restart: vi.fn().mockResolvedValue(status), + stop: vi.fn().mockResolvedValue(status), + getStatus: vi.fn(() => status) + } + const service = new CronJobsServiceCtor({ + sqlitePresenter: sqlitePresenter as never, + schedulerManager: schedulerManager as never + }) + + const { job } = await service.upsert({ + name: 'Manual smoke', + enabled: true, + cronExpr: '0 9 * * *', + timezone: 'UTC', + agentId: null, + nextRunAt: null + }) + const result = await service.runNow(job.id) + + expect(result.run).toEqual( + expect.objectContaining({ + jobId: job.id, + status: 'completed', + reason: 'manual', + error: null + }) + ) + expect(schedulerManager.reconcile).toHaveBeenCalledWith('job-upsert') + expect(schedulerManager.reconcile).toHaveBeenCalledWith('manual-run') + } finally { + db.close() + } + }) + + it('queues each due scheduled run once and clears next_run_at in the utility host', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepchat-cron-jobs-')) + const dbPath = path.join(tempDir, 'agent.db') + const db = new DatabaseCtor(dbPath) + const events: unknown[] = [] + + try { + const cronJobsTable = new CronJobsTableCtor(db) + const cronJobRunsTable = new CronJobRunsTableCtor(db) + cronJobsTable.createTable() + cronJobRunsTable.createTable() + const job = cronJobsTable.upsert({ + name: 'Due job', + enabled: true, + cronExpr: '0 9 * * *', + timezone: 'UTC', + agentId: null, + nextRunAt: 1, + now: 1 + }) + + const host = new CronJobsSchedulerUtilityHostCtor({ + dbPath, + postMessage: (message) => events.push(message) + }) + host.start() + host.reconcile() + host.shutdown() + + expect(events.filter((event) => (event as { type?: string }).type === 'RUN_DUE')).toEqual([ + expect.objectContaining({ + jobId: job.id, + scheduledAt: 1, + reason: 'scheduled' + }) + ]) + expect(cronJobsTable.get(job.id)?.next_run_at).toBeNull() + expect(db.prepare('SELECT COUNT(*) AS count FROM cron_job_runs').get()).toEqual({ + count: 1 + }) + } finally { + db.close() + fs.rmSync(tempDir, { recursive: true, force: true }) + } + }) + + it('starts the scheduler only for enabled jobs and stops after idle heartbeat', async () => { + vi.useFakeTimers() + try { + class FakeHost extends EventEmitter { + pid = 123 + killed = false + posted: unknown[] = [] + + postMessage(message: unknown): void { + this.posted.push(message) + } + + kill(): boolean { + this.killed = true + this.emit('exit', 0) + return true + } + } + + let snapshot = { + enabledJobCount: 0, + nextRunAt: null as number | null + } + const host = new FakeHost() + const spawnHost = vi.fn(async () => host) + const manager = new SchedulerProcessManagerCtor({ + dbPath: ':memory:', + getSnapshot: () => snapshot, + onRunDue: vi.fn(), + idleShutdownMs: 10, + spawnHost: spawnHost as never + }) + + expect(await manager.reconcile('initial')).toEqual( + expect.objectContaining({ + state: 'idle', + enabledJobCount: 0 + }) + ) + expect(spawnHost).not.toHaveBeenCalled() + + snapshot = { + enabledJobCount: 1, + nextRunAt: 100 + } + expect(await manager.reconcile('enabled')).toEqual( + expect.objectContaining({ + state: 'running', + pid: 123 + }) + ) + expect(host.posted).toEqual([ + expect.objectContaining({ type: 'START' }), + expect.objectContaining({ type: 'RECONCILE', reason: 'enabled' }) + ]) + + snapshot = { + enabledJobCount: 0, + nextRunAt: null + } + host.emit('message', { + type: 'HEARTBEAT', + enabledJobCount: 0, + nextRunAt: null, + now: 200 + }) + await vi.advanceTimersByTimeAsync(10) + + expect(host.killed).toBe(true) + expect(manager.getStatus()).toEqual( + expect.objectContaining({ + state: 'stopped', + pid: null + }) + ) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/test/main/routes/dispatcher.test.ts b/test/main/routes/dispatcher.test.ts index 20864a0e9..3564d0396 100644 --- a/test/main/routes/dispatcher.test.ts +++ b/test/main/routes/dispatcher.test.ts @@ -1155,6 +1155,50 @@ function createRuntime() { settings: { enabled: false, tasks: [{ id }] } })) } + const cronJob = { + id: 'cron-1', + name: 'Cron smoke', + enabled: true, + cronExpr: '0 9 * * *', + timezone: 'UTC', + agentId: null, + nextRunAt: null, + createdAt: 1, + updatedAt: 2 + } + const cronRun = { + id: 'run-1', + jobId: 'cron-1', + scheduledAt: 3, + queuedAt: 3, + startedAt: 4, + completedAt: 5, + status: 'completed' as const, + reason: 'manual' as const, + error: null, + createdAt: 3, + updatedAt: 5 + } + const cronStatus = { + state: 'idle' as const, + pid: null, + enabledJobCount: 1, + nextRunAt: null, + lastHeartbeatAt: null, + lastError: null, + restartAttempts: 0, + updatedAt: 6 + } + const cronJobs = { + list: vi.fn(async () => ({ jobs: [cronJob], schedulerStatus: cronStatus })), + upsert: vi.fn(async () => ({ job: cronJob, schedulerStatus: cronStatus })), + delete: vi.fn(async () => cronStatus), + toggle: vi.fn(async () => ({ job: cronJob, schedulerStatus: cronStatus })), + runNow: vi.fn(async () => ({ job: cronJob, run: cronRun, schedulerStatus: cronStatus })), + getSchedulerStatus: vi.fn(() => cronStatus), + reconcileScheduler: vi.fn(async () => cronStatus), + restartScheduler: vi.fn(async () => cronStatus) + } setDeepchatEventWindowPresenter(windowPresenter) @@ -1180,7 +1224,8 @@ function createRuntime() { workspacePresenter, yoBrowserPresenter, tabPresenter, - scheduledTasks + scheduledTasks, + cronJobs }), configPresenter, llmProviderPresenter, @@ -1200,11 +1245,115 @@ function createRuntime() { knowledgePresenter, workspacePresenter, yoBrowserPresenter, - tabPresenter + tabPresenter, + cronJobs } } describe('dispatchDeepchatRoute', () => { + it('dispatches Cron Jobs routes through the runtime service', async () => { + const { runtime, cronJobs } = createRuntime() + const context = { + webContentsId: 42, + windowId: 7 + } + + const listResult = await dispatchDeepchatRoute(runtime, 'cronJobs.list', {}, context) + const upsertResult = await dispatchDeepchatRoute( + runtime, + 'cronJobs.upsert', + { + name: 'Cron smoke', + enabled: true, + cronExpr: '0 9 * * *', + timezone: 'UTC', + agentId: null, + nextRunAt: null + }, + context + ) + const toggleResult = await dispatchDeepchatRoute( + runtime, + 'cronJobs.toggle', + { + id: 'cron-1', + enabled: false + }, + context + ) + const runNowResult = await dispatchDeepchatRoute( + runtime, + 'cronJobs.runNow', + { + id: 'cron-1' + }, + context + ) + const statusResult = await dispatchDeepchatRoute( + runtime, + 'cronJobs.getSchedulerStatus', + {}, + context + ) + const reconcileResult = await dispatchDeepchatRoute( + runtime, + 'cronJobs.reconcileScheduler', + { + reason: 'test' + }, + context + ) + const restartResult = await dispatchDeepchatRoute( + runtime, + 'cronJobs.restartScheduler', + {}, + context + ) + const deleteResult = await dispatchDeepchatRoute( + runtime, + 'cronJobs.delete', + { + id: 'cron-1' + }, + context + ) + + expect(listResult).toEqual({ + jobs: [expect.objectContaining({ id: 'cron-1' })], + schedulerStatus: expect.objectContaining({ state: 'idle' }) + }) + expect(upsertResult).toEqual({ + job: expect.objectContaining({ id: 'cron-1' }), + schedulerStatus: expect.objectContaining({ state: 'idle' }) + }) + expect(toggleResult).toEqual({ + job: expect.objectContaining({ id: 'cron-1' }), + schedulerStatus: expect.objectContaining({ state: 'idle' }) + }) + expect(runNowResult).toEqual({ + job: expect.objectContaining({ id: 'cron-1' }), + run: expect.objectContaining({ id: 'run-1', status: 'completed' }), + schedulerStatus: expect.objectContaining({ state: 'idle' }) + }) + expect(statusResult).toEqual({ + schedulerStatus: expect.objectContaining({ state: 'idle' }) + }) + expect(reconcileResult).toEqual({ + schedulerStatus: expect.objectContaining({ state: 'idle' }) + }) + expect(restartResult).toEqual({ + schedulerStatus: expect.objectContaining({ state: 'idle' }) + }) + expect(deleteResult).toEqual({ + schedulerStatus: expect.objectContaining({ state: 'idle' }) + }) + expect(cronJobs.toggle).toHaveBeenCalledWith('cron-1', false) + expect(cronJobs.runNow).toHaveBeenCalledWith('cron-1') + expect(cronJobs.reconcileScheduler).toHaveBeenCalledWith('test') + expect(cronJobs.restartScheduler).toHaveBeenCalledTimes(1) + expect(cronJobs.delete).toHaveBeenCalledWith('cron-1') + }) + it('ensures the built-in chat workspace before startup bootstrap returns', async () => { const { runtime, settings, projectPresenter } = createRuntime() vi.mocked(projectPresenter.ensureDefaultWorkspace).mockImplementation(async () => { diff --git a/test/renderer/api/cronJobsClient.test.ts b/test/renderer/api/cronJobsClient.test.ts new file mode 100644 index 000000000..8e3e3938d --- /dev/null +++ b/test/renderer/api/cronJobsClient.test.ts @@ -0,0 +1,109 @@ +import type { DeepchatBridge } from '@shared/contracts/bridge' +import { createCronJobsClient } from '../../../src/renderer/api/CronJobsClient' + +const schedulerStatus = { + state: 'idle' as const, + pid: null, + enabledJobCount: 1, + nextRunAt: null, + lastHeartbeatAt: null, + lastError: null, + restartAttempts: 0, + updatedAt: 1 +} + +const job = { + id: 'cron-1', + name: 'Cron smoke', + enabled: true, + cronExpr: '0 9 * * *', + timezone: 'UTC', + agentId: null, + nextRunAt: null, + createdAt: 1, + updatedAt: 2 +} + +const run = { + id: 'run-1', + jobId: 'cron-1', + scheduledAt: 3, + queuedAt: 3, + startedAt: 4, + completedAt: 5, + status: 'completed' as const, + reason: 'manual' as const, + error: null, + createdAt: 3, + updatedAt: 5 +} + +describe('CronJobsClient', () => { + it('invokes Cron Jobs routes and parses typed responses', async () => { + const bridge: DeepchatBridge = { + invoke: vi.fn(async (routeName: string) => { + switch (routeName) { + case 'cronJobs.list': + return { jobs: [job], schedulerStatus } + case 'cronJobs.upsert': + case 'cronJobs.toggle': + return { job, schedulerStatus } + case 'cronJobs.runNow': + return { job, run, schedulerStatus } + case 'cronJobs.delete': + case 'cronJobs.getSchedulerStatus': + case 'cronJobs.reconcileScheduler': + case 'cronJobs.restartScheduler': + return { schedulerStatus } + default: + throw new Error(`Unexpected route: ${routeName}`) + } + }), + on: vi.fn(() => () => undefined) + } + const client = createCronJobsClient(bridge) + + expect(await client.list()).toEqual({ jobs: [job], schedulerStatus }) + expect( + await client.upsert({ + name: job.name, + enabled: job.enabled, + cronExpr: job.cronExpr, + timezone: job.timezone, + agentId: null, + nextRunAt: null + }) + ).toEqual({ job, schedulerStatus }) + expect(await client.toggle(job.id, false)).toEqual({ job, schedulerStatus }) + expect(await client.runNow(job.id)).toEqual({ job, run, schedulerStatus }) + expect(await client.remove(job.id)).toEqual(schedulerStatus) + expect(await client.getSchedulerStatus()).toEqual(schedulerStatus) + expect(await client.reconcileScheduler('test')).toEqual(schedulerStatus) + expect(await client.restartScheduler()).toEqual(schedulerStatus) + + expect(bridge.invoke).toHaveBeenNthCalledWith(1, 'cronJobs.list', {}) + expect(bridge.invoke).toHaveBeenNthCalledWith(2, 'cronJobs.upsert', { + name: job.name, + enabled: job.enabled, + cronExpr: job.cronExpr, + timezone: job.timezone, + agentId: null, + nextRunAt: null + }) + expect(bridge.invoke).toHaveBeenNthCalledWith(3, 'cronJobs.toggle', { + id: job.id, + enabled: false + }) + expect(bridge.invoke).toHaveBeenNthCalledWith(4, 'cronJobs.runNow', { + id: job.id + }) + expect(bridge.invoke).toHaveBeenNthCalledWith(5, 'cronJobs.delete', { + id: job.id + }) + expect(bridge.invoke).toHaveBeenNthCalledWith(6, 'cronJobs.getSchedulerStatus', {}) + expect(bridge.invoke).toHaveBeenNthCalledWith(7, 'cronJobs.reconcileScheduler', { + reason: 'test' + }) + expect(bridge.invoke).toHaveBeenNthCalledWith(8, 'cronJobs.restartScheduler', {}) + }) +}) From 5f683fec34ec897fade73ecff0811abc62aef310 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Fri, 3 Jul 2026 12:42:41 +0800 Subject: [PATCH 03/36] feat(cron-jobs): add cron schedule engine --- .../cron-agent-jobs-phase-1-scheduler/plan.md | 6 +- .../cron-agent-jobs-phase-1-scheduler/spec.md | 4 +- .../tasks.md | 3 +- .../tasks.md | 30 +-- package.json | 1 + .../cronJobs/cronExpressionService.ts | 185 ++++++++++++++++++ src/main/presenter/cronJobs/index.ts | 90 ++++++++- src/main/presenter/cronJobs/repository.ts | 26 ++- .../cronJobs/schedulerUtilityHost.ts | 79 ++++++-- .../sqlitePresenter/tables/cronJobs.ts | 86 +++++++- src/main/routes/index.ts | 12 ++ src/renderer/api/CronJobsClient.ts | 23 ++- .../settings/components/CronJobsSettings.vue | 158 ++++++++------- src/renderer/src/i18n/en-US/settings.json | 2 +- src/renderer/src/i18n/zh-CN/settings.json | 2 +- src/renderer/src/i18n/zh-HK/settings.json | 2 +- src/renderer/src/i18n/zh-TW/settings.json | 2 +- src/shared/contracts/routes.ts | 4 + .../contracts/routes/cronJobs.routes.ts | 38 +++- src/shared/cronJobs.ts | 27 +++ test/main/presenter/cronJobs.test.ts | 166 +++++++++++++++- .../sqlitePresenter.migrationSqlSplit.test.ts | 2 + test/main/routes/dispatcher.test.ts | 42 +++- test/renderer/api/cronJobsClient.test.ts | 37 +++- 24 files changed, 893 insertions(+), 134 deletions(-) create mode 100644 src/main/presenter/cronJobs/cronExpressionService.ts diff --git a/docs/features/cron-agent-jobs-phase-1-scheduler/plan.md b/docs/features/cron-agent-jobs-phase-1-scheduler/plan.md index 3e4e4a15d..b6730a5ef 100644 --- a/docs/features/cron-agent-jobs-phase-1-scheduler/plan.md +++ b/docs/features/cron-agent-jobs-phase-1-scheduler/plan.md @@ -128,11 +128,15 @@ The first UI is intentionally operational: | Enabled: 2 | Next run: 2026-07-03 09:00 | | Last heartbeat: 3s ago | | | -| [Reconcile] [Restart] | +| [Restart Timer] | +---------------------------------------------------------+ ``` No nested cards. Use existing settings shell, shadcn controls, lucide icons, and i18n keys. +The UI exposes one user-facing scheduler recovery action; lower-level reconcile remains an +internal route for lifecycle hooks and diagnostics. +Schedule-derived next runs are displayed as one read-only preview. The page must not expose manual +date-time editing or a clear-next-run action in Phase 1. ## Compatibility diff --git a/docs/features/cron-agent-jobs-phase-1-scheduler/spec.md b/docs/features/cron-agent-jobs-phase-1-scheduler/spec.md index 9aee0b5c0..e6a508072 100644 --- a/docs/features/cron-agent-jobs-phase-1-scheduler/spec.md +++ b/docs/features/cron-agent-jobs-phase-1-scheduler/spec.md @@ -36,6 +36,8 @@ core. - App startup and OS resume trigger `RECONCILE`. - Utility-process crash is detected and restarted with bounded backoff while enabled jobs remain. - Scheduler status is available through a typed route and rendered in the Cron Jobs page. +- The Cron Jobs page renders schedule-derived next runs as a read-only indicator, not a + user-editable schedule setting. ## UX Shape @@ -47,7 +49,7 @@ Running state: | Scheduler: Running | utilityProcess | pid 18421 | | Enabled jobs: 2 | Next run: 2026-07-03 09:00 | | | -| [New Job] [Reconcile Now] [Restart Scheduler] | +| [New Job] [Restart Timer] | +---------------------------------------------------------+ ``` diff --git a/docs/features/cron-agent-jobs-phase-1-scheduler/tasks.md b/docs/features/cron-agent-jobs-phase-1-scheduler/tasks.md index ab81c5d4c..5ec33e545 100644 --- a/docs/features/cron-agent-jobs-phase-1-scheduler/tasks.md +++ b/docs/features/cron-agent-jobs-phase-1-scheduler/tasks.md @@ -19,7 +19,8 @@ - [x] Register `cronJobs.*` routes in shared contracts and main route dispatcher. - [x] Add `CronJobsClient`. -- [x] Add minimal Cron Jobs settings page with scheduler status, reconcile, and restart actions. +- [x] Add minimal Cron Jobs settings page with scheduler status and one restart-timer action. +- [x] Change per-job next run from an editable date-time field to a read-only indicator. - [x] Add i18n keys for all visible strings. ## Tests And Validation diff --git a/docs/features/cron-agent-jobs-phase-2-cron-trigger-engine/tasks.md b/docs/features/cron-agent-jobs-phase-2-cron-trigger-engine/tasks.md index 86ceb17c2..0b14e56d9 100644 --- a/docs/features/cron-agent-jobs-phase-2-cron-trigger-engine/tasks.md +++ b/docs/features/cron-agent-jobs-phase-2-cron-trigger-engine/tasks.md @@ -2,28 +2,30 @@ ## Parser Integration -- [ ] Add and lock the cron parser dependency. -- [ ] Implement `CronExpressionService` with validate, preview, next-run, misfire, and preset +- [x] Add and lock the cron parser dependency. +- [x] Implement `CronExpressionService` with validate, preview, next-run, misfire, and preset conversion methods. -- [ ] Add tests for minute interval, weekdays, monthly last day, nth weekday, timezone, DST, and +- [x] Add tests for minute interval, weekdays, monthly last day, nth weekday, timezone, DST, and invalid expressions. ## Persistence And Scheduler -- [ ] Add schedule-related migrations to `cron_jobs`. -- [ ] Recompute `next_run_at` on every create, update, toggle, run completion, and reconcile. -- [ ] Implement `skip` and `run_once` misfire behavior. -- [ ] Keep utility-process scans based on `next_run_at <= now`. +- [x] Add schedule-related migrations to `cron_jobs`. +- [x] Recompute `next_run_at` on create, update, toggle, list, and scheduler reconcile/due + advancement. +- [x] Implement `skip` and `run_once` misfire behavior. +- [x] Keep utility-process scans based on `next_run_at <= now`. ## Routes And UI -- [ ] Add `cronJobs.previewSchedule` and `cronJobs.validateSchedule` route contracts. -- [ ] Update `CronJobsClient`. -- [ ] Build the schedule editor with preset and raw cron modes. -- [ ] Add next-runs preview, loading, empty, and parser-error states. -- [ ] Add i18n keys. +- [x] Add `cronJobs.previewSchedule` and `cronJobs.validateSchedule` route contracts. +- [x] Update `CronJobsClient`. +- [x] Build the raw cron editor and computed next-run indicator. +- [ ] Build preset controls that write only cron expressions. +- [x] Add next-runs preview, loading, empty, and parser-error states. +- [x] Reuse existing i18n keys where no new visible copy is needed. ## Validation -- [ ] Run targeted main and renderer tests. -- [ ] Run `pnpm run format`, `pnpm run i18n`, and `pnpm run lint`. +- [x] Run targeted main and renderer tests. +- [x] Run `pnpm run format`, `pnpm run i18n`, and `pnpm run lint`. diff --git a/package.json b/package.json index b32a85f60..47d72fc78 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "axios": "^1.16.1", "better-sqlite3-multiple-ciphers": "12.9.0", "compare-versions": "^6.1.1", + "cron-parser": "^5.6.1", "cross-spawn": "^7.0.6", "diff": "^8.0.4", "electron-log": "^5.4.4", diff --git a/src/main/presenter/cronJobs/cronExpressionService.ts b/src/main/presenter/cronJobs/cronExpressionService.ts new file mode 100644 index 000000000..cc0dad2e5 --- /dev/null +++ b/src/main/presenter/cronJobs/cronExpressionService.ts @@ -0,0 +1,185 @@ +import { CronExpressionParser } from 'cron-parser' +import type { + CronJobMisfirePolicy, + CronSchedulePreset, + CronSchedulePreview, + CronScheduleValidation +} from '@shared/cronJobs' + +export interface CronJobSchedule { + cronExpr: string + timezone: string + misfirePolicy?: CronJobMisfirePolicy + maxCatchUpRuns?: number | null +} + +export interface DueRunReconciliation { + scheduledAts: number[] + nextRunAt: number | null + error: string | null +} + +const DEFAULT_PREVIEW_COUNT = 5 +const MAX_PREVIEW_COUNT = 10 +const DEFAULT_DUE_GRACE_MS = 60_000 + +const toErrorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error) + +const parseTime = (time: string): { hour: number; minute: number } => { + const match = /^(\d{1,2}):(\d{2})$/.exec(time) + if (!match) { + throw new Error(`Invalid time: ${time}`) + } + + const hour = Number(match[1]) + const minute = Number(match[2]) + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { + throw new Error(`Invalid time: ${time}`) + } + + return { hour, minute } +} + +export class CronExpressionService { + validate(cronExpr: string, timezone: string, from = Date.now()): CronScheduleValidation { + try { + return { + valid: true, + error: null, + nextRunAt: this.computeNextRunAt({ cronExpr, timezone }, from) + } + } catch (error) { + return { + valid: false, + error: toErrorMessage(error), + nextRunAt: null + } + } + } + + preview( + cronExpr: string, + timezone: string, + count = DEFAULT_PREVIEW_COUNT, + from = Date.now() + ): CronSchedulePreview { + try { + const safeCount = Math.min(Math.max(Math.trunc(count), 1), MAX_PREVIEW_COUNT) + const interval = CronExpressionParser.parse(cronExpr, { + currentDate: from, + tz: timezone + }) + return { + runs: interval.take(safeCount).map((date) => date.getTime()), + error: null + } + } catch (error) { + return { + runs: [], + error: toErrorMessage(error) + } + } + } + + computeNextRunAt(schedule: Pick, from = Date.now()) { + const interval = CronExpressionParser.parse(schedule.cronExpr, { + currentDate: from, + tz: schedule.timezone + }) + return interval.next().getTime() + } + + reconcileDueRun( + schedule: CronJobSchedule, + scheduledAt: number, + now = Date.now(), + dueGraceMs = DEFAULT_DUE_GRACE_MS + ): DueRunReconciliation { + try { + const scheduledAts = + schedule.misfirePolicy === 'run_once' + ? this.computeCatchUpRuns(schedule, scheduledAt, now) + : now - scheduledAt <= dueGraceMs + ? [scheduledAt] + : [] + + return { + scheduledAts, + nextRunAt: this.computeNextRunAt(schedule, now), + error: null + } + } catch (error) { + return { + scheduledAts: [], + nextRunAt: null, + error: toErrorMessage(error) + } + } + } + + presetToCron(preset: CronSchedulePreset): string { + switch (preset.type) { + case 'every_n_minutes': + if (!Number.isInteger(preset.n) || preset.n < 1 || preset.n > 59) { + throw new Error(`Invalid minute interval: ${preset.n}`) + } + return `*/${preset.n} * * * *` + case 'hourly': + if (!Number.isInteger(preset.minute) || preset.minute < 0 || preset.minute > 59) { + throw new Error(`Invalid minute: ${preset.minute}`) + } + return `${preset.minute} * * * *` + case 'daily': { + const { hour, minute } = parseTime(preset.time) + return `${minute} ${hour} * * *` + } + case 'weekdays': { + const { hour, minute } = parseTime(preset.time) + return `${minute} ${hour} * * 1-5` + } + case 'weekly': { + const { hour, minute } = parseTime(preset.time) + const days = [...new Set(preset.days)].sort((left, right) => left - right) + if (days.length === 0 || days.some((day) => !Number.isInteger(day) || day < 0 || day > 7)) { + throw new Error('Invalid weekly days') + } + return `${minute} ${hour} * * ${days.join(',')}` + } + case 'monthly': { + const { hour, minute } = parseTime(preset.time) + const day = preset.day === 'last' ? 'L' : preset.day + if (typeof day === 'number' && (!Number.isInteger(day) || day < 1 || day > 31)) { + throw new Error(`Invalid monthly day: ${day}`) + } + return `${minute} ${hour} ${day} * *` + } + case 'custom': + return preset.cronExpr + } + } + + private computeCatchUpRuns( + schedule: CronJobSchedule, + scheduledAt: number, + now: number + ): number[] { + const maxCatchUpRuns = schedule.maxCatchUpRuns ?? 1 + const safeLimit = Math.min(Math.max(Math.trunc(maxCatchUpRuns), 1), MAX_PREVIEW_COUNT) + const interval = CronExpressionParser.parse(schedule.cronExpr, { + currentDate: Math.max(0, scheduledAt - 1_000), + tz: schedule.timezone + }) + const scheduledAts: number[] = [] + + for (const date of interval.take(safeLimit)) { + const timestamp = date.getTime() + if (timestamp > now) { + break + } + scheduledAts.push(timestamp) + } + + return scheduledAts + } +} diff --git a/src/main/presenter/cronJobs/index.ts b/src/main/presenter/cronJobs/index.ts index f79ec9582..caae5b279 100644 --- a/src/main/presenter/cronJobs/index.ts +++ b/src/main/presenter/cronJobs/index.ts @@ -1,8 +1,15 @@ import type { PowerMonitor } from 'electron' -import type { CronJob, CronJobRun, CronJobsSchedulerStatus } from '@shared/cronJobs' +import type { + CronJob, + CronJobRun, + CronJobsSchedulerStatus, + CronSchedulePreview, + CronScheduleValidation +} from '@shared/cronJobs' import type { cronJobsUpsertInputSchema } from '@shared/contracts/routes/cronJobs.routes' import type { z } from 'zod' import type { SQLitePresenter } from '../sqlitePresenter' +import { CronExpressionService } from './cronExpressionService' import { CronJobsRepository } from './repository' import { SchedulerProcessManager, @@ -15,6 +22,7 @@ export type CronJobsUpsertInput = z.input export interface CronJobsServiceDeps { sqlitePresenter: SQLitePresenter schedulerManager?: SchedulerProcessManager + scheduleService?: CronExpressionService createSchedulerManager?: ( deps: Omit ) => SchedulerProcessManager @@ -24,6 +32,7 @@ export interface CronJobsServiceDeps { export class CronJobsService { private readonly repository: CronJobsRepository private readonly schedulerManager: SchedulerProcessManager + private readonly scheduleService: CronExpressionService private started = false private powerMonitor: Pick | null = null private readonly resumeHandler = () => { @@ -32,6 +41,7 @@ export class CronJobsService { constructor(deps: CronJobsServiceDeps) { this.repository = new CronJobsRepository(deps.sqlitePresenter) + this.scheduleService = deps.scheduleService ?? new CronExpressionService() const managerDeps: Omit = { dbPath: deps.sqlitePresenter.getDatabasePath(), dbPassword: deps.sqlitePresenter.getDatabasePassword(), @@ -80,7 +90,12 @@ export class CronJobsService { job: CronJob schedulerStatus: CronJobsSchedulerStatus }> { - const job = this.repository.upsertJob(input) + const scheduleState = this.computeScheduleState(input, Date.now()) + const job = this.repository.upsertJob({ + ...input, + nextRunAt: scheduleState.nextRunAt, + scheduleError: scheduleState.error + }) const schedulerStatus = await this.reconcileScheduler('job-upsert') return { job, schedulerStatus } } @@ -97,7 +112,20 @@ export class CronJobsService { job: CronJob schedulerStatus: CronJobsSchedulerStatus }> { - const job = this.repository.setJobEnabled(id, enabled) + const existing = this.repository.requireJob(id) + const scheduleState = this.computeScheduleState( + { + ...existing, + enabled + }, + Date.now() + ) + const job = this.repository.upsertJob({ + ...existing, + enabled, + nextRunAt: scheduleState.nextRunAt, + scheduleError: scheduleState.error + }) const schedulerStatus = await this.reconcileScheduler('job-toggle') return { job, schedulerStatus } } @@ -129,6 +157,7 @@ export class CronJobsService { } async reconcileScheduler(reason = 'manual'): Promise { + this.reconcileStoredSchedules(Date.now()) return await this.schedulerManager.reconcile(reason) } @@ -136,6 +165,23 @@ export class CronJobsService { return await this.schedulerManager.restart() } + validateSchedule(input: { + cronExpr: string + timezone: string + from?: number + }): CronScheduleValidation { + return this.scheduleService.validate(input.cronExpr, input.timezone, input.from) + } + + previewSchedule(input: { + cronExpr: string + timezone: string + count?: number + from?: number + }): CronSchedulePreview { + return this.scheduleService.preview(input.cronExpr, input.timezone, input.count, input.from) + } + private async attachPowerMonitor(): Promise { if (this.powerMonitor) { this.powerMonitor.off('resume', this.resumeHandler) @@ -153,6 +199,44 @@ export class CronJobsService { } } + private computeScheduleState( + input: Pick | CronJobsUpsertInput, + now: number + ): { nextRunAt: number | null; error: string | null } { + const validation = this.scheduleService.validate(input.cronExpr, input.timezone, now) + if (!validation.valid && input.enabled) { + throw new Error(validation.error ?? 'Invalid cron schedule.') + } + + return { + nextRunAt: validation.nextRunAt, + error: validation.error + } + } + + private reconcileStoredSchedules(now: number): void { + for (const job of this.repository.listJobs()) { + if (job.enabled && job.nextRunAt !== null && job.scheduleError === null) { + continue + } + if ( + !job.enabled && + job.nextRunAt !== null && + job.nextRunAt > now && + job.scheduleError === null + ) { + continue + } + + const validation = this.scheduleService.validate(job.cronExpr, job.timezone, now) + this.repository.updateScheduleState(job.id, { + nextRunAt: validation.nextRunAt, + scheduleError: validation.error, + now + }) + } + } + private async processDueRun(event: SchedulerRunDueEvent): Promise { const run = this.repository.getRun(event.runId) if (!run) { diff --git a/src/main/presenter/cronJobs/repository.ts b/src/main/presenter/cronJobs/repository.ts index d1b95b076..ea99592ec 100644 --- a/src/main/presenter/cronJobs/repository.ts +++ b/src/main/presenter/cronJobs/repository.ts @@ -1,4 +1,9 @@ -import type { CronJob, CronJobRun, CronJobRunReason } from '@shared/cronJobs' +import { + CRON_JOBS_DEFAULT_MISFIRE_POLICY, + type CronJob, + type CronJobRun, + type CronJobRunReason +} from '@shared/cronJobs' import type { cronJobsUpsertInputSchema } from '@shared/contracts/routes/cronJobs.routes' import type { z } from 'zod' import type { SQLitePresenter } from '../sqlitePresenter' @@ -40,7 +45,10 @@ export class CronJobsRepository { cronExpr: input.cronExpr, timezone: input.timezone, agentId: input.agentId, - nextRunAt: input.nextRunAt + nextRunAt: input.nextRunAt, + misfirePolicy: input.misfirePolicy, + maxCatchUpRuns: input.maxCatchUpRuns, + scheduleError: input.scheduleError }) return toCronJob(row) } @@ -60,6 +68,17 @@ export class CronJobsRepository { return toCronJob(this.sqlitePresenter.cronJobsTable.updateNextRunAt(id, nextRunAt)) } + updateScheduleState( + id: string, + input: { + nextRunAt: number | null + scheduleError: string | null + now?: number + } + ): CronJob { + return toCronJob(this.sqlitePresenter.cronJobsTable.updateScheduleState(id, input)) + } + getSchedulerSnapshot(): CronJobsSchedulerSnapshot { return { enabledJobCount: this.sqlitePresenter.cronJobsTable.countEnabled(), @@ -114,6 +133,9 @@ export function toCronJob(row: CronJobRow): CronJob { timezone: row.timezone, agentId: row.agent_id, nextRunAt: row.next_run_at, + misfirePolicy: row.misfire_policy ?? CRON_JOBS_DEFAULT_MISFIRE_POLICY, + maxCatchUpRuns: row.max_catch_up_runs ?? null, + scheduleError: row.schedule_error ?? null, createdAt: row.created_at, updatedAt: row.updated_at } diff --git a/src/main/presenter/cronJobs/schedulerUtilityHost.ts b/src/main/presenter/cronJobs/schedulerUtilityHost.ts index 5e92242b7..5b963dd6c 100644 --- a/src/main/presenter/cronJobs/schedulerUtilityHost.ts +++ b/src/main/presenter/cronJobs/schedulerUtilityHost.ts @@ -1,7 +1,13 @@ import { randomUUID } from 'node:crypto' import type Database from 'better-sqlite3-multiple-ciphers' import { openSQLiteDatabase } from '../sqlitePresenter' -import type { CronJobRunReason, SchedulerCommand, SchedulerEvent } from '@shared/cronJobs' +import type { + CronJobMisfirePolicy, + CronJobRunReason, + SchedulerCommand, + SchedulerEvent +} from '@shared/cronJobs' +import { CronExpressionService } from './cronExpressionService' const SCHEDULER_HOST_ARG = '--deepchat-cron-scheduler-host' const HEARTBEAT_INTERVAL_MS = 5_000 @@ -20,7 +26,11 @@ type ParentPortMessageEvent = { interface DueCronJobRow { id: string + cron_expr: string + timezone: string next_run_at: number + misfire_policy: CronJobMisfirePolicy + max_catch_up_runs: number | null } interface SchedulerSnapshot { @@ -77,6 +87,7 @@ export class CronJobsSchedulerUtilityHost { private heartbeatTimer: NodeJS.Timeout | null = null private scanTimer: NodeJS.Timeout | null = null private shuttingDown = false + private readonly scheduleService = new CronExpressionService() constructor( private readonly options: { @@ -182,7 +193,12 @@ export class CronJobsSchedulerUtilityHost { const now = Date.now() const dueRows = db .prepare( - `SELECT id, next_run_at + `SELECT id, + cron_expr, + timezone, + next_run_at, + misfire_policy, + max_catch_up_runs FROM cron_jobs WHERE enabled = 1 AND next_run_at IS NOT NULL @@ -194,27 +210,54 @@ export class CronJobsSchedulerUtilityHost { const dueEvents = db.transaction((rows: DueCronJobRow[]) => rows.flatMap((row) => { - const run = this.queueRun(row.id, row.next_run_at, 'scheduled', true) + const reconciliation = this.scheduleService.reconcileDueRun( + { + cronExpr: row.cron_expr, + timezone: row.timezone, + misfirePolicy: row.misfire_policy, + maxCatchUpRuns: row.max_catch_up_runs + }, + row.next_run_at, + now + ) + + if (reconciliation.error) { + db.prepare( + `UPDATE cron_jobs + SET next_run_at = NULL, + schedule_error = ?, + updated_at = ? + WHERE id = ?` + ).run(reconciliation.error, now, row.id) + return [] + } + + const events = reconciliation.scheduledAts.flatMap((scheduledAt) => { + const run = this.queueRun(row.id, scheduledAt, 'scheduled', true) + return run + ? [ + { + type: 'RUN_DUE' as const, + jobId: row.id, + runId: run.runId, + scheduledAt, + reason: 'scheduled' as const, + now: Date.now() + } + ] + : [] + }) + db.prepare( `UPDATE cron_jobs - SET next_run_at = NULL, + SET next_run_at = ?, + schedule_error = NULL, updated_at = ? WHERE id = ? AND next_run_at = ?` - ).run(now, row.id, row.next_run_at) - - return run - ? [ - { - type: 'RUN_DUE' as const, - jobId: row.id, - runId: run.runId, - scheduledAt: row.next_run_at, - reason: 'scheduled' as const, - now: Date.now() - } - ] - : [] + ).run(reconciliation.nextRunAt, now, row.id, row.next_run_at) + + return events }) )(dueRows) diff --git a/src/main/presenter/sqlitePresenter/tables/cronJobs.ts b/src/main/presenter/sqlitePresenter/tables/cronJobs.ts index 140ec00bd..d0db23941 100644 --- a/src/main/presenter/sqlitePresenter/tables/cronJobs.ts +++ b/src/main/presenter/sqlitePresenter/tables/cronJobs.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto' import Database from 'better-sqlite3-multiple-ciphers' +import { CRON_JOBS_DEFAULT_MISFIRE_POLICY, type CronJobMisfirePolicy } from '@shared/cronJobs' import { BaseTable } from './baseTable' export interface CronJobRow { @@ -10,6 +11,9 @@ export interface CronJobRow { timezone: string agent_id: string | null next_run_at: number | null + misfire_policy: CronJobMisfirePolicy + max_catch_up_runs: number | null + schedule_error: string | null created_at: number updated_at: number } @@ -22,9 +26,14 @@ export interface CronJobTableUpsertInput { timezone: string agentId?: string | null nextRunAt?: number | null + misfirePolicy?: CronJobMisfirePolicy + maxCatchUpRuns?: number | null + scheduleError?: string | null now?: number } +const CRON_JOBS_SCHEMA_VERSION = 38 + const CRON_JOBS_INDEX_SQL = ` CREATE INDEX IF NOT EXISTS idx_cron_jobs_enabled_next_run ON cron_jobs(enabled, next_run_at); @@ -47,6 +56,9 @@ export class CronJobsTable extends BaseTable { timezone TEXT NOT NULL, agent_id TEXT, next_run_at INTEGER, + misfire_policy TEXT NOT NULL DEFAULT 'skip' CHECK(misfire_policy IN ('skip', 'run_once')), + max_catch_up_runs INTEGER, + schedule_error TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); @@ -56,15 +68,37 @@ export class CronJobsTable extends BaseTable { override createTable(): void { super.createTable() + this.ensurePhase2Columns() this.db.exec(CRON_JOBS_INDEX_SQL) } - getMigrationSQL(): string | null { + getMigrationSQL(version: number): string | null { + if (version === CRON_JOBS_SCHEMA_VERSION) { + return ` + ALTER TABLE cron_jobs ADD COLUMN misfire_policy TEXT NOT NULL DEFAULT 'skip' CHECK(misfire_policy IN ('skip', 'run_once')); + ALTER TABLE cron_jobs ADD COLUMN max_catch_up_runs INTEGER; + ALTER TABLE cron_jobs ADD COLUMN schedule_error TEXT; + ` + } return null } getLatestVersion(): number { - return 0 + return CRON_JOBS_SCHEMA_VERSION + } + + private ensurePhase2Columns(): void { + if (!this.hasColumn('misfire_policy')) { + this.db.exec( + "ALTER TABLE cron_jobs ADD COLUMN misfire_policy TEXT NOT NULL DEFAULT 'skip' CHECK(misfire_policy IN ('skip', 'run_once'))" + ) + } + if (!this.hasColumn('max_catch_up_runs')) { + this.db.exec('ALTER TABLE cron_jobs ADD COLUMN max_catch_up_runs INTEGER') + } + if (!this.hasColumn('schedule_error')) { + this.db.exec('ALTER TABLE cron_jobs ADD COLUMN schedule_error TEXT') + } } list(): CronJobRow[] { @@ -88,6 +122,14 @@ export class CronJobsTable extends BaseTable { const createdAt = existing?.created_at ?? now const nextRunAt = input.nextRunAt === undefined ? (existing?.next_run_at ?? null) : input.nextRunAt + const misfirePolicy = + input.misfirePolicy ?? existing?.misfire_policy ?? CRON_JOBS_DEFAULT_MISFIRE_POLICY + const maxCatchUpRuns = + input.maxCatchUpRuns === undefined + ? (existing?.max_catch_up_runs ?? null) + : input.maxCatchUpRuns + const scheduleError = + input.scheduleError === undefined ? (existing?.schedule_error ?? null) : input.scheduleError this.db .prepare( @@ -99,10 +141,13 @@ export class CronJobsTable extends BaseTable { timezone, agent_id, next_run_at, + misfire_policy, + max_catch_up_runs, + schedule_error, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, enabled = excluded.enabled, @@ -110,6 +155,9 @@ export class CronJobsTable extends BaseTable { timezone = excluded.timezone, agent_id = excluded.agent_id, next_run_at = excluded.next_run_at, + misfire_policy = excluded.misfire_policy, + max_catch_up_runs = excluded.max_catch_up_runs, + schedule_error = excluded.schedule_error, updated_at = excluded.updated_at` ) .run( @@ -120,6 +168,9 @@ export class CronJobsTable extends BaseTable { input.timezone, input.agentId ?? null, nextRunAt, + misfirePolicy, + maxCatchUpRuns, + scheduleError, createdAt, now ) @@ -165,6 +216,35 @@ export class CronJobsTable extends BaseTable { return row } + updateScheduleState( + id: string, + input: { + nextRunAt: number | null + scheduleError: string | null + now?: number + } + ): CronJobRow { + const now = input.now ?? Date.now() + const result = this.db + .prepare( + `UPDATE cron_jobs + SET next_run_at = ?, + schedule_error = ?, + updated_at = ? + WHERE id = ?` + ) + .run(input.nextRunAt, input.scheduleError, now, id) + if (result.changes === 0) { + throw new Error(`Unknown cron job: ${id}`) + } + + const row = this.get(id) + if (!row) { + throw new Error(`Failed to reload cron job: ${id}`) + } + return row + } + countEnabled(): number { const row = this.db .prepare('SELECT COUNT(*) AS count FROM cron_jobs WHERE enabled = 1') diff --git a/src/main/routes/index.ts b/src/main/routes/index.ts index fe9cf3985..ecbb5badf 100644 --- a/src/main/routes/index.ts +++ b/src/main/routes/index.ts @@ -64,10 +64,12 @@ import { cronJobsDeleteRoute, cronJobsGetSchedulerStatusRoute, cronJobsListRoute, + cronJobsPreviewScheduleRoute, cronJobsReconcileSchedulerRoute, cronJobsRestartSchedulerRoute, cronJobsRunNowRoute, cronJobsToggleRoute, + cronJobsValidateScheduleRoute, cronJobsUpsertRoute, databaseSecurityChangePasswordRoute, databaseSecurityDisableRoute, @@ -2657,6 +2659,16 @@ export async function dispatchDeepchatRoute( return cronJobsRestartSchedulerRoute.output.parse({ schedulerStatus }) } + case cronJobsValidateScheduleRoute.name: { + const input = cronJobsValidateScheduleRoute.input.parse(rawInput) + return cronJobsValidateScheduleRoute.output.parse(runtime.cronJobs.validateSchedule(input)) + } + + case cronJobsPreviewScheduleRoute.name: { + const input = cronJobsPreviewScheduleRoute.input.parse(rawInput) + return cronJobsPreviewScheduleRoute.output.parse(runtime.cronJobs.previewSchedule(input)) + } + case startupGetBootstrapRoute.name: { startupGetBootstrapRoute.input.parse(rawInput) const coordinator = (runtime as Partial).startupWorkloadCoordinator diff --git a/src/renderer/api/CronJobsClient.ts b/src/renderer/api/CronJobsClient.ts index 403d52afb..ce0a4180b 100644 --- a/src/renderer/api/CronJobsClient.ts +++ b/src/renderer/api/CronJobsClient.ts @@ -5,11 +5,13 @@ import { cronJobsDeleteRoute, cronJobsGetSchedulerStatusRoute, cronJobsListRoute, + cronJobsPreviewScheduleRoute, cronJobsReconcileSchedulerRoute, cronJobsRestartSchedulerRoute, cronJobsRunNowRoute, cronJobsSchedulerStatusSchema, cronJobsToggleRoute, + cronJobsValidateScheduleRoute, cronJobsUpsertRoute, type cronJobsUpsertInputSchema } from '@shared/contracts/routes/cronJobs.routes' @@ -111,6 +113,23 @@ export function createCronJobsClient(bridge: DeepchatBridge = getDeepchatBridge( return parseSchedulerStatusResponse(cronJobsRestartSchedulerRoute.name, result) } + async function validateSchedule(input: { cronExpr: string; timezone: string; from?: number }) { + return cronJobsValidateScheduleRoute.output.parse( + await bridge.invoke(cronJobsValidateScheduleRoute.name, input) + ) + } + + async function previewSchedule(input: { + cronExpr: string + timezone: string + count?: number + from?: number + }) { + return cronJobsPreviewScheduleRoute.output.parse( + await bridge.invoke(cronJobsPreviewScheduleRoute.name, input) + ) + } + return { list, upsert, @@ -119,7 +138,9 @@ export function createCronJobsClient(bridge: DeepchatBridge = getDeepchatBridge( runNow, getSchedulerStatus, reconcileScheduler, - restartScheduler + restartScheduler, + validateSchedule, + previewSchedule } } diff --git a/src/renderer/settings/components/CronJobsSettings.vue b/src/renderer/settings/components/CronJobsSettings.vue index 5992e7408..1e30fd2c6 100644 --- a/src/renderer/settings/components/CronJobsSettings.vue +++ b/src/renderer/settings/components/CronJobsSettings.vue @@ -7,10 +7,6 @@ > @@ -202,6 +202,7 @@ import { createCronJobsClient } from '@api/CronJobsClient' import SettingsPageShell from './control-center/SettingsPageShell.vue' import { CRON_JOBS_DEFAULT_CRON_EXPR, + CRON_JOBS_DEFAULT_MISFIRE_POLICY, CRON_JOBS_DEFAULT_TIMEZONE, type CronJob, type CronJobsSchedulerStatus @@ -213,10 +214,12 @@ const client = createCronJobsClient() const jobs = ref([]) const schedulerStatus = ref(null) -const nextRunInputValues = ref>({}) const isLoading = ref(false) const isSaving = ref(false) const runningId = ref(null) +const previewRunsByJobId = ref>({}) +const previewErrorsByJobId = ref>({}) +const previewLoadingByJobId = ref>({}) const enabledJobCount = computed(() => jobs.value.filter((job) => job.enabled).length) @@ -241,21 +244,6 @@ const getBrowserTimezone = (): string => { } } -const padTwo = (value: number): string => value.toString().padStart(2, '0') - -const formatDateTimeLocal = (timestamp: number): string => { - const date = new Date(timestamp) - return `${date.getFullYear()}-${padTwo(date.getMonth() + 1)}-${padTwo(date.getDate())}T${padTwo(date.getHours())}:${padTwo(date.getMinutes())}` -} - -const parseDateTimeLocal = (value: string): number | null => { - if (!value) { - return null - } - const timestamp = new Date(value).getTime() - return Number.isFinite(timestamp) ? timestamp : null -} - const formatTimestamp = (timestamp: number | null): string => { if (!timestamp) { return t('settings.cronJobs.none') @@ -263,13 +251,6 @@ const formatTimestamp = (timestamp: number | null): string => { return new Date(timestamp).toLocaleString() } -const refreshNextRunInputs = () => { - nextRunInputValues.value = jobs.value.reduce>((acc, job) => { - acc[job.id] = job.nextRunAt ? formatDateTimeLocal(job.nextRunAt) : '' - return acc - }, {}) -} - const sortJobs = (items: CronJob[]) => items .slice() @@ -282,7 +263,7 @@ const applyJob = (job: CronJob) => { ? jobs.value.map((entry) => (entry.id === job.id ? job : entry)) : [job, ...jobs.value] jobs.value = sortJobs(next) - refreshNextRunInputs() + void refreshJobPreview(job) } const handleError = (scope: string, error: unknown) => { @@ -300,7 +281,9 @@ const loadJobs = async () => { const response = await client.list() jobs.value = sortJobs(response.jobs) schedulerStatus.value = response.schedulerStatus - refreshNextRunInputs() + for (const job of jobs.value) { + void refreshJobPreview(job) + } } catch (error) { handleError('Failed to load jobs', error) } finally { @@ -308,17 +291,45 @@ const loadJobs = async () => { } } -const updateJobField = (id: string, field: 'name' | 'cronExpr' | 'timezone', value: string) => { - jobs.value = jobs.value.map((job) => (job.id === id ? { ...job, [field]: value } : job)) +const refreshJobPreview = async (job: CronJob) => { + previewLoadingByJobId.value = { + ...previewLoadingByJobId.value, + [job.id]: true + } + try { + const response = await client.previewSchedule({ + cronExpr: job.cronExpr || CRON_JOBS_DEFAULT_CRON_EXPR, + timezone: job.timezone || getBrowserTimezone(), + count: 5 + }) + previewRunsByJobId.value = { + ...previewRunsByJobId.value, + [job.id]: response.runs + } + previewErrorsByJobId.value = { + ...previewErrorsByJobId.value, + [job.id]: response.error + } + } catch (error) { + console.error('[CronJobs] Failed to preview schedule:', error) + previewRunsByJobId.value = { + ...previewRunsByJobId.value, + [job.id]: [] + } + previewErrorsByJobId.value = { + ...previewErrorsByJobId.value, + [job.id]: error instanceof Error ? error.message : String(error) + } + } finally { + previewLoadingByJobId.value = { + ...previewLoadingByJobId.value, + [job.id]: false + } + } } -const updateNextRunInput = (id: string, value: string) => { - nextRunInputValues.value = { - ...nextRunInputValues.value, - [id]: value - } - const nextRunAt = parseDateTimeLocal(value) - jobs.value = jobs.value.map((job) => (job.id === id ? { ...job, nextRunAt } : job)) +const updateJobField = (id: string, field: 'name' | 'cronExpr' | 'timezone', value: string) => { + jobs.value = jobs.value.map((job) => (job.id === id ? { ...job, [field]: value } : job)) } const commitJob = async (id: string) => { @@ -336,7 +347,8 @@ const commitJob = async (id: string) => { cronExpr: job.cronExpr || CRON_JOBS_DEFAULT_CRON_EXPR, timezone: job.timezone || getBrowserTimezone(), agentId: job.agentId, - nextRunAt: job.nextRunAt + misfirePolicy: job.misfirePolicy, + maxCatchUpRuns: job.maxCatchUpRuns }) applyJob(response.job) schedulerStatus.value = response.schedulerStatus @@ -356,7 +368,8 @@ const addJob = async () => { cronExpr: CRON_JOBS_DEFAULT_CRON_EXPR, timezone: getBrowserTimezone(), agentId: null, - nextRunAt: null + misfirePolicy: CRON_JOBS_DEFAULT_MISFIRE_POLICY, + maxCatchUpRuns: null }) applyJob(response.job) schedulerStatus.value = response.schedulerStatus @@ -381,17 +394,20 @@ const deleteJob = async (id: string) => { try { schedulerStatus.value = await client.remove(id) jobs.value = jobs.value.filter((job) => job.id !== id) - refreshNextRunInputs() + const nextRuns = { ...previewRunsByJobId.value } + const nextErrors = { ...previewErrorsByJobId.value } + const nextLoading = { ...previewLoadingByJobId.value } + delete nextRuns[id] + delete nextErrors[id] + delete nextLoading[id] + previewRunsByJobId.value = nextRuns + previewErrorsByJobId.value = nextErrors + previewLoadingByJobId.value = nextLoading } catch (error) { handleError('Failed to delete job', error) } } -const clearNextRun = async (id: string) => { - updateNextRunInput(id, '') - await commitJob(id) -} - const runJobNow = async (id: string) => { runningId.value = id try { @@ -409,14 +425,6 @@ const runJobNow = async (id: string) => { } } -const reconcileScheduler = async () => { - try { - schedulerStatus.value = await client.reconcileScheduler('settings') - } catch (error) { - handleError('Failed to reconcile scheduler', error) - } -} - const restartScheduler = async () => { try { schedulerStatus.value = await client.restartScheduler() diff --git a/src/renderer/src/i18n/en-US/settings.json b/src/renderer/src/i18n/en-US/settings.json index 6a769fdd6..c7f247f23 100644 --- a/src/renderer/src/i18n/en-US/settings.json +++ b/src/renderer/src/i18n/en-US/settings.json @@ -1721,7 +1721,7 @@ "runNow": "Run now", "clearNextRun": "Clear next run", "reconcile": "Reconcile", - "restart": "Restart" + "restart": "Restart timer" }, "fields": { "name": "Name", diff --git a/src/renderer/src/i18n/zh-CN/settings.json b/src/renderer/src/i18n/zh-CN/settings.json index dd11e4f14..e9bbd59ed 100644 --- a/src/renderer/src/i18n/zh-CN/settings.json +++ b/src/renderer/src/i18n/zh-CN/settings.json @@ -1721,7 +1721,7 @@ "runNow": "立即运行", "clearNextRun": "清空下次运行", "reconcile": "重新对账", - "restart": "重启调度" + "restart": "重启定时器" }, "fields": { "name": "名称", diff --git a/src/renderer/src/i18n/zh-HK/settings.json b/src/renderer/src/i18n/zh-HK/settings.json index ce4bd5a7b..bfaef809c 100644 --- a/src/renderer/src/i18n/zh-HK/settings.json +++ b/src/renderer/src/i18n/zh-HK/settings.json @@ -1464,7 +1464,7 @@ "runNow": "立即运行", "clearNextRun": "清空下次运行", "reconcile": "重新对账", - "restart": "重启调度" + "restart": "重啟定時器" }, "fields": { "name": "名称", diff --git a/src/renderer/src/i18n/zh-TW/settings.json b/src/renderer/src/i18n/zh-TW/settings.json index b507074cf..e54763d8d 100644 --- a/src/renderer/src/i18n/zh-TW/settings.json +++ b/src/renderer/src/i18n/zh-TW/settings.json @@ -1464,7 +1464,7 @@ "runNow": "立即运行", "clearNextRun": "清空下次运行", "reconcile": "重新对账", - "restart": "重启调度" + "restart": "重啟定時器" }, "fields": { "name": "名称", diff --git a/src/shared/contracts/routes.ts b/src/shared/contracts/routes.ts index 2fa2a6d7c..38aa23e7b 100644 --- a/src/shared/contracts/routes.ts +++ b/src/shared/contracts/routes.ts @@ -268,10 +268,12 @@ import { cronJobsDeleteRoute, cronJobsGetSchedulerStatusRoute, cronJobsListRoute, + cronJobsPreviewScheduleRoute, cronJobsReconcileSchedulerRoute, cronJobsRestartSchedulerRoute, cronJobsRunNowRoute, cronJobsToggleRoute, + cronJobsValidateScheduleRoute, cronJobsUpsertRoute } from './routes/cronJobs.routes' import { @@ -618,6 +620,8 @@ const DEEPCHAT_ROUTE_CATALOG_PART_1 = { [cronJobsGetSchedulerStatusRoute.name]: cronJobsGetSchedulerStatusRoute, [cronJobsReconcileSchedulerRoute.name]: cronJobsReconcileSchedulerRoute, [cronJobsRestartSchedulerRoute.name]: cronJobsRestartSchedulerRoute, + [cronJobsValidateScheduleRoute.name]: cronJobsValidateScheduleRoute, + [cronJobsPreviewScheduleRoute.name]: cronJobsPreviewScheduleRoute, [pluginsListRoute.name]: pluginsListRoute, [pluginsGetRoute.name]: pluginsGetRoute, [pluginsEnableRoute.name]: pluginsEnableRoute, diff --git a/src/shared/contracts/routes/cronJobs.routes.ts b/src/shared/contracts/routes/cronJobs.routes.ts index 38e09fae6..1d8cca91c 100644 --- a/src/shared/contracts/routes/cronJobs.routes.ts +++ b/src/shared/contracts/routes/cronJobs.routes.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { defineRouteContract } from '../common' import { + CRON_JOB_MISFIRE_POLICIES, CRON_JOB_RUN_REASONS, CRON_JOB_RUN_STATUSES, CRON_JOBS_SCHEDULER_STATES @@ -10,6 +11,7 @@ const timestampMsSchema = z.number().int().nonnegative() export const cronJobRunStatusSchema = z.enum(CRON_JOB_RUN_STATUSES) export const cronJobRunReasonSchema = z.enum(CRON_JOB_RUN_REASONS) +export const cronJobMisfirePolicySchema = z.enum(CRON_JOB_MISFIRE_POLICIES) export const cronJobsSchedulerStateSchema = z.enum(CRON_JOBS_SCHEDULER_STATES) export const cronJobSchema = z.object({ @@ -20,6 +22,9 @@ export const cronJobSchema = z.object({ timezone: z.string().min(1).max(128), agentId: z.string().min(1).nullable(), nextRunAt: timestampMsSchema.nullable(), + misfirePolicy: cronJobMisfirePolicySchema, + maxCatchUpRuns: z.number().int().positive().nullable(), + scheduleError: z.string().nullable(), createdAt: timestampMsSchema, updatedAt: timestampMsSchema }) @@ -62,7 +67,10 @@ export const cronJobsUpsertInputSchema = cronJobSchema .omit({ id: true, createdAt: true, updatedAt: true }) .extend({ id: z.string().min(1).optional(), - nextRunAt: timestampMsSchema.nullable().optional() + nextRunAt: timestampMsSchema.nullable().optional(), + misfirePolicy: cronJobMisfirePolicySchema.optional(), + maxCatchUpRuns: z.number().int().positive().nullable().optional(), + scheduleError: z.string().nullable().optional() }) export const cronJobsUpsertRoute = defineRouteContract({ @@ -133,3 +141,31 @@ export const cronJobsRestartSchedulerRoute = defineRouteContract({ schedulerStatus: cronJobsSchedulerStatusSchema }) }) + +export const cronJobsValidateScheduleRoute = defineRouteContract({ + name: 'cronJobs.validateSchedule', + input: z.object({ + cronExpr: z.string().min(1).max(200), + timezone: z.string().min(1).max(128), + from: timestampMsSchema.optional() + }), + output: z.object({ + valid: z.boolean(), + error: z.string().nullable(), + nextRunAt: timestampMsSchema.nullable() + }) +}) + +export const cronJobsPreviewScheduleRoute = defineRouteContract({ + name: 'cronJobs.previewSchedule', + input: z.object({ + cronExpr: z.string().min(1).max(200), + timezone: z.string().min(1).max(128), + count: z.number().int().min(1).max(10).optional(), + from: timestampMsSchema.optional() + }), + output: z.object({ + runs: z.array(timestampMsSchema), + error: z.string().nullable() + }) +}) diff --git a/src/shared/cronJobs.ts b/src/shared/cronJobs.ts index 1773d0cda..6f3245d8b 100644 --- a/src/shared/cronJobs.ts +++ b/src/shared/cronJobs.ts @@ -1,5 +1,6 @@ export const CRON_JOBS_DEFAULT_CRON_EXPR = '0 9 * * *' export const CRON_JOBS_DEFAULT_TIMEZONE = 'UTC' +export const CRON_JOBS_DEFAULT_MISFIRE_POLICY = 'skip' export const CRON_JOB_RUN_STATUSES = [ 'queued', @@ -13,6 +14,9 @@ export type CronJobRunStatus = (typeof CRON_JOB_RUN_STATUSES)[number] export const CRON_JOB_RUN_REASONS = ['scheduled', 'manual'] as const export type CronJobRunReason = (typeof CRON_JOB_RUN_REASONS)[number] +export const CRON_JOB_MISFIRE_POLICIES = ['skip', 'run_once'] as const +export type CronJobMisfirePolicy = (typeof CRON_JOB_MISFIRE_POLICIES)[number] + export const CRON_JOBS_SCHEDULER_STATES = [ 'stopped', 'starting', @@ -30,10 +34,33 @@ export interface CronJob { timezone: string agentId: string | null nextRunAt: number | null + misfirePolicy: CronJobMisfirePolicy + maxCatchUpRuns: number | null + scheduleError: string | null createdAt: number updatedAt: number } +export type CronSchedulePreset = + | { type: 'every_n_minutes'; n: number } + | { type: 'hourly'; minute: number } + | { type: 'daily'; time: string } + | { type: 'weekdays'; time: string } + | { type: 'weekly'; days: number[]; time: string } + | { type: 'monthly'; day: number | 'last'; time: string } + | { type: 'custom'; cronExpr: string } + +export interface CronScheduleValidation { + valid: boolean + error: string | null + nextRunAt: number | null +} + +export interface CronSchedulePreview { + runs: number[] + error: string | null +} + export interface CronJobRun { id: string jobId: string diff --git a/test/main/presenter/cronJobs.test.ts b/test/main/presenter/cronJobs.test.ts index c57756c70..1d4e5019b 100644 --- a/test/main/presenter/cronJobs.test.ts +++ b/test/main/presenter/cronJobs.test.ts @@ -25,6 +25,7 @@ const schedulerManagerModule = repositoryModule const schedulerUtilityHostModule = repositoryModule ? await import('@/presenter/cronJobs/schedulerUtilityHost').catch(() => null) : null +const cronExpressionServiceModule = await import('@/presenter/cronJobs/cronExpressionService') const Database = sqliteModule?.default const CronJobsTable = cronJobsTableModule?.CronJobsTable @@ -33,6 +34,7 @@ const CronJobsRepository = repositoryModule?.CronJobsRepository const CronJobsService = serviceModule?.CronJobsService const SchedulerProcessManager = schedulerManagerModule?.SchedulerProcessManager const CronJobsSchedulerUtilityHost = schedulerUtilityHostModule?.CronJobsSchedulerUtilityHost +const CronExpressionService = cronExpressionServiceModule.CronExpressionService const DatabaseCtor = Database! const CronJobsTableCtor = CronJobsTable! const CronJobRunsTableCtor = CronJobRunsTable! @@ -63,6 +65,101 @@ const describeIfSqlite = ? describe : describe.skip +describe('CronExpressionService', () => { + it('previews parser-backed cron expressions from a fixed clock', () => { + const service = new CronExpressionService() + const from = Date.parse('2026-07-03T00:00:00.000Z') + + expect(service.preview('*/5 * * * *', 'UTC', 5, from)).toEqual({ + runs: [ + Date.parse('2026-07-03T00:05:00.000Z'), + Date.parse('2026-07-03T00:10:00.000Z'), + Date.parse('2026-07-03T00:15:00.000Z'), + Date.parse('2026-07-03T00:20:00.000Z'), + Date.parse('2026-07-03T00:25:00.000Z') + ], + error: null + }) + + expect(service.preview('0 9 * * 1-5', 'UTC', 3, from).runs).toEqual([ + Date.parse('2026-07-03T09:00:00.000Z'), + Date.parse('2026-07-06T09:00:00.000Z'), + Date.parse('2026-07-07T09:00:00.000Z') + ]) + }) + + it('uses locked parser support for monthly last day, nth weekday, and timezones', () => { + const service = new CronExpressionService() + const from = Date.parse('2026-07-03T00:00:00.000Z') + + expect(service.preview('0 0 9 L * *', 'UTC', 2, from).runs).toEqual([ + Date.parse('2026-07-31T09:00:00.000Z'), + Date.parse('2026-08-31T09:00:00.000Z') + ]) + expect(service.preview('0 0 9 * * 1#1', 'UTC', 2, from).runs).toEqual([ + Date.parse('2026-07-06T09:00:00.000Z'), + Date.parse('2026-08-03T09:00:00.000Z') + ]) + expect( + service.computeNextRunAt( + { cronExpr: '0 9 * * *', timezone: 'Asia/Tokyo' }, + Date.parse('2026-07-02T23:59:00.000Z') + ) + ).toBe(Date.parse('2026-07-03T00:00:00.000Z')) + expect( + service.preview('0 9 * * *', 'America/New_York', 3, Date.parse('2026-03-07T00:00:00.000Z')) + .runs + ).toEqual([ + Date.parse('2026-03-07T14:00:00.000Z'), + Date.parse('2026-03-08T13:00:00.000Z'), + Date.parse('2026-03-09T13:00:00.000Z') + ]) + }) + + it('reports invalid expressions and applies misfire policies', () => { + const service = new CronExpressionService() + const scheduledAt = Date.parse('2026-07-01T09:00:00.000Z') + const now = Date.parse('2026-07-03T10:00:00.000Z') + + expect(service.validate('61 * * * *', 'UTC', now)).toEqual( + expect.objectContaining({ + valid: false, + nextRunAt: null + }) + ) + expect( + service.reconcileDueRun( + { cronExpr: '0 9 * * *', timezone: 'UTC', misfirePolicy: 'skip' }, + scheduledAt, + now + ) + ).toEqual({ + scheduledAts: [], + nextRunAt: Date.parse('2026-07-04T09:00:00.000Z'), + error: null + }) + expect( + service.reconcileDueRun( + { + cronExpr: '0 9 * * *', + timezone: 'UTC', + misfirePolicy: 'run_once', + maxCatchUpRuns: 2 + }, + scheduledAt, + now + ) + ).toEqual({ + scheduledAts: [ + Date.parse('2026-07-01T09:00:00.000Z'), + Date.parse('2026-07-02T09:00:00.000Z') + ], + nextRunAt: Date.parse('2026-07-04T09:00:00.000Z'), + error: null + }) + }) +}) + const createHarness = () => { const db = new DatabaseCtor(':memory:') const cronJobsTable = new CronJobsTableCtor(db) @@ -93,6 +190,28 @@ const baseStatus = (): CronJobsSchedulerStatus => ({ }) describeIfSqlite('Cron Jobs persistence and service', () => { + it('normalizes legacy rows without phase 2 schedule columns', () => { + expect( + repositoryModule!.toCronJob({ + id: 'job-1', + name: 'Legacy job', + enabled: 0, + cron_expr: '0 9 * * *', + timezone: 'UTC', + agent_id: null, + next_run_at: null, + created_at: 1, + updated_at: 1 + } as never) + ).toEqual( + expect.objectContaining({ + misfirePolicy: 'skip', + maxCatchUpRuns: null, + scheduleError: null + }) + ) + }) + it('persists jobs, snapshots enabled rows, and cascades run deletion', () => { const { db, sqlitePresenter } = createHarness() try { @@ -167,9 +286,9 @@ describeIfSqlite('Cron Jobs persistence and service', () => { enabled: true, cronExpr: '0 9 * * *', timezone: 'UTC', - agentId: null, - nextRunAt: null + agentId: null }) + expect(job.nextRunAt).toEqual(expect.any(Number)) const result = await service.runNow(job.id) expect(result.run).toEqual( @@ -187,7 +306,41 @@ describeIfSqlite('Cron Jobs persistence and service', () => { } }) - it('queues each due scheduled run once and clears next_run_at in the utility host', () => { + it('recomputes missing next run indicators when listing jobs', async () => { + const { db, sqlitePresenter } = createHarness() + try { + const status = baseStatus() + const schedulerManager = { + reconcile: vi.fn().mockResolvedValue(status), + restart: vi.fn().mockResolvedValue(status), + stop: vi.fn().mockResolvedValue(status), + getStatus: vi.fn(() => status) + } + const service = new CronJobsServiceCtor({ + sqlitePresenter: sqlitePresenter as never, + schedulerManager: schedulerManager as never + }) + const stored = sqlitePresenter.cronJobsTable.upsert({ + name: 'Legacy indicator', + enabled: false, + cronExpr: '0 9 * * *', + timezone: 'UTC', + agentId: null, + nextRunAt: null + }) + + const response = await service.list() + + expect(response.jobs.find((job) => job.id === stored.id)?.nextRunAt).toEqual( + expect.any(Number) + ) + expect(schedulerManager.reconcile).toHaveBeenCalledWith('list') + } finally { + db.close() + } + }) + + it('queues each due scheduled run once and advances next_run_at in the utility host', () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepchat-cron-jobs-')) const dbPath = path.join(tempDir, 'agent.db') const db = new DatabaseCtor(dbPath) @@ -198,13 +351,14 @@ describeIfSqlite('Cron Jobs persistence and service', () => { const cronJobRunsTable = new CronJobRunsTableCtor(db) cronJobsTable.createTable() cronJobRunsTable.createTable() + const dueAt = Date.now() - 1_000 const job = cronJobsTable.upsert({ name: 'Due job', enabled: true, cronExpr: '0 9 * * *', timezone: 'UTC', agentId: null, - nextRunAt: 1, + nextRunAt: dueAt, now: 1 }) @@ -219,11 +373,11 @@ describeIfSqlite('Cron Jobs persistence and service', () => { expect(events.filter((event) => (event as { type?: string }).type === 'RUN_DUE')).toEqual([ expect.objectContaining({ jobId: job.id, - scheduledAt: 1, + scheduledAt: dueAt, reason: 'scheduled' }) ]) - expect(cronJobsTable.get(job.id)?.next_run_at).toBeNull() + expect(cronJobsTable.get(job.id)?.next_run_at).toEqual(expect.any(Number)) expect(db.prepare('SELECT COUNT(*) AS count FROM cron_job_runs').get()).toEqual({ count: 1 }) diff --git a/test/main/presenter/sqlitePresenter.migrationSqlSplit.test.ts b/test/main/presenter/sqlitePresenter.migrationSqlSplit.test.ts index 8474ac989..b3bc573c5 100644 --- a/test/main/presenter/sqlitePresenter.migrationSqlSplit.test.ts +++ b/test/main/presenter/sqlitePresenter.migrationSqlSplit.test.ts @@ -76,6 +76,8 @@ CREATE INDEX sample_value_idx ON sample(value);` presenter.newSessionActiveSkillsTable = emptyTable presenter.newSessionDisabledAgentToolsTable = emptyTable presenter.settingsActivityTable = emptyTable + presenter.cronJobsTable = emptyTable + presenter.cronJobRunsTable = emptyTable presenter.migrate() diff --git a/test/main/routes/dispatcher.test.ts b/test/main/routes/dispatcher.test.ts index 3564d0396..2158f648a 100644 --- a/test/main/routes/dispatcher.test.ts +++ b/test/main/routes/dispatcher.test.ts @@ -1163,6 +1163,9 @@ function createRuntime() { timezone: 'UTC', agentId: null, nextRunAt: null, + misfirePolicy: 'skip' as const, + maxCatchUpRuns: null, + scheduleError: null, createdAt: 1, updatedAt: 2 } @@ -1197,7 +1200,9 @@ function createRuntime() { runNow: vi.fn(async () => ({ job: cronJob, run: cronRun, schedulerStatus: cronStatus })), getSchedulerStatus: vi.fn(() => cronStatus), reconcileScheduler: vi.fn(async () => cronStatus), - restartScheduler: vi.fn(async () => cronStatus) + restartScheduler: vi.fn(async () => cronStatus), + validateSchedule: vi.fn(() => ({ valid: true, error: null, nextRunAt: 10 })), + previewSchedule: vi.fn(() => ({ runs: [10, 20, 30], error: null })) } setDeepchatEventWindowPresenter(windowPresenter) @@ -1268,7 +1273,10 @@ describe('dispatchDeepchatRoute', () => { cronExpr: '0 9 * * *', timezone: 'UTC', agentId: null, - nextRunAt: null + nextRunAt: null, + misfirePolicy: 'skip', + maxCatchUpRuns: null, + scheduleError: null }, context ) @@ -1317,6 +1325,25 @@ describe('dispatchDeepchatRoute', () => { }, context ) + const validateResult = await dispatchDeepchatRoute( + runtime, + 'cronJobs.validateSchedule', + { + cronExpr: '0 9 * * *', + timezone: 'UTC' + }, + context + ) + const previewResult = await dispatchDeepchatRoute( + runtime, + 'cronJobs.previewSchedule', + { + cronExpr: '0 9 * * *', + timezone: 'UTC', + count: 3 + }, + context + ) expect(listResult).toEqual({ jobs: [expect.objectContaining({ id: 'cron-1' })], @@ -1347,11 +1374,22 @@ describe('dispatchDeepchatRoute', () => { expect(deleteResult).toEqual({ schedulerStatus: expect.objectContaining({ state: 'idle' }) }) + expect(validateResult).toEqual({ valid: true, error: null, nextRunAt: 10 }) + expect(previewResult).toEqual({ runs: [10, 20, 30], error: null }) expect(cronJobs.toggle).toHaveBeenCalledWith('cron-1', false) expect(cronJobs.runNow).toHaveBeenCalledWith('cron-1') expect(cronJobs.reconcileScheduler).toHaveBeenCalledWith('test') expect(cronJobs.restartScheduler).toHaveBeenCalledTimes(1) expect(cronJobs.delete).toHaveBeenCalledWith('cron-1') + expect(cronJobs.validateSchedule).toHaveBeenCalledWith({ + cronExpr: '0 9 * * *', + timezone: 'UTC' + }) + expect(cronJobs.previewSchedule).toHaveBeenCalledWith({ + cronExpr: '0 9 * * *', + timezone: 'UTC', + count: 3 + }) }) it('ensures the built-in chat workspace before startup bootstrap returns', async () => { diff --git a/test/renderer/api/cronJobsClient.test.ts b/test/renderer/api/cronJobsClient.test.ts index 8e3e3938d..96e3afdaf 100644 --- a/test/renderer/api/cronJobsClient.test.ts +++ b/test/renderer/api/cronJobsClient.test.ts @@ -20,6 +20,9 @@ const job = { timezone: 'UTC', agentId: null, nextRunAt: null, + misfirePolicy: 'skip' as const, + maxCatchUpRuns: null, + scheduleError: null, createdAt: 1, updatedAt: 2 } @@ -55,6 +58,10 @@ describe('CronJobsClient', () => { case 'cronJobs.reconcileScheduler': case 'cronJobs.restartScheduler': return { schedulerStatus } + case 'cronJobs.validateSchedule': + return { valid: true, error: null, nextRunAt: 10 } + case 'cronJobs.previewSchedule': + return { runs: [10, 20, 30], error: null } default: throw new Error(`Unexpected route: ${routeName}`) } @@ -71,7 +78,10 @@ describe('CronJobsClient', () => { cronExpr: job.cronExpr, timezone: job.timezone, agentId: null, - nextRunAt: null + nextRunAt: null, + misfirePolicy: 'skip', + maxCatchUpRuns: null, + scheduleError: null }) ).toEqual({ job, schedulerStatus }) expect(await client.toggle(job.id, false)).toEqual({ job, schedulerStatus }) @@ -80,6 +90,17 @@ describe('CronJobsClient', () => { expect(await client.getSchedulerStatus()).toEqual(schedulerStatus) expect(await client.reconcileScheduler('test')).toEqual(schedulerStatus) expect(await client.restartScheduler()).toEqual(schedulerStatus) + expect(await client.validateSchedule({ cronExpr: '0 9 * * *', timezone: 'UTC' })).toEqual({ + valid: true, + error: null, + nextRunAt: 10 + }) + expect( + await client.previewSchedule({ cronExpr: '0 9 * * *', timezone: 'UTC', count: 3 }) + ).toEqual({ + runs: [10, 20, 30], + error: null + }) expect(bridge.invoke).toHaveBeenNthCalledWith(1, 'cronJobs.list', {}) expect(bridge.invoke).toHaveBeenNthCalledWith(2, 'cronJobs.upsert', { @@ -88,7 +109,10 @@ describe('CronJobsClient', () => { cronExpr: job.cronExpr, timezone: job.timezone, agentId: null, - nextRunAt: null + nextRunAt: null, + misfirePolicy: 'skip', + maxCatchUpRuns: null, + scheduleError: null }) expect(bridge.invoke).toHaveBeenNthCalledWith(3, 'cronJobs.toggle', { id: job.id, @@ -105,5 +129,14 @@ describe('CronJobsClient', () => { reason: 'test' }) expect(bridge.invoke).toHaveBeenNthCalledWith(8, 'cronJobs.restartScheduler', {}) + expect(bridge.invoke).toHaveBeenNthCalledWith(9, 'cronJobs.validateSchedule', { + cronExpr: '0 9 * * *', + timezone: 'UTC' + }) + expect(bridge.invoke).toHaveBeenNthCalledWith(10, 'cronJobs.previewSchedule', { + cronExpr: '0 9 * * *', + timezone: 'UTC', + count: 3 + }) }) }) From 09779315f5ba7a8d468324db7e0a48dbd324f2cb Mon Sep 17 00:00:00 2001 From: zerob13 Date: Fri, 3 Jul 2026 12:44:53 +0800 Subject: [PATCH 04/36] feat(cron-jobs): add schedule presets --- .../tasks.md | 2 +- .../settings/components/CronJobsSettings.vue | 55 +++++++++++++++++++ src/renderer/src/i18n/da-DK/settings.json | 10 +++- src/renderer/src/i18n/de-DE/settings.json | 10 +++- src/renderer/src/i18n/en-US/settings.json | 10 +++- src/renderer/src/i18n/es-ES/settings.json | 10 +++- src/renderer/src/i18n/fa-IR/settings.json | 10 +++- src/renderer/src/i18n/fr-FR/settings.json | 10 +++- src/renderer/src/i18n/he-IL/settings.json | 10 +++- src/renderer/src/i18n/id-ID/settings.json | 10 +++- src/renderer/src/i18n/it-IT/settings.json | 10 +++- src/renderer/src/i18n/ja-JP/settings.json | 10 +++- src/renderer/src/i18n/ko-KR/settings.json | 10 +++- src/renderer/src/i18n/ms-MY/settings.json | 10 +++- src/renderer/src/i18n/pl-PL/settings.json | 10 +++- src/renderer/src/i18n/pt-BR/settings.json | 10 +++- src/renderer/src/i18n/ru-RU/settings.json | 10 +++- src/renderer/src/i18n/tr-TR/settings.json | 10 +++- src/renderer/src/i18n/vi-VN/settings.json | 10 +++- src/renderer/src/i18n/zh-CN/settings.json | 10 +++- src/renderer/src/i18n/zh-HK/settings.json | 10 +++- src/renderer/src/i18n/zh-TW/settings.json | 10 +++- 22 files changed, 236 insertions(+), 21 deletions(-) diff --git a/docs/features/cron-agent-jobs-phase-2-cron-trigger-engine/tasks.md b/docs/features/cron-agent-jobs-phase-2-cron-trigger-engine/tasks.md index 0b14e56d9..dcbb024d9 100644 --- a/docs/features/cron-agent-jobs-phase-2-cron-trigger-engine/tasks.md +++ b/docs/features/cron-agent-jobs-phase-2-cron-trigger-engine/tasks.md @@ -21,7 +21,7 @@ - [x] Add `cronJobs.previewSchedule` and `cronJobs.validateSchedule` route contracts. - [x] Update `CronJobsClient`. - [x] Build the raw cron editor and computed next-run indicator. -- [ ] Build preset controls that write only cron expressions. +- [x] Build preset controls that write only cron expressions. - [x] Add next-runs preview, loading, empty, and parser-error states. - [x] Reuse existing i18n keys where no new visible copy is needed. diff --git a/src/renderer/settings/components/CronJobsSettings.vue b/src/renderer/settings/components/CronJobsSettings.vue index 1e30fd2c6..cd46480d6 100644 --- a/src/renderer/settings/components/CronJobsSettings.vue +++ b/src/renderer/settings/components/CronJobsSettings.vue @@ -96,6 +96,28 @@ @blur="commitJob(job.id)" /> +
+ + +
+
+ + +
+
+
+ +