From 2020a8375bf4aebead2a000984da7b6208d16f4d Mon Sep 17 00:00:00 2001 From: gaopengju Date: Tue, 4 Aug 2026 11:17:28 +0800 Subject: [PATCH 1/6] feat(cron): add schedule configuration and task management --- scripts/tui-e2e-permission.tsx | 1 + src/cli/pilotdeck.ts | 3 + src/cron/index.ts | 2 + src/cron/protocol/types.ts | 20 + src/cron/runtime/CronFire.ts | 83 +- src/cron/runtime/CronManager.ts | 8 + src/cron/runtime/CronRuntime.ts | 77 +- src/cron/runtime/CronScheduler.ts | 51 +- src/cron/storage/CronTaskStore.ts | 3 + src/gateway/client/InProcessGateway.ts | 6 + src/gateway/client/RemoteGateway.ts | 6 + src/gateway/protocol/frames.ts | 1 + src/gateway/protocol/types.ts | 4 + src/gateway/server/GatewayWsConnection.ts | 2 + src/web/client/GatewayBrowserClient.ts | 3 + src/web/client/protocol.ts | 1 + tests/cron/cron-editing.spec.ts | 426 ++++++++ ui/server/index.js | 3 + ui/server/projects.cron.test.js | 78 ++ ui/server/projects.js | 3 + ui/server/routes/cron-jobs.js | 94 ++ ui/server/routes/cron-jobs.test.js | 174 ++++ .../main-content-v2/CronV2.test.tsx | 589 ++++++++++- ui/src/components/main-content-v2/CronV2.tsx | 958 +++++++++++++++--- .../main-content-v2/cronSchedule.spec.ts | 80 ++ .../main-content-v2/cronSchedule.ts | 99 ++ ui/src/i18n/locales/en/alwaysOn.json | 88 +- ui/src/i18n/locales/zh-CN/alwaysOn.json | 88 +- ui/src/types/app.ts | 14 + ui/src/utils/api.js | 5 + 30 files changed, 2784 insertions(+), 186 deletions(-) create mode 100644 tests/cron/cron-editing.spec.ts create mode 100644 ui/server/projects.cron.test.js create mode 100644 ui/server/routes/cron-jobs.js create mode 100644 ui/server/routes/cron-jobs.test.js create mode 100644 ui/src/components/main-content-v2/cronSchedule.spec.ts create mode 100644 ui/src/components/main-content-v2/cronSchedule.ts diff --git a/scripts/tui-e2e-permission.tsx b/scripts/tui-e2e-permission.tsx index ac81e0f01..3dc632bae 100644 --- a/scripts/tui-e2e-permission.tsx +++ b/scripts/tui-e2e-permission.tsx @@ -91,6 +91,7 @@ class MockGateway implements Gateway { describeServer = stub({ mode: "in_process" as const }); cronCreate = stub({ taskId: "c", task: {} as any, created: true }) as unknown as Gateway["cronCreate"]; cronList = stub({ tasks: [] }) as Gateway["cronList"]; + cronUpdate = stub({ updated: false, reason: "not_found" }) as Gateway["cronUpdate"]; cronDelete = stub({ deleted: true }) as Gateway["cronDelete"]; cronStop = stub({ stopped: true }) as Gateway["cronStop"]; cronRunNow = stub({ triggered: true }) as unknown as Gateway["cronRunNow"]; diff --git a/src/cli/pilotdeck.ts b/src/cli/pilotdeck.ts index f6d580258..95de684a1 100644 --- a/src/cli/pilotdeck.ts +++ b/src/cli/pilotdeck.ts @@ -783,6 +783,9 @@ function createFallbackGateway(): Gateway { cronList: async () => { throw new Error("Cron runtime is not configured."); }, + cronUpdate: async () => { + throw new Error("Cron runtime is not configured."); + }, cronDelete: async () => { throw new Error("Cron runtime is not configured."); }, diff --git a/src/cron/index.ts b/src/cron/index.ts index 9e23ee227..fb9c4e218 100644 --- a/src/cron/index.ts +++ b/src/cron/index.ts @@ -33,4 +33,6 @@ export type { CronStopResult, CronTask, CronTaskStatus, + CronUpdateInput, + CronUpdateResult, } from "./protocol/types.js"; diff --git a/src/cron/protocol/types.ts b/src/cron/protocol/types.ts index 28bc7d180..a0a6807a5 100644 --- a/src/cron/protocol/types.ts +++ b/src/cron/protocol/types.ts @@ -38,6 +38,7 @@ export type CronTask = { updatedAt: string; nextRunAt?: string; lastRunId?: string; + revision?: number; scheduleComputationVersion?: 2; originSessionKey?: string; originChannelKey?: GatewayChannelKey; @@ -92,6 +93,25 @@ export type CronCreateResult = { task: CronTask; }; +export type CronUpdateInput = { + taskId: string; + projectKey: string; + expectedRevision: number; + message: string; + schedule: CronTaskSchedule; + timezone?: string; +}; + +export type CronUpdateResult = + | { + updated: true; + task: CronTask; + } + | { + updated: false; + reason: "not_found" | "running" | "conflict"; + }; + export type CronListInput = { projectKey?: string; includeHistory?: boolean; diff --git a/src/cron/runtime/CronFire.ts b/src/cron/runtime/CronFire.ts index eb7b516d3..660dc0d38 100644 --- a/src/cron/runtime/CronFire.ts +++ b/src/cron/runtime/CronFire.ts @@ -42,17 +42,18 @@ export type CronFireDependencies = { export class CronFire { constructor(private readonly deps: CronFireDependencies) {} - async runTask(task: CronTask, runId: string): Promise { + async runTask(taskSnapshot: CronTask, runId: string): Promise { const startedAt = this.deps.now(); const activeRun: CronActiveRun = { runId, - taskId: task.taskId, - sessionKey: task.sessionKey, - scheduleType: task.schedule.type, + taskId: taskSnapshot.taskId, + sessionKey: taskSnapshot.sessionKey, + scheduleType: taskSnapshot.schedule.type, stopRequested: false, }; this.deps.registerActiveRun(activeRun); + let task = taskSnapshot; let outcome: CronRunOutcome = "completed"; let error: CronRunRecord["error"]; let forcedFailure = false; @@ -60,16 +61,25 @@ export class CronFire { let startedRun = false; let assistantText = ""; try { - const started = await this.deps.store.replaceTask({ - ...task, - status: "running", - lastRunId: runId, - updatedAt: startedAt.toISOString(), + let claimed = false; + const currentTask = await this.deps.store.updateTask(taskSnapshot.taskId, (current) => { + if (!matchesScheduledSnapshot(current, taskSnapshot)) { + return current; + } + claimed = true; + return { + ...current, + status: "running", + lastRunId: runId, + revision: (current.revision ?? 0) + 1, + updatedAt: startedAt.toISOString(), + }; }); - if (!started) { + if (!claimed || !currentTask) { outcome = "aborted"; return; } + task = currentTask; startedRun = true; this.deps.onPhaseEvent?.({ phase: "cron_started", @@ -177,7 +187,7 @@ export class CronFire { error: deliveryError instanceof Error ? deliveryError.message : String(deliveryError), }); }); - await this.updateTaskAfterRun(task, finishedAt, outcome).catch((updateError: unknown) => { + await this.updateTaskAfterRun(task, runId, finishedAt, outcome).catch((updateError: unknown) => { this.deps.logger?.warn("cron task post-run update failed", { taskId: task.taskId, runId, @@ -212,11 +222,17 @@ export class CronFire { }); } - private async updateTaskAfterRun(task: CronTask, finishedAt: Date, outcome: CronRunOutcome): Promise { + private async updateTaskAfterRun(task: CronTask, runId: string, finishedAt: Date, outcome: CronRunOutcome): Promise { if (task.schedule.type === "once") { - try { - await this.deps.store.deleteTask(task.taskId); - } finally { + let deleted = false; + await this.deps.store.updateTask(task.taskId, (current) => { + if (!matchesRunningTask(current, task, runId)) { + return current; + } + deleted = true; + return undefined; + }); + if (deleted) { await this.deps.releaseTaskSession(task); } return; @@ -228,15 +244,34 @@ export class CronFire { ); const schedule = { ...task.schedule, timezone }; const nextRunAt = computeNextRunAt(schedule, finishedAt, timezone)?.toISOString(); - await this.deps.store.updateTask(task.taskId, (current) => ({ - ...current, - schedule, - timezone, - status: "scheduled", - nextRunAt, - scheduleComputationVersion: 2, - updatedAt: finishedAt.toISOString(), - })); + await this.deps.store.updateTask(task.taskId, (current) => { + if (!matchesRunningTask(current, task, runId)) { + return current; + } + return { + ...current, + schedule, + timezone, + status: "scheduled", + nextRunAt, + revision: (current.revision ?? 0) + 1, + scheduleComputationVersion: 2, + updatedAt: finishedAt.toISOString(), + }; + }); void outcome; } } + +function matchesScheduledSnapshot(current: CronTask, snapshot: CronTask): boolean { + return current.status === "scheduled" + && (current.revision ?? 0) === (snapshot.revision ?? 0) + && current.nextRunAt === snapshot.nextRunAt + && current.lastRunId === snapshot.lastRunId; +} + +function matchesRunningTask(current: CronTask, claimedTask: CronTask, runId: string): boolean { + return current.status === "running" + && current.lastRunId === runId + && (current.revision ?? 0) === (claimedTask.revision ?? 0); +} diff --git a/src/cron/runtime/CronManager.ts b/src/cron/runtime/CronManager.ts index a7a18dc89..acc7408d0 100644 --- a/src/cron/runtime/CronManager.ts +++ b/src/cron/runtime/CronManager.ts @@ -20,6 +20,8 @@ import type { CronStopInput, CronStopResult, CronTask, + CronUpdateInput, + CronUpdateResult, } from "../protocol/types.js"; import { resolveCronPaths } from "../storage/CronPaths.js"; import { createCronCreateTool } from "../tool/CronCreateTool.js"; @@ -131,6 +133,12 @@ export class CronManager { return result; } + async updateTask(input: CronUpdateInput): Promise { + const runtime = await this.resolveTaskRuntime(input.taskId, input.projectKey); + if (!runtime) return { updated: false, reason: "not_found" }; + return runtime.updateTask(input); + } + async deleteTask(input: CronDeleteInput): Promise { const runtime = await this.resolveTaskRuntime(input.taskId, input.projectKey); if (!runtime) return { deleted: false }; diff --git a/src/cron/runtime/CronRuntime.ts b/src/cron/runtime/CronRuntime.ts index e785fe939..aa80980d6 100644 --- a/src/cron/runtime/CronRuntime.ts +++ b/src/cron/runtime/CronRuntime.ts @@ -20,6 +20,8 @@ import type { CronStopResult, CronTask, CronResultDeliveryHandler, + CronUpdateInput, + CronUpdateResult, } from "../protocol/types.js"; import { resolveCronPaths, type CronPaths } from "../storage/CronPaths.js"; import { CronTaskStore } from "../storage/CronTaskStore.js"; @@ -234,6 +236,7 @@ export class CronRuntime { createdAt: now.toISOString(), updatedAt: now.toISOString(), nextRunAt: nextRunAt.toISOString(), + revision: 0, scheduleComputationVersion: schedule.type === "cron" ? 2 : undefined, }; this.registerTaskSession(task); @@ -267,6 +270,69 @@ export class CronRuntime { return result; } + async updateTask(input: CronUpdateInput): Promise { + if (!this.config.enabled) { + throw new Error("Cron is disabled. Enable it in pilotdeck.yaml to update tasks."); + } + if (typeof input.projectKey !== "string" || !input.projectKey.trim() || !this.matchesProject(input.projectKey)) { + return { updated: false, reason: "not_found" }; + } + if (!Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0) { + throw new Error("Cron task expectedRevision must be a non-negative integer."); + } + if (typeof input.message !== "string" || !input.message.trim()) { + throw new Error("Cron task message is required."); + } + if (input.timezone !== undefined && (typeof input.timezone !== "string" || !isValidCronTimezone(input.timezone))) { + throw new Error(`Invalid Cron timezone: ${input.timezone}`); + } + + const now = this.now(); + const schedule = normalizeSchedule(input, this.config.timezone, now); + const timezone = schedule.type === "cron" + ? schedule.timezone + : input.timezone ?? this.config.timezone; + const nextRunAt = computeNextRunAt(schedule, now, timezone); + if (!nextRunAt) { + throw new Error("Cron schedule does not produce a valid future run time."); + } + if (schedule.type === "once" && nextRunAt.getTime() < now.getTime()) { + throw new Error("One-time Cron tasks must be scheduled in the future."); + } + + let reason: Extract["reason"] | undefined; + const updated = await this.store.updateTask(input.taskId, (current) => { + const currentProjectKey = current.projectKey?.trim() ? resolve(current.projectKey) : this.projectKey; + if (currentProjectKey !== this.projectKey || currentProjectKey !== resolve(input.projectKey)) { + reason = "not_found"; + return current; + } + if (current.status === "running") { + reason = "running"; + return current; + } + if ((current.revision ?? 0) !== input.expectedRevision) { + reason = "conflict"; + return current; + } + return { + ...current, + message: input.message, + schedule, + timezone, + nextRunAt: nextRunAt.toISOString(), + updatedAt: now.toISOString(), + revision: (current.revision ?? 0) + 1, + scheduleComputationVersion: schedule.type === "cron" ? 2 : undefined, + }; + }); + + if (!updated) return { updated: false, reason: "not_found" }; + if (reason) return { updated: false, reason }; + this.scheduler?.poke(); + return { updated: true, task: updated }; + } + async deleteTask(input: CronDeleteInput): Promise { let stoppedRunId: string | undefined; if (input.stopRunning) { @@ -352,6 +418,10 @@ export class CronRuntime { return undefined; } + private matchesProject(projectKey: string): boolean { + return resolve(projectKey) === this.projectKey; + } + private async migrateLegacyTaskSessions(): Promise { const tasks = await this.store.listTasks(); let migratedCount = 0; @@ -369,6 +439,7 @@ export class CronRuntime { ...task, sessionKey: nextSessionKey, channelKey: "cron", + revision: (task.revision ?? 0) + 1, updatedAt: this.now().toISOString(), }); } @@ -462,6 +533,7 @@ export class CronRuntime { timezone, status: "scheduled", nextRunAt: computeNextRunAt(schedule, now, timezone)?.toISOString(), + revision: (task.revision ?? 0) + 1, scheduleComputationVersion: 2, updatedAt: now.toISOString(), }); @@ -477,7 +549,10 @@ export function createCronRuntime(options: CreateCronRuntimeOptions): CronRuntim return new CronRuntime(options); } -function normalizeSchedule(input: CronCreateInput, configTimezone: string, now: Date): CronTask["schedule"] { +type CronScheduleInput = Pick + | Pick; + +function normalizeSchedule(input: CronScheduleInput, configTimezone: string, now: Date): CronTask["schedule"] { if (input.schedule.type === "once") { return { type: "once", runAt: input.schedule.runAt }; } diff --git a/src/cron/runtime/CronScheduler.ts b/src/cron/runtime/CronScheduler.ts index 726c81c1b..593750231 100644 --- a/src/cron/runtime/CronScheduler.ts +++ b/src/cron/runtime/CronScheduler.ts @@ -120,10 +120,18 @@ export class CronScheduler { } const nextRunAt = computeNextRunAt(task.schedule, now)?.toISOString(); if (!nextRunAt) { - await this.deps.store.deleteTask(task.taskId); + await this.deps.store.updateTask(task.taskId, (current) => matchesTaskSnapshot(current, task) ? undefined : current); return; } - await this.deps.store.putTask({ ...task, nextRunAt, updatedAt: now.toISOString() }); + await this.deps.store.updateTask(task.taskId, (current) => { + if (!matchesTaskSnapshot(current, task)) return current; + return { + ...current, + nextRunAt, + revision: (current.revision ?? 0) + 1, + updatedAt: now.toISOString(), + }; + }); return; } @@ -137,14 +145,18 @@ export class CronScheduler { ); const schedule = { ...task.schedule, timezone }; const nextRunAt = computeNextRunAt(schedule, now, timezone)?.toISOString(); - await this.deps.store.putTask({ - ...task, - schedule, - timezone, - status: "scheduled", - nextRunAt, - scheduleComputationVersion: 2, - updatedAt: now.toISOString(), + await this.deps.store.updateTask(task.taskId, (current) => { + if (!matchesTaskSnapshot(current, task)) return current; + return { + ...current, + schedule, + timezone, + status: "scheduled", + nextRunAt, + revision: (current.revision ?? 0) + 1, + scheduleComputationVersion: 2, + updatedAt: now.toISOString(), + }; }); }), ); @@ -152,10 +164,14 @@ export class CronScheduler { private async delayTask(task: CronTask, now: Date): Promise { const nextRunAt = new Date(now.getTime() + DEFAULT_IDLE_POLL_MS).toISOString(); - await this.deps.store.putTask({ - ...task, - nextRunAt, - updatedAt: now.toISOString(), + await this.deps.store.updateTask(task.taskId, (current) => { + if (!matchesTaskSnapshot(current, task) || current.status !== "scheduled") return current; + return { + ...current, + nextRunAt, + revision: (current.revision ?? 0) + 1, + updatedAt: now.toISOString(), + }; }); } } @@ -170,3 +186,10 @@ function isDue(task: CronTask, now: Date): boolean { const dueAt = new Date(task.nextRunAt); return !Number.isNaN(dueAt.getTime()) && dueAt.getTime() <= now.getTime(); } + +function matchesTaskSnapshot(current: CronTask, snapshot: CronTask): boolean { + return current.status === snapshot.status + && (current.revision ?? 0) === (snapshot.revision ?? 0) + && current.nextRunAt === snapshot.nextRunAt + && current.lastRunId === snapshot.lastRunId; +} diff --git a/src/cron/storage/CronTaskStore.ts b/src/cron/storage/CronTaskStore.ts index cba08f854..6ef2c036d 100644 --- a/src/cron/storage/CronTaskStore.ts +++ b/src/cron/storage/CronTaskStore.ts @@ -213,6 +213,9 @@ function normalizeTask(value: unknown): CronTask | undefined { updatedAt: candidate.updatedAt, nextRunAt: typeof candidate.nextRunAt === "string" ? candidate.nextRunAt : undefined, lastRunId: typeof candidate.lastRunId === "string" ? candidate.lastRunId : undefined, + revision: Number.isSafeInteger(candidate.revision) && candidate.revision! >= 0 + ? candidate.revision + : 0, scheduleComputationVersion: candidate.scheduleComputationVersion === 2 ? 2 : undefined, originSessionKey: typeof candidate.originSessionKey === "string" ? candidate.originSessionKey : undefined, originChannelKey: typeof candidate.originChannelKey === "string" ? candidate.originChannelKey : undefined, diff --git a/src/gateway/client/InProcessGateway.ts b/src/gateway/client/InProcessGateway.ts index e02eec2c6..00f89e5d5 100644 --- a/src/gateway/client/InProcessGateway.ts +++ b/src/gateway/client/InProcessGateway.ts @@ -59,6 +59,8 @@ import type { CronRunNowResult, CronStopInput, CronStopResult, + CronUpdateInput, + CronUpdateResult, } from "../../cron/protocol/types.js"; import { permissionEntryToRule, permissionSettingsToRuleSet, readPermissionSettings } from "../../permission/index.js"; import type { PermissionRule } from "../../permission/index.js"; @@ -657,6 +659,10 @@ export class InProcessGateway implements Gateway { return this.requireCron().listTasks(input); } + async cronUpdate(input: CronUpdateInput): Promise { + return this.requireCron().updateTask(input); + } + async cronDelete(input: CronDeleteInput): Promise { return this.requireCron().deleteTask(input); } diff --git a/src/gateway/client/RemoteGateway.ts b/src/gateway/client/RemoteGateway.ts index 3701182b7..504844fdb 100644 --- a/src/gateway/client/RemoteGateway.ts +++ b/src/gateway/client/RemoteGateway.ts @@ -55,6 +55,8 @@ import type { CronRunNowResult, CronStopInput, CronStopResult, + CronUpdateInput, + CronUpdateResult, } from "../../cron/protocol/types.js"; import { GatewayWsClient, type GatewayWsNotificationHandler } from "./GatewayWsClient.js"; import { parseReloadConfigResult } from "../protocol/reloadConfigResult.js"; @@ -110,6 +112,10 @@ export class RemoteGateway implements Gateway { return (await this.client.request("cron_list", input)) as CronListResult; } + async cronUpdate(input: CronUpdateInput): Promise { + return (await this.client.request("cron_update", input)) as CronUpdateResult; + } + async cronDelete(input: CronDeleteInput): Promise { return (await this.client.request("cron_delete", input)) as CronDeleteResult; } diff --git a/src/gateway/protocol/frames.ts b/src/gateway/protocol/frames.ts index 49e370372..5d23baeb2 100644 --- a/src/gateway/protocol/frames.ts +++ b/src/gateway/protocol/frames.ts @@ -29,6 +29,7 @@ export type WsGatewayMethod = | "active_turn_snapshot" | "cron_create" | "cron_list" + | "cron_update" | "cron_delete" | "cron_stop" | "cron_run_now" diff --git a/src/gateway/protocol/types.ts b/src/gateway/protocol/types.ts index e3c7a5ff1..a555f1db5 100644 --- a/src/gateway/protocol/types.ts +++ b/src/gateway/protocol/types.ts @@ -12,6 +12,8 @@ import type { CronRunNowResult, CronStopInput, CronStopResult, + CronUpdateInput, + CronUpdateResult, } from "../../cron/protocol/types.js"; import type { CanonicalUsage } from "../../model/index.js"; import type { TelemetryExecutionKind, TelemetryModule } from "../../telemetry/index.js"; @@ -329,6 +331,7 @@ export type GatewayServerInfo = { export type GatewayCronController = { createTask(input: CronCreateInput): Promise; listTasks(input: CronListInput): Promise; + updateTask(input: CronUpdateInput): Promise; deleteTask(input: CronDeleteInput): Promise; stopTask(input: CronStopInput): Promise; runTaskNow(input: CronRunNowInput): Promise; @@ -391,6 +394,7 @@ export interface Gateway { getActiveTurnSnapshot?(input: GatewayActiveTurnSnapshotInput): Promise; cronCreate(input: CronCreateInput): Promise; cronList(input: CronListInput): Promise; + cronUpdate(input: CronUpdateInput): Promise; cronDelete(input: CronDeleteInput): Promise; cronStop(input: CronStopInput): Promise; cronRunNow(input: CronRunNowInput): Promise; diff --git a/src/gateway/server/GatewayWsConnection.ts b/src/gateway/server/GatewayWsConnection.ts index 72fdd8e47..24438550a 100644 --- a/src/gateway/server/GatewayWsConnection.ts +++ b/src/gateway/server/GatewayWsConnection.ts @@ -199,6 +199,8 @@ export class GatewayWsConnection { return this.options.gateway.cronCreate(frame.params as never); case "cron_list": return this.options.gateway.cronList(frame.params as never); + case "cron_update": + return this.options.gateway.cronUpdate(frame.params as never); case "cron_delete": return this.options.gateway.cronDelete(frame.params as never); case "cron_stop": diff --git a/src/web/client/GatewayBrowserClient.ts b/src/web/client/GatewayBrowserClient.ts index c0a4fc46a..c2c76412d 100644 --- a/src/web/client/GatewayBrowserClient.ts +++ b/src/web/client/GatewayBrowserClient.ts @@ -227,6 +227,9 @@ export class GatewayBrowserClient { cronList(input: unknown) { return this.request("cron_list", input); } + cronUpdate(input: unknown) { + return this.request("cron_update", input); + } cronDelete(input: unknown) { return this.request("cron_delete", input); } diff --git a/src/web/client/protocol.ts b/src/web/client/protocol.ts index a88cdf3b0..d0341d61c 100644 --- a/src/web/client/protocol.ts +++ b/src/web/client/protocol.ts @@ -128,6 +128,7 @@ export type WebGatewayMethod = | "active_turn_snapshot" | "cron_create" | "cron_list" + | "cron_update" | "cron_delete" | "cron_stop" | "cron_run_now" diff --git a/tests/cron/cron-editing.spec.ts b/tests/cron/cron-editing.spec.ts new file mode 100644 index 000000000..6534e4c3a --- /dev/null +++ b/tests/cron/cron-editing.spec.ts @@ -0,0 +1,426 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { createCronRuntime, defaultCronConfig } from "../../src/cron/index.js"; +import type { CronTask, CronUpdateInput } from "../../src/cron/protocol/types.js"; +import { CronFire } from "../../src/cron/runtime/CronFire.js"; +import { CronScheduler } from "../../src/cron/runtime/CronScheduler.js"; +import { resolveCronPaths } from "../../src/cron/storage/CronPaths.js"; +import { CronTaskStore } from "../../src/cron/storage/CronTaskStore.js"; +import { InProcessGateway } from "../../src/gateway/client/InProcessGateway.js"; +import { RemoteGateway } from "../../src/gateway/client/RemoteGateway.js"; +import type { GatewayWsClient } from "../../src/gateway/client/GatewayWsClient.js"; +import type { Gateway, GatewayCronController } from "../../src/gateway/index.js"; +import { PILOTDECK_GATEWAY_PROTOCOL_VERSION } from "../../src/gateway/protocol/version.js"; +import { GatewayWsConnection } from "../../src/gateway/server/GatewayWsConnection.js"; +import type { TextWebSocketConnection } from "../../src/gateway/server/websocket.js"; +import type { SessionRouter } from "../../src/gateway/SessionRouter.js"; +import { GatewayBrowserClient } from "../../src/web/client/GatewayBrowserClient.js"; +import type { WebGatewayMethod } from "../../src/web/client/protocol.js"; + +function createStore(pilotHome: string, projectKey: string): CronTaskStore { + return new CronTaskStore(resolveCronPaths({ pilotHome, projectKey })); +} + +function makeTask(overrides: Partial = {}): CronTask { + return { + schemaVersion: 1, + taskId: "task-1", + message: "Run the report", + schedule: { type: "cron", expression: "0 * * * *", timezone: "UTC" }, + status: "scheduled", + sessionKey: "cron:task-1", + channelKey: "cron", + projectKey: "/tmp/projects/cron-editing", + timezone: "UTC", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + nextRunAt: "2026-01-01T00:00:00.000Z", + revision: 0, + scheduleComputationVersion: 2, + ...overrides, + }; +} + +function createFire(store: CronTaskStore, gateway: Gateway, now: () => Date): CronFire { + const activeRuns = new Map(); + return new CronFire({ + gateway, + store, + now, + registerActiveRun: (run) => activeRuns.set(run.runId, run), + unregisterActiveRun: (runId) => { + const run = activeRuns.get(runId); + activeRuns.delete(runId); + return run; + }, + getActiveRun: (runId) => activeRuns.get(runId), + runTimeoutMs: 60_000, + defaultTimezone: "UTC", + releaseTaskSession: async () => undefined, + }); +} + +class FakeTextWebSocketConnection { + readonly sent: unknown[] = []; + private messageHandler?: (message: string) => void; + + onMessage(handler: (message: string) => void): void { + this.messageHandler = handler; + } + + onClose(_handler: () => void): void {} + + sendText(message: string): void { + this.sent.push(JSON.parse(message)); + } + + close(): void {} + + dispatch(frame: unknown): void { + this.messageHandler?.(JSON.stringify(frame)); + } +} + +async function flushAsyncWork(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + +test("CronTaskStore normalizes legacy tasks without a revision to zero", async () => { + const pilotHome = mkdtempSync(join(tmpdir(), "pilotdeck-cron-legacy-revision-")); + const projectKey = "/tmp/projects/cron-editing"; + try { + const store = createStore(pilotHome, projectKey); + await store.putTask(makeTask({ revision: undefined })); + assert.equal((await store.getTask("task-1"))?.revision, 0); + } finally { + rmSync(pilotHome, { recursive: true, force: true }); + } +}); + +test("CronRuntime updates a task in place and rejects running or stale updates", async () => { + const pilotHome = mkdtempSync(join(tmpdir(), "pilotdeck-cron-editing-")); + const projectKey = "/tmp/projects/cron-editing"; + let now = new Date("2026-01-01T00:00:00.000Z"); + try { + const store = createStore(pilotHome, projectKey); + const runtime = createCronRuntime({ + config: defaultCronConfig(), + pilotHome, + projectKey, + store, + now: () => now, + uuid: () => "task-1", + skipToolCreation: true, + }); + const created = await runtime.createTask({ + message: "Original prompt", + projectKey, + timezone: "Asia/Shanghai", + schedule: { type: "cron", expression: "0 8 * * *", timezone: "Asia/Shanghai" }, + }); + assert.equal(created.task.revision, 0); + await store.appendRun({ + schemaVersion: 1, + runId: "historic-run", + taskId: created.task.taskId, + sessionKey: created.task.sessionKey, + projectKey, + startedAt: "2026-01-01T00:00:00.000Z", + finishedAt: "2026-01-01T00:01:00.000Z", + outcome: "completed", + }); + + now = new Date("2026-01-03T00:00:00.000Z"); + const updated = await runtime.updateTask({ + taskId: created.task.taskId, + projectKey, + expectedRevision: 0, + message: "Updated prompt", + timezone: "America/New_York", + schedule: { type: "once", runAt: "2026-02-01T13:30:00.000Z" }, + }); + assert.equal(updated.updated, true); + if (!updated.updated) return; + assert.equal(updated.task.taskId, created.task.taskId); + assert.equal(updated.task.createdAt, created.task.createdAt); + assert.equal(updated.task.sessionKey, created.task.sessionKey); + assert.equal(updated.task.message, "Updated prompt"); + assert.equal(updated.task.timezone, "America/New_York"); + assert.equal(updated.task.revision, 1); + assert.deepEqual(updated.task.schedule, { type: "once", runAt: "2026-02-01T13:30:00.000Z" }); + assert.equal((await runtime.listTasks({ includeHistory: true })).recentRuns?.[0]?.runId, "historic-run"); + + assert.deepEqual(await runtime.updateTask({ + taskId: created.task.taskId, + projectKey, + expectedRevision: 0, + message: "Stale prompt", + schedule: { type: "once", runAt: "2026-02-02T13:30:00.000Z" }, + }), { updated: false, reason: "conflict" }); + assert.deepEqual(await runtime.updateTask({ + taskId: created.task.taskId, + projectKey: "/tmp/projects/another-project", + expectedRevision: 1, + message: "Wrong workspace", + schedule: { type: "once", runAt: "2026-02-02T13:30:00.000Z" }, + }), { updated: false, reason: "not_found" }); + await assert.rejects(runtime.updateTask({ + taskId: created.task.taskId, + projectKey, + expectedRevision: 1, + message: "Invalid timezone", + timezone: "Not/AZone", + schedule: { type: "once", runAt: "2026-02-02T13:30:00.000Z" }, + }), /Invalid Cron timezone/); + assert.equal((await store.getTask(created.task.taskId))?.revision, 1); + assert.equal((await store.getTask(created.task.taskId))?.timezone, "America/New_York"); + + await store.updateTask(created.task.taskId, (current) => ({ + ...current, + status: "running", + lastRunId: "active-run", + revision: (current.revision ?? 0) + 1, + })); + assert.deepEqual(await runtime.updateTask({ + taskId: created.task.taskId, + projectKey, + expectedRevision: 2, + message: "Cannot edit while running", + schedule: { type: "once", runAt: "2026-02-03T13:30:00.000Z" }, + }), { updated: false, reason: "running" }); + await assert.rejects(runtime.updateTask({ + taskId: created.task.taskId, + projectKey, + expectedRevision: 2, + message: " ", + schedule: { type: "once", runAt: "2026-02-03T13:30:00.000Z" }, + }), /message is required/); + } finally { + rmSync(pilotHome, { recursive: true, force: true }); + } +}); + +test("CronRuntime updates daily, weekly, monthly, yearly, and one-time schedules in place", async () => { + const pilotHome = mkdtempSync(join(tmpdir(), "pilotdeck-cron-editing-schedules-")); + const projectKey = "/tmp/projects/cron-editing-schedules"; + const now = new Date("2026-01-01T00:00:00.000Z"); + try { + const runtime = createCronRuntime({ + config: defaultCronConfig(), + pilotHome, + projectKey, + now: () => now, + uuid: () => "task-schedules", + skipToolCreation: true, + }); + const created = await runtime.createTask({ + message: "Original", + projectKey, + timezone: "UTC", + schedule: { type: "once", runAt: "2026-01-02T12:00:00.000Z" }, + }); + const cases: Array<{ schedule: CronUpdateInput["schedule"]; nextRunAt: string }> = [ + { schedule: { type: "cron", expression: "30 8 * * *", timezone: "UTC" }, nextRunAt: "2026-01-01T08:30:00.000Z" }, + { schedule: { type: "cron", expression: "30 8 * * 1", timezone: "UTC" }, nextRunAt: "2026-01-05T08:30:00.000Z" }, + { schedule: { type: "cron", expression: "30 8 15 * *", timezone: "UTC" }, nextRunAt: "2026-01-15T08:30:00.000Z" }, + { schedule: { type: "cron", expression: "30 8 15 9 *", timezone: "UTC" }, nextRunAt: "2026-09-15T08:30:00.000Z" }, + { schedule: { type: "once", runAt: "2026-01-02T12:00:00.000Z" }, nextRunAt: "2026-01-02T12:00:00.000Z" }, + ]; + + let revision = 0; + for (const [index, scheduleCase] of cases.entries()) { + const result = await runtime.updateTask({ + taskId: created.task.taskId, + projectKey, + expectedRevision: revision, + message: `Updated ${index + 1}`, + schedule: scheduleCase.schedule, + timezone: "UTC", + }); + assert.equal(result.updated, true); + if (!result.updated) continue; + revision += 1; + assert.equal(result.task.taskId, created.task.taskId); + assert.equal(result.task.createdAt, created.task.createdAt); + assert.equal(result.task.sessionKey, created.task.sessionKey); + assert.equal(result.task.revision, revision); + assert.equal(result.task.nextRunAt, scheduleCase.nextRunAt); + assert.deepEqual(result.task.schedule, scheduleCase.schedule); + assert.equal(result.task.scheduleComputationVersion, scheduleCase.schedule.type === "cron" ? 2 : undefined); + } + } finally { + rmSync(pilotHome, { recursive: true, force: true }); + } +}); + +test("CronRuntime checks the persisted task workspace inside the atomic update", async () => { + const pilotHome = mkdtempSync(join(tmpdir(), "pilotdeck-cron-editing-project-")); + const projectKey = "/tmp/projects/cron-editing-project"; + try { + const store = createStore(pilotHome, projectKey); + await store.putTask(makeTask({ projectKey: "/tmp/projects/another-project" })); + const runtime = createCronRuntime({ + config: defaultCronConfig(), + pilotHome, + projectKey, + store, + now: () => new Date("2026-01-01T00:00:00.000Z"), + skipToolCreation: true, + }); + assert.deepEqual(await runtime.updateTask({ + taskId: "task-1", + projectKey, + expectedRevision: 0, + message: "Must not move workspaces", + schedule: { type: "cron", expression: "30 8 * * *", timezone: "UTC" }, + timezone: "UTC", + }), { updated: false, reason: "not_found" }); + assert.equal((await store.getTask("task-1"))?.projectKey, "/tmp/projects/another-project"); + } finally { + rmSync(pilotHome, { recursive: true, force: true }); + } +}); + +test("CronFire never executes an edited snapshot and claims a current snapshot only once", async () => { + const pilotHome = mkdtempSync(join(tmpdir(), "pilotdeck-cron-claim-")); + const projectKey = "/tmp/projects/cron-editing"; + const now = () => new Date("2026-01-01T00:00:00.000Z"); + try { + const store = createStore(pilotHome, projectKey); + const oldSnapshot = makeTask(); + await store.putTask(oldSnapshot); + let submitCount = 0; + const gateway = { + submitTurn: async function* () { + submitCount += 1; + }, + } as unknown as Gateway; + const fire = createFire(store, gateway, now); + + await store.updateTask(oldSnapshot.taskId, (current) => ({ + ...current, + message: "Edited before execution", + schedule: { type: "cron", expression: "15 * * * *", timezone: "UTC" }, + nextRunAt: "2026-01-01T00:15:00.000Z", + revision: (current.revision ?? 0) + 1, + })); + await fire.runTask(oldSnapshot, "old-run"); + assert.equal(submitCount, 0); + assert.equal((await store.getTask(oldSnapshot.taskId))?.message, "Edited before execution"); + + const current = await store.getTask(oldSnapshot.taskId); + assert.ok(current); + await Promise.all([fire.runTask(current, "run-a"), fire.runTask(current, "run-b")]); + assert.equal(submitCount, 1); + const completed = await store.getTask(oldSnapshot.taskId); + assert.equal(completed?.status, "scheduled"); + assert.equal(completed?.revision, 3); + } finally { + rmSync(pilotHome, { recursive: true, force: true }); + } +}); + +test("CronScheduler does not delay a task after the task was edited", async () => { + const now = new Date("2026-01-01T00:00:00.000Z"); + const staleSnapshot = makeTask({ nextRunAt: now.toISOString() }); + let currentTask = makeTask({ + message: "Edited schedule", + nextRunAt: "2026-01-01T01:00:00.000Z", + revision: 1, + }); + const store = { + listTasks: async () => [{ ...staleSnapshot }], + updateTask: async (_taskId: string, update: (task: CronTask) => CronTask | undefined) => { + const updated = update(currentTask); + if (updated) currentTask = updated; + return updated; + }, + } as unknown as CronTaskStore; + const scheduler = new CronScheduler({ + config: { ...defaultCronConfig(), maxConcurrentRuns: 1 }, + store, + fire: { runTask: async () => undefined } as unknown as CronFire, + uuid: () => "run-1", + now: () => now, + activeRunCount: () => 1, + }); + + await scheduler.runTickOnce(); + assert.equal(currentTask.message, "Edited schedule"); + assert.equal(currentTask.nextRunAt, "2026-01-01T01:00:00.000Z"); + assert.equal(currentTask.revision, 1); +}); + +test("Cron update is forwarded by gateway dispatchers and clients", async () => { + const input: CronUpdateInput = { + taskId: "task-1", + projectKey: "/tmp/projects/cron-editing", + expectedRevision: 0, + message: "Updated", + schedule: { type: "cron", expression: "30 8 * * 1", timezone: "UTC" }, + timezone: "UTC", + }; + const expected = { updated: false as const, reason: "not_found" as const }; + let inProcessInput: CronUpdateInput | undefined; + const controller = { + updateTask: async (received: CronUpdateInput) => { + inProcessInput = received; + return expected; + }, + } as unknown as GatewayCronController; + const inProcess = new InProcessGateway({} as SessionRouter, { cron: controller }); + assert.deepEqual(await inProcess.cronUpdate(input), expected); + assert.deepEqual(inProcessInput, input); + + let remoteMethod: string | undefined; + let remoteInput: unknown; + const remote = new RemoteGateway({ + request: async (method: string, received: unknown) => { + remoteMethod = method; + remoteInput = received; + return expected; + }, + } as unknown as GatewayWsClient); + assert.deepEqual(await remote.cronUpdate(input), expected); + assert.equal(remoteMethod, "cron_update"); + assert.deepEqual(remoteInput, input); + + let websocketInput: CronUpdateInput | undefined; + const socket = new FakeTextWebSocketConnection(); + new GatewayWsConnection(socket as unknown as TextWebSocketConnection, { + token: "secret", + serverVersion: "test", + gateway: { + describeServer: async () => ({ mode: "in_process" }), + cronUpdate: async (received: CronUpdateInput) => { + websocketInput = received; + return expected; + }, + } as unknown as Gateway, + }); + socket.dispatch({ + type: "hello", + protocolVersion: PILOTDECK_GATEWAY_PROTOCOL_VERSION, + clientName: "test", + clientVersion: "test", + token: "secret", + }); + await flushAsyncWork(); + socket.dispatch({ type: "request", id: "cron-update-1", method: "cron_update", params: input }); + await flushAsyncWork(); + assert.deepEqual(websocketInput, input); + assert.deepEqual(socket.sent.at(-1), { + type: "response", + id: "cron-update-1", + ok: true, + result: expected, + }); + + const browserMethod: WebGatewayMethod = "cron_update"; + assert.equal(browserMethod, "cron_update"); + assert.equal(typeof GatewayBrowserClient.prototype.cronUpdate, "function"); +}); diff --git a/ui/server/index.js b/ui/server/index.js index 91e5351a0..2bfb33c8a 100755 --- a/ui/server/index.js +++ b/ui/server/index.js @@ -85,6 +85,7 @@ import skillsRoutes from './routes/skills.js'; import settingsRoutes from './routes/settings.js'; import configRoutes from './routes/config.js'; import gatewayRoutes from './routes/gateway.js'; +import { createCronUpdateHandler } from './routes/cron-jobs.js'; import { OFFICE_PREVIEW_SERVICE_BUILTIN, OFFICE_PREVIEW_SERVICE_LIBREOFFICE, @@ -635,6 +636,8 @@ app.post('/api/always-on/cron-jobs', authenticateToken, async (req, res) => { } }); +app.patch('/api/always-on/cron-jobs/:taskId', authenticateToken, createCronUpdateHandler({ getGateway: getPilotDeckGateway })); + app.post('/api/always-on/cron-jobs/:taskId/run-now', authenticateToken, async (req, res) => { try { const gateway = await getPilotDeckGateway(); diff --git a/ui/server/projects.cron.test.js b/ui/server/projects.cron.test.js new file mode 100644 index 000000000..44b9c8935 --- /dev/null +++ b/ui/server/projects.cron.test.js @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const gateway = vi.hoisted(() => ({ + cronList: vi.fn(), +})); + +vi.mock('./pilotdeck-bridge.js', () => ({ + getPilotDeckGateway: vi.fn(async () => gateway), +})); + +import { getProjectCronJobsOverview } from './projects.js'; + +describe('getProjectCronJobsOverview', () => { + beforeEach(() => { + gateway.cronList.mockReset(); + }); + + it('returns editable schedules and keeps the legacy cron field', async () => { + gateway.cronList.mockResolvedValue({ + tasks: [ + { + taskId: 'weekly-task', + message: 'Weekly report', + projectKey: '/workspace/project', + schedule: { + type: 'cron', + expression: '30 8 * * 1', + timezone: 'Asia/Shanghai', + }, + status: 'scheduled', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-02T00:00:00.000Z', + revision: 3, + }, + { + taskId: 'once-task', + message: 'One-time report', + projectKey: '/workspace/project', + schedule: { + type: 'once', + runAt: '2026-08-05T08:00:00.000Z', + }, + timezone: 'America/New_York', + status: 'scheduled', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-02T00:00:00.000Z', + }, + ], + recentRuns: [], + }); + + const result = await getProjectCronJobsOverview(); + + expect(result.jobs).toEqual([ + expect.objectContaining({ + id: 'weekly-task', + cron: '30 8 * * 1', + schedule: { + type: 'cron', + expression: '30 8 * * 1', + timezone: 'Asia/Shanghai', + }, + timezone: 'Asia/Shanghai', + revision: 3, + }), + expect.objectContaining({ + id: 'once-task', + cron: '', + schedule: { + type: 'once', + runAt: '2026-08-05T08:00:00.000Z', + }, + timezone: 'America/New_York', + revision: 0, + }), + ]); + }); +}); diff --git a/ui/server/projects.js b/ui/server/projects.js index 4ad1f742f..61c742de4 100755 --- a/ui/server/projects.js +++ b/ui/server/projects.js @@ -514,6 +514,9 @@ async function getProjectCronJobsOverview(projectName) { id: task.taskId, projectKey: task.projectKey || null, cron: isCron ? task.schedule.expression : '', + schedule: task.schedule, + timezone: task.timezone || task.schedule?.timezone || null, + revision: Number.isSafeInteger(task.revision) && task.revision >= 0 ? task.revision : 0, prompt: task.message || '', createdAt: task.createdAt, nextRunAt: task.nextRunAt, diff --git a/ui/server/routes/cron-jobs.js b/ui/server/routes/cron-jobs.js new file mode 100644 index 000000000..90baa1e11 --- /dev/null +++ b/ui/server/routes/cron-jobs.js @@ -0,0 +1,94 @@ +export function createCronUpdateHandler({ getGateway }) { + return async function handleCronUpdate(req, res) { + try { + const message = typeof req.body?.message === 'string' ? req.body.message.trim() : ''; + const projectKey = typeof req.body?.projectKey === 'string' ? req.body.projectKey : ''; + const schedule = req.body?.schedule; + const timezone = typeof req.body?.timezone === 'string' && req.body.timezone.trim() + ? req.body.timezone.trim() + : undefined; + const expectedRevision = req.body?.expectedRevision; + + if (!message) { + res.status(400).json({ error: 'Cron message is required.' }); + return; + } + if (!projectKey) { + res.status(400).json({ error: 'Cron projectKey is required.' }); + return; + } + if (!isCronTaskSchedule(schedule)) { + res.status(400).json({ error: 'Cron schedule is invalid.' }); + return; + } + if (hasInvalidTimezone(req.body)) { + res.status(400).json({ error: 'Cron timezone is invalid.' }); + return; + } + if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) { + res.status(400).json({ error: 'Cron expectedRevision must be a non-negative integer.' }); + return; + } + + const gateway = await getGateway(); + const result = await gateway.cronUpdate({ + taskId: req.params.taskId, + projectKey, + expectedRevision, + message, + schedule, + timezone, + }); + if (!result.updated) { + if (result.reason === 'running') { + res.status(409).json({ + error: 'Cron task is running. Stop it before editing.', + code: 'cron_running', + }); + return; + } + if (result.reason === 'conflict') { + res.status(409).json({ + error: 'Cron task changed. Refresh and try again.', + code: 'cron_conflict', + }); + return; + } + res.status(404).json({ error: 'Cron task was not found.', code: 'cron_not_found' }); + return; + } + res.json(result); + } catch (error) { + console.error('[always-on-cron-update] failed:', error); + const status = isCronValidationError(error) ? 400 : 500; + res.status(status).json({ error: error?.message || 'cron update failed' }); + } + }; +} + +function isCronTaskSchedule(schedule) { + if (!schedule || typeof schedule !== 'object' || Array.isArray(schedule)) { + return false; + } + if (schedule.type === 'once') { + return typeof schedule.runAt === 'string' && schedule.runAt.trim().length > 0; + } + if (schedule.type === 'cron') { + return typeof schedule.expression === 'string' + && schedule.expression.trim().length > 0 + && !hasInvalidTimezone(schedule); + } + return false; +} + +function hasInvalidTimezone(value) { + if (!value || typeof value !== 'object' || !Object.hasOwn(value, 'timezone')) { + return false; + } + return typeof value.timezone !== 'string' || value.timezone.trim().length === 0; +} + +function isCronValidationError(error) { + const message = error instanceof Error ? error.message : String(error || ''); + return /^(Invalid Cron|Cron (expression|timezone|schedule|task|delay)|One-time Cron)/i.test(message); +} diff --git a/ui/server/routes/cron-jobs.test.js b/ui/server/routes/cron-jobs.test.js new file mode 100644 index 000000000..a236283f9 --- /dev/null +++ b/ui/server/routes/cron-jobs.test.js @@ -0,0 +1,174 @@ +import express from 'express'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createCronUpdateHandler } from './cron-jobs.js'; + +const nativeFetch = globalThis.fetch; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('Cron update route', () => { + it('returns 200, normalizes message and timezone, and preserves projectKey', async () => { + const task = { taskId: 'task-1', revision: 4 }; + const cronUpdate = vi.fn(async () => ({ updated: true, task })); + const { request } = createCronUpdateApp(cronUpdate); + + const response = await request('/api/always-on/cron-jobs/task-1', { + method: 'PATCH', + body: JSON.stringify({ + ...validPayload(), + message: ' Generate report ', + projectKey: '/workspace/project ', + timezone: ' Asia/Shanghai ', + }), + }); + + expect(response).toEqual({ + status: 200, + body: { updated: true, task }, + }); + expect(cronUpdate).toHaveBeenCalledWith({ + taskId: 'task-1', + message: 'Generate report', + projectKey: '/workspace/project ', + expectedRevision: 3, + schedule: { + type: 'cron', + expression: '30 8 * * 1', + timezone: 'Asia/Shanghai', + }, + timezone: 'Asia/Shanghai', + }); + }); + + it.each([ + ['missing message', { message: '' }], + ['missing projectKey', { projectKey: '' }], + ['invalid schedule', { schedule: { type: 'cron', expression: '' } }], + ['empty timezone', { timezone: ' ' }], + ['non-string timezone', { timezone: null }], + ['empty schedule timezone', { schedule: { type: 'cron', expression: '30 8 * * 1', timezone: '' } }], + ['non-string schedule timezone', { schedule: { type: 'cron', expression: '30 8 * * 1', timezone: 8 } }], + ['invalid revision', { expectedRevision: -1 }], + ])('returns 400 for %s without calling the Gateway', async (_label, override) => { + const cronUpdate = vi.fn(); + const { request } = createCronUpdateApp(cronUpdate); + + const response = await request('/api/always-on/cron-jobs/task-1', { + method: 'PATCH', + body: JSON.stringify({ ...validPayload(), ...override }), + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBeTruthy(); + expect(cronUpdate).not.toHaveBeenCalled(); + }); + + it('returns 400 when the Cron runtime rejects the schedule', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const cronUpdate = vi.fn(async () => { + throw new Error('Invalid Cron timezone: Mars/Olympus'); + }); + const { request } = createCronUpdateApp(cronUpdate); + + const response = await request('/api/always-on/cron-jobs/task-1', { + method: 'PATCH', + body: JSON.stringify(validPayload()), + }); + + expect(response).toEqual({ + status: 400, + body: { error: 'Invalid Cron timezone: Mars/Olympus' }, + }); + }); + + it('returns 404 when the task does not exist in the workspace', async () => { + const { request } = createCronUpdateApp(vi.fn(async () => ({ updated: false, reason: 'not_found' }))); + + const response = await request('/api/always-on/cron-jobs/task-1', { + method: 'PATCH', + body: JSON.stringify(validPayload()), + }); + + expect(response).toEqual({ + status: 404, + body: { error: 'Cron task was not found.', code: 'cron_not_found' }, + }); + }); + + it.each([ + ['running', 'cron_running'], + ['conflict', 'cron_conflict'], + ])('returns 409 with %s details', async (reason, code) => { + const { request } = createCronUpdateApp(vi.fn(async () => ({ updated: false, reason }))); + + const response = await request('/api/always-on/cron-jobs/task-1', { + method: 'PATCH', + body: JSON.stringify(validPayload()), + }); + + expect(response.status).toBe(409); + expect(response.body).toMatchObject({ code }); + }); + + it('returns 500 when the Gateway fails unexpectedly', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const cronUpdate = vi.fn(async () => { + throw new Error('Gateway unavailable'); + }); + const { request } = createCronUpdateApp(cronUpdate); + + const response = await request('/api/always-on/cron-jobs/task-1', { + method: 'PATCH', + body: JSON.stringify(validPayload()), + }); + + expect(response).toEqual({ + status: 500, + body: { error: 'Gateway unavailable' }, + }); + }); +}); + +function validPayload() { + return { + message: 'Generate report', + projectKey: '/workspace/project', + expectedRevision: 3, + schedule: { + type: 'cron', + expression: '30 8 * * 1', + timezone: 'Asia/Shanghai', + }, + timezone: 'Asia/Shanghai', + }; +} + +function createCronUpdateApp(cronUpdate) { + const app = express(); + app.use(express.json()); + app.patch('/api/always-on/cron-jobs/:taskId', createCronUpdateHandler({ + getGateway: vi.fn(async () => ({ cronUpdate })), + })); + return { + request: (path, init) => requestJson(app, path, init), + }; +} + +async function requestJson(app, path, init = {}) { + const server = app.listen(0); + try { + const { port } = server.address(); + const response = await nativeFetch(`http://127.0.0.1:${port}${path}`, { + headers: { 'Content-Type': 'application/json', ...(init.headers || {}) }, + ...init, + }); + return { + status: response.status, + body: await response.json(), + }; + } finally { + await new Promise((resolve) => server.close(resolve)); + } +} diff --git a/ui/src/components/main-content-v2/CronV2.test.tsx b/ui/src/components/main-content-v2/CronV2.test.tsx index 4e394f213..215ad8acd 100644 --- a/ui/src/components/main-content-v2/CronV2.test.tsx +++ b/ui/src/components/main-content-v2/CronV2.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import React from 'react'; -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { CronJobOverview, Project } from '../../types/app'; import CronV2 from './CronV2'; @@ -9,6 +9,7 @@ const apiMock = vi.hoisted(() => ({ projects: vi.fn(), allCronJobs: vi.fn(), cronCreate: vi.fn(), + cronUpdate: vi.fn(), cronDelete: vi.fn(), cronRunNow: vi.fn(), cronStop: vi.fn(), @@ -20,9 +21,10 @@ vi.mock('../../utils/api', () => ({ vi.mock('react-i18next', () => ({ useTranslation: () => ({ - t: (_key: string, options?: Record) => ( - typeof options?.defaultValue === 'string' ? options.defaultValue : _key - ), + t: (_key: string, options?: Record) => { + if (typeof options?.defaultValue !== 'string') return _key; + return options.defaultValue.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => String(options[key] ?? '')); + }, }), })); @@ -32,10 +34,10 @@ const project: Project = { fullPath: '/project/general', }; -function jsonResponse(body: T, ok = true): Response { +function jsonResponse(body: T, ok = true, status = ok ? 200 : 400): Response { return { ok, - status: ok ? 200 : 400, + status, json: vi.fn(async () => body), } as unknown as Response; } @@ -44,7 +46,10 @@ function makeJob(overrides: Partial): CronJobOverview { return { id: 'job-1', projectKey: '/project/general', - cron: '0 * * * *', + cron: '0 8 * * *', + schedule: { type: 'cron', expression: '0 8 * * *', timezone: 'UTC' }, + timezone: 'UTC', + revision: 0, prompt: 'Run hourly report', createdAt: '2026-01-01T00:00:00.000Z', recurring: true, @@ -58,6 +63,7 @@ function setup(jobs: CronJobOverview[]) { apiMock.projects.mockResolvedValue(jsonResponse([project])); apiMock.allCronJobs.mockResolvedValue(jsonResponse({ jobs })); apiMock.cronCreate.mockResolvedValue(jsonResponse({ task: { taskId: 'created-task' } })); + apiMock.cronUpdate.mockResolvedValue(jsonResponse({ task: { taskId: 'updated-task' } })); apiMock.cronRunNow.mockResolvedValue(jsonResponse({ triggered: true })); apiMock.cronStop.mockResolvedValue(jsonResponse({ stopped: true })); apiMock.cronDelete.mockResolvedValue(jsonResponse({ deleted: true })); @@ -72,6 +78,7 @@ describe('CronV2', () => { afterEach(() => { cleanup(); + vi.useRealTimers(); }); it('loads active cron jobs and groups them by project', async () => { @@ -167,6 +174,245 @@ describe('CronV2', () => { }); }); + it('creates a weekly cron task', async () => { + setup([]); + + fireEvent.click(screen.getByRole('button', { name: 'Create Task' })); + await screen.findByText('Create Cron Task'); + fireEvent.change(screen.getByLabelText('Prompt'), { target: { value: 'Weekly digest' } }); + fireEvent.change(screen.getByLabelText('Workspace'), { target: { value: '/project/general' } }); + fireEvent.click(screen.getByRole('button', { name: 'Recurring' })); + fireEvent.change(screen.getByLabelText('Repeat period'), { target: { value: 'weekly' } }); + fireEvent.change(screen.getByLabelText('Day of week'), { target: { value: '1' } }); + fireEvent.change(screen.getByLabelText('Time'), { target: { value: '08:30' } }); + fireEvent.change(screen.getByLabelText('Timezone'), { target: { value: 'Asia/Shanghai' } }); + fireEvent.click(screen.getAllByRole('button', { name: 'Create Task' }).at(-1)!); + + await waitFor(() => { + expect(apiMock.cronCreate).toHaveBeenCalledWith(expect.objectContaining({ + schedule: { type: 'cron', expression: '30 8 * * 1', timezone: 'Asia/Shanghai' }, + })); + }); + }); + + it('creates a monthly cron task', async () => { + setup([]); + + fireEvent.click(screen.getByRole('button', { name: 'Create Task' })); + await screen.findByText('Create Cron Task'); + fireEvent.change(screen.getByLabelText('Prompt'), { target: { value: 'Monthly digest' } }); + fireEvent.change(screen.getByLabelText('Workspace'), { target: { value: '/project/general' } }); + fireEvent.click(screen.getByRole('button', { name: 'Recurring' })); + fireEvent.change(screen.getByLabelText('Repeat period'), { target: { value: 'monthly' } }); + fireEvent.change(screen.getByLabelText('Day of month'), { target: { value: '15' } }); + fireEvent.change(screen.getByLabelText('Time'), { target: { value: '08:30' } }); + fireEvent.change(screen.getByLabelText('Timezone'), { target: { value: 'Asia/Shanghai' } }); + fireEvent.click(screen.getAllByRole('button', { name: 'Create Task' }).at(-1)!); + + await waitFor(() => { + expect(apiMock.cronCreate).toHaveBeenCalledWith(expect.objectContaining({ + schedule: { type: 'cron', expression: '30 8 15 * *', timezone: 'Asia/Shanghai' }, + })); + }); + }); + + it('creates a yearly cron task', async () => { + setup([]); + + fireEvent.click(screen.getByRole('button', { name: 'Create Task' })); + await screen.findByText('Create Cron Task'); + fireEvent.change(screen.getByLabelText('Prompt'), { target: { value: 'Yearly digest' } }); + fireEvent.change(screen.getByLabelText('Workspace'), { target: { value: '/project/general' } }); + fireEvent.click(screen.getByRole('button', { name: 'Recurring' })); + fireEvent.change(screen.getByLabelText('Repeat period'), { target: { value: 'yearly' } }); + fireEvent.change(screen.getByLabelText('Month'), { target: { value: '9' } }); + fireEvent.change(screen.getByLabelText('Day of month'), { target: { value: '15' } }); + fireEvent.change(screen.getByLabelText('Time'), { target: { value: '08:30' } }); + fireEvent.change(screen.getByLabelText('Timezone'), { target: { value: 'Asia/Shanghai' } }); + fireEvent.click(screen.getAllByRole('button', { name: 'Create Task' }).at(-1)!); + + await waitFor(() => { + expect(apiMock.cronCreate).toHaveBeenCalledWith(expect.objectContaining({ + schedule: { type: 'cron', expression: '30 8 15 9 *', timezone: 'Asia/Shanghai' }, + })); + }); + }); + + it('clamps the yearly day when the selected month is shorter', async () => { + setup([]); + + fireEvent.click(screen.getByRole('button', { name: 'Create Task' })); + await screen.findByText('Create Cron Task'); + fireEvent.change(screen.getByLabelText('Prompt'), { target: { value: 'Yearly month end' } }); + fireEvent.change(screen.getByLabelText('Workspace'), { target: { value: '/project/general' } }); + fireEvent.click(screen.getByRole('button', { name: 'Recurring' })); + fireEvent.change(screen.getByLabelText('Repeat period'), { target: { value: 'monthly' } }); + fireEvent.change(screen.getByLabelText('Day of month'), { target: { value: '31' } }); + fireEvent.change(screen.getByLabelText('Repeat period'), { target: { value: 'yearly' } }); + fireEvent.change(screen.getByLabelText('Month'), { target: { value: '4' } }); + + expect((screen.getByLabelText('Day of month') as HTMLSelectElement).value).toBe('30'); + + fireEvent.change(screen.getByLabelText('Month'), { target: { value: '2' } }); + expect((screen.getByLabelText('Day of month') as HTMLSelectElement).options).toHaveLength(28); + }); + + it('updates the cron expression when the standard fields change', async () => { + setup([]); + + fireEvent.click(screen.getByRole('button', { name: 'Create Task' })); + await screen.findByText('Create Cron Task'); + fireEvent.click(screen.getByRole('button', { name: 'Recurring' })); + const expression = screen.getByLabelText('Cron expression') as HTMLInputElement; + + fireEvent.change(screen.getByLabelText('Time'), { target: { value: '08:30' } }); + expect(expression.value).toBe('30 8 * * *'); + + fireEvent.change(screen.getByLabelText('Time'), { target: { value: '' } }); + expect(expression.value).toBe(''); + expect(screen.getByText('Cron expression format is invalid.')).toBeTruthy(); + fireEvent.change(screen.getByLabelText('Time'), { target: { value: '08:30' } }); + expect(screen.queryByText('Cron expression format is invalid.')).toBeNull(); + + fireEvent.change(screen.getByLabelText('Repeat period'), { target: { value: 'weekly' } }); + fireEvent.change(screen.getByLabelText('Day of week'), { target: { value: '1' } }); + expect(expression.value).toBe('30 8 * * 1'); + + fireEvent.change(screen.getByLabelText('Repeat period'), { target: { value: 'monthly' } }); + fireEvent.change(screen.getByLabelText('Day of month'), { target: { value: '15' } }); + expect(expression.value).toBe('30 8 15 * *'); + + fireEvent.change(screen.getByLabelText('Repeat period'), { target: { value: 'yearly' } }); + fireEvent.change(screen.getByLabelText('Month'), { target: { value: '9' } }); + fireEvent.change(screen.getByLabelText('Day of month'), { target: { value: '15' } }); + expect(expression.value).toBe('30 8 15 9 *'); + }); + + it('updates the standard fields from each supported cron expression', async () => { + setup([]); + + fireEvent.click(screen.getByRole('button', { name: 'Create Task' })); + await screen.findByText('Create Cron Task'); + fireEvent.change(screen.getByLabelText('Prompt'), { target: { value: 'Linked schedule' } }); + fireEvent.change(screen.getByLabelText('Workspace'), { target: { value: '/project/general' } }); + fireEvent.click(screen.getByRole('button', { name: 'Recurring' })); + const expression = screen.getByLabelText('Cron expression'); + + fireEvent.change(expression, { target: { value: '15 9 * * 2' } }); + expect((screen.getByLabelText('Repeat period') as HTMLSelectElement).value).toBe('weekly'); + expect((screen.getByLabelText('Day of week') as HTMLSelectElement).value).toBe('2'); + expect((screen.getByLabelText('Time') as HTMLInputElement).value).toBe('09:15'); + + fireEvent.change(expression, { target: { value: '45 6 12 * *' } }); + expect((screen.getByLabelText('Repeat period') as HTMLSelectElement).value).toBe('monthly'); + expect((screen.getByLabelText('Day of month') as HTMLSelectElement).value).toBe('12'); + expect((screen.getByLabelText('Time') as HTMLInputElement).value).toBe('06:45'); + + fireEvent.change(expression, { target: { value: '0 7 20 11 *' } }); + expect((screen.getByLabelText('Repeat period') as HTMLSelectElement).value).toBe('yearly'); + expect((screen.getByLabelText('Month') as HTMLSelectElement).value).toBe('11'); + expect((screen.getByLabelText('Day of month') as HTMLSelectElement).value).toBe('20'); + expect((screen.getByLabelText('Time') as HTMLInputElement).value).toBe('07:00'); + + fireEvent.change(expression, { target: { value: '5 8 * * *' } }); + expect((screen.getByLabelText('Repeat period') as HTMLSelectElement).value).toBe('daily'); + expect((screen.getByLabelText('Time') as HTMLInputElement).value).toBe('08:05'); + + fireEvent.change(expression, { target: { value: '30 8 * * 7' } }); + expect((screen.getByLabelText('Repeat period') as HTMLSelectElement).value).toBe('weekly'); + expect((screen.getByLabelText('Day of week') as HTMLSelectElement).value).toBe('0'); + fireEvent.click(screen.getAllByRole('button', { name: 'Create Task' }).at(-1)!); + + await waitFor(() => { + expect(apiMock.cronCreate).toHaveBeenCalledWith(expect.objectContaining({ + schedule: { type: 'cron', expression: '30 8 * * 0', timezone: expect.any(String) }, + })); + }); + }); + + it('rejects unsupported cron rules and keeps the last valid standard fields', async () => { + setup([]); + + fireEvent.click(screen.getByRole('button', { name: 'Create Task' })); + await screen.findByText('Create Cron Task'); + fireEvent.change(screen.getByLabelText('Workspace'), { target: { value: '/project/general' } }); + fireEvent.click(screen.getByRole('button', { name: 'Recurring' })); + fireEvent.change(screen.getByLabelText('Cron expression'), { target: { value: '*/15 9-17/2 * * 1-5' } }); + + expect(screen.getByText('Cron expression format is invalid.')).toBeTruthy(); + expect((screen.getByLabelText('Repeat period') as HTMLSelectElement).value).toBe('daily'); + fireEvent.click(screen.getByRole('button', { name: /Advanced Cron expression/ })); + expect(screen.queryByLabelText('Cron expression')).toBeNull(); + fireEvent.click(screen.getAllByRole('button', { name: 'Create Task' }).at(-1)!); + expect(screen.getByText('Prompt is required.')).toBeTruthy(); + expect(screen.queryByLabelText('Cron expression')).toBeNull(); + + fireEvent.change(screen.getByLabelText('Prompt'), { target: { value: 'Invalid schedule' } }); + fireEvent.click(screen.getAllByRole('button', { name: 'Create Task' }).at(-1)!); + + await waitFor(() => { + expect(screen.getAllByText('Cron expression format is invalid.')).toHaveLength(1); + expect(screen.queryByText('Prompt is required.')).toBeNull(); + expect(screen.getByLabelText('Cron expression')).toBeTruthy(); + expect(apiMock.cronCreate).not.toHaveBeenCalled(); + }); + + fireEvent.change(screen.getByLabelText('Time'), { target: { value: '08:30' } }); + expect((screen.getByLabelText('Cron expression') as HTMLInputElement).value).toBe('30 8 * * *'); + expect(screen.queryByText('Cron expression format is invalid.')).toBeNull(); + }); + + it('restores the default daily expression after switching from one-time back to recurring', async () => { + setup([]); + + fireEvent.click(screen.getByRole('button', { name: 'Create Task' })); + await screen.findByText('Create Cron Task'); + fireEvent.click(screen.getByRole('button', { name: 'Recurring' })); + fireEvent.change(screen.getByLabelText('Time'), { target: { value: '08:30' } }); + fireEvent.change(screen.getByLabelText('Cron expression'), { target: { value: '30 8 *' } }); + expect(screen.getByText('Cron expression format is invalid.')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: 'One-time' })); + fireEvent.click(screen.getByRole('button', { name: 'Recurring' })); + + expect((screen.getByLabelText('Repeat period') as HTMLSelectElement).value).toBe('daily'); + expect((screen.getByLabelText('Cron expression') as HTMLInputElement).value).toBe('30 8 * * *'); + expect(screen.queryByText('Cron expression format is invalid.')).toBeNull(); + }); + + it('collapses and restores the linked cron expression without changing it', async () => { + setup([]); + + fireEvent.click(screen.getByRole('button', { name: 'Create Task' })); + await screen.findByText('Create Cron Task'); + fireEvent.click(screen.getByRole('button', { name: 'Recurring' })); + fireEvent.change(screen.getByLabelText('Time'), { target: { value: '08:30' } }); + const toggle = screen.getByRole('button', { name: /Advanced Cron expression/ }); + + expect((screen.getByLabelText('Cron expression') as HTMLInputElement).value).toBe('30 8 * * *'); + fireEvent.click(toggle); + expect(screen.queryByLabelText('Cron expression')).toBeNull(); + fireEvent.click(toggle); + expect((screen.getByLabelText('Cron expression') as HTMLInputElement).value).toBe('30 8 * * *'); + }); + + it('keeps linked input when the create API rejects the schedule', async () => { + setup([]); + apiMock.cronCreate.mockResolvedValue(jsonResponse({ error: 'No future run time.' }, false)); + + fireEvent.click(screen.getByRole('button', { name: 'Create Task' })); + await screen.findByText('Create Cron Task'); + fireEvent.change(screen.getByLabelText('Prompt'), { target: { value: 'Keep this input' } }); + fireEvent.change(screen.getByLabelText('Workspace'), { target: { value: '/project/general' } }); + fireEvent.click(screen.getByRole('button', { name: 'Recurring' })); + fireEvent.change(screen.getByLabelText('Cron expression'), { target: { value: '0 8 28 2 *' } }); + fireEvent.click(screen.getAllByRole('button', { name: 'Create Task' }).at(-1)!); + + await screen.findByText('No future run time.'); + expect((screen.getByLabelText('Cron expression') as HTMLInputElement).value).toBe('0 8 28 2 *'); + expect((screen.getByLabelText('Prompt') as HTMLTextAreaElement).value).toBe('Keep this input'); + }); + it('validates required create fields before calling the API', async () => { setup([]); @@ -178,6 +424,275 @@ describe('CronV2', () => { expect(apiMock.cronCreate).not.toHaveBeenCalled(); }); + it('shows a specific validation error for an invalid timezone', async () => { + setup([makeJob({ id: 'job-invalid-timezone', prompt: 'Timezone task', revision: 1 })]); + + await screen.findByText('Timezone task'); + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + fireEvent.change(screen.getByLabelText('Timezone'), { target: { value: 'Not/AZone' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save Changes' })); + + expect(await screen.findByText('Timezone is invalid.')).toBeTruthy(); + expect(apiMock.cronUpdate).not.toHaveBeenCalled(); + }); + + it.each([ + { name: 'daily', expression: '30 8 * * *', mode: 'daily', time: '08:30' }, + { name: 'weekly', expression: '15 9 * * 2', mode: 'weekly', time: '09:15' }, + { name: 'monthly', expression: '45 6 12 * *', mode: 'monthly', time: '06:45' }, + { name: 'yearly', expression: '0 7 20 11 *', mode: 'yearly', time: '07:00' }, + ])('fills the edit form from a $name schedule', async ({ expression, mode, time }) => { + setup([makeJob({ + id: `job-${mode}`, + prompt: `${mode} task`, + cron: expression, + schedule: { type: 'cron', expression, timezone: 'Asia/Shanghai' }, + timezone: 'Asia/Shanghai', + revision: 4, + })]); + + await screen.findByText(`${mode} task`); + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + + await screen.findByText('Edit Cron Task'); + expect((screen.getByLabelText('Workspace') as HTMLSelectElement).value).toBe('/project/general'); + expect((screen.getByLabelText('Workspace') as HTMLSelectElement).disabled).toBe(true); + expect((screen.getByLabelText('Repeat period') as HTMLSelectElement).value).toBe(mode); + expect((screen.getByLabelText('Time') as HTMLInputElement).value).toBe(time); + expect((screen.getByLabelText('Cron expression') as HTMLInputElement).value).toBe(expression); + expect((screen.getByLabelText('Timezone') as HTMLInputElement).value).toBe('Asia/Shanghai'); + }); + + it('updates a task in place and refreshes the list', async () => { + setup([makeJob({ + id: 'job-edit', + prompt: 'Weekly report', + cron: '30 8 * * 1', + schedule: { type: 'cron', expression: '30 8 * * 1', timezone: 'Asia/Shanghai' }, + timezone: 'Asia/Shanghai', + revision: 7, + })]); + + await screen.findByText('Weekly report'); + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + fireEvent.change(screen.getByLabelText('Prompt'), { target: { value: 'Updated weekly report' } }); + fireEvent.change(screen.getByLabelText('Day of week'), { target: { value: '5' } }); + fireEvent.change(screen.getByLabelText('Time'), { target: { value: '10:45' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save Changes' })); + + await waitFor(() => { + expect(apiMock.cronUpdate).toHaveBeenCalledWith('job-edit', { + message: 'Updated weekly report', + projectKey: '/project/general', + expectedRevision: 7, + schedule: { type: 'cron', expression: '45 10 * * 5', timezone: 'Asia/Shanghai' }, + timezone: 'Asia/Shanghai', + }); + expect(apiMock.cronCreate).not.toHaveBeenCalled(); + expect(apiMock.allCronJobs).toHaveBeenCalledTimes(2); + }); + }); + + it('edits a one-time task and keeps the original task id', async () => { + const runAt = new Date('2099-01-01T10:30').toISOString(); + setup([makeJob({ + id: 'job-once', + prompt: 'One-time task', + cron: '', + schedule: { type: 'once', runAt }, + timezone: 'Asia/Shanghai', + recurring: false, + revision: 3, + })]); + + await screen.findByText('One-time task'); + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + expect((screen.getByLabelText('Date') as HTMLInputElement).value).toBe('2099-01-01'); + expect((screen.getByLabelText('Time') as HTMLInputElement).value).toBe('10:30'); + fireEvent.change(screen.getByLabelText('Time'), { target: { value: '11:45' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save Changes' })); + + await waitFor(() => { + expect(apiMock.cronUpdate).toHaveBeenCalledWith('job-once', expect.objectContaining({ + expectedRevision: 3, + schedule: { type: 'once', runAt: new Date('2099-01-01T11:45').toISOString() }, + })); + }); + }); + + it('preserves seconds and milliseconds when editing only the prompt of a one-time task', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(new Date(2099, 0, 1, 10, 30, 0)); + const runAt = new Date(2099, 0, 1, 10, 30, 45, 500).toISOString(); + setup([makeJob({ + id: 'job-once-precision', + prompt: 'Precise one-time task', + cron: '', + schedule: { type: 'once', runAt }, + recurring: false, + revision: 9, + })]); + + await screen.findByText('Precise one-time task'); + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + expect((screen.getByLabelText('Time') as HTMLInputElement).value).toBe('10:30'); + fireEvent.change(screen.getByLabelText('Prompt'), { target: { value: 'Updated precise task' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save Changes' })); + + await waitFor(() => { + expect(apiMock.cronUpdate).toHaveBeenCalledWith('job-once-precision', expect.objectContaining({ + message: 'Updated precise task', + schedule: { type: 'once', runAt }, + })); + }); + }); + + it('converts a one-time task to the default daily schedule while preserving time', async () => { + setup([makeJob({ + id: 'job-once-to-cron', + prompt: 'Convert to recurring', + cron: '', + schedule: { type: 'once', runAt: new Date('2099-01-01T10:30').toISOString() }, + recurring: false, + revision: 2, + })]); + + await screen.findByText('Convert to recurring'); + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + fireEvent.click(screen.getByRole('button', { name: 'Recurring' })); + + expect((screen.getByLabelText('Repeat period') as HTMLSelectElement).value).toBe('daily'); + expect((screen.getByLabelText('Time') as HTMLInputElement).value).toBe('10:30'); + expect((screen.getByLabelText('Cron expression') as HTMLInputElement).value).toBe('30 10 * * *'); + fireEvent.click(screen.getByRole('button', { name: 'Save Changes' })); + + await waitFor(() => { + expect(apiMock.cronUpdate).toHaveBeenCalledWith('job-once-to-cron', { + message: 'Convert to recurring', + projectKey: '/project/general', + expectedRevision: 2, + schedule: { type: 'cron', expression: '30 10 * * *', timezone: 'UTC' }, + timezone: 'UTC', + }); + }); + }); + + it('converts a recurring task to the next future one-time date while preserving time', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(new Date('2026-01-15T12:00:00')); + setup([makeJob({ + id: 'job-cron-to-once', + prompt: 'Convert to one-time', + cron: '30 13 * * *', + schedule: { type: 'cron', expression: '30 13 * * *', timezone: 'UTC' }, + revision: 2, + })]); + + await screen.findByText('Convert to one-time'); + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + fireEvent.click(screen.getByRole('button', { name: 'One-time' })); + + expect((screen.getByLabelText('Date') as HTMLInputElement).value).toBe('2026-01-15'); + expect((screen.getByLabelText('Time') as HTMLInputElement).value).toBe('13:30'); + fireEvent.click(screen.getByRole('button', { name: 'Save Changes' })); + + await waitFor(() => { + expect(apiMock.cronUpdate).toHaveBeenCalledWith('job-cron-to-once', { + message: 'Convert to one-time', + projectKey: '/project/general', + expectedRevision: 2, + schedule: { type: 'once', runAt: new Date('2026-01-15T13:30').toISOString() }, + timezone: 'UTC', + }); + }); + }); + + it('cancels editing without sending an update request', async () => { + setup([makeJob({ id: 'job-cancel-edit', prompt: 'Keep original task' })]); + + await screen.findByText('Keep original task'); + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + fireEvent.change(screen.getByLabelText('Prompt'), { target: { value: 'Unsaved change' } }); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + await screen.findByText('Keep original task'); + expect(screen.queryByText('Edit Cron Task')).toBeNull(); + expect(apiMock.cronUpdate).not.toHaveBeenCalled(); + }); + + it('shows the original workspace when it is no longer in the project list', async () => { + setup([makeJob({ id: 'job-removed-project', projectKey: '/project/removed', prompt: 'Removed workspace task' })]); + + await screen.findByText('Removed workspace task'); + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + + const workspace = screen.getByLabelText('Workspace') as HTMLSelectElement; + expect(workspace.value).toBe('/project/removed'); + expect(workspace.disabled).toBe(true); + expect(workspace.selectedOptions[0]?.textContent).toBe('/project/removed'); + }); + + it('keeps edit input and shows a localized message after a version conflict', async () => { + setup([makeJob({ id: 'job-conflict', prompt: 'Original task', revision: 8 })]); + apiMock.cronUpdate.mockResolvedValueOnce(jsonResponse({ code: 'cron_conflict' }, false, 409)); + + await screen.findByText('Original task'); + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + fireEvent.change(screen.getByLabelText('Prompt'), { target: { value: 'Keep this edit' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save Changes' })); + + await screen.findByText('This task has changed. Refresh the list and edit it again.'); + expect((screen.getByLabelText('Prompt') as HTMLTextAreaElement).value).toBe('Keep this edit'); + expect(apiMock.allCronJobs).toHaveBeenCalledTimes(1); + }); + + it('keeps edit input and shows a localized message when the task starts running', async () => { + setup([makeJob({ id: 'job-running-conflict', prompt: 'Original running task', revision: 5 })]); + apiMock.cronUpdate.mockResolvedValueOnce(jsonResponse({ code: 'cron_running' }, false, 409)); + + await screen.findByText('Original running task'); + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + fireEvent.change(screen.getByLabelText('Prompt'), { target: { value: 'Keep running conflict edit' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save Changes' })); + + await screen.findByText('Stop the running task before saving changes.'); + expect((screen.getByLabelText('Prompt') as HTMLTextAreaElement).value).toBe('Keep running conflict edit'); + expect(apiMock.allCronJobs).toHaveBeenCalledTimes(1); + }); + + it('keeps edit input and shows a localized message after a general save failure', async () => { + setup([makeJob({ id: 'job-save-failure', prompt: 'Original failure task', revision: 6 })]); + apiMock.cronUpdate.mockResolvedValueOnce(jsonResponse({ error: 'Storage unavailable' }, false, 500)); + + await screen.findByText('Original failure task'); + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + fireEvent.change(screen.getByLabelText('Prompt'), { target: { value: 'Keep failed edit' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save Changes' })); + + await screen.findByText('Failed to save cron task changes.'); + expect((screen.getByLabelText('Prompt') as HTMLTextAreaElement).value).toBe('Keep failed edit'); + expect(screen.queryByText('Storage unavailable')).toBeNull(); + expect(apiMock.allCronJobs).toHaveBeenCalledTimes(1); + }); + + it('disables editing for running, unsupported, and incomplete tasks with explanations', async () => { + setup([ + makeJob({ id: 'job-running-edit', prompt: 'Running task', status: 'running' }), + makeJob({ + id: 'job-complex-edit', + prompt: 'Complex task', + cron: '*/5 * * * *', + schedule: { type: 'cron', expression: '*/5 * * * *', timezone: 'UTC' }, + }), + makeJob({ id: 'job-incomplete-edit', prompt: 'Incomplete task', revision: undefined }), + ]); + + await screen.findByText('Running task'); + expect((within(screen.getByTitle('Stop the running task before editing.')).getByRole('button') as HTMLButtonElement).disabled).toBe(true); + expect((within(screen.getByTitle('This task uses a schedule that cannot be edited on this page.')).getByRole('button') as HTMLButtonElement).disabled).toBe(true); + expect((within(screen.getByTitle('This task is missing the details required for editing.')).getByRole('button') as HTMLButtonElement).disabled).toBe(true); + }); + it('runs a scheduled cron job immediately and refreshes', async () => { setup([makeJob({ id: 'job-run', prompt: 'Run this now', status: 'scheduled' })]); @@ -202,16 +717,74 @@ describe('CronV2', () => { }); }); - it('deletes a cron job and refreshes', async () => { + it('opens the delete confirmation and cancels without deleting', async () => { setup([makeJob({ id: 'job-delete', prompt: 'Delete this job' })]); await screen.findByText('Delete this job'); fireEvent.click(screen.getByTitle('Delete')); + const dialog = screen.getByRole('dialog', { name: 'Delete cron task?' }); + expect(within(dialog).getByText('Delete this job')).toBeTruthy(); + expect(apiMock.cronDelete).not.toHaveBeenCalled(); + + const cancelButton = within(dialog).getByRole('button', { name: 'Cancel' }); + const confirmButton = within(dialog).getByRole('button', { name: 'Delete' }); + expect(document.activeElement).toBe(cancelButton); + fireEvent.keyDown(cancelButton, { key: 'Tab', shiftKey: true }); + expect(document.activeElement).toBe(confirmButton); + fireEvent.keyDown(confirmButton, { key: 'Tab' }); + expect(document.activeElement).toBe(cancelButton); + fireEvent.click(cancelButton); + + expect(screen.queryByRole('dialog', { name: 'Delete cron task?' })).toBeNull(); + expect(apiMock.cronDelete).not.toHaveBeenCalled(); + }); + + it('closes the delete confirmation with Escape without deleting', async () => { + setup([makeJob({ id: 'job-delete', prompt: 'Delete this job' })]); + + await screen.findByText('Delete this job'); + fireEvent.click(screen.getByTitle('Delete')); + + const dialog = screen.getByRole('dialog', { name: 'Delete cron task?' }); + fireEvent.keyDown(dialog, { key: 'Escape' }); + + expect(screen.queryByRole('dialog', { name: 'Delete cron task?' })).toBeNull(); + expect(apiMock.cronDelete).not.toHaveBeenCalled(); + }); + + it('deletes a cron job after confirmation and refreshes', async () => { + setup([makeJob({ id: 'job-delete', prompt: 'Delete this job' })]); + + await screen.findByText('Delete this job'); + fireEvent.click(screen.getByTitle('Delete')); + + const dialog = screen.getByRole('dialog', { name: 'Delete cron task?' }); + expect(apiMock.cronDelete).not.toHaveBeenCalled(); + fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' })); + await waitFor(() => { expect(apiMock.cronDelete).toHaveBeenCalledWith('job-delete'); + expect(apiMock.cronDelete).toHaveBeenCalledTimes(1); expect(apiMock.allCronJobs).toHaveBeenCalledTimes(2); }); + expect(screen.queryByRole('dialog', { name: 'Delete cron task?' })).toBeNull(); + }); + + it('keeps the delete confirmation open when deletion fails', async () => { + setup([makeJob({ id: 'job-delete', prompt: 'Delete this job' })]); + apiMock.cronDelete.mockResolvedValueOnce(jsonResponse({ error: 'Delete failed' }, false)); + + await screen.findByText('Delete this job'); + fireEvent.click(screen.getByTitle('Delete')); + + const dialog = screen.getByRole('dialog', { name: 'Delete cron task?' }); + fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' })); + + expect((await within(dialog).findByRole('alert')).textContent).toContain('Delete failed'); + expect(screen.getByRole('dialog', { name: 'Delete cron task?' })).toBeTruthy(); + expect(screen.getAllByText('Delete this job').length).toBeGreaterThan(0); + expect(apiMock.allCronJobs).toHaveBeenCalledTimes(1); }); it('shows an empty state when there are no active cron jobs', async () => { diff --git a/ui/src/components/main-content-v2/CronV2.tsx b/ui/src/components/main-content-v2/CronV2.tsx index d45a2b455..6bbb56711 100644 --- a/ui/src/components/main-content-v2/CronV2.tsx +++ b/ui/src/components/main-content-v2/CronV2.tsx @@ -1,27 +1,88 @@ -import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react'; +import { useCallback, useEffect, useMemo, useState, type FormEvent, type KeyboardEvent } from 'react'; import { useTranslation } from 'react-i18next'; +import ReactDOM from 'react-dom'; import { AlertCircle, CalendarClock, ChevronDown, ChevronRight, Clock, + Info, ListChecks, Loader2, + Pencil, Play, PlusCircle, RefreshCw, Square, Trash2, } from 'lucide-react'; -import type { CronJobOverview, CronJobsOverviewResponse, Project } from '../../types/app'; +import type { CronJobOverview, CronJobSchedule, CronJobsOverviewResponse, Project } from '../../types/app'; import { cn } from '../../lib/utils.js'; import { api } from '../../utils/api'; +import { + buildSimpleCronExpression, + getYearlyMonthDayCount, + parseSimpleCronExpression, + type SimpleCronSchedule, + type SimpleRecurrenceMode, +} from './cronSchedule'; const POLL_INTERVAL_MS = 15_000; type CronSubTab = 'list' | 'create'; type ScheduleKind = 'once' | 'cron'; +type EditDisabledReason = 'running' | 'unsupported' | 'incomplete'; + +type CronFormValues = { + message: string; + projectKey: string; + scheduleKind: ScheduleKind; + scheduleDate: string; + scheduleTime: string; + timezone: string; + recurrenceMode: SimpleRecurrenceMode; + weekday: number; + dayOfMonth: number; + monthOfYear: number; + advancedExpression: string; +}; + +const SIMPLE_RECURRENCE_MODES: { + id: SimpleRecurrenceMode; + labelKey: string; + defaultLabel: string; +}[] = [ + { id: 'daily', labelKey: 'cron.create.schedule.daily', defaultLabel: 'Daily' }, + { id: 'weekly', labelKey: 'cron.create.schedule.weekly', defaultLabel: 'Weekly' }, + { id: 'monthly', labelKey: 'cron.create.schedule.monthly', defaultLabel: 'Monthly' }, + { id: 'yearly', labelKey: 'cron.create.schedule.yearly', defaultLabel: 'Yearly' }, +]; + +const WEEKDAY_OPTIONS = [ + { value: 1, labelKey: 'cron.create.weekdays.monday', defaultLabel: 'Monday' }, + { value: 2, labelKey: 'cron.create.weekdays.tuesday', defaultLabel: 'Tuesday' }, + { value: 3, labelKey: 'cron.create.weekdays.wednesday', defaultLabel: 'Wednesday' }, + { value: 4, labelKey: 'cron.create.weekdays.thursday', defaultLabel: 'Thursday' }, + { value: 5, labelKey: 'cron.create.weekdays.friday', defaultLabel: 'Friday' }, + { value: 6, labelKey: 'cron.create.weekdays.saturday', defaultLabel: 'Saturday' }, + { value: 0, labelKey: 'cron.create.weekdays.sunday', defaultLabel: 'Sunday' }, +] as const; + +const MONTH_OPTIONS = [ + { value: 1, labelKey: 'cron.create.months.january', defaultLabel: 'January' }, + { value: 2, labelKey: 'cron.create.months.february', defaultLabel: 'February' }, + { value: 3, labelKey: 'cron.create.months.march', defaultLabel: 'March' }, + { value: 4, labelKey: 'cron.create.months.april', defaultLabel: 'April' }, + { value: 5, labelKey: 'cron.create.months.may', defaultLabel: 'May' }, + { value: 6, labelKey: 'cron.create.months.june', defaultLabel: 'June' }, + { value: 7, labelKey: 'cron.create.months.july', defaultLabel: 'July' }, + { value: 8, labelKey: 'cron.create.months.august', defaultLabel: 'August' }, + { value: 9, labelKey: 'cron.create.months.september', defaultLabel: 'September' }, + { value: 10, labelKey: 'cron.create.months.october', defaultLabel: 'October' }, + { value: 11, labelKey: 'cron.create.months.november', defaultLabel: 'November' }, + { value: 12, labelKey: 'cron.create.months.december', defaultLabel: 'December' }, +] as const; const SUB_TABS: { id: CronSubTab; labelKey: string; defaultLabel: string; icon: typeof ListChecks }[] = [ { id: 'list', labelKey: 'cron.tabs.list', defaultLabel: 'Task List', icon: ListChecks }, @@ -59,6 +120,15 @@ function getBrowserTimezone(): string { } } +function isValidTimezone(timezone: string): boolean { + try { + new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(); + return true; + } catch { + return false; + } +} + function formatDateTimeLocal(date: Date): string { const pad = (value: number) => String(value).padStart(2, '0'); return [ @@ -82,9 +152,24 @@ function formatTimeLocal(date: Date): string { return formatDateTimeLocal(date).slice(11, 16); } -function buildDailyCronExpression(time: string): string { - const [hour = '0', minute = '0'] = time.split(':'); - return `${Number(minute)} ${Number(hour)} * * *`; +function resolveOneTimeRunAt( + editingJob: CronJobOverview | null, + scheduleDate: string, + scheduleTime: string, +): string | undefined { + const candidate = new Date(`${scheduleDate}T${scheduleTime}`); + if (Number.isNaN(candidate.getTime())) return undefined; + + if (editingJob?.schedule?.type === 'once') { + const original = new Date(editingJob.schedule.runAt); + if (!Number.isNaN(original.getTime()) + && formatDateLocal(original) === scheduleDate + && formatTimeLocal(original) === scheduleTime) { + return editingJob.schedule.runAt; + } + } + + return candidate.toISOString(); } function formatAbsoluteTime(iso: string | number): string { @@ -100,6 +185,90 @@ function formatAbsoluteTime(iso: string | number): string { }); } +function getDefaultFormValues(): CronFormValues { + const defaultRunAt = new Date(Date.now() + 60 * 60 * 1000); + const scheduleTime = formatTimeLocal(defaultRunAt); + return { + message: '', + projectKey: '', + scheduleKind: 'once', + scheduleDate: formatDateLocal(defaultRunAt), + scheduleTime, + timezone: getBrowserTimezone(), + recurrenceMode: 'daily', + weekday: defaultRunAt.getDay(), + dayOfMonth: defaultRunAt.getDate(), + monthOfYear: defaultRunAt.getMonth() + 1, + advancedExpression: buildSimpleCronExpression({ mode: 'daily', time: scheduleTime }), + }; +} + +function getFormValues(job: CronJobOverview | null): CronFormValues { + const defaults = getDefaultFormValues(); + if (!job?.schedule || !job.projectKey) return defaults; + + const timezone = job.timezone?.trim() + || (job.schedule.type === 'cron' ? job.schedule.timezone?.trim() : '') + || defaults.timezone; + if (job.schedule.type === 'once') { + const runAt = new Date(job.schedule.runAt); + if (Number.isNaN(runAt.getTime())) return defaults; + const scheduleTime = formatTimeLocal(runAt); + return { + ...defaults, + message: job.prompt, + projectKey: job.projectKey, + scheduleKind: 'once', + scheduleDate: formatDateLocal(runAt), + scheduleTime, + timezone, + advancedExpression: buildSimpleCronExpression({ mode: 'daily', time: scheduleTime }), + }; + } + + const parsed = parseSimpleCronExpression(job.schedule.expression); + if (!parsed) return defaults; + const values: CronFormValues = { + ...defaults, + message: job.prompt, + projectKey: job.projectKey, + scheduleKind: 'cron', + scheduleTime: parsed.time, + timezone, + recurrenceMode: parsed.mode, + advancedExpression: buildSimpleCronExpression(parsed), + }; + if (parsed.mode === 'weekly') { + values.weekday = parsed.weekday; + } else if (parsed.mode === 'monthly') { + values.dayOfMonth = parsed.dayOfMonth; + } else if (parsed.mode === 'yearly') { + values.dayOfMonth = parsed.dayOfMonth; + values.monthOfYear = parsed.monthOfYear; + } + return values; +} + +function getEditDisabledReason(job: CronJobOverview): EditDisabledReason | null { + if (job.status === 'running') return 'running'; + if (job.status !== 'scheduled' || !job.projectKey || typeof job.revision !== 'number' || !Number.isInteger(job.revision) || job.revision < 0) { + return 'incomplete'; + } + if (!job.schedule) return 'incomplete'; + if (job.schedule.type === 'once') { + return Number.isNaN(Date.parse(job.schedule.runAt)) ? 'unsupported' : null; + } + return parseSimpleCronExpression(job.schedule.expression) ? null : 'unsupported'; +} + +function getNextFutureDateForTime(time: string): string { + const now = new Date(); + const candidate = new Date(`${formatDateLocal(now)}T${time}`); + if (Number.isNaN(candidate.getTime())) return formatDateLocal(new Date(now.getTime() + 24 * 60 * 60 * 1000)); + if (candidate.getTime() <= now.getTime()) candidate.setDate(candidate.getDate() + 1); + return formatDateLocal(candidate); +} + export default function CronV2() { const { t } = useTranslation('alwaysOn'); const [subTab, setSubTab] = useState('list'); @@ -108,6 +277,7 @@ export default function CronV2() { const [projects, setProjects] = useState([]); const [jobs, setJobs] = useState([]); const [collapsedProjects, setCollapsedProjects] = useState>(new Set()); + const [editingJob, setEditingJob] = useState(null); const refresh = useCallback(async () => { setLoading(true); @@ -201,7 +371,10 @@ export default function CronV2() { + + {advancedExpanded ? ( +
+ +

+ {t('cron.create.help.expressionFormat', { defaultValue: 'Format: minute hour day month weekday' })} +

+ {!advancedExpressionIsValid ? ( +

+ {t('cron.create.validation.expressionInvalid', { defaultValue: 'Cron expression format is invalid.' })} +

+ ) : ( +

+ + + + {t('cron.create.plan.currentLabel', { defaultValue: 'Current plan:' })} + {' '} + {scheduleDescriptionWithTimezone} + +

+ )} +
+ ) : null} + + + )}
+ {isEditing ? ( + + ) : null}
@@ -614,24 +1148,34 @@ function CronJobRow({ job, t, onRefresh, + onEdit, }: { job: CronJobOverview; t: (key: string, opts?: Record) => string; onRefresh: () => Promise; + onEdit: (job: CronJobOverview) => void; }) { const [busy, setBusy] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deleteError, setDeleteError] = useState(null); const status = job.status === 'running' ? 'running' : 'scheduled'; const meta = CRON_STATUS_LABEL[status]; + const editDisabledReason = getEditDisabledReason(job); + const editTitle = editDisabledReason === 'running' + ? t('cron.edit.disabled.running', { defaultValue: 'Stop the running task before editing.' }) + : editDisabledReason === 'unsupported' + ? t('cron.edit.disabled.unsupported', { defaultValue: 'This task uses a schedule that cannot be edited on this page.' }) + : editDisabledReason === 'incomplete' + ? t('cron.edit.disabled.incomplete', { defaultValue: 'This task is missing the details required for editing.' }) + : t('cron.actions.edit', { defaultValue: 'Edit' }); - const runAction = async (action: 'runNow' | 'stop' | 'delete') => { + const runAction = async (action: 'runNow' | 'stop') => { if (busy) return; setBusy(true); try { const response = action === 'runNow' ? await api.cronRunNow(job.id) - : action === 'stop' - ? await api.cronStop(job.id) - : await api.cronDelete(job.id); + : await api.cronStop(job.id); if (!response.ok) { const body = await response.json().catch(() => ({})) as { error?: string }; throw new Error(body?.error || `HTTP ${response.status}`); @@ -644,65 +1188,217 @@ function CronJobRow({ } }; + const openDeleteDialog = () => { + if (busy) return; + setDeleteError(null); + setDeleteDialogOpen(true); + }; + + const closeDeleteDialog = () => { + if (busy) return; + setDeleteDialogOpen(false); + setDeleteError(null); + }; + + const confirmDelete = async () => { + if (busy) return; + setBusy(true); + setDeleteError(null); + try { + const response = await api.cronDelete(job.id); + if (!response.ok) { + const body = await response.json().catch(() => ({})) as { error?: string }; + throw new Error(body?.error || `HTTP ${response.status}`); + } + setDeleteDialogOpen(false); + await onRefresh(); + } catch (caught) { + setDeleteError(caught instanceof Error + ? caught.message + : t('cron.deleteConfirm.failed', { defaultValue: 'Failed to delete cron task.' })); + } finally { + setBusy(false); + } + }; + return ( -
-
- {job.prompt || '—'} -
-
- {formatAbsoluteTime(job.createdAt)} -
-
- {job.nextRunAt ? formatAbsoluteTime(job.nextRunAt) || '—' : '—'} -
-
- - {t(meta.key, { defaultValue: meta.defaultValue })} - + <> +
+
+ {job.prompt || '—'} +
+
+ {formatAbsoluteTime(job.createdAt)} +
+
+ {job.nextRunAt ? formatAbsoluteTime(job.nextRunAt) || '—' : '—'} +
+
+ + {t(meta.key, { defaultValue: meta.defaultValue })} + +
+
+ {status === 'running' ? ( + + ) : ( + + )} + + + + +
-
- {status === 'running' ? ( + + {deleteDialogOpen + ? ReactDOM.createPortal( + void confirmDelete()} + />, + document.body, + ) + : null} + + ); +} + +function DeleteCronJobDialog({ + job, + t, + busy, + error, + onCancel, + onConfirm, +}: { + job: CronJobOverview; + t: (key: string, opts?: Record) => string; + busy: boolean; + error: string | null; + onCancel: () => void; + onConfirm: () => void; +}) { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && !busy) { + event.preventDefault(); + onCancel(); + return; + } + if (event.key !== 'Tab') return; + + const buttons = [...event.currentTarget.querySelectorAll('button:not(:disabled)')]; + const firstButton = buttons[0]; + const lastButton = buttons.at(-1); + if (!firstButton || !lastButton) return; + + if (event.shiftKey && document.activeElement === firstButton) { + event.preventDefault(); + lastButton.focus(); + } else if (!event.shiftKey && document.activeElement === lastButton) { + event.preventDefault(); + firstButton.focus(); + } + }; + + return ( +
+
+
+
+ +
+
+

+ {t('cron.deleteConfirm.title', { defaultValue: 'Delete cron task?' })} +

+

+ {job.prompt || '—'} +

+
+
+ +
+

+ {t('cron.deleteConfirm.description', { defaultValue: 'This cron task will be permanently deleted.' })} +

+ {error ? ( +
+ {error} +
+ ) : null} +
+ +
- ) : ( - )} - +
); diff --git a/ui/src/components/main-content-v2/cronSchedule.spec.ts b/ui/src/components/main-content-v2/cronSchedule.spec.ts new file mode 100644 index 000000000..5e6bde3ca --- /dev/null +++ b/ui/src/components/main-content-v2/cronSchedule.spec.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { + buildSimpleCronExpression, + getYearlyMonthDayCount, + parseSimpleCronExpression, +} from './cronSchedule'; + +describe('buildSimpleCronExpression', () => { + it('builds daily, weekly, monthly, and yearly expressions', () => { + expect(buildSimpleCronExpression({ mode: 'daily', time: '08:30' })).toBe('30 8 * * *'); + expect(buildSimpleCronExpression({ mode: 'weekly', time: '08:30', weekday: 1 })).toBe('30 8 * * 1'); + expect(buildSimpleCronExpression({ mode: 'monthly', time: '08:30', dayOfMonth: 15 })).toBe('30 8 15 * *'); + expect(buildSimpleCronExpression({ mode: 'yearly', time: '08:30', dayOfMonth: 15, monthOfYear: 9 })).toBe('30 8 15 9 *'); + }); + + it('supports boundary times and Sunday', () => { + expect(buildSimpleCronExpression({ mode: 'daily', time: '00:00' })).toBe('0 0 * * *'); + expect(buildSimpleCronExpression({ mode: 'weekly', time: '23:59', weekday: 0 })).toBe('59 23 * * 0'); + }); + + it('rejects invalid standard schedule values', () => { + expect(() => buildSimpleCronExpression({ mode: 'daily', time: '24:00' })).toThrow(); + expect(() => buildSimpleCronExpression({ mode: 'weekly', time: '08:30', weekday: 7 })).toThrow(); + expect(() => buildSimpleCronExpression({ mode: 'monthly', time: '08:30', dayOfMonth: 32 })).toThrow(); + expect(() => buildSimpleCronExpression({ mode: 'yearly', time: '08:30', dayOfMonth: 31, monthOfYear: 4 })).toThrow(); + expect(() => buildSimpleCronExpression({ mode: 'yearly', time: '08:30', dayOfMonth: 29, monthOfYear: 2 })).toThrow(); + }); +}); + +describe('parseSimpleCronExpression', () => { + it.each([ + ['30 8 * * *', { mode: 'daily', time: '08:30' }], + ['30 8 * * 1', { mode: 'weekly', time: '08:30', weekday: 1 }], + ['30 8 15 * *', { mode: 'monthly', time: '08:30', dayOfMonth: 15 }], + ['30 8 15 9 *', { mode: 'yearly', time: '08:30', dayOfMonth: 15, monthOfYear: 9 }], + ])('parses a supported standard expression: %s', (expression, expected) => { + expect(parseSimpleCronExpression(expression)).toEqual(expected); + }); + + it('normalizes whitespace, leading zeros, and Sunday 7 through a round trip', () => { + const parsed = parseSimpleCronExpression(' 05 09 * * 7 '); + expect(parsed).toEqual({ mode: 'weekly', time: '09:05', weekday: 0 }); + expect(buildSimpleCronExpression(parsed!)).toBe('5 9 * * 0'); + }); + + it.each([ + '', + '* * * * *', + '* 9 * * *', + '0 * * * *', + '*/15 9 * * *', + '0 9,18 * * *', + '0 9 * * 1,3', + '0 9 * * 1-5', + '0 9 15 * 1', + '0 9 * 9 *', + '0 9 31 4 *', + '0 9 29 2 *', + '60 9 * * *', + '0 24 * * *', + '0 9 32 * *', + '0 9 1 13 *', + '0 9 * * 8', + '0 9 * * MON', + '@daily', + '0 9 * *', + '0 9 * * * *', + '5abc 9 * * *', + ])('rejects an expression outside the four standard rules: %s', (expression) => { + expect(parseSimpleCronExpression(expression)).toBeUndefined(); + }); +}); + +describe('yearly dates', () => { + it('excludes February 29 from standard yearly schedules', () => { + expect(getYearlyMonthDayCount(2)).toBe(28); + expect(getYearlyMonthDayCount(4)).toBe(30); + expect(getYearlyMonthDayCount(12)).toBe(31); + }); +}); diff --git a/ui/src/components/main-content-v2/cronSchedule.ts b/ui/src/components/main-content-v2/cronSchedule.ts new file mode 100644 index 000000000..33e391e06 --- /dev/null +++ b/ui/src/components/main-content-v2/cronSchedule.ts @@ -0,0 +1,99 @@ +export type SimpleCronSchedule = + | { mode: 'daily'; time: string } + | { mode: 'weekly'; time: string; weekday: number } + | { mode: 'monthly'; time: string; dayOfMonth: number } + | { mode: 'yearly'; time: string; dayOfMonth: number; monthOfYear: number }; + +export type SimpleRecurrenceMode = SimpleCronSchedule['mode']; + +export function buildSimpleCronExpression(schedule: SimpleCronSchedule): string { + const { hour, minute } = parseTime(schedule.time); + + switch (schedule.mode) { + case 'daily': + return `${minute} ${hour} * * *`; + case 'weekly': + assertIntegerInRange(schedule.weekday, 0, 6, 'weekday'); + return `${minute} ${hour} * * ${schedule.weekday}`; + case 'monthly': + assertIntegerInRange(schedule.dayOfMonth, 1, 31, 'dayOfMonth'); + return `${minute} ${hour} ${schedule.dayOfMonth} * *`; + case 'yearly': + assertIntegerInRange(schedule.monthOfYear, 1, 12, 'monthOfYear'); + assertIntegerInRange(schedule.dayOfMonth, 1, 31, 'dayOfMonth'); + if (!isValidYearlyDate(schedule.monthOfYear, schedule.dayOfMonth)) { + throw new RangeError('Invalid yearly date'); + } + return `${minute} ${hour} ${schedule.dayOfMonth} ${schedule.monthOfYear} *`; + } +} + +export function parseSimpleCronExpression(expression: string): SimpleCronSchedule | undefined { + const fields = expression.trim().split(/\s+/); + if (fields.length !== 5) return undefined; + + const [minuteField, hourField, dayField, monthField, weekdayField] = fields; + const minute = parseIntegerInRange(minuteField, 0, 59); + const hour = parseIntegerInRange(hourField, 0, 23); + if (minute === undefined || hour === undefined) return undefined; + + const time = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; + if (dayField === '*' && monthField === '*' && weekdayField === '*') { + return { mode: 'daily', time }; + } + + if (dayField === '*' && monthField === '*') { + const parsedWeekday = parseIntegerInRange(weekdayField, 0, 7); + if (parsedWeekday !== undefined) { + return { mode: 'weekly', time, weekday: parsedWeekday === 7 ? 0 : parsedWeekday }; + } + return undefined; + } + + if (monthField === '*' && weekdayField === '*') { + const dayOfMonth = parseIntegerInRange(dayField, 1, 31); + return dayOfMonth === undefined ? undefined : { mode: 'monthly', time, dayOfMonth }; + } + + if (weekdayField === '*') { + const dayOfMonth = parseIntegerInRange(dayField, 1, 31); + const monthOfYear = parseIntegerInRange(monthField, 1, 12); + if (dayOfMonth !== undefined && monthOfYear !== undefined && isValidYearlyDate(monthOfYear, dayOfMonth)) { + return { mode: 'yearly', time, dayOfMonth, monthOfYear }; + } + } + + return undefined; +} + +export function getYearlyMonthDayCount(month: number): number { + assertIntegerInRange(month, 1, 12, 'month'); + return new Date(2023, month, 0).getDate(); +} + +function isValidYearlyDate(month: number, day: number): boolean { + return day <= getYearlyMonthDayCount(month); +} + +function parseTime(time: string): { hour: number; minute: number } { + const match = /^(\d{2}):(\d{2})$/.exec(time); + if (!match) throw new RangeError('Invalid time'); + + const hour = Number(match[1]); + const minute = Number(match[2]); + assertIntegerInRange(hour, 0, 23, 'hour'); + assertIntegerInRange(minute, 0, 59, 'minute'); + return { hour, minute }; +} + +function parseIntegerInRange(value: string, min: number, max: number): number | undefined { + if (!/^\d+$/.test(value)) return undefined; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed >= min && parsed <= max ? parsed : undefined; +} + +function assertIntegerInRange(value: number, min: number, max: number, name: string): void { + if (!Number.isInteger(value) || value < min || value > max) { + throw new RangeError(`Invalid ${name}`); + } +} diff --git a/ui/src/i18n/locales/en/alwaysOn.json b/ui/src/i18n/locales/en/alwaysOn.json index 111041585..6481eba4c 100644 --- a/ui/src/i18n/locales/en/alwaysOn.json +++ b/ui/src/i18n/locales/en/alwaysOn.json @@ -122,9 +122,18 @@ }, "actions": { "delete": "Delete", + "edit": "Edit", "runNow": "Run Now", "stop": "Stop" }, + "deleteConfirm": { + "title": "Delete cron task?", + "description": "This cron task will be permanently deleted and cannot be recovered.", + "cancel": "Cancel", + "confirm": "Delete", + "deleting": "Deleting…", + "failed": "Failed to delete cron task." + }, "status": { "scheduled": "Scheduled", "running": "Running" @@ -139,15 +148,64 @@ "scheduleType": "Schedule Type", "date": "Date", "time": "Time", - "timezone": "Timezone" + "timezone": "Timezone", + "repeatPeriod": "Repeat period", + "executionDay": "Execution day", + "weekday": "Day of week", + "monthDay": "Day of month", + "month": "Month", + "expression": "Cron expression" }, "placeholders": { "prompt": "Describe what PilotDeck should do when this task runs.", - "workspace": "Select a workspace" + "workspace": "Select a workspace", + "expression": "30 8 * * 1" }, "schedule": { "once": "One-time", - "cron": "Recurring" + "cron": "Recurring", + "advanced": "Advanced Cron expression", + "daily": "Daily", + "weekly": "Weekly", + "monthly": "Monthly", + "yearly": "Yearly" + }, + "options": { + "everyDay": "Every day" + }, + "weekdays": { + "monday": "Monday", + "tuesday": "Tuesday", + "wednesday": "Wednesday", + "thursday": "Thursday", + "friday": "Friday", + "saturday": "Saturday", + "sunday": "Sunday" + }, + "months": { + "january": "January", + "february": "February", + "march": "March", + "april": "April", + "may": "May", + "june": "June", + "july": "July", + "august": "August", + "september": "September", + "october": "October", + "november": "November", + "december": "December" + }, + "help": { + "expressionFormat": "Format: minute hour day month weekday" + }, + "plan": { + "label": "Plan:", + "currentLabel": "Current plan:", + "daily": "Daily at {{time}}", + "weekly": "Every {{weekday}} at {{time}}", + "monthly": "Monthly on day {{day}} at {{time}}", + "yearly": "Yearly on {{month}}/{{day}} at {{time}}" }, "validation": { "messageRequired": "Prompt is required.", @@ -155,11 +213,33 @@ "dateRequired": "Date is required.", "timeRequired": "Time is required.", "timezoneRequired": "Timezone is required.", - "runAtFuture": "Run time must be in the future." + "timezoneInvalid": "Timezone is invalid.", + "runAtFuture": "Run time must be in the future.", + "expressionInvalid": "Cron expression format is invalid." }, "actions": { + "expandAdvanced": "Expand", + "collapseAdvanced": "Collapse", "submit": "Create Task" } + }, + "edit": { + "title": "Edit Cron Task", + "subtitle": "Update the prompt or schedule for this task.", + "disabled": { + "running": "Stop the running task before editing.", + "unsupported": "This task uses a schedule that cannot be edited on this page.", + "incomplete": "This task is missing the details required for editing." + }, + "errors": { + "running": "Stop the running task before saving changes.", + "conflict": "This task has changed. Refresh the list and edit it again.", + "saveFailed": "Failed to save cron task changes." + }, + "actions": { + "cancel": "Cancel", + "save": "Save Changes" + } } } } diff --git a/ui/src/i18n/locales/zh-CN/alwaysOn.json b/ui/src/i18n/locales/zh-CN/alwaysOn.json index 037032a7d..19c588d77 100644 --- a/ui/src/i18n/locales/zh-CN/alwaysOn.json +++ b/ui/src/i18n/locales/zh-CN/alwaysOn.json @@ -122,9 +122,18 @@ }, "actions": { "delete": "删除", + "edit": "编辑", "runNow": "立即运行", "stop": "停止" }, + "deleteConfirm": { + "title": "删除定时任务?", + "description": "该定时任务将被永久删除,且无法恢复。", + "cancel": "取消", + "confirm": "删除", + "deleting": "正在删除…", + "failed": "删除定时任务失败。" + }, "status": { "scheduled": "已安排", "running": "运行中" @@ -139,15 +148,64 @@ "scheduleType": "任务类型", "date": "日期", "time": "时间", - "timezone": "时区" + "timezone": "时区", + "repeatPeriod": "重复周期", + "executionDay": "执行日", + "weekday": "星期", + "monthDay": "日期", + "month": "月份", + "expression": "Cron 表达式" }, "placeholders": { "prompt": "描述 PilotDeck 在任务运行时应该做什么。", - "workspace": "选择工作区" + "workspace": "选择工作区", + "expression": "30 8 * * 1" }, "schedule": { "once": "一次性", - "cron": "周期" + "cron": "周期", + "advanced": "高级 Cron 表达式", + "daily": "每天", + "weekly": "每周", + "monthly": "每月", + "yearly": "每年" + }, + "options": { + "everyDay": "每天" + }, + "weekdays": { + "monday": "周一", + "tuesday": "周二", + "wednesday": "周三", + "thursday": "周四", + "friday": "周五", + "saturday": "周六", + "sunday": "周日" + }, + "months": { + "january": "一月", + "february": "二月", + "march": "三月", + "april": "四月", + "may": "五月", + "june": "六月", + "july": "七月", + "august": "八月", + "september": "九月", + "october": "十月", + "november": "十一月", + "december": "十二月" + }, + "help": { + "expressionFormat": "格式:分钟 小时 日期 月份 星期" + }, + "plan": { + "label": "计划:", + "currentLabel": "当前计划:", + "daily": "每天 {{time}}", + "weekly": "每{{weekday}} {{time}}", + "monthly": "每月 {{day}} 日 {{time}}", + "yearly": "每年 {{month}} 月 {{day}} 日 {{time}}" }, "validation": { "messageRequired": "请填写提示词。", @@ -155,11 +213,33 @@ "dateRequired": "请选择日期。", "timeRequired": "请选择时间。", "timezoneRequired": "请填写时区。", - "runAtFuture": "运行时间必须晚于当前时间。" + "timezoneInvalid": "时区无效,请输入有效的 IANA 时区。", + "runAtFuture": "运行时间必须晚于当前时间。", + "expressionInvalid": "Cron 表达式格式无效。" }, "actions": { + "expandAdvanced": "展开", + "collapseAdvanced": "收起", "submit": "创建任务" } + }, + "edit": { + "title": "编辑 Cron 任务", + "subtitle": "修改该任务的提示词或执行计划。", + "disabled": { + "running": "请先停止正在运行的任务,再进行编辑。", + "unsupported": "该任务使用了当前页面不支持编辑的计划规则。", + "incomplete": "该任务缺少编辑所需的完整信息。" + }, + "errors": { + "running": "该任务正在运行,请先停止后再保存。", + "conflict": "该任务已发生变化,请刷新列表后重新编辑。", + "saveFailed": "保存 Cron 任务修改失败。" + }, + "actions": { + "cancel": "取消", + "save": "保存修改" + } } } } diff --git a/ui/src/types/app.ts b/ui/src/types/app.ts index f365a02e0..b2a8b9896 100644 --- a/ui/src/types/app.ts +++ b/ui/src/types/app.ts @@ -116,10 +116,24 @@ export interface DiscoveryContextPlanItem { export type CronJobOverviewStatus = 'scheduled' | 'running' | 'completed' | 'failed'; +export type CronJobSchedule = + | { + type: 'once'; + runAt: string; + } + | { + type: 'cron'; + expression: string; + timezone?: string; + }; + export interface CronJobOverview { id: string; projectKey: string | null; cron: string; + schedule?: CronJobSchedule; + timezone?: string | null; + revision?: number; prompt: string; createdAt: string; nextRunAt?: string; diff --git a/ui/src/utils/api.js b/ui/src/utils/api.js index 9708022c3..711fdf770 100644 --- a/ui/src/utils/api.js +++ b/ui/src/utils/api.js @@ -183,6 +183,11 @@ export const api = { method: 'POST', body: JSON.stringify(payload), }), + cronUpdate: (taskId, payload) => + authenticatedFetch(`/api/always-on/cron-jobs/${encodeURIComponent(taskId)}`, { + method: 'PATCH', + body: JSON.stringify(payload), + }), cronRunNow: (taskId) => authenticatedFetch(`/api/always-on/cron-jobs/${encodeURIComponent(taskId)}/run-now`, { method: 'POST' }), cronStop: (taskId) => From 00b19b7ee0447eb3478459f1deb1e5b866f21753 Mon Sep 17 00:00:00 2001 From: gaopengju Date: Wed, 5 Aug 2026 09:24:12 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix(cron):=20=E4=BF=AE=E5=A4=8D=E5=AE=9A?= =?UTF-8?q?=E6=97=B6=E4=BB=BB=E5=8A=A1=E8=A1=A8=E5=8D=95=E7=9A=84=E8=8D=89?= =?UTF-8?q?=E7=A8=BF=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main-content-v2/CronV2.test.tsx | 71 ++++++- ui/src/components/main-content-v2/CronV2.tsx | 197 +++++++++++------- 2 files changed, 186 insertions(+), 82 deletions(-) diff --git a/ui/src/components/main-content-v2/CronV2.test.tsx b/ui/src/components/main-content-v2/CronV2.test.tsx index 215ad8acd..64fecdaaf 100644 --- a/ui/src/components/main-content-v2/CronV2.test.tsx +++ b/ui/src/components/main-content-v2/CronV2.test.tsx @@ -362,7 +362,7 @@ describe('CronV2', () => { expect(screen.queryByText('Cron expression format is invalid.')).toBeNull(); }); - it('restores the default daily expression after switching from one-time back to recurring', async () => { + it('keeps an invalid recurring expression after temporarily switching to one-time', async () => { setup([]); fireEvent.click(screen.getByRole('button', { name: 'Create Task' })); @@ -376,8 +376,32 @@ describe('CronV2', () => { fireEvent.click(screen.getByRole('button', { name: 'Recurring' })); expect((screen.getByLabelText('Repeat period') as HTMLSelectElement).value).toBe('daily'); - expect((screen.getByLabelText('Cron expression') as HTMLInputElement).value).toBe('30 8 * * *'); - expect(screen.queryByText('Cron expression format is invalid.')).toBeNull(); + expect((screen.getByLabelText('Cron expression') as HTMLInputElement).value).toBe('30 8 *'); + expect(screen.getByText('Cron expression format is invalid.')).toBeTruthy(); + }); + + it('keeps separate one-time and recurring drafts while creating a task', async () => { + setup([]); + + fireEvent.click(screen.getByRole('button', { name: 'Create Task' })); + await screen.findByText('Create Cron Task'); + fireEvent.change(screen.getByLabelText('Date'), { target: { value: '2099-06-20' } }); + fireEvent.change(screen.getByLabelText('Time'), { target: { value: '09:15' } }); + + fireEvent.click(screen.getByRole('button', { name: 'Recurring' })); + fireEvent.change(screen.getByLabelText('Repeat period'), { target: { value: 'weekly' } }); + fireEvent.change(screen.getByLabelText('Day of week'), { target: { value: '1' } }); + fireEvent.change(screen.getByLabelText('Time'), { target: { value: '08:30' } }); + + fireEvent.click(screen.getByRole('button', { name: 'One-time' })); + expect((screen.getByLabelText('Date') as HTMLInputElement).value).toBe('2099-06-20'); + expect((screen.getByLabelText('Time') as HTMLInputElement).value).toBe('09:15'); + + fireEvent.click(screen.getByRole('button', { name: 'Recurring' })); + expect((screen.getByLabelText('Repeat period') as HTMLSelectElement).value).toBe('weekly'); + expect((screen.getByLabelText('Day of week') as HTMLSelectElement).value).toBe('1'); + expect((screen.getByLabelText('Time') as HTMLInputElement).value).toBe('08:30'); + expect((screen.getByLabelText('Cron expression') as HTMLInputElement).value).toBe('30 8 * * 1'); }); it('collapses and restores the linked cron expression without changing it', async () => { @@ -607,6 +631,34 @@ describe('CronV2', () => { }); }); + it('keeps separate one-time and recurring drafts while editing a task', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(new Date('2026-01-15T12:00:00')); + setup([makeJob({ + id: 'job-edit-drafts', + prompt: 'Keep both drafts', + cron: '30 13 * * 1', + schedule: { type: 'cron', expression: '30 13 * * 1', timezone: 'UTC' }, + revision: 2, + })]); + + await screen.findByText('Keep both drafts'); + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + fireEvent.click(screen.getByRole('button', { name: 'One-time' })); + fireEvent.change(screen.getByLabelText('Date'), { target: { value: '2026-01-20' } }); + fireEvent.change(screen.getByLabelText('Time'), { target: { value: '14:15' } }); + + fireEvent.click(screen.getByRole('button', { name: 'Recurring' })); + expect((screen.getByLabelText('Repeat period') as HTMLSelectElement).value).toBe('weekly'); + expect((screen.getByLabelText('Day of week') as HTMLSelectElement).value).toBe('1'); + expect((screen.getByLabelText('Time') as HTMLInputElement).value).toBe('13:30'); + expect((screen.getByLabelText('Cron expression') as HTMLInputElement).value).toBe('30 13 * * 1'); + + fireEvent.click(screen.getByRole('button', { name: 'One-time' })); + expect((screen.getByLabelText('Date') as HTMLInputElement).value).toBe('2026-01-20'); + expect((screen.getByLabelText('Time') as HTMLInputElement).value).toBe('14:15'); + }); + it('cancels editing without sending an update request', async () => { setup([makeJob({ id: 'job-cancel-edit', prompt: 'Keep original task' })]); @@ -740,6 +792,19 @@ describe('CronV2', () => { expect(apiMock.cronDelete).not.toHaveBeenCalled(); }); + it('shows only a short preview of a long prompt in the delete confirmation', async () => { + const longPrompt = 'Long cron task prompt '.repeat(30); + const preview = `${longPrompt.slice(0, 160)}…`; + setup([makeJob({ id: 'job-delete', prompt: longPrompt })]); + + fireEvent.click(await screen.findByTitle('Delete')); + + const dialog = screen.getByRole('dialog', { name: 'Delete cron task?' }); + const promptPreview = within(dialog).getByText(preview); + expect(promptPreview.className).toContain('line-clamp-2'); + expect(dialog.textContent).not.toContain(longPrompt); + }); + it('closes the delete confirmation with Escape without deleting', async () => { setup([makeJob({ id: 'job-delete', prompt: 'Delete this job' })]); diff --git a/ui/src/components/main-content-v2/CronV2.tsx b/ui/src/components/main-content-v2/CronV2.tsx index 6bbb56711..7dd03e32a 100644 --- a/ui/src/components/main-content-v2/CronV2.tsx +++ b/ui/src/components/main-content-v2/CronV2.tsx @@ -48,6 +48,30 @@ type CronFormValues = { advancedExpression: string; }; +type OnceScheduleDraft = Pick; +type RecurringScheduleDraft = Pick; + +function getSimpleCronSchedule(draft: RecurringScheduleDraft): SimpleCronSchedule { + switch (draft.recurrenceMode) { + case 'daily': + return { mode: 'daily', time: draft.scheduleTime }; + case 'weekly': + return { mode: 'weekly', time: draft.scheduleTime, weekday: draft.weekday }; + case 'monthly': + return { mode: 'monthly', time: draft.scheduleTime, dayOfMonth: draft.dayOfMonth }; + case 'yearly': + return { mode: 'yearly', time: draft.scheduleTime, dayOfMonth: draft.dayOfMonth, monthOfYear: draft.monthOfYear }; + } +} + +function buildSimpleExpression(draft: RecurringScheduleDraft): string { + try { + return buildSimpleCronExpression(getSimpleCronSchedule(draft)); + } catch { + return ''; + } +} + const SIMPLE_RECURRENCE_MODES: { id: SimpleRecurrenceMode; labelKey: string; @@ -557,39 +581,28 @@ function CronCreateView({ const [message, setMessage] = useState(initialValues.message); const [projectKey, setProjectKey] = useState(initialValues.projectKey); const [scheduleKind, setScheduleKind] = useState(initialValues.scheduleKind); - const [scheduleDate, setScheduleDate] = useState(initialValues.scheduleDate); - const [scheduleTime, setScheduleTime] = useState(initialValues.scheduleTime); + const [onceDraft, setOnceDraft] = useState({ + scheduleDate: initialValues.scheduleDate, + scheduleTime: initialValues.scheduleTime, + }); + const [recurringDraft, setRecurringDraft] = useState({ + scheduleTime: initialValues.scheduleTime, + recurrenceMode: initialValues.recurrenceMode, + weekday: initialValues.weekday, + dayOfMonth: initialValues.dayOfMonth, + monthOfYear: initialValues.monthOfYear, + advancedExpression: initialValues.advancedExpression, + }); + const [hasOnceDraft, setHasOnceDraft] = useState(initialValues.scheduleKind === 'once'); + const [hasRecurringDraft, setHasRecurringDraft] = useState(initialValues.scheduleKind === 'cron'); const [timezone, setTimezone] = useState(initialValues.timezone); - const [recurrenceMode, setRecurrenceMode] = useState(initialValues.recurrenceMode); - const [weekday, setWeekday] = useState(initialValues.weekday); - const [dayOfMonth, setDayOfMonth] = useState(initialValues.dayOfMonth); - const [monthOfYear, setMonthOfYear] = useState(initialValues.monthOfYear); const [advancedExpanded, setAdvancedExpanded] = useState(true); - const [advancedExpression, setAdvancedExpression] = useState(initialValues.advancedExpression); const [submitting, setSubmitting] = useState(false); const [formError, setFormError] = useState(null); const [success, setSuccess] = useState(null); const projectOptionExists = projects.some((project) => (project.fullPath || project.name) === projectKey); + const { scheduleTime, recurrenceMode, weekday, dayOfMonth, monthOfYear, advancedExpression } = recurringDraft; - const simpleSchedule = useMemo(() => { - switch (recurrenceMode) { - case 'daily': - return { mode: 'daily', time: scheduleTime }; - case 'weekly': - return { mode: 'weekly', time: scheduleTime, weekday }; - case 'monthly': - return { mode: 'monthly', time: scheduleTime, dayOfMonth }; - case 'yearly': - return { mode: 'yearly', time: scheduleTime, dayOfMonth, monthOfYear }; - } - }, [dayOfMonth, monthOfYear, recurrenceMode, scheduleTime, weekday]); - const simpleExpression = useMemo(() => { - try { - return buildSimpleCronExpression(simpleSchedule); - } catch { - return ''; - } - }, [simpleSchedule]); const parsedAdvancedExpression = useMemo( () => parseSimpleCronExpression(advancedExpression), [advancedExpression], @@ -599,9 +612,13 @@ function CronCreateView({ ? buildSimpleCronExpression(parsedAdvancedExpression) : ''; - useEffect(() => { - setAdvancedExpression(simpleExpression); - }, [simpleExpression]); + const updateRecurringDraft = (updates: Partial) => { + setRecurringDraft((current) => { + const next = { ...current, ...updates }; + return { ...next, advancedExpression: buildSimpleExpression(next) }; + }); + setHasRecurringDraft(true); + }; const scheduleDescription = useMemo(() => { switch (recurrenceMode) { @@ -630,47 +647,58 @@ function CronCreateView({ setMessage(defaults.message); setProjectKey(defaults.projectKey); setScheduleKind(defaults.scheduleKind); - setScheduleDate(defaults.scheduleDate); - setScheduleTime(defaults.scheduleTime); + setOnceDraft({ scheduleDate: defaults.scheduleDate, scheduleTime: defaults.scheduleTime }); + setRecurringDraft({ + scheduleTime: defaults.scheduleTime, + recurrenceMode: defaults.recurrenceMode, + weekday: defaults.weekday, + dayOfMonth: defaults.dayOfMonth, + monthOfYear: defaults.monthOfYear, + advancedExpression: defaults.advancedExpression, + }); + setHasOnceDraft(true); + setHasRecurringDraft(false); setTimezone(defaults.timezone); - setRecurrenceMode(defaults.recurrenceMode); - setWeekday(defaults.weekday); - setDayOfMonth(defaults.dayOfMonth); - setMonthOfYear(defaults.monthOfYear); setAdvancedExpanded(true); - setAdvancedExpression(defaults.advancedExpression); }; const handleScheduleKindChange = (kind: ScheduleKind) => { if (kind === scheduleKind) return; setFormError(null); - if (kind === 'cron') { - setRecurrenceMode('daily'); - setAdvancedExpression(scheduleTime - ? buildSimpleCronExpression({ mode: 'daily', time: scheduleTime }) - : ''); - } else { - setScheduleDate(getNextFutureDateForTime(scheduleTime)); + if (kind === 'cron' && !hasRecurringDraft) { + const nextDraft: RecurringScheduleDraft = { + ...recurringDraft, + scheduleTime: onceDraft.scheduleTime, + recurrenceMode: 'daily', + }; + setRecurringDraft({ ...nextDraft, advancedExpression: buildSimpleExpression(nextDraft) }); + setHasRecurringDraft(true); + } else if (kind === 'once' && !hasOnceDraft) { + setOnceDraft({ + scheduleDate: getNextFutureDateForTime(recurringDraft.scheduleTime), + scheduleTime: recurringDraft.scheduleTime, + }); + setHasOnceDraft(true); } setScheduleKind(kind); }; const handleExpressionChange = (expression: string) => { - setAdvancedExpression(expression); setFormError(null); const parsed = parseSimpleCronExpression(expression); - if (!parsed) return; - - setRecurrenceMode(parsed.mode); - setScheduleTime(parsed.time); - if (parsed.mode === 'weekly') { - setWeekday(parsed.weekday); - } else if (parsed.mode === 'monthly') { - setDayOfMonth(parsed.dayOfMonth); - } else if (parsed.mode === 'yearly') { - setDayOfMonth(parsed.dayOfMonth); - setMonthOfYear(parsed.monthOfYear); - } + setRecurringDraft((current) => { + if (!parsed) return { ...current, advancedExpression: expression }; + return { + ...current, + advancedExpression: expression, + scheduleTime: parsed.time, + recurrenceMode: parsed.mode, + weekday: parsed.mode === 'weekly' ? parsed.weekday : current.weekday, + dayOfMonth: parsed.mode === 'monthly' || parsed.mode === 'yearly' ? parsed.dayOfMonth : current.dayOfMonth, + monthOfYear: parsed.mode === 'yearly' ? parsed.monthOfYear : current.monthOfYear, + }; + }); + setHasRecurringDraft(true); }; const validate = () => { @@ -680,7 +708,7 @@ function CronCreateView({ if (!projectKey) { return t('cron.create.validation.workspaceRequired', { defaultValue: 'Workspace is required.' }); } - if (scheduleKind === 'once' && !scheduleTime) { + if (scheduleKind === 'once' && !onceDraft.scheduleTime) { return t('cron.create.validation.timeRequired', { defaultValue: 'Time is required.' }); } if (!timezone.trim()) { @@ -690,10 +718,10 @@ function CronCreateView({ return t('cron.create.validation.timezoneInvalid', { defaultValue: 'Timezone is invalid.' }); } if (scheduleKind === 'once') { - if (!scheduleDate) { + if (!onceDraft.scheduleDate) { return t('cron.create.validation.dateRequired', { defaultValue: 'Date is required.' }); } - const runAt = resolveOneTimeRunAt(editingJob, scheduleDate, scheduleTime); + const runAt = resolveOneTimeRunAt(editingJob, onceDraft.scheduleDate, onceDraft.scheduleTime); if (!runAt || Date.parse(runAt) <= Date.now()) { return t('cron.create.validation.runAtFuture', { defaultValue: 'Run time must be in the future.' }); } @@ -731,7 +759,7 @@ function CronCreateView({ try { let schedule: CronJobSchedule; if (scheduleKind === 'once') { - const runAt = resolveOneTimeRunAt(editingJob, scheduleDate, scheduleTime); + const runAt = resolveOneTimeRunAt(editingJob, onceDraft.scheduleDate, onceDraft.scheduleTime); if (!runAt) { setFormError(t('cron.create.validation.runAtFuture', { defaultValue: 'Run time must be in the future.' })); return; @@ -879,9 +907,12 @@ function CronCreateView({ setScheduleDate(event.target.value)} + onChange={(event) => { + setOnceDraft((current) => ({ ...current, scheduleDate: event.target.value })); + setHasOnceDraft(true); + }} className="mt-1.5 h-9 w-full rounded-md border border-neutral-200 bg-white px-3 text-[13px] text-neutral-900 outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-100 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-100 dark:focus:border-blue-500 dark:focus:ring-blue-950" /> @@ -891,8 +922,11 @@ function CronCreateView({ setScheduleTime(event.target.value)} + value={onceDraft.scheduleTime} + onChange={(event) => { + setOnceDraft((current) => ({ ...current, scheduleTime: event.target.value })); + setHasOnceDraft(true); + }} className="mt-1.5 h-9 w-full rounded-md border border-neutral-200 bg-white px-3 text-[13px] text-neutral-900 outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-100 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-100 dark:focus:border-blue-500 dark:focus:ring-blue-950" /> @@ -918,10 +952,10 @@ function CronCreateView({ value={recurrenceMode} onChange={(event) => { const nextMode = event.target.value as SimpleRecurrenceMode; - setRecurrenceMode(nextMode); - if (nextMode === 'yearly') { - setDayOfMonth((current) => Math.min(current, getYearlyMonthDayCount(monthOfYear))); - } + updateRecurringDraft({ + recurrenceMode: nextMode, + dayOfMonth: nextMode === 'yearly' ? Math.min(dayOfMonth, getYearlyMonthDayCount(monthOfYear)) : dayOfMonth, + }); }} className="mt-1.5 h-9 w-full rounded-md border border-neutral-200 bg-white px-3 text-[13px] text-neutral-900 outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-100 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-100 dark:focus:border-blue-500 dark:focus:ring-blue-950" > @@ -945,7 +979,7 @@ function CronCreateView({ setDayOfMonth(Number(event.target.value))} + onChange={(event) => updateRecurringDraft({ dayOfMonth: Number(event.target.value) })} className="mt-1.5 h-9 w-full rounded-md border border-neutral-200 bg-white px-3 text-[13px] text-neutral-900 outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-100 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-100 dark:focus:border-blue-500 dark:focus:ring-blue-950" > {Array.from({ length: 31 }, (_, index) => index + 1).map((day) => ( @@ -972,8 +1006,10 @@ function CronCreateView({ value={monthOfYear} onChange={(event) => { const nextMonth = Number(event.target.value); - setMonthOfYear(nextMonth); - setDayOfMonth((current) => Math.min(current, getYearlyMonthDayCount(nextMonth))); + updateRecurringDraft({ + monthOfYear: nextMonth, + dayOfMonth: Math.min(dayOfMonth, getYearlyMonthDayCount(nextMonth)), + }); }} className="h-9 min-w-0 rounded-md border border-neutral-200 bg-white px-2 text-[13px] text-neutral-900 outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-100 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-100 dark:focus:border-blue-500 dark:focus:ring-blue-950" > @@ -986,7 +1022,7 @@ function CronCreateView({ setScheduleTime(event.target.value)} + onChange={(event) => updateRecurringDraft({ scheduleTime: event.target.value })} className="mt-1.5 h-9 w-full rounded-md border border-neutral-200 bg-white px-3 text-[13px] text-neutral-900 outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-100 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-100 dark:focus:border-blue-500 dark:focus:ring-blue-950" /> @@ -1224,7 +1260,7 @@ function CronJobRow({ return ( <>
-
+
{job.prompt || '—'}
@@ -1327,6 +1363,9 @@ function DeleteCronJobDialog({ onCancel: () => void; onConfirm: () => void; }) { + const prompt = job.prompt?.trim() || ''; + const promptPreview = prompt.length > 160 ? `${prompt.slice(0, 160)}…` : prompt || '—'; + const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape' && !busy) { event.preventDefault(); @@ -1351,7 +1390,7 @@ function DeleteCronJobDialog({ return (
-
+
@@ -1360,13 +1399,13 @@ function DeleteCronJobDialog({

{t('cron.deleteConfirm.title', { defaultValue: 'Delete cron task?' })}

-

- {job.prompt || '—'} +

+ {promptPreview}

-
+

{t('cron.deleteConfirm.description', { defaultValue: 'This cron task will be permanently deleted.' })}

From 6b9b2c56365b3e6637d962be95b541ed55fc39f2 Mon Sep 17 00:00:00 2001 From: gaopengju Date: Wed, 5 Aug 2026 11:28:50 +0800 Subject: [PATCH 3/6] fix(chat): keep cron runs active in session status --- src/gateway/client/InProcessGateway.ts | 8 +- src/gateway/protocol/types.ts | 2 + src/web/client/protocol.ts | 2 + src/web/server/readSessionMessages.ts | 2 +- tests/gateway/active-turn-snapshot.spec.ts | 129 ++++++++++++++++++ ui/server/index.js | 12 +- ui/server/pilotdeck-bridge.js | 32 ++++- .../components/chat-v2/SubagentCard.test.tsx | 63 +++++++++ ui/src/components/chat-v2/SubagentCard.tsx | 11 +- 9 files changed, 235 insertions(+), 26 deletions(-) create mode 100644 tests/gateway/active-turn-snapshot.spec.ts create mode 100644 ui/src/components/chat-v2/SubagentCard.test.tsx diff --git a/src/gateway/client/InProcessGateway.ts b/src/gateway/client/InProcessGateway.ts index 00f89e5d5..c041f69ef 100644 --- a/src/gateway/client/InProcessGateway.ts +++ b/src/gateway/client/InProcessGateway.ts @@ -644,9 +644,11 @@ export class InProcessGateway implements Gateway { active: true, sessionKey: replay.sessionKey, runId: replay.runId, - events: replay.events - .filter((event) => this.shouldReplayActiveTurnEvent(input.sessionKey, event)) - .map((event) => cloneGatewayEvent(event)), + events: input.includeEvents === false + ? [] + : replay.events + .filter((event) => this.shouldReplayActiveTurnEvent(input.sessionKey, event)) + .map((event) => cloneGatewayEvent(event)), ...(replay.truncated ? { truncated: true } : {}), }; } diff --git a/src/gateway/protocol/types.ts b/src/gateway/protocol/types.ts index a555f1db5..1a5ccedd9 100644 --- a/src/gateway/protocol/types.ts +++ b/src/gateway/protocol/types.ts @@ -239,6 +239,8 @@ export type GatewayEvent = GatewayTurnScopedEventMetadata & ( export type GatewayActiveTurnSnapshotInput = { sessionKey: string; + /** Defaults to true. Set false for status-only polling. */ + includeEvents?: boolean; }; export type GatewayActiveTurnSnapshot = { diff --git a/src/web/client/protocol.ts b/src/web/client/protocol.ts index d0341d61c..bf50eb537 100644 --- a/src/web/client/protocol.ts +++ b/src/web/client/protocol.ts @@ -311,6 +311,8 @@ export type WebForkSessionResult = { export type WebActiveTurnSnapshotInput = { sessionKey: string; + /** Defaults to true. Set false for status-only polling. */ + includeEvents?: boolean; }; export type WebActiveTurnSnapshot = { diff --git a/src/web/server/readSessionMessages.ts b/src/web/server/readSessionMessages.ts index ec191d120..254c7d7dd 100644 --- a/src/web/server/readSessionMessages.ts +++ b/src/web/server/readSessionMessages.ts @@ -419,7 +419,7 @@ function createIncompleteTurnStatusMessage( provider: "pilotdeck", role: "system", kind: "status", - text: "上次运行未正常结束或已中断,已恢复当时产生的工具调用和输出。", + text: "本轮记录尚未写入最终结果,已恢复当时产生的工具调用和输出。", payload: { incompleteTurnIds: turnIds }, source: "history", }; diff --git a/tests/gateway/active-turn-snapshot.spec.ts b/tests/gateway/active-turn-snapshot.spec.ts new file mode 100644 index 000000000..4ffa9f444 --- /dev/null +++ b/tests/gateway/active-turn-snapshot.spec.ts @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { InProcessGateway } from "../../src/gateway/client/InProcessGateway.js"; +import { RemoteGateway } from "../../src/gateway/client/RemoteGateway.js"; +import type { GatewayWsClient } from "../../src/gateway/client/GatewayWsClient.js"; +import type { Gateway } from "../../src/gateway/protocol/types.js"; +import { PILOTDECK_GATEWAY_PROTOCOL_VERSION } from "../../src/gateway/protocol/version.js"; +import { GatewayWsConnection } from "../../src/gateway/server/GatewayWsConnection.js"; +import type { TextWebSocketConnection } from "../../src/gateway/server/websocket.js"; +import type { SessionRouter } from "../../src/gateway/SessionRouter.js"; +import type { GatewayEvent } from "../../src/gateway/protocol/types.js"; + +type ActiveTurnReplayStore = { + activeTurnReplays: Map; +}; + +class FakeTextWebSocketConnection { + readonly sent: unknown[] = []; + private messageHandler?: (message: string) => void; + + onMessage(handler: (message: string) => void): void { + this.messageHandler = handler; + } + + onClose(_handler: () => void): void {} + + sendText(message: string): void { + this.sent.push(JSON.parse(message)); + } + + close(): void {} + + dispatch(frame: unknown): void { + this.messageHandler?.(JSON.stringify(frame)); + } +} + +async function flushAsyncWork(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + +test("status-only active turn snapshots omit buffered events", async () => { + const gateway = new InProcessGateway({} as SessionRouter); + const event: GatewayEvent = { type: "assistant_text_delta", text: "still running" }; + const replays = (gateway as unknown as ActiveTurnReplayStore).activeTurnReplays; + + replays.set("cron:status-only", { + sessionKey: "cron:status-only", + runId: "run-1", + get events(): GatewayEvent[] { + throw new Error("status-only polling must not read buffered events"); + }, + bytes: 1, + truncated: false, + }); + + const statusOnly = await gateway.getActiveTurnSnapshot({ sessionKey: "cron:status-only", includeEvents: false }); + assert.equal(statusOnly.active, true); + assert.deepEqual(statusOnly.events, []); + + replays.set("cron:active", { + sessionKey: "cron:active", + runId: "run-1", + events: [event], + bytes: 1, + truncated: false, + }); + + const replay = await gateway.getActiveTurnSnapshot({ sessionKey: "cron:active" }); + assert.deepEqual(replay.events, [event]); + assert.notEqual(replay.events[0], event, "default replay remains a defensive copy"); +}); + +test("status-only active turn snapshots preserve includeEvents through remote and WebSocket gateways", async () => { + const input = { sessionKey: "cron:status-only", includeEvents: false }; + const expected = { active: true, sessionKey: input.sessionKey, events: [] }; + + let remoteMethod: string | undefined; + let remoteInput: unknown; + const remote = new RemoteGateway({ + request: async (method: string, received: unknown) => { + remoteMethod = method; + remoteInput = received; + return expected; + }, + } as unknown as GatewayWsClient); + assert.deepEqual(await remote.getActiveTurnSnapshot(input), expected); + assert.equal(remoteMethod, "active_turn_snapshot"); + assert.deepEqual(remoteInput, input); + + let websocketInput: typeof input | undefined; + const socket = new FakeTextWebSocketConnection(); + new GatewayWsConnection(socket as unknown as TextWebSocketConnection, { + token: "secret", + serverVersion: "test", + gateway: { + describeServer: async () => ({ mode: "in_process" }), + getActiveTurnSnapshot: async (received: typeof input) => { + websocketInput = received; + return expected; + }, + } as unknown as Gateway, + }); + socket.dispatch({ + type: "hello", + protocolVersion: PILOTDECK_GATEWAY_PROTOCOL_VERSION, + clientName: "test", + clientVersion: "test", + token: "secret", + }); + await flushAsyncWork(); + socket.dispatch({ type: "request", id: "active-turn-status-only", method: "active_turn_snapshot", params: input }); + await flushAsyncWork(); + + assert.deepEqual(websocketInput, input); + assert.deepEqual(socket.sent.at(-1), { + type: "response", + id: "active-turn-status-only", + ok: true, + result: expected, + }); +}); diff --git a/ui/server/index.js b/ui/server/index.js index 2bfb33c8a..47c549763 100755 --- a/ui/server/index.js +++ b/ui/server/index.js @@ -62,8 +62,7 @@ import { abortViaGateway, decidePermissionViaGateway, grantSessionPermissionViaGateway, - isSessionActiveViaGateway, - getActiveTurnSnapshotFramesViaGateway, + getSessionActivityViaGateway, getActiveSessionIdsViaGateway, elicitationRespondViaGateway, getRouterDashboardData, @@ -2512,17 +2511,14 @@ function handleChatConnection(ws, request) { if (normalizeSessionId(sessionId)) { sessionWatchRegistry.watch(sessionId, ws); } - const isProcessing = isSessionActiveViaGateway(sessionId); const includeActiveTurnMessages = data.includeActiveTurnMessages !== false; - const activeTurnMessages = (isProcessing && includeActiveTurnMessages) - ? await getActiveTurnSnapshotFramesViaGateway(sessionId, data.provider || 'pilotdeck') - : []; + const activity = await getSessionActivityViaGateway(sessionId, data.provider || 'pilotdeck', includeActiveTurnMessages); writer.send({ type: 'session-status', sessionId, provider: data.provider || 'pilotdeck', - isProcessing, - activeTurnMessages, + isProcessing: activity.isProcessing, + activeTurnMessages: includeActiveTurnMessages ? activity.activeTurnMessages : [], tokenBudget: getSessionTokenBudget(sessionId), }); } else if (data.type === 'get-pending-permissions') { diff --git a/ui/server/pilotdeck-bridge.js b/ui/server/pilotdeck-bridge.js index 0c2f9898b..890305e02 100644 --- a/ui/server/pilotdeck-bridge.js +++ b/ui/server/pilotdeck-bridge.js @@ -1369,13 +1369,31 @@ export function isSessionActiveViaGateway(sessionId) { return Boolean(sessionState.get(sessionId)?.active); } -export async function getActiveTurnSnapshotFramesViaGateway(sessionId, provider = 'pilotdeck') { - if (!isPilotDeckSessionKey(sessionId)) return []; - const gw = await ensureGateway(); - if (typeof gw.getActiveTurnSnapshot !== 'function') return []; - const snapshot = await gw.getActiveTurnSnapshot({ sessionKey: sessionId }); - if (!snapshot?.active || !Array.isArray(snapshot.events)) return []; - return snapshot.events.flatMap((event) => gatewayEventToFrames(event, sessionId, provider) || []); +export async function getSessionActivityViaGateway(sessionId, provider = 'pilotdeck', includeActiveTurnMessages = true) { + if (!isPilotDeckSessionKey(sessionId)) { + return { isProcessing: false, activeTurnMessages: [] }; + } + + const localIsProcessing = Boolean(sessionState.get(sessionId)?.active); + try { + const gw = await ensureGateway(); + if (typeof gw.getActiveTurnSnapshot !== 'function') { + return { isProcessing: localIsProcessing, activeTurnMessages: [] }; + } + const snapshot = await gw.getActiveTurnSnapshot({ sessionKey: sessionId, includeEvents: includeActiveTurnMessages }); + if (!snapshot || typeof snapshot.active !== 'boolean') { + return { isProcessing: localIsProcessing, activeTurnMessages: [] }; + } + return { + isProcessing: snapshot.active, + activeTurnMessages: includeActiveTurnMessages && snapshot.active && Array.isArray(snapshot.events) + ? snapshot.events.flatMap((event) => gatewayEventToFrames(event, sessionId, provider) || []) + : [], + }; + } catch (error) { + console.warn('[pilotdeck-bridge] failed to read active turn snapshot:', error?.message || error); + return { isProcessing: localIsProcessing, activeTurnMessages: [] }; + } } export function getActiveSessionIdsViaGateway() { diff --git a/ui/src/components/chat-v2/SubagentCard.test.tsx b/ui/src/components/chat-v2/SubagentCard.test.tsx new file mode 100644 index 000000000..ce5c77c94 --- /dev/null +++ b/ui/src/components/chat-v2/SubagentCard.test.tsx @@ -0,0 +1,63 @@ +// @vitest-environment jsdom +import React from 'react'; +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ChatMessage } from '../chat/types/types'; +import SubagentCard from './SubagentCard'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { defaultValue?: string; toolName?: string }) => { + const messages: Record = { + 'subagent.defaultDescription': '执行任务', + 'subagent.status.failed': '执行失败', + 'subagent.status.completed': '已完成', + 'subagent.status.stopped': '已停止', + 'subagent.status.thinking': '思考中', + }; + return messages[key] || options?.defaultValue || key; + }, + }), +})); + +afterEach(cleanup); + +function createSubagentMessage(): ChatMessage { + return { + id: 'subagent-container', + type: 'assistant', + timestamp: new Date().toISOString(), + isSubagentContainer: true, + subagentId: 'subagent-1', + toolInput: JSON.stringify({ subagent_type: 'general-purpose', description: '采集任务' }), + }; +} + +describe('SubagentCard', () => { + it('keeps an unfinished subagent thinking while parent activity is being synchronized', () => { + render(); + + expect(screen.getByText('思考中')).toBeTruthy(); + expect(screen.queryByText('已停止')).toBeNull(); + }); + + it('keeps a live subagent running even before the parent session status arrives', () => { + render( + , + ); + + expect(screen.getByText('正在采集')).toBeTruthy(); + expect(screen.queryByText('已停止')).toBeNull(); + }); +}); diff --git a/ui/src/components/chat-v2/SubagentCard.tsx b/ui/src/components/chat-v2/SubagentCard.tsx index 9dc1a15a3..a27420eda 100644 --- a/ui/src/components/chat-v2/SubagentCard.tsx +++ b/ui/src/components/chat-v2/SubagentCard.tsx @@ -41,13 +41,10 @@ export default function SubagentCard({ message, liveActivity, onOpenDetail, thin if (state === 'failed') { return { icon: 'failed' as const, text: text || t('subagent.status.failed') }; } - if (state === 'completed' || state === 'cancelled') { - if (!isSessionRunning && !hasToolResult) { - return { icon: 'failed' as const, text: t('subagent.status.stopped') }; - } + if (state === 'completed') { return { icon: 'completed' as const, text: text || t('subagent.status.completed') }; } - if (!isSessionRunning) { + if (state === 'cancelled') { return { icon: 'failed' as const, text: t('subagent.status.stopped') }; } return { icon: 'running' as const, text: text || t('subagent.status.thinking') }; @@ -65,10 +62,10 @@ export default function SubagentCard({ message, liveActivity, onOpenDetail, thin return { icon: 'running' as const, text: t('subagent.status.executingTool', { toolName: currentTool.toolName }) }; } if (!isSessionRunning) { - return { icon: 'failed' as const, text: t('subagent.status.stopped') }; + return { icon: 'running' as const, text: t('subagent.status.thinking') }; } return { icon: 'running' as const, text: t('subagent.status.thinking') }; - }, [isComplete, isFailed, hasToolResult, currentTool, liveActivity, isSessionRunning, t]); + }, [isComplete, isFailed, hasToolResult, currentTool, liveActivity, isSessionRunning, subagentId, t]); const handleClick = () => { if (subagentId && onOpenDetail) { From 6082356dbba64393abfa2739ee43d8621fa0cb4d Mon Sep 17 00:00:00 2001 From: gaopengju Date: Wed, 5 Aug 2026 13:34:03 +0800 Subject: [PATCH 4/6] fix(cron): stream run progress to chat --- src/cli/pilotdeck.ts | 3 + src/cron/runtime/CronFire.ts | 16 +++ src/cron/runtime/CronManager.ts | 3 + src/cron/runtime/CronRuntime.ts | 6 +- tests/cron/cron-editing.spec.ts | 87 +++++++++++- ui/server/pilotdeck-bridge.js | 134 ++++++++++-------- ui/server/pilotdeck-bridge.test.js | 44 ++++++ .../components/chat-v2/SubagentCard.test.tsx | 19 +++ .../hooks/useChatRealtimeHandlers.test.tsx | 78 ++++++++++ .../chat/hooks/useChatRealtimeHandlers.ts | 3 + .../stores/useSessionStore.streaming.test.ts | 44 ++++++ ui/src/stores/useSessionStore.ts | 31 +++- 12 files changed, 405 insertions(+), 63 deletions(-) create mode 100644 ui/src/components/chat/hooks/useChatRealtimeHandlers.test.tsx diff --git a/src/cli/pilotdeck.ts b/src/cli/pilotdeck.ts index 95de684a1..80c1eb42d 100644 --- a/src/cli/pilotdeck.ts +++ b/src/cli/pilotdeck.ts @@ -88,6 +88,9 @@ async function main(argv = process.argv.slice(2)): Promise { sessionOverrides, logger: cronLogger, telemetry, + onTurnEvent: (sessionKey, channelKey, event) => { + deferredBroadcast?.("always-on:turn-event", { sessionKey, channelKey, event }); + }, onResultDelivery: (delivery) => { void serverRef?.deliverCronResult(delivery) .then((delivered) => { diff --git a/src/cron/runtime/CronFire.ts b/src/cron/runtime/CronFire.ts index 660dc0d38..27b0560dc 100644 --- a/src/cron/runtime/CronFire.ts +++ b/src/cron/runtime/CronFire.ts @@ -22,6 +22,8 @@ export type CronPhaseEventCallback = (event: { error?: { code: string; message: string }; }) => void; +export type CronTurnEventHandler = (sessionKey: string, channelKey: string, event: GatewayEvent) => void; + export type CronFireDependencies = { gateway: Gateway; store: CronTaskStore; @@ -33,6 +35,7 @@ export type CronFireDependencies = { defaultTimezone: string; releaseTaskSession: (task: CronTask) => Promise; onResultDelivery?: CronResultDeliveryHandler; + onTurnEvent?: CronTurnEventHandler; logger?: { warn: (message: string, data?: Record) => void; }; @@ -99,6 +102,7 @@ export class CronFire { timeoutMs: this.deps.runTimeoutMs, })) { await this.deps.store.appendRunEvent(runId, event); + this.forwardTurnEvent(task, event); if (event.type === "assistant_text_delta") { assistantText += event.text; } @@ -197,6 +201,18 @@ export class CronFire { } } + private forwardTurnEvent(task: CronTask, event: GatewayEvent): void { + try { + this.deps.onTurnEvent?.(task.sessionKey, task.channelKey, event); + } catch (error) { + this.deps.logger?.warn("cron turn event delivery failed", { + taskId: task.taskId, + runId: task.lastRunId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + private async deliverResult( task: CronTask, runId: string, diff --git a/src/cron/runtime/CronManager.ts b/src/cron/runtime/CronManager.ts index acc7408d0..1dfa95187 100644 --- a/src/cron/runtime/CronManager.ts +++ b/src/cron/runtime/CronManager.ts @@ -30,6 +30,7 @@ import { createCronListTool } from "../tool/CronListTool.js"; import { createCronStopTool } from "../tool/CronStopTool.js"; import { migrateCronStores } from "../storage/CronStoreMigration.js"; import { CronRuntime, type CronRuntimeLogger } from "./CronRuntime.js"; +import type { CronTurnEventHandler } from "./CronFire.js"; export type CreateCronManagerOptions = { config: CronConfig; @@ -40,6 +41,7 @@ export type CreateCronManagerOptions = { logger?: CronRuntimeLogger; telemetry?: TelemetryClient; onResultDelivery?: CronResultDeliveryHandler; + onTurnEvent?: CronTurnEventHandler; }; export class CronManager { @@ -188,6 +190,7 @@ export class CronManager { logger: this.options.logger, telemetry: this.options.telemetry, onResultDelivery: this.options.onResultDelivery, + onTurnEvent: this.options.onTurnEvent, activeRunCount: () => this.activeRunCount(), skipToolCreation: true, }); diff --git a/src/cron/runtime/CronRuntime.ts b/src/cron/runtime/CronRuntime.ts index aa80980d6..e0dc378ad 100644 --- a/src/cron/runtime/CronRuntime.ts +++ b/src/cron/runtime/CronRuntime.ts @@ -30,7 +30,7 @@ import { createCronCreateTool } from "../tool/CronCreateTool.js"; import { createCronDeleteTool } from "../tool/CronDeleteTool.js"; import { createCronListTool } from "../tool/CronListTool.js"; import { createCronStopTool } from "../tool/CronStopTool.js"; -import { CronFire, type CronActiveRun } from "./CronFire.js"; +import { CronFire, type CronActiveRun, type CronTurnEventHandler } from "./CronFire.js"; import { computeNextRunAt } from "./CronSchedule.js"; import { CronScheduler } from "./CronScheduler.js"; import type { TelemetryClient } from "../../telemetry/index.js"; @@ -53,6 +53,7 @@ export type CreateCronRuntimeOptions = { activeRunCount?: () => number; skipToolCreation?: boolean; onResultDelivery?: CronResultDeliveryHandler; + onTurnEvent?: CronTurnEventHandler; }; const NOOP_LOGGER: CronRuntimeLogger = { @@ -71,6 +72,7 @@ export class CronRuntime { private readonly logger: CronRuntimeLogger; private readonly telemetry?: TelemetryClient; private readonly onResultDelivery?: CronResultDeliveryHandler; + private readonly onTurnEvent?: CronTurnEventHandler; private readonly sessionOverrides: SessionConfigOverrides; private readonly tools: PilotDeckToolDefinition[]; private readonly activeRuns = new Map(); @@ -89,6 +91,7 @@ export class CronRuntime { this.logger = options.logger ?? NOOP_LOGGER; this.telemetry = options.telemetry; this.onResultDelivery = options.onResultDelivery; + this.onTurnEvent = options.onTurnEvent; this.sessionOverrides = options.sessionOverrides ?? new SessionConfigOverrides(); this.sharedActiveRunCount = options.activeRunCount; this.tools = options.skipToolCreation @@ -123,6 +126,7 @@ export class CronRuntime { defaultTimezone: this.config.timezone, releaseTaskSession: (task) => this.releaseTaskSession(task), onResultDelivery: this.onResultDelivery, + onTurnEvent: this.onTurnEvent, onPhaseEvent: (event) => { this.telemetry?.trackFeatureLoopStage({ module: "cron_job", diff --git a/tests/cron/cron-editing.spec.ts b/tests/cron/cron-editing.spec.ts index 6534e4c3a..e893d30ff 100644 --- a/tests/cron/cron-editing.spec.ts +++ b/tests/cron/cron-editing.spec.ts @@ -13,7 +13,7 @@ import { CronTaskStore } from "../../src/cron/storage/CronTaskStore.js"; import { InProcessGateway } from "../../src/gateway/client/InProcessGateway.js"; import { RemoteGateway } from "../../src/gateway/client/RemoteGateway.js"; import type { GatewayWsClient } from "../../src/gateway/client/GatewayWsClient.js"; -import type { Gateway, GatewayCronController } from "../../src/gateway/index.js"; +import type { Gateway, GatewayCronController, GatewayEvent } from "../../src/gateway/index.js"; import { PILOTDECK_GATEWAY_PROTOCOL_VERSION } from "../../src/gateway/protocol/version.js"; import { GatewayWsConnection } from "../../src/gateway/server/GatewayWsConnection.js"; import type { TextWebSocketConnection } from "../../src/gateway/server/websocket.js"; @@ -45,7 +45,12 @@ function makeTask(overrides: Partial = {}): CronTask { }; } -function createFire(store: CronTaskStore, gateway: Gateway, now: () => Date): CronFire { +function createFire( + store: CronTaskStore, + gateway: Gateway, + now: () => Date, + onTurnEvent?: (sessionKey: string, channelKey: string, event: GatewayEvent) => void, +): CronFire { const activeRuns = new Map(); return new CronFire({ gateway, @@ -61,6 +66,7 @@ function createFire(store: CronTaskStore, gateway: Gateway, now: () => Date): Cr runTimeoutMs: 60_000, defaultTimezone: "UTC", releaseTaskSession: async () => undefined, + onTurnEvent, }); } @@ -324,6 +330,83 @@ test("CronFire never executes an edited snapshot and claims a current snapshot o } }); +test("CronFire forwards live gateway events", async () => { + const pilotHome = mkdtempSync(join(tmpdir(), "pilotdeck-cron-turn-events-")); + const projectKey = "/tmp/projects/cron-editing"; + const now = () => new Date("2026-01-01T00:00:00.000Z"); + try { + const store = createStore(pilotHome, projectKey); + const task = makeTask(); + await store.putTask(task); + const gatewayEvents: GatewayEvent[] = [ + { type: "assistant_text_delta", text: "Working" }, + { type: "turn_completed", usage: {}, finishReason: "completed" }, + ]; + const gateway = { + submitTurn: async function* () { + yield* gatewayEvents; + }, + } as unknown as Gateway; + const forwarded: Array<{ sessionKey: string; channelKey: string; event: GatewayEvent }> = []; + const fire = createFire(store, gateway, now, (sessionKey, channelKey, event) => { + forwarded.push({ sessionKey, channelKey, event }); + }); + + await fire.runTask(task, "run-1"); + + assert.deepEqual(forwarded, gatewayEvents.map((event) => ({ + sessionKey: task.sessionKey, + channelKey: task.channelKey, + event, + }))); + assert.equal((await store.getTask(task.taskId))?.status, "scheduled"); + } finally { + rmSync(pilotHome, { recursive: true, force: true }); + } +}); + +test("CronFire completes when live event forwarding fails", async () => { + const pilotHome = mkdtempSync(join(tmpdir(), "pilotdeck-cron-turn-events-failure-")); + const projectKey = "/tmp/projects/cron-editing"; + const now = () => new Date("2026-01-01T00:00:00.000Z"); + try { + const store = createStore(pilotHome, projectKey); + const task = makeTask(); + await store.putTask(task); + const gateway = { + submitTurn: async function* () { + yield { type: "assistant_text_delta", text: "Working" } as GatewayEvent; + yield { type: "turn_completed", usage: {}, finishReason: "completed" } as GatewayEvent; + }, + } as unknown as Gateway; + const warnings: string[] = []; + const fire = new CronFire({ + gateway, + store, + now, + registerActiveRun: () => undefined, + unregisterActiveRun: () => undefined, + getActiveRun: () => undefined, + runTimeoutMs: 60_000, + defaultTimezone: "UTC", + releaseTaskSession: async () => undefined, + onTurnEvent: () => { + throw new Error("notification unavailable"); + }, + logger: { + warn: (message) => warnings.push(message), + }, + }); + + await fire.runTask(task, "run-1"); + + assert.equal((await store.getTask(task.taskId))?.status, "scheduled"); + assert.ok(warnings.includes("cron turn event delivery failed")); + } finally { + rmSync(pilotHome, { recursive: true, force: true }); + } +}); + test("CronScheduler does not delay a task after the task was edited", async () => { const now = new Date("2026-01-01T00:00:00.000Z"); const staleSnapshot = makeTask({ nextRunAt: now.toISOString() }); diff --git a/ui/server/pilotdeck-bridge.js b/ui/server/pilotdeck-bridge.js index 890305e02..5231b8505 100644 --- a/ui/server/pilotdeck-bridge.js +++ b/ui/server/pilotdeck-bridge.js @@ -2254,6 +2254,79 @@ export function getRouterStatsSummary() { }; } +export function isTerminalAlwaysOnTurnEvent(event) { + return event?.type === 'turn_completed' || event?.type === 'error'; +} + +/** + * Creates the notification callback used by the UI server for Always-On + * runs. Kept separate so terminal notification handling can be tested + * without opening a Gateway socket. + * + * @param {(sessionId: string, frame: object) => void} forwardFrame + */ +export function createAlwaysOnTurnEventForwarder(forwardFrame) { + const knownSessions = new Set(); + + return (name, payload) => { + if (name !== 'always-on:turn-event') return; + const { sessionKey, channelKey, event } = payload ?? {}; + if (!sessionKey || !event) return; + + const provider = 'pilotdeck'; + + if (!knownSessions.has(sessionKey)) { + knownSessions.add(sessionKey); + const createdFrame = createNormalizedMessage({ + provider, + sessionId: sessionKey, + kind: 'session_created', + newSessionId: sessionKey, + sessionKey, + channelKey, + }); + forwardFrame(sessionKey, createdFrame); + } + + if (event.type === 'context_budget') { + const aoState = ensureSessionState(sessionKey, '', channelKey || 'web'); + aoState.tokenBudget = { + used: event.used, + displayUsed: event.displayUsed, + budgetUsed: event.budgetUsed, + total: event.total, + effectiveTotal: event.effectiveTotal, + reservedOutputTokens: event.reservedOutputTokens, + ratio: event.ratio, + state: event.state, + }; + } + const aoState = ensureSessionState(sessionKey, '', channelKey || 'web'); + const compactTokenBudget = event.type === 'agent_status' && event.event === 'compact_completed' + ? tokenBudgetFromCompact(aoState.tokenBudget, event.detail) + : null; + const eventForFrames = compactTokenBudget + ? { + ...event, + detail: { + ...(event.detail || {}), + tokenBudget: compactTokenBudget, + }, + } + : event; + if (compactTokenBudget) { + aoState.tokenBudget = compactTokenBudget; + } + for (const frame of gatewayEventToFrames(eventForFrames, sessionKey, provider)) { + forwardFrame(sessionKey, frame); + } + + if (isTerminalAlwaysOnTurnEvent(event)) { + knownSessions.delete(sessionKey); + } + }; +} + /** * Register a notification handler that forwards Always-On turn events as * NormalizedMessage frames. The UI server can provide a session-scoped @@ -2266,8 +2339,6 @@ export function getRouterStatsSummary() { * @param {(sessionId: string, frame: object) => void} [forwardToSessionWatchers] */ export function registerAlwaysOnNotificationForwarding(clients, forwardToSessionWatchers) { - const knownSessions = new Set(); - const forwardFrame = (sessionId, frame) => { if (typeof forwardToSessionWatchers === 'function') { forwardToSessionWatchers(sessionId, frame); @@ -2281,65 +2352,10 @@ export function registerAlwaysOnNotificationForwarding(clients, forwardToSession if (client.readyState === 1) client.send(msg); } }; + const onNotification = createAlwaysOnTurnEventForwarder(forwardFrame); ensureGateway().then((gw) => { - gw.onNotification((name, payload) => { - if (name !== 'always-on:turn-event') return; - const { sessionKey, channelKey, event } = payload ?? {}; - if (!sessionKey || !event) return; - - const provider = 'pilotdeck'; - - if (!knownSessions.has(sessionKey)) { - knownSessions.add(sessionKey); - const createdFrame = createNormalizedMessage({ - provider, - sessionId: sessionKey, - kind: 'session_created', - newSessionId: sessionKey, - sessionKey, - channelKey, - }); - forwardFrame(sessionKey, createdFrame); - } - - if (event.type === 'context_budget') { - const aoState = ensureSessionState(sessionKey, '', channelKey || 'web'); - aoState.tokenBudget = { - used: event.used, - displayUsed: event.displayUsed, - budgetUsed: event.budgetUsed, - total: event.total, - effectiveTotal: event.effectiveTotal, - reservedOutputTokens: event.reservedOutputTokens, - ratio: event.ratio, - state: event.state, - }; - } - const aoState = ensureSessionState(sessionKey, '', channelKey || 'web'); - const compactTokenBudget = event.type === 'agent_status' && event.event === 'compact_completed' - ? tokenBudgetFromCompact(aoState.tokenBudget, event.detail) - : null; - const eventForFrames = compactTokenBudget - ? { - ...event, - detail: { - ...(event.detail || {}), - tokenBudget: compactTokenBudget, - }, - } - : event; - if (compactTokenBudget) { - aoState.tokenBudget = compactTokenBudget; - } - for (const frame of gatewayEventToFrames(eventForFrames, sessionKey, provider)) { - forwardFrame(sessionKey, frame); - } - - if (event.type === 'turn_completed') { - knownSessions.delete(sessionKey); - } - }); + gw.onNotification(onNotification); }).catch((err) => { console.warn('[pilotdeck-bridge] failed to register always-on notification forwarding:', err?.message || err); }); diff --git a/ui/server/pilotdeck-bridge.test.js b/ui/server/pilotdeck-bridge.test.js index 879e2eafb..9c0198a39 100644 --- a/ui/server/pilotdeck-bridge.test.js +++ b/ui/server/pilotdeck-bridge.test.js @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest'; import { + createAlwaysOnTurnEventForwarder, gatewayEventToFrames, isGatewayUnavailableError, + isTerminalAlwaysOnTurnEvent, } from './pilotdeck-bridge.js'; describe('gatewayEventToFrames agent status errors', () => { @@ -178,3 +180,45 @@ describe('isGatewayUnavailableError', () => { expect(isGatewayUnavailableError(new Error('Unexpected frame payload'))).toBe(false); }); }); + +describe('Always-On turn notification forwarding', () => { + it('cleans an aborted run so its next run receives session_created again', () => { + const forwarded = []; + const forward = createAlwaysOnTurnEventForwarder((sessionId, frame) => { + forwarded.push({ sessionId, frame }); + }); + const payload = (event) => ({ + sessionKey: 'cron:task-1', + channelKey: 'cron', + event, + }); + + forward('always-on:turn-event', payload({ + type: 'agent_status', + event: 'subagent_started', + detail: { subagentId: 'child-1', subagentType: 'general-purpose' }, + })); + forward('always-on:turn-event', payload({ + type: 'error', + code: 'agent_aborted', + message: 'The run was stopped.', + recoverable: true, + })); + forward('always-on:turn-event', payload({ + type: 'agent_status', + event: 'subagent_started', + detail: { subagentId: 'child-2', subagentType: 'general-purpose' }, + })); + + expect(forwarded.filter(({ frame }) => frame.kind === 'session_created')).toHaveLength(2); + expect(forwarded.find(({ frame }) => frame.kind === 'error')?.frame).toMatchObject({ + code: 'agent_aborted', + }); + }); + + it('treats normal completion and top-level errors as terminal', () => { + expect(isTerminalAlwaysOnTurnEvent({ type: 'turn_completed' })).toBe(true); + expect(isTerminalAlwaysOnTurnEvent({ type: 'error', code: 'agent_aborted' })).toBe(true); + expect(isTerminalAlwaysOnTurnEvent({ type: 'assistant_text_delta', text: 'still running' })).toBe(false); + }); +}); diff --git a/ui/src/components/chat-v2/SubagentCard.test.tsx b/ui/src/components/chat-v2/SubagentCard.test.tsx index ce5c77c94..e51dabbaf 100644 --- a/ui/src/components/chat-v2/SubagentCard.test.tsx +++ b/ui/src/components/chat-v2/SubagentCard.test.tsx @@ -60,4 +60,23 @@ describe('SubagentCard', () => { expect(screen.getByText('正在采集')).toBeTruthy(); expect(screen.queryByText('已停止')).toBeNull(); }); + + it('shows stopped after the session receives a confirmed abort event', () => { + render( + , + ); + + expect(screen.getByText('已停止')).toBeTruthy(); + expect(screen.queryByText('思考中')).toBeNull(); + }); }); diff --git a/ui/src/components/chat/hooks/useChatRealtimeHandlers.test.tsx b/ui/src/components/chat/hooks/useChatRealtimeHandlers.test.tsx new file mode 100644 index 000000000..cc404ffc7 --- /dev/null +++ b/ui/src/components/chat/hooks/useChatRealtimeHandlers.test.tsx @@ -0,0 +1,78 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Project, ProjectSession, SessionProvider } from '../../../types/app'; +import type { SessionStore } from '../../../stores/useSessionStore'; +import { createAlwaysOnTurnEventForwarder } from '../../../../server/pilotdeck-bridge.js'; +import { useChatRealtimeHandlers } from './useChatRealtimeHandlers'; + +const mocks = vi.hoisted(() => ({ + listener: null as ((message: unknown) => void) | null, + subscribe: vi.fn(), +})); + +vi.mock('../../../contexts/WebSocketContext', () => ({ + useWebSocket: () => ({ subscribe: mocks.subscribe }), +})); + +const provider = 'pilotdeck' as SessionProvider; +const noop = () => undefined; + +function createSessionStore() { + return { + cancelRunningActivities: vi.fn(), + refreshFromServer: vi.fn().mockResolvedValue(undefined), + setActiveSession: vi.fn(), + finalizeStreaming: vi.fn(), + finalizeStreamingThinking: vi.fn(), + appendRealtime: vi.fn(), + } as unknown as SessionStore; +} + +describe('useChatRealtimeHandlers terminal errors', () => { + beforeEach(() => { + mocks.listener = null; + mocks.subscribe.mockReset(); + mocks.subscribe.mockImplementation((listener) => { + mocks.listener = listener; + return noop; + }); + }); + + it('cancels running subagents when the bridge forwards agent_aborted', () => { + const sessionStore = createSessionStore(); + renderHook(() => useChatRealtimeHandlers({ + provider, + selectedProject: { name: 'project', fullPath: '/tmp/project' } as unknown as Project, + selectedSession: { id: 'cron:task-1' } as unknown as ProjectSession, + currentSessionId: 'cron:task-1', + setCurrentSessionId: noop, + setIsLoading: noop, + setCanAbortSession: noop, + setIsAborting: noop, + setClaudeStatus: noop, + setPilotDeckStatus: noop, + setTokenBudget: noop, + setPendingPermissionRequests: noop, + pendingViewSessionRef: { current: null }, + sessionStore, + })); + + act(() => { + const forward = createAlwaysOnTurnEventForwarder((_sessionId, frame) => { + mocks.listener?.(frame); + }); + forward('always-on:turn-event', { + sessionKey: 'cron:task-1', + channelKey: 'cron', + event: { + type: 'error', + code: 'agent_aborted', + message: 'The run was stopped.', + recoverable: true, + }, + }); + }); + + expect(sessionStore.cancelRunningActivities).toHaveBeenCalledWith('cron:task-1'); + }); +}); diff --git a/ui/src/components/chat/hooks/useChatRealtimeHandlers.ts b/ui/src/components/chat/hooks/useChatRealtimeHandlers.ts index 647cebf53..0923969c4 100644 --- a/ui/src/components/chat/hooks/useChatRealtimeHandlers.ts +++ b/ui/src/components/chat/hooks/useChatRealtimeHandlers.ts @@ -744,6 +744,9 @@ export function useChatRealtimeHandlers({ } case 'error': { + if (sid && msg.code === 'agent_aborted') { + sessionStore.cancelRunningActivities(sid); + } if (isForActiveView) { setIsLoading(false); setCanAbortSession(false); diff --git a/ui/src/stores/useSessionStore.streaming.test.ts b/ui/src/stores/useSessionStore.streaming.test.ts index 5e01e5359..6984d2aa0 100644 --- a/ui/src/stores/useSessionStore.streaming.test.ts +++ b/ui/src/stores/useSessionStore.streaming.test.ts @@ -7,6 +7,7 @@ import { } from '../components/chat/hooks/useChatRealtimeHandlers'; import { computeMerged, + cancelRunningAgentActivities, createRafNotifyScheduler, getFinalizedSubagentThinkingId, patchMergedStreamingMessage, @@ -81,6 +82,49 @@ describe('isSessionForActiveView', () => { }); }); +describe('cancelRunningAgentActivities', () => { + it('marks only unfinished agent activities as cancelled after a confirmed abort', () => { + const running = { + id: 'subagent-running', + sessionId: 'cron:task-1', + timestamp: '2026-08-05T00:00:00.000Z', + provider: PROVIDER, + kind: 'agent_activity' as const, + activityId: 'subagent:running', + state: 'running', + }; + const completed = { + ...running, + id: 'subagent-completed', + activityId: 'subagent:completed', + state: 'completed', + endedAt: '2026-08-05T00:01:00.000Z', + }; + const text = textMessage('assistant-text', 'Already written', '2026-08-05T00:00:30.000Z'); + const endedAt = '2026-08-05T00:02:00.000Z'; + + const activities = cancelRunningAgentActivities([running, completed, text], endedAt); + + expect(activities[0]).toMatchObject({ state: 'cancelled', endedAt }); + expect(activities[1]).toBe(completed); + expect(activities[2]).toBe(text); + }); + + it('keeps the same array when there are no running agent activities', () => { + const activities: NormalizedMessage[] = [{ + id: 'subagent-completed', + sessionId: 'cron:task-1', + timestamp: '2026-08-05T00:00:00.000Z', + provider: PROVIDER, + kind: 'agent_activity', + activityId: 'subagent:completed', + state: 'completed', + }]; + + expect(cancelRunningAgentActivities(activities, '2026-08-05T00:02:00.000Z')).toBe(activities); + }); +}); + describe('patchMergedStreamingMessage', () => { it('updates merged content without recomputing from store inputs', () => { const sessionId = 'web:s_test'; diff --git a/ui/src/stores/useSessionStore.ts b/ui/src/stores/useSessionStore.ts index d22fed544..c86726081 100644 --- a/ui/src/stores/useSessionStore.ts +++ b/ui/src/stores/useSessionStore.ts @@ -191,6 +191,25 @@ export interface SessionSlot { tokenUsage: unknown; } +export function cancelRunningAgentActivities( + activities: NormalizedMessage[], + endedAt: string, +): NormalizedMessage[] { + let changed = false; + const updated = activities.map((activity) => { + if (activity.kind !== 'agent_activity' || ['completed', 'failed', 'cancelled'].includes(String(activity.state || ''))) { + return activity; + } + changed = true; + return { + ...activity, + state: 'cancelled', + endedAt, + }; + }); + return changed ? updated : activities; +} + const EMPTY: NormalizedMessage[] = []; function createEmptySlot(): SessionSlot { @@ -1067,6 +1086,15 @@ export function useSessionStore() { notify(sessionId); }, [getSlot, notify]); + const cancelRunningActivities = useCallback((sessionId: string) => { + const slot = storeRef.current.get(sessionId); + if (!slot) return; + const activities = cancelRunningAgentActivities(slot.activityMessages, new Date().toISOString()); + if (activities === slot.activityMessages) return; + slot.activityMessages = activities; + notify(sessionId); + }, [notify]); + /** * Append multiple realtime messages at once (batch). */ @@ -1363,6 +1391,7 @@ export function useSessionStore() { appendRealtime, upsertActivity, setActivities, + cancelRunningActivities, appendRealtimeBatch, refreshFromServer, setActiveSession, @@ -1386,7 +1415,7 @@ export function useSessionStore() { finalizeSubagentDetailThinking, }), [ getSlot, has, fetchFromServer, fetchMore, - appendRealtime, upsertActivity, setActivities, appendRealtimeBatch, refreshFromServer, + appendRealtime, upsertActivity, setActivities, cancelRunningActivities, appendRealtimeBatch, refreshFromServer, setActiveSession, setStatus, isStale, updateStreaming, finalizeStreaming, updateStreamingThinking, finalizeStreamingThinking, clearRealtime, clearAssistantRealtime, getMessages, getActivityMessages, getSubagentDetailMessages, getSessionSlot, From dd6455534b7a5b6230de7f55e0758653c6ada850 Mon Sep 17 00:00:00 2001 From: gaopengju Date: Thu, 6 Aug 2026 14:55:38 +0800 Subject: [PATCH 5/6] fix(cron): handle sparse calendar dates --- src/cron/runtime/CronSchedule.ts | 35 +++++++++++++++++++ tests/cron/cron-editing.spec.ts | 9 +++++ .../main-content-v2/CronV2.test.tsx | 24 ++++++++++++- ui/src/components/main-content-v2/CronV2.tsx | 10 ++++++ .../main-content-v2/cronSchedule.spec.ts | 8 ++--- .../main-content-v2/cronSchedule.ts | 3 +- ui/src/i18n/locales/en/alwaysOn.json | 4 ++- ui/src/i18n/locales/zh-CN/alwaysOn.json | 4 ++- 8 files changed, 89 insertions(+), 8 deletions(-) diff --git a/src/cron/runtime/CronSchedule.ts b/src/cron/runtime/CronSchedule.ts index 9288ae5e7..95922514f 100644 --- a/src/cron/runtime/CronSchedule.ts +++ b/src/cron/runtime/CronSchedule.ts @@ -43,6 +43,9 @@ export function computeNextCronRunAt( if (!parsed || !isValidCronTimezone(timezone)) return undefined; const formatter = createCronDateFormatter(timezone); let candidate = new Date(Math.floor(after.getTime() / MINUTE_MS) * MINUTE_MS + MINUTE_MS); + if (isLeapDayOnlySchedule(parsed)) { + return computeNextLeapDayRunAt(candidate, parsed, formatter); + } for (let index = 0; index < MAX_SEARCH_MINUTES; index += 1) { if (matchesCron(candidate, parsed, formatter)) { return candidate; @@ -52,6 +55,38 @@ export function computeNextCronRunAt( return undefined; } +function isLeapDayOnlySchedule(cron: ParsedCron): boolean { + return cron.daysOfMonth.size === 1 + && cron.daysOfMonth.has(29) + && cron.months.size === 1 + && cron.months.has(2) + && cron.daysOfWeek.size === 7; +} + +function computeNextLeapDayRunAt( + after: Date, + cron: ParsedCron, + formatter: Intl.DateTimeFormat, +): Date | undefined { + const startYear = after.getUTCFullYear(); + for (let year = startYear; year <= startYear + 8; year += 1) { + if (!isLeapYear(year)) continue; + let candidate = new Date(Date.UTC(year, 1, 28)); + const end = Date.UTC(year, 2, 2); + while (candidate.getTime() < end) { + if (candidate.getTime() >= after.getTime() && matchesCron(candidate, cron, formatter)) { + return candidate; + } + candidate = new Date(candidate.getTime() + MINUTE_MS); + } + } + return undefined; +} + +function isLeapYear(year: number): boolean { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); +} + type ParsedCron = { minutes: Set; hours: Set; diff --git a/tests/cron/cron-editing.spec.ts b/tests/cron/cron-editing.spec.ts index e893d30ff..8ac08534a 100644 --- a/tests/cron/cron-editing.spec.ts +++ b/tests/cron/cron-editing.spec.ts @@ -7,6 +7,7 @@ import test from "node:test"; import { createCronRuntime, defaultCronConfig } from "../../src/cron/index.js"; import type { CronTask, CronUpdateInput } from "../../src/cron/protocol/types.js"; import { CronFire } from "../../src/cron/runtime/CronFire.js"; +import { computeNextCronRunAt } from "../../src/cron/runtime/CronSchedule.js"; import { CronScheduler } from "../../src/cron/runtime/CronScheduler.js"; import { resolveCronPaths } from "../../src/cron/storage/CronPaths.js"; import { CronTaskStore } from "../../src/cron/storage/CronTaskStore.js"; @@ -107,6 +108,13 @@ test("CronTaskStore normalizes legacy tasks without a revision to zero", async ( } }); +test("CronSchedule finds the next February 29 in the task timezone", () => { + assert.equal( + computeNextCronRunAt("30 8 29 2 *", new Date("2026-01-01T00:00:00.000Z"), "Asia/Shanghai")?.toISOString(), + "2028-02-29T00:30:00.000Z", + ); +}); + test("CronRuntime updates a task in place and rejects running or stale updates", async () => { const pilotHome = mkdtempSync(join(tmpdir(), "pilotdeck-cron-editing-")); const projectKey = "/tmp/projects/cron-editing"; @@ -234,6 +242,7 @@ test("CronRuntime updates daily, weekly, monthly, yearly, and one-time schedules { schedule: { type: "cron", expression: "30 8 * * 1", timezone: "UTC" }, nextRunAt: "2026-01-05T08:30:00.000Z" }, { schedule: { type: "cron", expression: "30 8 15 * *", timezone: "UTC" }, nextRunAt: "2026-01-15T08:30:00.000Z" }, { schedule: { type: "cron", expression: "30 8 15 9 *", timezone: "UTC" }, nextRunAt: "2026-09-15T08:30:00.000Z" }, + { schedule: { type: "cron", expression: "30 8 29 2 *", timezone: "UTC" }, nextRunAt: "2028-02-29T08:30:00.000Z" }, { schedule: { type: "once", runAt: "2026-01-02T12:00:00.000Z" }, nextRunAt: "2026-01-02T12:00:00.000Z" }, ]; diff --git a/ui/src/components/main-content-v2/CronV2.test.tsx b/ui/src/components/main-content-v2/CronV2.test.tsx index 64fecdaaf..a55cc1aa2 100644 --- a/ui/src/components/main-content-v2/CronV2.test.tsx +++ b/ui/src/components/main-content-v2/CronV2.test.tsx @@ -238,6 +238,28 @@ describe('CronV2', () => { }); }); + it('shows recurrence notices for monthly short dates and leap day', async () => { + setup([]); + + fireEvent.click(screen.getByRole('button', { name: 'Create Task' })); + await screen.findByText('Create Cron Task'); + fireEvent.click(screen.getByRole('button', { name: 'Recurring' })); + fireEvent.change(screen.getByLabelText('Repeat period'), { target: { value: 'monthly' } }); + + for (const day of ['29', '30', '31']) { + fireEvent.change(screen.getByLabelText('Day of month'), { target: { value: day } }); + expect(screen.getByText('Months without this date will not trigger the task.')).toBeTruthy(); + } + fireEvent.change(screen.getByLabelText('Day of month'), { target: { value: '28' } }); + expect(screen.queryByText('Months without this date will not trigger the task.')).toBeNull(); + + fireEvent.change(screen.getByLabelText('Repeat period'), { target: { value: 'yearly' } }); + fireEvent.change(screen.getByLabelText('Month'), { target: { value: '2' } }); + expect((screen.getByLabelText('Day of month') as HTMLSelectElement).options).toHaveLength(29); + fireEvent.change(screen.getByLabelText('Day of month'), { target: { value: '29' } }); + expect(screen.getByText('This task will only trigger in leap years.')).toBeTruthy(); + }); + it('clamps the yearly day when the selected month is shorter', async () => { setup([]); @@ -254,7 +276,7 @@ describe('CronV2', () => { expect((screen.getByLabelText('Day of month') as HTMLSelectElement).value).toBe('30'); fireEvent.change(screen.getByLabelText('Month'), { target: { value: '2' } }); - expect((screen.getByLabelText('Day of month') as HTMLSelectElement).options).toHaveLength(28); + expect((screen.getByLabelText('Day of month') as HTMLSelectElement).options).toHaveLength(29); }); it('updates the cron expression when the standard fields change', async () => { diff --git a/ui/src/components/main-content-v2/CronV2.tsx b/ui/src/components/main-content-v2/CronV2.tsx index 7dd03e32a..66c13665f 100644 --- a/ui/src/components/main-content-v2/CronV2.tsx +++ b/ui/src/components/main-content-v2/CronV2.tsx @@ -641,6 +641,11 @@ function CronCreateView({ const scheduleDescriptionWithTimezone = timezone.trim() ? `${scheduleDescription} (${timezone.trim()})` : scheduleDescription; + const scheduleNotice = recurrenceMode === 'monthly' && dayOfMonth >= 29 + ? t('cron.create.help.monthlyDateMayBeSkipped', { defaultValue: 'Months without this date will not trigger the task.' }) + : recurrenceMode === 'yearly' && monthOfYear === 2 && dayOfMonth === 29 + ? t('cron.create.help.yearlyLeapDay', { defaultValue: 'This task will only trigger in leap years.' }) + : null; const resetForm = () => { const defaults = getDefaultFormValues(); @@ -1063,6 +1068,11 @@ function CronCreateView({ {' '} {scheduleDescriptionWithTimezone}

+ {scheduleNotice ? ( +

+ {scheduleNotice} +

+ ) : null}