diff --git a/docs/adr/0002-in-process-runtime-host-with-quarantined-cold-start.md b/docs/adr/0002-in-process-runtime-host-with-quarantined-cold-start.md index a56bd743b..d10374dd1 100644 --- a/docs/adr/0002-in-process-runtime-host-with-quarantined-cold-start.md +++ b/docs/adr/0002-in-process-runtime-host-with-quarantined-cold-start.md @@ -24,3 +24,54 @@ session is an explicit `resumeSession` with a cursor, not an automatic continuat Execution state that is not persisted — stream buffers, subscriptions, pending permission and questionnaire requests — belongs to the owner process and cannot be recovered from disk. + +## Amendment: the resident service owns the scheduled-task schedule + +The WebUI is a resident local service, not a surface that runs on demand. The +published `mcode-webui` bin assembles the host, prints a URL, and blocks until +SIGINT or SIGTERM; the browser page is a client that attaches to it. The +scheduled-task panel is therefore a control surface over a store the service owns, +and the service runs the schedule for as long as it is up. + +**Unchanged.** `startupExecutionPolicy` stays `'quarantined'` and +`runtimeOwnerKind` stays `'tui'`. Neither is widened; the new capability arrives as +a separate host option rather than as a reclassification of the owner. + +**What the option does.** The host takes `enableScheduledTasks`. When set, the +process composes the in-process scheduler and the cron service, and the scheduler +starts with `restorePersistedJobExecution: true`, so a restart re-arms the timers +for definitions already in the store. The timers are in-process: nothing is handed +to an operating-system scheduler, because these tasks are harness business and +have to stay governable from inside it. With the process down, a schedule that +comes due simply does not fire. + +Re-arming at startup is the difference between a resident service and a page that +happens to run something. A schedule created from the desktop or the CLI has to +keep firing across a WebUI restart; otherwise every restart silently retires every +task, with nothing in the logs to say so. + +**The split, stated precisely.** Two mechanisms get conflated while this is being +designed, and the amendment is clearer for separating them: + +- *Restoring the schedule* — re-arming timers for stored definitions. Enabled here, + per capability. +- *Recovering in-flight work* — picking up runs that fired but did not finish. + Governed by `recoverPersistedRuns`, and **not** changed by this amendment. + +**Sessions and turns are untouched.** A restart still marks a running turn +`interrupted`; reopening a session is still an explicit `resumeSession` with a +cursor. Restoring a schedule is not resuming anybody's unfinished work. + +**Re-opened on purpose.** The second rejected option above turned on "a WebUI +restart and the terminal client would both attempt to resume the same persisted +jobs". That hazard is now accepted for scheduled tasks specifically: the data +directory is shared, so the WebUI service and the terminal client can both hold the +scheduler for one agent and fire the same task twice. The busy-queue bounds overlap +within one process; it does not arbitrate across two. The panel states which side is +executing rather than implying the WebUI owns execution exclusively. + +**The predicate is not shared.** The WebUI reaches the cron service through a +cron-specific ownership check, deliberately not by widening the existing +Electron-capability predicate: that predicate also gates channel delivery, and +widening it would hand a local web service the IM adapters it was never meant to +own. diff --git a/docs/webui/webui-v1-scope.md b/docs/webui/webui-v1-scope.md index de42ae0ff..39ac47968 100644 --- a/docs/webui/webui-v1-scope.md +++ b/docs/webui/webui-v1-scope.md @@ -22,7 +22,7 @@ assembly it needs. The reasoning behind the decisions lives in [`../adr`](adr/). ## Out of scope for the first version -- Account, provider, plugin, cron and update panels. States that need them are +- Account, provider, plugin and update panels. States that need them are reported as messages, not as configuration interfaces. - Terminal rendering, terminal image preview, check-in - Remote or LAN access — see @@ -32,6 +32,12 @@ assembly it needs. The reasoning behind the decisions lives in [`../adr`](adr/). - Automatic resume of persisted jobs at cold start — see [ADR 0002](../adr/0002-in-process-runtime-host-with-quarantined-cold-start.md) +The scheduled-task (`定时`) panel is a later addition to this list. The WebUI is a +resident local service, so it owns the schedule for the life of the process and +re-arms its timers on start. ADR 0002 carries an amendment describing what that +does and does not change, including the two mechanisms it is easy to conflate: +restoring a schedule is not recovering in-flight work. + ## Behaviour boundaries - Shared history is readable, but live execution belongs to the runtime owner that diff --git a/packages/local-runtime-v2/src/local/host-contract.ts b/packages/local-runtime-v2/src/local/host-contract.ts index b5b768139..cc6d34020 100644 --- a/packages/local-runtime-v2/src/local/host-contract.ts +++ b/packages/local-runtime-v2/src/local/host-contract.ts @@ -72,6 +72,15 @@ interface CreateLocalRuntimeHostOptions extends V1CreateLocalRuntimeHostOptions interface CreatedLocalRuntimeHost extends V1CreatedLocalRuntimeHost { application?: LocalRuntimeApplication; cliService?: import('./cli-service.js').CliService; + /** + * The scheduled-task capability, published on its own rather than as the whole + * `RuntimeServices` graph. A host that manages schedules needs exactly this one + * service; handing over `services` instead would also hand it `channelSystem`, + * `browserUse` and the session/turn owners, which is how a surface ends up + * owning capabilities it was never granted. Absent when this host owns no + * scheduler, so every consumer can fail closed on `undefined`. + */ + scheduledTasks?: import('../services.js').RuntimeServices['cron']; } export type { diff --git a/packages/local-runtime-v2/src/runtime.ts b/packages/local-runtime-v2/src/runtime.ts index f9ecfd5b2..6ab939dec 100644 --- a/packages/local-runtime-v2/src/runtime.ts +++ b/packages/local-runtime-v2/src/runtime.ts @@ -15,6 +15,7 @@ import { createBackgroundRuntime, type BackgroundRuntime, } from "./background-runtime.js"; +import { resolveScheduledTaskScheduling } from "./service/cron/ownership.js"; import { cleanupFailedV1Startup, createDeferredAgentRuntimeTelemetry, @@ -446,6 +447,11 @@ function createStartedHost( ...v1, ...(ownerRuntime ? { application: ownerRuntime.services.application } : {}), ...(cliService ? { cliService } : {}), + // Only the scheduled-task capability leaves this function, never the whole + // `services` graph -- see `CreatedLocalRuntimeHost.scheduledTasks`. + ...(ownerRuntime?.services.cron + ? { scheduledTasks: ownerRuntime.services.cron } + : {}), apiHost: v1.apiHost, ready, ...(ownerRuntime @@ -812,6 +818,21 @@ function createBrowserUseServiceOptions( }; } +/** + * v2 host option for scheduled tasks. `CreateLocalRuntimeHostOptions` lives in + * the host contract, so the optional field is read through a narrow widening + * here; hosts forward `enableScheduledTasks` at the factory boundary. + */ +function requestsScheduledTasks(options: CreateLocalRuntimeHostOptions): boolean { + return ( + ( + options as CreateLocalRuntimeHostOptions & { + readonly enableScheduledTasks?: boolean; + } + ).enableScheduledTasks === true + ); +} + async function initializeOwnerRuntime(input: { readonly v1: V1CreatedLocalRuntimeHost; readonly compatibility: V1RuntimeCompatibility; @@ -841,14 +862,22 @@ async function initializeOwnerRuntime(input: { options.startupExecutionPolicy, ); const electronOwner = options.runtimeOwnerKind === "electron"; + // Scheduled tasks stay process-local: an opted-in resident host owns the + // Scheduler and cron services without inheriting any Electron-only surface. + const scheduledTaskOwner = requestsScheduledTasks(options); + const scheduling = resolveScheduledTaskScheduling({ + electronOwner, + startupExecutionEnabled, + enableScheduledTasks: scheduledTaskOwner, + }); background = await createBackgroundRuntime({ db: database.db, dataDir: v1.dataDir, logger, metrics: v1.metricsClient, ...(options.nowMs ? { nowMs: options.nowMs } : {}), - restorePersistedJobExecution: startupExecutionEnabled, - enableScheduler: electronOwner, + restorePersistedJobExecution: scheduling.restorePersistedJobExecution, + enableScheduler: scheduling.schedulerOwned, }); services = await createRuntimeServices({ db: database.db, @@ -871,6 +900,7 @@ async function initializeOwnerRuntime(input: { recoverPersistedState: startupExecutionEnabled, greetingEnabled: electronOwner && startupExecutionEnabled, runtimeOwnerKind: options.runtimeOwnerKind, + enableScheduledTasks: scheduledTaskOwner, browserUse: createBrowserUseServiceOptions(options), ...(options.promptConfigKey ? { promptConfigKey: options.promptConfigKey } diff --git a/packages/local-runtime-v2/src/service/cron/ownership.ts b/packages/local-runtime-v2/src/service/cron/ownership.ts new file mode 100644 index 000000000..e986ff873 --- /dev/null +++ b/packages/local-runtime-v2/src/service/cron/ownership.ts @@ -0,0 +1,56 @@ +import { ownsElectronRuntimeCapabilities } from "../../application/agent/runtime-browser-use-composition.js"; + +/** Ownership inputs for the process-local scheduling capability. */ +export interface ScheduledTaskRuntimeOwnership { + readonly runtimeOwnerKind?: string; + /** Frozen host option: `true` requests an owned Scheduler plus `services.cron`. */ + readonly enableScheduledTasks?: boolean; +} + +/** + * Cron-specific ownership predicate. Electron owners, and resident hosts that + * explicitly opt in with `enableScheduledTasks`, own the in-process Scheduler + * and the Cron service. Every other host keeps the previous behavior. + * + * Kept separate from the `enableChannel` predicate on purpose: that call site + * shares `ownsElectronRuntimeCapabilities` with cron today, and widening it + * would open IM channel delivery as a side effect. This predicate layers the + * opt-in on top of the unchanged Electron rule instead of relaxing it. + */ +export function ownsScheduledTaskRuntime(options: ScheduledTaskRuntimeOwnership): boolean { + return ( + ownsElectronRuntimeCapabilities(options.runtimeOwnerKind) || + options.enableScheduledTasks === true + ); +} + +/** Scheduling inputs resolved once during host assembly. */ +export interface ScheduledTaskSchedulingInput { + readonly electronOwner: boolean; + /** Startup execution policy decides persisted execution for existing owners. */ + readonly startupExecutionEnabled: boolean; + readonly enableScheduledTasks?: boolean; +} + +export interface ScheduledTaskScheduling { + /** Assemble the in-process Scheduler; false omits it entirely. */ + readonly schedulerOwned: boolean; + /** Arm croner timers for persisted jobs instead of refreshing them for inspection. */ + readonly restorePersistedJobExecution: boolean; +} + +/** + * Resolves background-runtime scheduling for one host. A host that opted into + * scheduled tasks always restores persisted job execution, because a resident + * host exists to run its schedule; every other host keeps the value derived + * from the startup execution policy. + */ +export function resolveScheduledTaskScheduling( + input: ScheduledTaskSchedulingInput, +): ScheduledTaskScheduling { + const scheduledTaskOwner = input.enableScheduledTasks === true; + return { + schedulerOwned: input.electronOwner || scheduledTaskOwner, + restorePersistedJobExecution: scheduledTaskOwner || input.startupExecutionEnabled, + }; +} diff --git a/packages/local-runtime-v2/src/services.test.ts b/packages/local-runtime-v2/src/services.test.ts index f503f3028..7fd3f9384 100644 --- a/packages/local-runtime-v2/src/services.test.ts +++ b/packages/local-runtime-v2/src/services.test.ts @@ -23,7 +23,9 @@ import type { AppDb } from "./infra/db/client.js"; import { readPreferenceValue } from "./infra/db/preference-values.js"; import { migratePluginTestDatabase } from "../test/helpers/plugin-database.js"; import { EventBus } from "./infra/event-bus/index.js"; -import type { SchedulerClient } from "./infra/scheduler/index.js"; +import { createBackgroundRuntime } from "./background-runtime.js"; +import { Scheduler, type SchedulerClient } from "./infra/scheduler/index.js"; +import { resolveScheduledTaskScheduling } from "./service/cron/ownership.js"; import type { LocalAgentService } from "./service/agent/index.js"; import { resolveAgentPromptSurface } from "./service/turn-system/agent-host/preparation/agent-prompt-surface.js"; import type { InitializeTurnSystemOptions } from "./service/turn-system/index.js"; @@ -2056,6 +2058,124 @@ describe("runtime services CLI composition", () => { expect(mocked.events.at(-1)).toBe("agent:ensure"); }); + it("composes the Cron service for a resident host that opts into scheduled tasks", async () => { + const scheduler = {} as SchedulerClient; + const services = await createRuntimeServices({ + db: {} as AppDb, + dataDir: "/data/scheduled-tasks", + logger: noopLogger, + scheduler, + eventBus: new EventBus(), + compatibility: defaultCompatibility(), + agentService: localAgentService, + runtimeOwnerKind: "tui", + capabilityProfile: "cli", + enableScheduledTasks: true, + }); + + expect(services.cron).toBe(mocked.service); + expect(mocked.cronOptions?.scheduler).toBe(scheduler); + // The opt-in is Cron-scoped: channel delivery keeps following its own owner rule. + expect(services.channelSystem).toBeUndefined(); + expect(services.cronDelivery).toBeDefined(); + await services.close(); + }); + + it("leaves a resident host unchanged when the scheduled-task option is absent or false", async () => { + const resident = await createRuntimeServices({ + db: {} as AppDb, + dataDir: "/data/resident-without-opt-in", + logger: noopLogger, + scheduler: {} as SchedulerClient, + eventBus: new EventBus(), + compatibility: defaultCompatibility(), + agentService: localAgentService, + runtimeOwnerKind: "tui", + capabilityProfile: "cli", + }); + expect(resident.cron).toBeUndefined(); + expect(resident.cronDelivery).toBeUndefined(); + expect(resident.channelSystem).toBeUndefined(); + expect(mocked.cronOptions).toBeUndefined(); + await resident.close(); + + mocked.cronOptions = undefined; + const explicitlyDisabled = await createRuntimeServices({ + db: {} as AppDb, + dataDir: "/data/resident-opt-out", + logger: noopLogger, + scheduler: {} as SchedulerClient, + eventBus: new EventBus(), + compatibility: defaultCompatibility(), + agentService: localAgentService, + runtimeOwnerKind: "tui", + capabilityProfile: "cli", + enableScheduledTasks: false, + }); + expect(explicitlyDisabled.cron).toBeUndefined(); + expect(explicitlyDisabled.channelSystem).toBeUndefined(); + expect(mocked.cronOptions).toBeUndefined(); + await explicitlyDisabled.close(); + + // Electron owners keep their pre-existing Cron capability without the option. + mocked.cronOptions = undefined; + const electron = await createRuntimeServices({ + db: {} as AppDb, + dataDir: "/data/electron-default", + logger: noopLogger, + scheduler: {} as SchedulerClient, + eventBus: new EventBus(), + compatibility: defaultCompatibility(), + agentService: localAgentService, + runtimeOwnerKind: "electron", + }); + expect(electron.cron).toBe(mocked.service); + await electron.close(); + }); + + it("starts the Scheduler with persisted job execution for an opted-in host", async () => { + const scheduling = resolveScheduledTaskScheduling({ + electronOwner: false, + startupExecutionEnabled: false, + enableScheduledTasks: true, + }); + expect(scheduling).toEqual({ + schedulerOwned: true, + restorePersistedJobExecution: true, + }); + + const start = vi + .spyOn(Scheduler.prototype, "start") + .mockImplementation(() => undefined); + try { + const background = await createBackgroundRuntime({ + db: {} as AppDb, + enableScheduler: scheduling.schedulerOwned, + restorePersistedJobExecution: scheduling.restorePersistedJobExecution, + }); + await background.start(); + + expect(start).toHaveBeenCalledWith({ restorePersistedJobExecution: true }); + await background.close(); + } finally { + start.mockRestore(); + } + + // Without the opt-in the resident host keeps the policy-derived value. + expect( + resolveScheduledTaskScheduling({ + electronOwner: false, + startupExecutionEnabled: false, + }), + ).toEqual({ schedulerOwned: false, restorePersistedJobExecution: false }); + expect( + resolveScheduledTaskScheduling({ + electronOwner: true, + startupExecutionEnabled: false, + }), + ).toEqual({ schedulerOwned: true, restorePersistedJobExecution: false }); + }); + /** * The Inspector is composed on a test build, so composition must tolerate a * product that carries no model resolver at all. Reading `.fetchImpl` off an diff --git a/packages/local-runtime-v2/src/services.ts b/packages/local-runtime-v2/src/services.ts index 8b62bbb32..14a589695 100644 --- a/packages/local-runtime-v2/src/services.ts +++ b/packages/local-runtime-v2/src/services.ts @@ -125,6 +125,7 @@ import { type CronTurnDeliveryPort, type InitializedCronService, } from "./service/cron/index.js"; +import { ownsScheduledTaskRuntime } from "./service/cron/ownership.js"; import { createRuntimeInspector, type ComposedInspector, @@ -307,6 +308,8 @@ export interface CreateRuntimeServicesOptions readonly recoverPersistedState?: boolean; /** Composition owner mode; CLI omits Electron-only capabilities. */ readonly runtimeOwnerKind?: string; + /** v2 host option: request ownership of scheduling (in-process timers + Cron service assembly). */ + readonly enableScheduledTasks?: boolean; /** Electron-owned fixed key for encrypted Desktop Prompt bundles. */ readonly promptConfigKey?: Uint8Array; /** Client capability ceiling; omitted owners retain the shared legacy surface. */ @@ -490,7 +493,10 @@ export async function createRuntimeServices( internalTurnPromptReads, writeGlobalEvent, nowMs, - enableCron: ownsElectronRuntimeCapabilities(options.runtimeOwnerKind), + enableCron: ownsScheduledTaskRuntime({ + runtimeOwnerKind: options.runtimeOwnerKind, + enableScheduledTasks: options.enableScheduledTasks, + }), runtimeOwnerIdentity: options.runtimeOwnerIdentity, planEntryEnabled, agentPlanEntryEnabled, diff --git a/packages/webui/src/client/components/CronChatCreateFlow.tsx b/packages/webui/src/client/components/CronChatCreateFlow.tsx new file mode 100644 index 000000000..b18c5c337 --- /dev/null +++ b/packages/webui/src/client/components/CronChatCreateFlow.tsx @@ -0,0 +1,137 @@ +// 定时任务 — 在对话中创建. +// +// The second creation path. Instead of filling the form first, the user gets a +// real conversation: the panel opens a session, seeds it with one guidance +// message, and the user describes the task in their own words. The closing +// action is the user's own click on 「完成并创建」 — this version is WebUI- +// orchestrated, it does not parse the transcript into a schedule. +// +// The created task targets that conversation (`sessionTarget: { mode: +// "sessionId" }`), so the run continues where the user already described it +// rather than starting cold in a new session. + +import type { ReactElement } from "react"; +import type { WebuiCreateCronDefinitionRequest } from "../contracts.js"; +import { + WebuiCronCreateDialog, + createCronRequestFromDraft, + type WebuiCronDraft, +} from "./CronCreateDialog.js"; + +/** The one message the flow seeds the new conversation with. */ +export const CHAT_CREATE_GUIDE_PROMPT = + "我想创建一个定时任务。请帮我把它说清楚:\n" + + "1. 这个 Agent 定期要做什么(例如「汇总昨天的提交并写进 CHANGELOG.md」);\n" + + "2. 多久执行一次(每天几点 / 每小时 / 每周几);\n" + + "3. 需要在哪个项目目录里执行。\n" + + "说完之后回到「定时」页面,点「完成并创建」把它保存成定时任务。"; + +/** The frozen create request for the conversational path: same fields as the + * manual dialog, but the session the user just talked in is the target. A + * session is required here — unlike 始终使用同一对话, this path always knows + * which conversation it means. */ +export function buildChatCronRequest( + draft: WebuiCronDraft, + sessionId: string, +): WebuiCreateCronDefinitionRequest | undefined { + const trimmed = sessionId.trim(); + if (!trimmed) return undefined; + return createCronRequestFromDraft({ ...draft, sessionMode: "sessionId", sessionId: trimmed }); +} + +export interface WebuiCronChatCreateFlowProps { + /** The conversation opened for this task, or undefined before the user + * starts one. Lifted by the panel so it survives the view switch into the + * conversation and back. */ + readonly sessionId?: string; + readonly draft: WebuiCronDraft; + readonly onDraftChange: (next: WebuiCronDraft) => void; + readonly agents?: Parameters[0]["agents"]; + readonly models?: Parameters[0]["models"]; + readonly projects?: Parameters[0]["projects"]; + readonly busy?: boolean; + readonly onStart: () => void; + readonly onSubmit: () => void; + readonly onClose: () => void; +} + +export function WebuiCronChatCreateFlow({ + sessionId, + draft, + onDraftChange, + agents, + models, + projects, + busy = false, + onStart, + onSubmit, + onClose, +}: WebuiCronChatCreateFlowProps): ReactElement { + if (!sessionId) { + return ( +
+

