Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,33 @@ 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 scheduled-task panel starts the cron scheduler on demand

The scheduled-task panel (`定时`) reads and writes the same cron store the terminal
client uses, reached through `apiHost.cronRuntime` rather than the `services.cron`
composition that `runtimeOwnerKind: 'tui'` leaves undefined. Using it calls
`cronRuntime.ensureStarted()`, which starts the croner scheduler inside the WebUI
process. That is a deliberate change to the picture the options above describe, and
it is worth being precise about what did and did not change.

**Unchanged.** `startupExecutionPolicy` stays `'quarantined'`. It gates only the
host's own cold-start path, so a WebUI restart still never resumes persisted jobs on
its own. `runtimeOwnerKind` stays `'tui'`; no Electron-only capability is claimed.

**Changed.** Reaching the scheduler is a *use-time* action, not a startup one. The
first schedules operation loads persisted cron definitions from the shared store and
schedules them for the lifetime of the process. So a WebUI service that is left
running will fire cron tasks at their times, where before it would not.

**Accepted hazard.** The data directory is shared, so a WebUI service and the
terminal client can both hold the scheduler for the same agent at the same time and
each fire the same task. The engine's busy-queue bounds overlap within one process;
it does not arbitrate across two. The panel therefore states which side is executing
rather than implying the WebUI owns execution.

**Rejected alternative.** Restricting the panel to list plus manual trigger, and
leaving scheduling to the terminal client only. It keeps one scheduler per data
directory, but a task created in the WebUI does nothing until the user happens to run
the desktop client, which is the "builds but never runs" failure the panel exists to
avoid.
6 changes: 5 additions & 1 deletion docs/webui/webui-v1-scope.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,6 +32,10 @@ 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. It manages
tasks in the shared cron store and starts the scheduler on first use, so ADR 0002
carries an amendment describing what that does and does not change.

## Behaviour boundaries

- Shared history is readable, but live execution belongs to the runtime owner that
Expand Down
Binary file not shown.
24 changes: 22 additions & 2 deletions packages/webui/src/client/components/WebuiClientFoundationApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
} from "../projection/shell-surface.js";
import { ConversationUsageBanner } from "./ConversationUsageBanner.js";
import { PluginManagement } from "./PluginManagement.js";
import { SchedulesPanel } from "./SchedulesPanel.js";
import { WebuiComposer } from "./SessionComposer.js";
import { WebuiSessionTranscript } from "./SessionTranscript.js";
import {
Expand Down Expand Up @@ -271,6 +272,11 @@ export function WebuiClientFoundationApp(
const dispatchShellSurface = useCallback((command: WebuiShellSurfaceCommand) => {
setShellSurface((current) => reduceWebuiShellSurface(current, command));
}, []);
// The 「定时」 page is a second replacement surface next to plugin management.
// It is local state rather than a third `WebuiShellSurface` variant because
// that union is read by the rail's active state and by the reducer tests;
// widening it for one page would change every reader.
const [schedulesOpen, setSchedulesOpen] = useState(false);
const [selectedSessionId, setSelectedSessionId] =
useSelectedSessionId(locationHash);
// Rail session links are plain `#session=<id>` anchors, so the navigation runs
Expand All @@ -283,6 +289,13 @@ export function WebuiClientFoundationApp(
useEffect(() => {
dispatchShellSurface({ type: "show-conversation" });
}, [dispatchShellSurface, selectedSessionId]);
// The schedules page replaces the conversation the same way plugin
// management does, so it leaves on the same funnel — the rail is its only
// way back. A sibling effect rather than a second statement in the one above,
// because that effect's exact body is asserted by `shell-surface.test.ts`.
useEffect(() => {
setSchedulesOpen(false);
}, [selectedSessionId]);
// Composer input history + per-session drafts (roadmap Module B:
// 输入历史/草稿). One persisted store keyed by session (home has its own
// slot), so a draft survives both a session switch and a reload, and ↑ in
Expand Down Expand Up @@ -875,8 +888,15 @@ export function WebuiClientFoundationApp(
const pluginManagementArea = webuiPluginManagementArea(shellSurface);
const pluginManagementOpen = isPluginManagementSurface(shellSurface);
const openPluginManagement = useCallback((area: WebuiPluginManagementArea) => {
// The two pages replace the same column, so opening one closes the other.
setSchedulesOpen(false);
dispatchShellSurface({ type: "open-plugin-management", area });
}, [dispatchShellSurface]);
const openSchedules = useCallback(() => {
dispatchShellSurface({ type: "close-plugin-management" });
setSchedulesOpen(true);
}, [dispatchShellSurface]);
const closeSchedules = useCallback(() => setSchedulesOpen(false), []);
const [workspacePanelStates, setWorkspacePanelStates] = useState<WorkspacePanelSessionStates>(() => new Map());
// ---- Rail activity: which sessions are running, and when each last moved.
//
Expand Down Expand Up @@ -1172,7 +1192,7 @@ export function WebuiClientFoundationApp(
<div className="webui-rail-scroll h-full overflow-x-hidden overflow-y-auto px-4">
<div className="space-y-px pb-2">
<RailRow label="插件" icon={<WebuiIconPlugins />} active={pluginManagementOpen} onSelect={() => openPluginManagement("plugins")} />
<RailRow label="定时" icon={<WebuiIconSchedule />} inert />
<RailRow label="定时" icon={<WebuiIconSchedule />} active={schedulesOpen} onSelect={openSchedules} />
<RailRow label="网站" icon={<WebuiIconSites />} inert />
<RailRow label="远程" icon={<WebuiIconRemote />} inert />
</div>
Expand Down Expand Up @@ -1255,7 +1275,7 @@ export function WebuiClientFoundationApp(
data-webui-shell-region="surface"
className="relative flex min-h-0 min-w-0 flex-1 flex-row"
>
{pluginManagementArea ? <PluginManagement transport={transport} initialArea={pluginManagementArea} onChatWithAgent={async (name) => {
{schedulesOpen ? <SchedulesPanel transport={transport} onClose={closeSchedules} /> : pluginManagementArea ? <PluginManagement transport={transport} initialArea={pluginManagementArea} onChatWithAgent={async (name) => {
if (!transport?.createSession) throw new Error("当前 WebUI 未连接会话创建服务");
const created = await transport.createSession({ name });
const sessionId = created.sessionId ?? created.session?.sessionId;
Expand Down
81 changes: 81 additions & 0 deletions packages/webui/src/client/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,72 @@ export interface WebuiModelSelectionRequest {
readonly sessionId?: string;
}

/* Scheduled task (cron) wire shapes.
*
* These mirror the runtime's cron contract field for field. The panel is a
* projection of the registry the desktop and the CLI also write to, so a
* renamed field here would silently write somewhere else instead of failing.
* `enabled` is the wire-side polarity; the stored config's `disabled` is the
* runtime's own business and never crosses this boundary. */

export type WebuiCronSession =
| { readonly mode: "root" }
| { readonly mode: "sessionId"; readonly sessionId: string }
| { readonly mode: "new"; readonly keepSessions?: number | null };

export interface WebuiCronTask {
readonly cronName: string;
readonly agentName: string;
readonly cronId?: string;
readonly schedule: string;
readonly scheduleType: "cron" | "once";
readonly timezone?: string;
readonly enabled: boolean;
readonly prompt: string;
readonly session: WebuiCronSession;
readonly activeHours?: { readonly start: string; readonly end: string };
readonly status: "idle" | "running" | "skipped";
readonly lastRun: number | null;
readonly lastResult: string | null;
readonly lastError: string | null;
readonly nextRun: number | null;
}

export interface WebuiListCronsResult {
readonly tasks: WebuiCronTask[];
}

export interface WebuiCreateCronRequest {
readonly agentName: string;
readonly cronName: string;
readonly schedule: string;
readonly prompt: string;
readonly timezone?: string;
readonly enabled?: boolean;
readonly session?: WebuiCronSession;
readonly activeHours?: { readonly start: string; readonly end: string };
}

export interface WebuiUpdateCronRequest {
readonly agentName: string;
readonly cronName: string;
readonly schedule?: string;
readonly prompt?: string;
/** 空字符串表示清除时区。 */
readonly timezone?: string;
readonly enabled?: boolean;
}

export interface WebuiDeleteCronRequest {
readonly agentName: string;
readonly cronName: string;
}

export interface WebuiTriggerCronRequest {
readonly agentName: string;
readonly cronName: string;
}

/* Transport — the single bag of methods the foundation app and the
* composer consume. Every method here was previously an optional prop on
* `WebuiClientFoundationAppProps`. The optional semantics are preserved:
Expand Down Expand Up @@ -603,6 +669,21 @@ export interface WebuiTransport {
readonly description?: string;
}[];
}>;
/** Scheduled tasks. Mutations return `success`; the panel re-reads the list
* afterwards rather than trusting a partial echo of the new state. */
readonly listCrons?: () => Promise<WebuiListCronsResult>;
readonly createCron?: (
request: WebuiCreateCronRequest,
) => Promise<{ readonly success?: boolean }>;
readonly updateCron?: (
request: WebuiUpdateCronRequest,
) => Promise<{ readonly success?: boolean }>;
readonly deleteCron?: (
request: WebuiDeleteCronRequest,
) => Promise<{ readonly success?: boolean }>;
readonly triggerCron?: (
request: WebuiTriggerCronRequest,
) => Promise<{ readonly success?: boolean }>;
readonly pluginManagement?: (request: import("../shared/plugin-management.js").WebuiPluginManagementRequest) => Promise<unknown>;
readonly getPermissionMode?: () => Promise<unknown>;
readonly setPermissionMode?: (request: { readonly mode: "default" | "auto" | "bypassPermissions" }) => Promise<unknown>;
Expand Down
5 changes: 5 additions & 0 deletions packages/webui/src/client/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,11 @@ export function createWebuiTransport({
deleteQueueItem: (body) => request("deleteQueueItem", body),
listModels: (body) => request("listModels", body ?? {}),
listSkills: (body) => request("listSkills", body ?? {}),
listCrons: () => request("listCrons", undefined),
createCron: (body) => request("createCron", body),
updateCron: (body) => request("updateCron", body),
deleteCron: (body) => request("deleteCron", body),
triggerCron: (body) => request("triggerCron", body),
pluginManagement: (body) => request("pluginManagement", body),
getPermissionMode: () => request("getPermissionMode", {}),
setPermissionMode: (body) => request("setPermissionMode", body),
Expand Down
119 changes: 118 additions & 1 deletion packages/webui/src/server/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,21 @@ import type {
WebuiGoalCreateRequest,
WebuiGoalPatchRequest,
} from "./port.js";
import {
toWebuiCronError,
webuiCreateRequestToEngineConfig,
webuiCronTaskFromEngineState,
webuiUpdateRequestToEngineUpdate,
WebuiCronError,
type WebuiCronRuntime,
} from "./operation/cron.js";
import {
CREATE_CRON_OPERATION_NAME,
DELETE_CRON_OPERATION_NAME,
LIST_CRONS_OPERATION_NAME,
TRIGGER_CRON_OPERATION_NAME,
UPDATE_CRON_OPERATION_NAME,
} from "./operation/names.js";

/**
* The exact surface of the runtime `cliService` the WebUI talks to. The
Expand Down Expand Up @@ -317,7 +332,17 @@ export interface WebuiRuntimeCliService {
}

export interface WebuiRuntimeHostHandle {
readonly apiHost: { close(): Promise<void> };
readonly apiHost: {
close(): Promise<void>;
/**
* Scheduled-task engine, borrowed from the runtime host. Optional on
* purpose: the WebUI only reads it, and every host that predates it
* (including the test doubles) must keep type-checking. Each cron port
* method fails closed with `runtime host does not expose cron` when the
* slot is empty — the same nested-guard shape as `requestCompaction`.
*/
readonly cronRuntime?: WebuiCronRuntime;
};
readonly appVersion?: string;
readonly dataDir?: string;
readonly invalidateAuth?: () => void;
Expand Down Expand Up @@ -444,6 +469,79 @@ export function createHarnessPortFromHost(
async clearGoal(request) {
return { success: await requireCliService(host).clearGoal(request.sessionId) };
},
/* Scheduled tasks.
*
* The registry lives on the runtime host, not the cliService, and the
* scheduler is never started implicitly: ADR 0002 keeps the cold start
* quarantined, so each call pulls it up with an explicit, attributable
* `ensureStarted("webui:<operation>")` before reading or mutating. That
* is what makes a WebUI-initiated run distinguishable in the runtime's
* logs from a host-initiated one.
*
* Tasks persist in the shared `~/.minimax` store the desktop and the CLI
* also read and write — the WebUI is one more writer on it, not an owner.
*/
async listCrons() {
const cron = await requireCronRuntime(host, LIST_CRONS_OPERATION_NAME);
return {
tasks: cron.registry.listAllTasks().map(webuiCronTaskFromEngineState),
};
},
async createCron(request) {
const cron = await requireCronRuntime(host, CREATE_CRON_OPERATION_NAME);
const config = webuiCreateRequestToEngineConfig(request);
try {
await cron.registry.createTask(request.agentName, request.cronName, config);
} catch (error) {
// A duplicate name is the case the panel has to explain, so it keeps
// the runtime's own wording and the derived status.
throw toWebuiCronError(error);
}
return { success: true };
},
async updateCron(request) {
const cron = await requireCronRuntime(host, UPDATE_CRON_OPERATION_NAME);
try {
const state = await cron.registry.updateConfig(
request.agentName,
request.cronName,
webuiUpdateRequestToEngineUpdate(request),
);
if (!state)
throw new WebuiCronError(
404,
`cron task not found: ${request.agentName}/${request.cronName}`,
"CRON_TASK_NOT_FOUND",
);
} catch (error) {
throw toWebuiCronError(error);
}
return { success: true };
},
async deleteCron(request) {
const cron = await requireCronRuntime(host, DELETE_CRON_OPERATION_NAME);
try {
// Idempotent by contract: `deleteCron` answers `{ success }` where the
// boolean reports whether a task was actually there, so a name that is
// not registered yields `success: false` rather than a 404. That is the
// one deliberate asymmetry with `updateCron`, whose result carries no
// such boolean and therefore raises 404 through the state check.
return {
success: await cron.registry.deleteTask(request.agentName, request.cronName),
};
} catch (error) {
throw toWebuiCronError(error);
}
},
async triggerCron(request) {
const cron = await requireCronRuntime(host, TRIGGER_CRON_OPERATION_NAME);
try {
await cron.registry.triggerTask(request.agentName, request.cronName);
} catch (error) {
throw toWebuiCronError(error);
}
return { success: true };
},
async listWorkspaceFileTree(request) {
const tree = await requireCliService(host).listWorkspaceFileTree!(request) as readonly WebuiWorkspaceFile[];
// The runtime reports names and shape but no file facts, while the port
Expand Down Expand Up @@ -760,6 +858,25 @@ function requireCliService(host: WebuiRuntimeHostHandle): WebuiRuntimeCliService
return host.cliService;
}

/**
* Resolve the runtime host's scheduled-task engine and pull the scheduler up.
*
* Same nested-guard shape as `requireCliService`, one level deeper: the host
* is checked first, then the engine slot. Failing closed here means a WebUI
* without a cron-capable runtime answers
* `runtime host does not expose cron` on all five operations instead of
* half-reading a store the scheduler never owns.
*/
async function requireCronRuntime(
host: WebuiRuntimeHostHandle,
operation: string,
): Promise<WebuiCronRuntime> {
if (!host.apiHost.cronRuntime)
throw new Error("runtime host does not expose cron");
await host.apiHost.cronRuntime.ensureStarted(`webui:${operation}`);
return host.apiHost.cronRuntime;
}

function permissionReplyValue(reply: WebuiPermissionDecision): number {
switch (reply) {
case "allowOnce":
Expand Down
7 changes: 7 additions & 0 deletions packages/webui/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ export type {
WebuiPermissionDecision,
WebuiQueueItem,
WebuiModelEntry,
WebuiCronSession,
WebuiCronTask,
WebuiListCronsResult,
WebuiCreateCronRequest,
WebuiUpdateCronRequest,
WebuiDeleteCronRequest,
WebuiTriggerCronRequest,
} from "./port.js";
export {
createHarnessPortFromHost,
Expand Down
Loading
Loading