在对话中创建

+

+ 先开一个对话,把定时任务说清楚:我会新创建一个会话并把引导语发进去, + 你在对话里描述想让 Agent 定期做什么、多久执行一次, + 说完回到这里点「完成并创建」保存。 +

+
+ + +
+
+ ); + } + + return ( +
+
+

在对话中创建

+

+ 已在会话 {sessionId}{" "} + 里发起了引导。请在对话中把任务说清楚,然后回到这里补全下面的字段并点「完成并创建」; + 这个任务会继续使用该会话。 +

+
+ {CHAT_CREATE_GUIDE_PROMPT} +
+
+ +
+ ); +} + +export default WebuiCronChatCreateFlow; diff --git a/packages/webui/src/client/components/CronCreateDialog.tsx b/packages/webui/src/client/components/CronCreateDialog.tsx new file mode 100644 index 000000000..9dc96e4a8 --- /dev/null +++ b/packages/webui/src/client/components/CronCreateDialog.tsx @@ -0,0 +1,706 @@ +// 定时任务 — the desktop's create / edit dialog, field for field. +// +// `D:\temp\mmx-webui-cron\CONTRACT.md` §6 freezes the mapping from the desktop +// controls to the v2 wire fields; §3 forbids handing the user a raw cron +// expression to type. So the schedule is a structured control (周期 + 时间) that +// *produces* `WebuiCronSchedule`, and this module owns that translation in both +// directions so the panel and the tests share one implementation. +// +// The component is controlled and presentational: it owns no transport and no +// effects, so the panel owns the wire and the shell's SSR tests can render the +// real form without a DOM. + +import type { ReactElement } from "react"; +import type { + WebuiAgentRef, + WebuiCreateCronDefinitionRequest, + WebuiCronDefinition, + WebuiCronSchedule, + WebuiCronSessionTarget, + WebuiUpdateCronDefinitionRequest, +} from "../contracts.js"; +import type { WebuiModelEntry } from "../../server/port.js"; + +/** Desktop limits (CONTRACT §6): 名称 n/50, 指令 n/8000. */ +export const CRON_NAME_LIMIT = 50; +export const CRON_PROMPT_LIMIT = 8000; + +/** The runtime's own default agent, as the rest of the WebUI resolves it + * (`commands/runner.ts`, `WebuiClientFoundationApp`'s `selectedAgentName`). */ +export const DEFAULT_AGENT_NAME = "main"; + +/** The desktop's four periods. There is no `once` entry: a one-shot task is + * something the runtime can already hold, not something this form creates, so + * editing one shows it read-only instead (see `onceAtMs`). */ +export type WebuiCronPeriod = "minutes" | "hours" | "daily" | "weekly"; + +export const CRON_PERIOD_OPTIONS: readonly { readonly value: WebuiCronPeriod; readonly label: string }[] = [ + { value: "minutes", label: "每 {N} 分钟" }, + { value: "hours", label: "每 {N} 小时" }, + { value: "daily", label: "每天" }, + { value: "weekly", label: "每周" }, +]; + +/** The interval sub-selectors' value sets. Each period shows only the ones its + * expression needs: `每 N 分钟` takes one, `每 N 小时` takes two, 每天 and 每周 + * take a clock time instead. */ +export const CRON_MINUTE_INTERVALS: readonly number[] = [1, 2, 5, 10, 15, 20, 30, 45]; +export const CRON_HOUR_INTERVALS: readonly number[] = [1, 2, 3, 4, 6, 8, 12]; +export const CRON_HOUR_MINUTES: readonly number[] = [0, 5, 10, 15, 20, 30, 45]; + +/** cron day-of-week, 0 = 周日. */ +export const CRON_WEEKDAYS: readonly { readonly value: number; readonly label: string }[] = [ + { value: 1, label: "周一" }, + { value: 2, label: "周二" }, + { value: 3, label: "周三" }, + { value: 4, label: "周四" }, + { value: 5, label: "周五" }, + { value: 6, label: "周六" }, + { value: 0, label: "周日" }, +]; + +export interface WebuiCronDraft { + readonly name: string; + readonly agentName: string; + readonly prompt: string; + /** `new` → 每次新建对话;`sessionId` → 始终使用同一对话。 */ + readonly sessionMode: "new" | "sessionId"; + /** The conversation 始终使用同一对话 targets. Empty means "let the server + * bind it", which the contract allows by leaving `sessionId` off. */ + readonly sessionId: string; + /** Workspace directory, or "" for 不需要项目. */ + readonly project: string; + /** Model id, or "" for 使用当前模型. */ + readonly model: string; + readonly period: WebuiCronPeriod; + /** N in `每 N 分钟`. */ + readonly intervalMinutes: number; + /** N in `每 N 小时`. */ + readonly intervalHours: number; + /** M in `每 N 小时`'s `M` + step-`N` hour field. */ + readonly minuteOfHour: number; + /** 0 = 周日 … 6 = 周六. */ + readonly weekday: number; + /** `HH:MM`, 24-hour, for 每天 and 每周. */ + readonly time: string; + /** Set only when editing an existing `kind: "once"` task. The schedule is + * then read-only and the update request omits `schedule` entirely. */ + readonly onceAtMs?: number; + /** An expression this module did not produce, kept verbatim so editing a + * task the desktop created cannot quietly rewrite its schedule. */ + readonly rawExpression: string; +} + +const pad2 = (value: number): string => String(value).padStart(2, "0"); + +export function emptyCronDraft(agentName: string = DEFAULT_AGENT_NAME): WebuiCronDraft { + return { + name: "", + agentName, + prompt: "", + sessionMode: "new", + sessionId: "", + project: "", + model: "", + period: "daily", + intervalMinutes: 5, + intervalHours: 2, + minuteOfHour: 0, + weekday: 1, + time: "09:00", + rawExpression: "", + }; +} + +/** 「默认」 entry: the runtime's default agent when `listAgents` has not + * answered yet, otherwise the listed agent of that name. */ +export function resolveDefaultAgentName(agents: readonly WebuiAgentRef[]): string { + return agents.some((agent) => agent.agentName === DEFAULT_AGENT_NAME) + ? DEFAULT_AGENT_NAME + : agents[0]?.agentName ?? DEFAULT_AGENT_NAME; +} + +/** The wire is untyped at runtime: `listAgents` answers a bare + * `WebuiAgentRef[]`, and one entry that is not an agent ref must not empty the + * whole Agent dropdown. */ +export function readAgentRefs(value: unknown): readonly WebuiAgentRef[] { + if (!Array.isArray(value)) return []; + return value.filter( + (item): item is WebuiAgentRef => + typeof item === "object" && item !== null && typeof (item as WebuiAgentRef).agentName === "string", + ); +} + +const TIME_PATTERN = /^(\d{1,2}):(\d{2})$/u; + +const inRange = (value: number, min: number, max: number): boolean => + Number.isInteger(value) && value >= min && value <= max; + +/** Structured control → `WebuiCronSchedule`, per the desktop's four periods: + * `每 N 分钟` puts a step-N field in the minute column, `每 N 小时` pairs a + * minute with a step-N hour column, 每天 is `M H * * *`, and 每周 appends the + * weekday: `M H * * D`. Undefined means "not fillable yet", which is what keeps + * 确认 disabled rather than raising. */ +export function buildCronSchedule(draft: WebuiCronDraft): WebuiCronSchedule | undefined { + if (draft.onceAtMs !== undefined) return { kind: "once", runAtMs: draft.onceAtMs }; + if (draft.rawExpression.trim()) return { kind: "recurring", expression: draft.rawExpression.trim() }; + if (draft.period === "minutes") { + return inRange(draft.intervalMinutes, 1, 59) + ? { kind: "recurring", expression: `*/${draft.intervalMinutes} * * * *` } + : undefined; + } + if (draft.period === "hours") { + if (!inRange(draft.intervalHours, 1, 23) || !inRange(draft.minuteOfHour, 0, 59)) return undefined; + return { kind: "recurring", expression: `${draft.minuteOfHour} */${draft.intervalHours} * * *` }; + } + const time = TIME_PATTERN.exec(draft.time.trim()); + if (!time) return undefined; + const hour = Number(time[1]); + const minute = Number(time[2]); + if (!inRange(hour, 0, 23) || !inRange(minute, 0, 59)) return undefined; + const clock = `${minute} ${hour}`; + if (draft.period === "weekly") { + return inRange(draft.weekday, 0, 6) + ? { kind: "recurring", expression: `${clock} * * ${draft.weekday}` } + : undefined; + } + return { kind: "recurring", expression: `${clock} * * *` }; +} + +/** `WebuiCronSchedule` → the control's fields. A `once` schedule comes back as + * `onceAtMs` (read-only), and an expression this module cannot decompose comes + * back as `rawExpression`, so neither can be silently rewritten into a daily + * one by opening the editor. */ +export function scheduleFields(schedule: WebuiCronSchedule): { + period: WebuiCronPeriod; + intervalMinutes: number; + intervalHours: number; + minuteOfHour: number; + weekday: number; + time: string; + onceAtMs?: number; + rawExpression: string; +} { + const fallback = { rawExpression: "" }; + if (schedule.kind === "once") { + // There is no `once` period in the control, so the draft is marked instead. + return { ...fallback, period: "daily", intervalMinutes: 5, intervalHours: 2, minuteOfHour: 0, weekday: 1, time: "09:00", onceAtMs: schedule.runAtMs }; + } + const undecodable = (): ReturnType => ({ + period: "daily", + intervalMinutes: 5, + intervalHours: 2, + minuteOfHour: 0, + weekday: 1, + time: "09:00", + rawExpression: schedule.expression, + }); + const fields = schedule.expression.trim().split(/\s+/u); + if (fields.length !== 5) return undecodable(); + const base = { ...fallback, period: "daily" as WebuiCronPeriod, intervalMinutes: 5, intervalHours: 2, minuteOfHour: 0, weekday: 1, time: "09:00" }; + + // A step-N minute column with every other column wildcard → 每 N 分钟. + const step = /^\*\/(\d{1,2})$/u.exec(fields[0]); + if (step && inRange(Number(step[1]), 1, 59) && fields.slice(1).every((field) => field === "*")) + return { ...base, period: "minutes", intervalMinutes: Number(step[1]) }; + + // A minute plus a step-N hour column → 每 N 小时. + if (fields[2] === "*" && fields[3] === "*" && fields[4] === "*") { + const hourStep = /^\*\/(\d{1,2})$/u.exec(fields[1]); + const minute = Number(fields[0]); + if (hourStep && inRange(Number(hourStep[1]), 1, 23) && inRange(minute, 0, 59)) + return { ...base, period: "hours", intervalHours: Number(hourStep[1]), minuteOfHour: minute }; + // A plain hour in the same shape is 每天, not an interval. + const hour = Number(fields[1]); + if (/^\d{1,2}$/u.test(fields[1]) && inRange(hour, 0, 23) && inRange(minute, 0, 59)) + return { ...base, time: `${pad2(hour)}:${pad2(minute)}` }; + return undecodable(); + } + + // `M H * * D` → 每周, `M H * * *` → 每天. + if (fields[2] === "*" && fields[3] === "*") { + const minute = Number(fields[0]); + const hour = Number(fields[1]); + if (!inRange(minute, 0, 59) || !inRange(hour, 0, 23)) return undecodable(); + const time = `${pad2(hour)}:${pad2(minute)}`; + if (fields[4] === "*") return { ...base, time }; + const weekday = Number(fields[4]); + return inRange(weekday, 0, 6) ? { ...base, period: "weekly", time, weekday } : undecodable(); + } + return undecodable(); +} + +export function cronDraftFromDefinition(definition: WebuiCronDefinition): WebuiCronDraft { + return { + ...emptyCronDraft(definition.agentName || DEFAULT_AGENT_NAME), + name: definition.name, + agentName: definition.agentName, + prompt: definition.prompt, + sessionMode: definition.sessionTarget.mode === "sessionId" ? "sessionId" : "new", + sessionId: definition.sessionTarget.mode === "sessionId" ? definition.sessionTarget.sessionId ?? "" : "", + project: definition.project ?? "", + model: definition.model ?? "", + ...scheduleFields(definition.schedule), + }; +} + +/** Empty string when 确认 may be pressed; otherwise the reason it may not. */ +export function cronDraftIssue(draft: WebuiCronDraft): string { + if (!draft.name.trim()) return "请填写名称"; + if (draft.name.length > CRON_NAME_LIMIT) return `名称不能超过 ${CRON_NAME_LIMIT} 个字符`; + if (!draft.agentName.trim()) return "请选择 Agent"; + if (!draft.prompt.trim()) return "请填写指令"; + if (draft.prompt.length > CRON_PROMPT_LIMIT) return `指令不能超过 ${CRON_PROMPT_LIMIT} 个字符`; + if (!buildCronSchedule(draft)) return "请填写执行时间"; + return ""; +} + +/** `始终使用同一对话` with no session yet submits `sessionId` absent, which the + * contract allows and the server binds. */ +function sessionTargetFrom(draft: WebuiCronDraft): WebuiCronSessionTarget { + if (draft.sessionMode !== "sessionId") return { mode: "new" }; + const sessionId = draft.sessionId.trim(); + return sessionId ? { mode: "sessionId", sessionId } : { mode: "sessionId" }; +} + +/** The frozen `WebuiCreateCronDefinitionRequest`, or undefined while the draft + * is still incomplete — the same condition that disables 确认. A `once` draft + * is edit-only: the form cannot express a one-shot, so it cannot create one. */ +export function createCronRequestFromDraft( + draft: WebuiCronDraft, +): WebuiCreateCronDefinitionRequest | undefined { + if (draft.onceAtMs !== undefined) return undefined; + if (cronDraftIssue(draft)) return undefined; + const schedule = buildCronSchedule(draft); + if (!schedule) return undefined; + return { + name: draft.name.trim(), + agentName: draft.agentName.trim(), + schedule, + prompt: draft.prompt.trim(), + sessionTarget: sessionTargetFrom(draft), + project: draft.project.trim() || null, + model: draft.model.trim() || null, + }; +} + +/** The frozen update request. A task whose schedule the form does not own — a + * one-shot, or an expression the control could not decompose — is saved + * without a `schedule` field at all, so the stored schedule is left alone + * instead of being rewritten. */ +export function updateCronRequestFromDraft( + draft: WebuiCronDraft, + cronId: string, +): WebuiUpdateCronDefinitionRequest | undefined { + if (cronDraftIssue(draft)) return undefined; + const schedule = buildCronSchedule(draft); + const scheduleOwned = draft.onceAtMs === undefined && draft.rawExpression.trim() === ""; + return { + cronId, + name: draft.name.trim(), + ...(scheduleOwned && schedule ? { schedule } : {}), + prompt: draft.prompt.trim(), + sessionTarget: sessionTargetFrom(draft), + project: draft.project.trim() || null, + model: draft.model.trim() || null, + }; +} + +const DIALOG_MASK = "fixed inset-0 z-50 flex items-center justify-center bg-[rgba(0,0,0,0.25)]"; +const DIALOG_SURFACE = "w-[520px] max-w-[calc(100vw-32px)] overflow-clip rounded-[20px] bg-bg_grouped_secondary p-6"; +const FIELD_LABEL = "flex min-w-0 flex-1 flex-col gap-1 text-sm text-text_default_secondary"; +const FIELD_CONTROL = + "h-9 w-full min-w-0 rounded-[8px] border-[0.5px] border-border_default bg-bg_default_primary px-2 text-sm text-text_default_primary outline-none disabled:opacity-50"; +const GHOST_BUTTON = + "inline-flex h-9 min-w-[68px] items-center justify-center gap-1.5 rounded-[8px] px-4 text-sm text-text_default_secondary transition-colors hover:bg-bg_interaction_tertiary_hover disabled:opacity-50"; +const PRIMARY_BUTTON = + "inline-flex h-9 min-w-[68px] items-center justify-center gap-1.5 rounded-[8px] bg-bg_interaction_primary_default px-4 text-sm text-text_default_inverted transition-colors hover:bg-bg_interaction_primary_hover disabled:opacity-50"; + +function RequiredMark(): ReactElement { + return ( + + ); +} + +function FieldLabel({ children }: { readonly children: ReactElement | string }): ReactElement { + return {children}; +} + +/** 「每 {N} 分钟」 carries the currently chosen interval, so the dropdown label + * matches the sub-selector sitting next to it. */ +function periodLabel( + option: { readonly value: WebuiCronPeriod; readonly label: string }, + draft: WebuiCronDraft, +): string { + if (option.value === "minutes") return `每 ${draft.intervalMinutes} 分钟`; + if (option.value === "hours") return `每 ${draft.intervalHours} 小时`; + return option.label; +} + +const formatOnceAt = (runAtMs: number): string => { + const at = new Date(runAtMs); + if (Number.isNaN(at.getTime())) return EMPTY_ONCE_LABEL; + return `${at.getFullYear()}-${pad2(at.getMonth() + 1)}-${pad2(at.getDate())} ${pad2(at.getHours())}:${pad2(at.getMinutes())}`; +}; + +const EMPTY_ONCE_LABEL = "(时间未知)"; + +export interface WebuiCronCreateDialogProps { + readonly title?: string; + readonly draft: WebuiCronDraft; + readonly onDraftChange: (next: WebuiCronDraft) => void; + readonly agents?: readonly WebuiAgentRef[]; + readonly models?: readonly WebuiModelEntry[]; + readonly projects?: readonly string[]; + readonly busy?: boolean; + /** 确认 on the desktop; the 在对话中创建 flow closes with 完成并创建. */ + readonly submitLabel?: string; + /** Set by the 在对话中创建 path: the conversation session is the target and + * cannot be pointed elsewhere from the form. */ + readonly lockedSessionId?: string; + /** The session 始终使用同一对话 defaults to. The server binds one when the + * dialog has none, so this only pre-fills the field. */ + readonly currentSessionId?: string; + readonly onSubmit: () => void; + readonly onClose: () => void; +} + +/** The desktop dialog: 名称 / Agent, 指令, 运行模式 / 项目 / 模型, 执行时间, + * 取消 / 确认. 确认 stays disabled until every starred field is filled. */ +export function WebuiCronCreateDialog({ + title = "定时任务", + draft, + onDraftChange, + agents = [], + models = [], + projects = [], + busy = false, + submitLabel = "确认", + lockedSessionId, + currentSessionId, + onSubmit, + onClose, +}: WebuiCronCreateDialogProps): ReactElement { + const issue = cronDraftIssue(draft); + const patch = (next: Partial): void => onDraftChange({ ...draft, ...next }); + const defaultAgent = resolveDefaultAgentName(agents); + const agentOptions = [ + ...agents.filter((agent) => agent.agentName !== DEFAULT_AGENT_NAME), + ]; + const projectOptions = [...new Set(projects.map((dir) => dir.trim()).filter(Boolean))]; + const rawExpression = draft.rawExpression.trim(); + // A schedule the form does not own — a one-shot, or an expression the control + // could not decompose — is shown but not editable, and the update request + // leaves it out. + const onceAtMs = draft.onceAtMs; + const scheduleReadOnly = onceAtMs !== undefined || rawExpression !== ""; + + return ( +
{ + if (event.target === event.currentTarget) onClose(); + }} + > +
event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === "Escape") onClose(); + }} + > +
+

{title}

+ +
+ +
{ + event.preventDefault(); + onSubmit(); + }} + > +
+ + +
+ +