Merge pull request #13755 from esengine/a5/schedule-api

feat(schedule): controller port, serve routes and port types for scheduled runs
This commit is contained in:
YHH authored and GitHub committed 2026-10-19 23:53:38 -07:00
commit d87d0d959e
45 files changed
+2044 -257

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@ import { parse } from "@babel/parser";
// about a quarter of the product's inputs, including ones that write kernel
// state. A ceiling measured against that set could only ever fall for the
// wrong reason.
const SOURCES = import.meta.glob(["./**/*.{ts,tsx}", "!./**/*.test.*", "!./actions.ts"], {
const SOURCES = import.meta.glob(["./**/*.{ts,tsx}", "!./**/*.test.*", "!./actions.ts", "!./actions_schedule.ts"], {
query: "?raw",
import: "default",
eager: true,
+3
View File
@@ -1,3 +1,5 @@
import { SCHEDULE_ACTIONS } from "./actions_schedule";
// The census oracle is independent of production literals so disagreements fail.
// An id names the intent; data-target and data-value name its entity and choice.
@@ -791,6 +793,7 @@ export const ACTIONS: UIAction[] = [
// ── The two side panels. Reached from the keyboard here, and from each
// gutter's own grip, which this pass has not annotated yet.
{ id: "rail.toggle", kind: "view", target: "none", proof: "interaction" },
...SCHEDULE_ACTIONS,
];
// No action in Studio is reachable from the keyboard alone: every shortcut in
@@ -0,0 +1,23 @@
import type { UIAction } from "./actions";
export const SCHEDULE_ACTIONS: UIAction[] = [
{ id: "schedule.resume-all", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "schedule.pause-all", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "schedule.pause", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "schedule.resume", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "schedule.renew", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "schedule.delete", kind: "destructive", target: "entity", proof: "authority-effect" },
{ id: "schedule.delete-ask", kind: "view", target: "entity", proof: "interaction" },
{ id: "schedule.result", kind: "view", target: "entity", proof: "interaction" },
{ id: "schedule.result-close", kind: "view", target: "none", proof: "interaction" },
{ id: "schedule.retry", kind: "view", target: "none", proof: "interaction" },
{ id: "schedule.new", kind: "view", target: "none", proof: "interaction" },
{ id: "schedule.folder", kind: "view", target: "none", proof: "interaction" },
{ id: "schedule.prompt", kind: "view", target: "none", proof: "interaction" },
{ id: "schedule.label", kind: "view", target: "none", proof: "interaction" },
{ id: "schedule.every", kind: "view", target: "none", proof: "interaction" },
{ id: "schedule.cancel", kind: "view", target: "none", proof: "interaction" },
{ id: "schedule.preview", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "schedule.back", kind: "view", target: "none", proof: "interaction" },
{ id: "schedule.create", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
];
+78
View File
@@ -262,6 +262,70 @@ export const EN: Record<string, string> = {
"界面大小": "Interface size",
"等待浏览器登录…": "Waiting for the browser sign-in…",
"等待浏览器授权…": "Waiting for the browser authorization…",
"每小时": "Every hour",
"每 2 小时": "Every 2 hours",
"每 6 小时": "Every 6 hours",
"每 12 小时": "Every 12 hours",
"每天": "Every day",
"每周": "Every week",
"一次:{at}": "Once: {at}",
"每 {n} 分钟": "Every {n} minutes",
"已到期": "Expired",
"已熔断": "Stopped by the safety breaker",
"你暂停了它": "You paused it",
"连续失败后自动暂停": "Paused automatically after repeated failures",
"额度保护自动暂停": "Paused automatically to protect the budget",
"用完了总额度": "The lifetime budget is used up",
"记录损坏": "Its record is damaged",
"等你决定": "Waiting for you",
"额度用尽": "Budget used up",
"被中断": "Interrupted",
"已跳过": "Skipped",
"即将开始": "About to start",
"错过了时间": "Missed its time",
"额度窗口已满": "The budget window is full",
"有任务在运行": "Another run was in progress",
"定时运行": "Scheduled runs",
"按你确认的时间,只读地运行任务:它能读这个文件夹里的文件,不能写任何东西;需要你批准的事会挂起等你,不会替你答复。": "Runs tasks read-only at the time you confirmed: it can read files in the folder and write nothing; anything that needs your approval is parked for you and never answered for you.",
"全部定时运行已暂停": "All scheduled runs are paused",
"定时运行进行中": "Scheduled runs are on",
"最多 {n} 个;每次至少间隔 {m} 分钟;每天合计最多 {k} 个令牌。": "At most {n}; at least {m} minutes apart; at most {k} tokens a day in total.",
"全部恢复": "Resume all",
"全部暂停": "Pause all",
"还没有定时运行。": "No scheduled runs yet.",
"定时运行列表": "Scheduled runs",
"已用 {a} / {b} 令牌,{d} 到期": "Used {a} of {b} tokens; ends {d}",
"暂停": "Pause",
"恢复": "Resume",
"续期": "Renew",
"最近的运行": "Recent runs",
"令牌": "tokens",
"查看结果": "View result",
"运行结果": "Result",
"以下是模型写的内容,只作参考,不是 Reasonix 的说明。": "The text below was written by the model; treat it as a reference, not as something Reasonix says.",
"报告太长,已截断。": "The report was too long and was cut.",
"还没有受信任的项目。先在 Studio 或终端界面里打开一个项目并信任它,再回来选。": "There is no trusted project yet. Open one in Studio or the terminal UI and trust it, then come back to choose it.",
"选择一个受信任的项目": "Choose a trusted project",
"无法读取定时运行:{reason}": "Could not read the scheduled runs: {reason}",
"无法刷新定时运行:{reason}(下面是上一次读到的内容)": "Could not refresh the scheduled runs: {reason} (what is below is what was read last)",
"等你决定的事": "Waiting for your decision",
"这些都没有被执行,也没有被代答。": "None of these was run or answered for you.",
"新建定时运行": "New scheduled run",
"文件夹(它只能读这里)": "Folder (it can only read here)",
"要做什么": "What to do",
"名称(可选)": "Name (optional)",
"多久一次": "How often",
"确认创建": "Confirm and create",
"请确认": "Please confirm",
"第一次运行": "First run",
"文件夹": "Folder",
"(只读:它能读这里的文件,不写任何东西)": "(read-only: it can read files here and writes nothing)",
"每次最多": "Each run at most",
"{a} 令牌,{b} 步,{c} 秒": "{a} tokens, {b} steps, {c} s",
"总共最多": "In total at most",
"每天 {a} 令牌,一共 {b} 令牌,{d} 自动到期": "{a} tokens a day, {b} in all, ends on its own {d}",
"需要你批准的事会挂起等你,不会替你答复。": "Anything that needs your approval is parked for you and never answered for you.",
"返回修改": "Back",
"授权更多权限": "Grant more access",
"追踪导出": "Trace export",
"把每轮对话以 OpenTelemetry 追踪的形式发给你自己的观测系统。默认关闭;只发送结构、耗时、数量和名称,不发送提示词、工具参数、结果或文件路径。": "Send each turn to your own observability stack as OpenTelemetry traces. Off by default; only structure, timing, counts and names are sent, never prompts, tool arguments, results or file paths.",
@@ -286,6 +350,20 @@ export const EN: Record<string, string> = {
"这不是可用的请求头名称": "That is not a usable header name",
"凭据环境变量必须以 OTEL_ 或 REASONIX_OTEL_ 开头": "The credential variable must start with OTEL_ or REASONIX_OTEL_",
"追踪导出设置没有保存成功": "The trace export settings were not saved",
"这个客户端不能修改定时运行": "This client cannot change scheduled runs",
"定时任务只能在你确认预览后创建": "A schedule is created only after you confirm its preview",
"要创建的内容和你确认的预览不一致": "What is being created is not what you confirmed",
"找不到这个定时任务或结果": "No such schedule or result",
"间隔低于允许的最小值": "The interval is under the minimum",
"定时任务数量已达上限": "The schedule limit is reached",
"请只设置“每隔多久”或“某个时间”其中一个": "Set either an interval or a time, not both and not neither",
"这个文件夹不存在": "That folder does not exist",
"定时运行只能读已受信任的项目文件夹": "A scheduled run can only read a project folder you have trusted",
"这个模型还没有配置": "That model is not configured",
"这个定时任务的内容不合法": "The schedule is not valid",
"定时任务由更新的版本写入,这里只读": "The schedules were written by a newer version and are read-only here",
"这个定时任务当前不可用(已暂停或已过期)": "The schedule is not active (paused or expired)",
"定时任务没有保存成功": "The schedule was not saved",
"接受并以后都自动接受这个服务器的改动": "Accept, and accept this server's changes by themselves from now on",
"自动接受这个服务器的改动": "Changes from this server are accepted by themselves",
"这个服务器的改动会自动接受,并在通知里告诉你。": "This server's changes are accepted by themselves, and you are told in a notice.",
+14
View File
@@ -454,6 +454,20 @@ const SAID: Record<string, string> = {
"observability.header_invalid": "这不是可用的请求头名称",
"observability.header_env_invalid": "凭据环境变量必须以 OTEL_ 或 REASONIX_OTEL_ 开头",
"observability.save_failed": "追踪导出设置没有保存成功",
"schedule.editing_disabled": "这个客户端不能修改定时运行",
"schedule.confirmation_required": "定时任务只能在你确认预览后创建",
"schedule.digest_mismatch": "要创建的内容和你确认的预览不一致",
"schedule.not_found": "找不到这个定时任务或结果",
"schedule.interval_too_short": "间隔低于允许的最小值",
"schedule.limit_reached": "定时任务数量已达上限",
"schedule.bad_trigger": "请只设置“每隔多久”或“某个时间”其中一个",
"schedule.no_folder": "这个文件夹不存在",
"schedule.workspace_unknown": "定时运行只能读已受信任的项目文件夹",
"schedule.model_unconfigured": "这个模型还没有配置",
"schedule.invalid": "这个定时任务的内容不合法",
"schedule.schema_readonly": "定时任务由更新的版本写入,这里只读",
"schedule.not_active": "这个定时任务当前不可用(已暂停或已过期)",
"schedule.save_failed": "定时任务没有保存成功",
"mcp.no_scope_upgrade": "这个服务器没有要求更多权限",
"mcp.signin_busy": "这个服务器已经在登录中",
"mcp.signin_no_host": "服务器给出的登录页没有主机名,已停止",
@@ -1,6 +1,7 @@
import type { Adjudications, BrowserToolsSettings, SubagentClaimsSettings, SubagentSteerSettings, WriteLeaseSettings, PlanApprovalSettings, DefaultModeSettings, ToolSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings, UpdateMode } from "./port";
import type { AskTimeoutSettings, ModStatus, ModsSettings, ModsSettingsEdit, SessionRefSettings, AutoArchiveSettings, ChangedFilesSettings, FeedbackLevelNotices, FoldWorkSettings, TurnNarrationSettings, ObservabilityPatch, ObservabilitySettings, WebAccessPatch, WebAccessSettings, SearxngProbe, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
import { mockMods } from "./mock_mods";
import type { ScheduleBoard, ScheduleDraft, SchedulePreview, ScheduleResultView, ScheduleView } from "./schedule";
import { HttpError } from "./http_error";
import { MockShell } from "./mock_shell";
import { MockCompositions } from "./mock_composition";
@@ -236,6 +237,63 @@ export class MockBoundary extends MockShell {
protected peerState: PeersState = { enabled: false, peers: [], held: [] };
private scheduled: ScheduleBoard = {
pausedAll: false,
policy: { minIntervalMinutes: 60, maxSchedules: 8, globalTokensDay: 1_200_000, expiryDays: 30 },
folders: [{ path: "/Users/you/src/app", name: "app" }, { path: "/Users/you/src/docs", name: "docs" }],
schedules: [
{ id: "sch_a1b2c3d4", label: "Morning briefing", status: "active", everySec: 86_400, workspace: "/Users/you/src/app", model: "deepseek/deepseek-v4-pro",
prompt: "Summarize what changed in the repository since yesterday and list open TODOs.", perRunTokens: 250_000, lifetimeTokens: 3_000_000, spentLifetimeTokens: 410_000,
expiresAtUnix: 1_793_000_000 },
{ id: "sch_e5f6a7b8", label: "Docs drift", status: "paused", pausedReason: "consecutive_failures", everySec: 604_800, workspace: "/Users/you/src/docs", model: "deepseek/deepseek-v4-pro",
prompt: "Check the docs against the code and report what is out of date.", perRunTokens: 250_000, lifetimeTokens: 3_000_000, spentLifetimeTokens: 1_240_000,
consecutiveFailures: 3, expiresAtUnix: 1_793_000_000 },
],
runs: [
{ triggerId: "sch_a1b2c3d4/1790000000", scheduleId: "sch_a1b2c3d4", slotUnix: 1_790_000_000, state: "succeeded", chargedTokens: 118_400, endedUnix: 1_790_000_260 },
{ triggerId: "sch_a1b2c3d4/1789913600", scheduleId: "sch_a1b2c3d4", slotUnix: 1_789_913_600, state: "blocked", chargedTokens: 64_000, endedUnix: 1_789_913_700 },
{ triggerId: "sch_e5f6a7b8/1789400000", scheduleId: "sch_e5f6a7b8", slotUnix: 1_789_400_000, state: "skipped", skipReason: "missed", chargedTokens: 0 },
],
};
async schedules(): Promise<ScheduleBoard> {
return structuredClone(this.scheduled);
}
async previewSchedule(d: ScheduleDraft): Promise<SchedulePreview> {
return { firstRunUnix: 1_790_100_000, workspace: d.workspace, model: d.model || "deepseek/deepseek-v4-pro", prompt: d.prompt, perRunTokens: 250_000, perRunSteps: 40,
perRunWallSec: 900, perDayMax: 1_000_000, lifetimeTokens: 3_000_000, expiresAtUnix: 1_792_700_000, confirm: "mock-confirm" };
}
async createSchedule(d: ScheduleDraft, confirm: string): Promise<ScheduleView> {
// A refused write is the only thing that shows the read-only state, so a rendered check reaches it by this label.
if (d.label === "no-edit-grant") throw new HttpError(403, "x", { code: "schedule.editing_disabled", error: "this window has no edit grant" });
if (confirm !== "mock-confirm") throw new HttpError(409, "x", { code: "schedule.digest_mismatch", error: "what is being created is not what was previewed" });
const sc: ScheduleView = { id: "sch_" + Math.random().toString(16).slice(2, 10), label: d.label, status: "active", everySec: d.everySec, atUnix: d.atUnix, workspace: d.workspace,
model: d.model || "deepseek/deepseek-v4-pro", prompt: d.prompt, perRunTokens: 250_000, lifetimeTokens: 3_000_000, spentLifetimeTokens: 0, expiresAtUnix: 1_792_700_000 };
this.scheduled.schedules = [...this.scheduled.schedules, sc];
return sc;
}
async scheduleAction(id: string, verb: "pause" | "resume" | "renew" | "delete"): Promise<ScheduleBoard> {
const sc = this.scheduled.schedules.find((s) => s.id === id);
if (!sc) throw new HttpError(404, "x", { code: "schedule.not_found", error: "no such schedule" });
if (verb === "delete") this.scheduled.schedules = this.scheduled.schedules.filter((s) => s.id !== id);
else if (verb === "pause") { sc.status = "paused"; sc.pausedReason = "user"; }
else if (verb === "resume") { sc.status = "active"; sc.pausedReason = undefined; sc.consecutiveFailures = undefined; }
return structuredClone(this.scheduled);
}
async setSchedulesPaused(paused: boolean): Promise<ScheduleBoard> {
this.scheduled.pausedAll = paused;
return structuredClone(this.scheduled);
}
async scheduleResult(triggerId: string): Promise<ScheduleResultView> {
if (!this.scheduled.runs.some((r) => r.triggerId === triggerId)) throw new HttpError(404, "x", { code: "schedule.not_found", error: "no such result" });
return { triggerId, report: "Three files changed since yesterday. Two TODOs were added in `internal/store`.", pending: [{ kind: "approval", summary: "Wanted to run a shell command", detail: "git log --stat" }], enforcement: "tool-filter", recordedUnix: 1_790_000_260 };
}
async peers(): Promise<PeersState> {
return { ...this.peerState, peers: [...this.peerState.peers], held: [...this.peerState.held], threads: [...(this.peerState.threads ?? [])] };
}
+8
View File
@@ -50,6 +50,7 @@ import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan } fr
import type { MarketDetail, MarketList, MarketOwnRequest, MarketPackage, MarketPlan, MarketPublished, MarketQuery, MarketRequest, MarketSubmission, MarketVote } from "./market";
import type { Appearance, ThemeImport, ThemePack } from "./look";
import type { AskTimeoutSettings, ModStatus, ModsSettings, ModsSettingsEdit, SessionRefSettings, AutoArchiveSettings, ChangedFilesSettings, FeedbackLevelNotices, FoldWorkSettings, TurnNarrationSettings, ObservabilityPatch, ObservabilitySettings, WebAccessPatch, WebAccessSettings, SearxngProbe, BrowserToolsSettings, SubagentClaimsSettings, SubagentSteerSettings, WriteLeaseSettings, PlanApprovalSettings, DefaultModeSettings, ToolSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, DisplayCurrencyMode, DisplayCurrencySettings, PermissionLists, PermissionRules, ProgressWatchSettings, SandboxSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
import type { ScheduleBoard, ScheduleDraft, SchedulePreview, ScheduleResultView, ScheduleView } from "./schedule";
import type { Protocol, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider";
export type { ModelEffort, ModelLimit, ModelRate, Protocol, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider";
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
@@ -57,6 +58,7 @@ import type { FeedbackBoard, FeedbackBoardMe, FeedbackEnv, FeedbackImage, Feedba
import type { PersonaDraft, PersonaSettings } from "./boundary";
export type * from "./plugin";
export type * from "./schedule";
export type * from "./market";
export type * from "./feedback";
@@ -311,6 +313,12 @@ export interface AgentPort {
saveToolDisabled(name: string, disabled: boolean): Promise<ToolSettings>;
writeLease(): Promise<WriteLeaseSettings>;
saveWriteLease(edit: { optimistic?: boolean; maxWaitMinutes?: number }): Promise<WriteLeaseSettings>;
schedules(): Promise<ScheduleBoard>;
previewSchedule(draft: ScheduleDraft): Promise<SchedulePreview>;
createSchedule(draft: ScheduleDraft, confirm: string): Promise<ScheduleView>;
scheduleAction(id: string, verb: "pause" | "resume" | "renew" | "delete"): Promise<ScheduleBoard>;
setSchedulesPaused(paused: boolean): Promise<ScheduleBoard>;
scheduleResult(triggerId: string): Promise<ScheduleResultView>;
peers(): Promise<PeersState>;
sendPeerMessage(to: string, text: string): Promise<PeerReceipt>;
resolvePeerMessage(id: string, approve: boolean, remember?: PeerRemember): Promise<void>;
@@ -0,0 +1,92 @@
// Scheduled runs: read-only tasks a person confirmed, run at a time they chose.
// Times are Unix seconds. A report is the model's own text: it is labelled as
// such and drawn without loading remote content.
export interface ScheduleDraft {
label: string;
everySec?: number;
atUnix?: number;
workspace: string;
prompt: string;
model?: string;
}
export interface ScheduleView {
id: string;
label: string;
status: string;
pausedReason?: string;
everySec?: number;
atUnix?: number;
workspace: string;
model: string;
prompt: string;
perRunTokens: number;
lifetimeTokens: number;
spentLifetimeTokens: number;
consecutiveFailures?: number;
expiresAtUnix: number;
}
export interface ScheduleRunView {
triggerId: string;
scheduleId: string;
slotUnix: number;
state: string;
skipReason?: string;
chargedTokens: number;
endedUnix?: number;
}
export interface SchedulePolicyView {
minIntervalMinutes: number;
maxSchedules: number;
globalTokensDay: number;
expiryDays: number;
}
export interface ScheduleBoard {
pausedAll: boolean;
pausedReason?: string;
schedules: ScheduleView[];
runs: ScheduleRunView[];
policy: SchedulePolicyView;
// The trusted projects a schedule may be given. A creation names one of these
// and nothing else; the kernel refuses any other path.
folders: ScheduleFolder[];
}
export interface ScheduleFolder {
path: string;
name: string;
}
export interface SchedulePreview {
firstRunUnix: number;
workspace: string;
model: string;
prompt: string;
perRunTokens: number;
perRunSteps: number;
perRunWallSec: number;
perDayMax: number;
lifetimeTokens: number;
expiresAtUnix: number;
confirm: string;
}
export interface SchedulePendingView {
kind: string;
summary: string;
detail?: string;
}
export interface ScheduleResultView {
triggerId: string;
report: string;
reportTruncated?: boolean;
pending: SchedulePendingView[];
pendingDropped?: number;
enforcement: string;
recordedUnix: number;
}
@@ -1,5 +1,6 @@
import { SseShell } from "./sse_shell";
import type { AgentComposition, AgentPresetVersion, AgentPresetCatalog, CompositionSummary } from "./composition";
import type { ScheduleBoard, ScheduleDraft, SchedulePreview, ScheduleResultView, ScheduleView } from "./schedule";
import type { PersonaDraft, PersonaSettings } from "./boundary";
import type { Adjudications, BrowserToolsSettings, SubagentClaimsSettings, SubagentSteerSettings, WriteLeaseSettings, PlanApprovalSettings, DefaultModeSettings, ToolSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings } from "./port";
import type { AskTimeoutSettings, ModStatus, ModsSettings, ModsSettingsEdit, SessionRefSettings, AutoArchiveSettings, ChangedFilesSettings, FeedbackLevelNotices, FoldWorkSettings, TurnNarrationSettings, ObservabilityPatch, ObservabilitySettings, WebAccessPatch, WebAccessSettings, SearxngProbe, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
@@ -78,6 +79,24 @@ export class SseBoundary extends SseShell {
saveWriteLease(edit: { optimistic?: boolean; maxWaitMinutes?: number }) {
return this.post0<WriteLeaseSettings>("/write-lease", edit);
}
schedules() {
return this.get<ScheduleBoard>("/schedules");
}
previewSchedule(draft: ScheduleDraft) {
return this.post0<SchedulePreview>("/schedules/preview", draft);
}
createSchedule(draft: ScheduleDraft, confirm: string) {
return this.post0<ScheduleView>("/schedules", { ...draft, confirm });
}
scheduleAction(id: string, verb: "pause" | "resume" | "renew" | "delete") {
return this.post0<ScheduleBoard>(`/schedules/${encodeURIComponent(id)}/${verb}`, {});
}
setSchedulesPaused(paused: boolean) {
return this.post0<ScheduleBoard>(paused ? "/schedules/pause-all" : "/schedules/resume-all", {});
}
scheduleResult(triggerId: string) {
return this.get<ScheduleResultView>(`/schedules/result?trigger=${encodeURIComponent(triggerId)}`);
}
peers() {
return this.get<PeersState>("/peers");
}
@@ -0,0 +1,237 @@
import { useCallback, useEffect, useState } from "react";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import { HttpError } from "../port/http_error";
import type { AgentPort, ScheduleBoard, ScheduleDraft, SchedulePreview, ScheduleResultView, ScheduleRunView, ScheduleView } from "../port/port";
import { Group } from "./Group";
const EVERY: [number, string][] = [[3600, "每小时"], [7200, "每 2 小时"], [21600, "每 6 小时"], [43200, "每 12 小时"], [86400, "每天"], [604800, "每周"]];
const POLL_MS = 30_000;
const when = (unix: number) => new Date(unix * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
const base = (path: string) => path.split(/[\\/]/).filter(Boolean).pop() ?? path;
function trigger(s: ScheduleView): string {
if (s.atUnix) return t("一次:{at}", { at: when(s.atUnix) });
const hit = EVERY.find(([sec]) => sec === s.everySec);
return hit ? t(hit[1]) : t("每 {n} 分钟", { n: Math.round((s.everySec ?? 0) / 60) });
}
const STATUS: Record<string, string> = { active: "运行中", paused: "已暂停", expired: "已到期", "circuit-open": "已熔断" };
const PAUSE: Record<string, string> = { user: "你暂停了它", consecutive_failures: "连续失败后自动暂停", breaker: "额度保护自动暂停", lifetime_budget: "用完了总额度", corrupt: "记录损坏" };
const RUN: Record<string, string> = {
succeeded: "已完成", failed: "失败", blocked: "等你决定", budget_stopped: "额度用尽", interrupted: "被中断", skipped: "已跳过", claimed: "即将开始", running: "运行中",
};
const SKIP: Record<string, string> = { missed: "错过了时间", budget_window: "额度窗口已满", lease_held: "有任务在运行", concurrency: "有任务在运行" };
/** Read-only tasks a person confirmed, run at a time they chose. A creation is
* shown back as a preview with the most it can spend, and only that preview's
* own confirmation creates it. A report is the model's text: it is labelled as
* such and drawn as plain text, never as markup. */
export function ScheduledRuns({ port }: { port: AgentPort }) {
const [board, setBoard] = useState<ScheduleBoard | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [readOnly, setReadOnly] = useState(false);
const [form, setForm] = useState<ScheduleDraft | null>(null);
const [preview, setPreview] = useState<SchedulePreview | null>(null);
const [confirmDelete, setConfirmDelete] = useState("");
const [result, setResult] = useState<ScheduleResultView | null>(null);
const [loadError, setLoadError] = useState("");
// A failed read keeps the last board and says why: the block vanishing would
// leave a person who just created a schedule unable to tell whether it exists.
const refresh = useCallback(
() => port.schedules().then((b) => { setBoard(b); setLoadError(""); }).catch((e) => setLoadError(reason(e))),
[port],
);
useEffect(() => {
let alive = true;
const load = () => { if (alive) void refresh(); };
load();
const id = setInterval(load, POLL_MS);
return () => { alive = false; clearInterval(id); };
}, [refresh]);
const run = useCallback(async (fn: () => Promise<unknown>) => {
setBusy(true);
setError("");
try {
await fn();
await refresh();
} catch (e) {
if (e instanceof HttpError && e.reason?.code === "schedule.editing_disabled") setReadOnly(true);
setError(reason(e));
} finally {
setBusy(false);
}
}, [refresh]);
const unreadable = loadError && (
<div className="peerform" role="alert">
<div className="why">{board ? t("无法刷新定时运行:{reason}(下面是上一次读到的内容)", { reason: loadError }) : t("无法读取定时运行:{reason}", { reason: loadError })}</div>
<span className="acts"><button className="act" data-action="schedule.retry" onClick={() => void refresh()}>{t("重试")}</button></span>
</div>
);
if (!board) {
return unreadable ? (
<Group id="scheduled-runs" title={t("定时运行")} hint={t("按你确认的时间,只读地运行任务:它能读这个文件夹里的文件,不能写任何东西;需要你批准的事会挂起等你,不会替你答复。")}>
<div className="box">{unreadable}</div>
</Group>
) : null;
}
const locked = busy || readOnly;
const name = (s: ScheduleView) => s.label || base(s.workspace);
const scheduleName = (r: ScheduleRunView) => { const s = board.schedules.find((x) => x.id === r.scheduleId); return s ? name(s) : r.scheduleId; };
const draft = (patch: Partial<ScheduleDraft>) => setForm((f) => (f ? { ...f, ...patch } : f));
return (
<Group id="scheduled-runs" title={t("定时运行")} hint={t("按你确认的时间,只读地运行任务:它能读这个文件夹里的文件,不能写任何东西;需要你批准的事会挂起等你,不会替你答复。")}>
<div className="box">
{unreadable}
<div className="lrow">
<span className="tx">
<span className="lb">{board.pausedAll ? t("全部定时运行已暂停") : t("定时运行进行中")}</span>
<span className="ds">{t("最多 {n} 个;每次至少间隔 {m} 分钟;每天合计最多 {k} 个令牌。", { n: board.policy.maxSchedules, m: board.policy.minIntervalMinutes, k: board.policy.globalTokensDay.toLocaleString() })}</span>
</span>
{board.pausedAll ? (
<button className="act" data-action="schedule.resume-all" disabled={locked} onClick={() => void run(() => port.setSchedulesPaused(false))}>{t("全部恢复")}</button>
) : (
<button className="act" data-action="schedule.pause-all" disabled={busy} onClick={() => void run(() => port.setSchedulesPaused(true))}>{t("全部暂停")}</button>
)}
</div>
{board.schedules.length === 0 && <div className="peernote">{t("还没有定时运行。")}</div>}
<ul className="wa-list" aria-label={t("定时运行列表")}>
{board.schedules.map((s) => (
<li key={s.id}>
<span className="tx">
<span className="lb">{name(s)}</span>
<span className="ds">{trigger(s)} · {t(STATUS[s.status] ?? s.status)}{s.pausedReason ? " · " + t(PAUSE[s.pausedReason] ?? s.pausedReason) : ""}</span>
<span className="ds">{s.workspace} · {s.model}</span>
<span className="ds">{t("已用 {a} / {b} 令牌,{d} 到期", { a: s.spentLifetimeTokens.toLocaleString(), b: s.lifetimeTokens.toLocaleString(), d: when(s.expiresAtUnix) })}</span>
</span>
<span className="acts">
{s.status === "active" ? (
<button className="act" data-action="schedule.pause" data-target={s.id} disabled={busy} onClick={() => void run(() => port.scheduleAction(s.id, "pause"))}>{t("暂停")}</button>
) : (
<button className="act" data-action="schedule.resume" data-target={s.id} disabled={locked} onClick={() => void run(() => port.scheduleAction(s.id, "resume"))}>{t("恢复")}</button>
)}
<button className="act" data-action="schedule.renew" data-target={s.id} disabled={locked} onClick={() => void run(() => port.scheduleAction(s.id, "renew"))}>{t("续期")}</button>
{confirmDelete === s.id ? (
<button className="act danger" data-action="schedule.delete" data-target={s.id} disabled={busy} onClick={() => void run(async () => { await port.scheduleAction(s.id, "delete"); setConfirmDelete(""); })}>{t("确认删除")}</button>
) : (
<button className="act ghost" data-action="schedule.delete-ask" data-target={s.id} disabled={busy} onClick={() => setConfirmDelete(s.id)}>{t("删除")}</button>
)}
</span>
</li>
))}
</ul>
{board.runs.length > 0 && (
<>
<div className="peernote"><b>{t("最近的运行")}</b></div>
<ul className="wa-list" aria-label={t("最近的运行")}>
{board.runs.slice(0, 8).map((r) => (
<li key={r.triggerId}>
<span className="tx">
<span className="lb">{scheduleName(r)} · {t(RUN[r.state] ?? r.state)}</span>
<span className="ds">{when(r.slotUnix)} · {r.chargedTokens.toLocaleString()} {t("令牌")}{r.skipReason ? " · " + t(SKIP[r.skipReason] ?? r.skipReason) : ""}</span>
</span>
{r.state !== "skipped" && r.state !== "claimed" && (
<button className="act" data-action="schedule.result" data-target={r.triggerId} disabled={busy} onClick={() => void run(async () => setResult(await port.scheduleResult(r.triggerId)))}>{t("查看结果")}</button>
)}
</li>
))}
</ul>
</>
)}
{result && (
<div className="peerform" role="region" aria-label={t("运行结果")}>
<div className="peernote"><b>{t("运行结果")}</b> · {when(result.recordedUnix)}</div>
<div className="peernote">{t("以下是模型写的内容,只作参考,不是 Reasonix 的说明。")}</div>
<pre className="peerbody" data-testid="schedule-report">{result.report}</pre>
{result.reportTruncated && <div className="peernote">{t("报告太长,已截断。")}</div>}
{result.pending.length > 0 && (
<>
<div className="peernote"><b>{t("等你决定的事")}</b></div>
<div className="peernote">{t("以下是模型写的内容,只作参考,不是 Reasonix 的说明。")}</div>
<ul className="wa-list" data-testid="schedule-pending">
{result.pending.map((p, i) => (
<li key={i}><span className="tx"><span className="lb">{p.summary}</span>{p.detail && <span className="ds">{p.detail}</span>}</span></li>
))}
</ul>
<div className="peernote">{t("这些都没有被执行,也没有被代答。")}</div>
</>
)}
<span className="acts"><button className="act ghost" data-action="schedule.result-close" onClick={() => setResult(null)}>{t("关闭")}</button></span>
</div>
)}
{!form && (
<div className="peerform">
<span className="acts">
<button className="act" data-action="schedule.new" disabled={locked || board.schedules.length >= board.policy.maxSchedules}
onClick={() => { setError(""); setPreview(null); setForm({ label: "", everySec: 86400, workspace: "", prompt: "" }); }}>{t("新建定时运行")}</button>
</span>
</div>
)}
{form && !preview && (
<div className="peerform" role="group" aria-label={t("新建定时运行")}>
<label className="peernote" htmlFor="sched-folder">{t("文件夹(它只能读这里)")}</label>
{board.folders.length === 0 ? (
<div className="peernote" id="sched-folder">{t("还没有受信任的项目。先在 Studio 或终端界面里打开一个项目并信任它,再回来选。")}</div>
) : (
<select id="sched-folder" className="peerfield" data-action-change="schedule.folder" value={form.workspace} disabled={busy}
onChange={(e) => draft({ workspace: e.target.value })}>
<option value="">{t("选择一个受信任的项目")}</option>
{board.folders.map((f) => <option key={f.path} value={f.path}>{f.name} — {f.path}</option>)}
</select>
)}
<label className="peernote" htmlFor="sched-prompt">{t("要做什么")}</label>
<textarea id="sched-prompt" className="peerfield" data-action-change="schedule.prompt" value={form.prompt} disabled={busy}
onChange={(e) => draft({ prompt: e.target.value })} />
<label className="peernote" htmlFor="sched-label">{t("名称(可选)")}</label>
<input id="sched-label" className="peerfield" data-action-change="schedule.label" value={form.label} disabled={busy} onChange={(e) => draft({ label: e.target.value })} />
<label className="peernote" htmlFor="sched-every">{t("多久一次")}</label>
<select id="sched-every" className="peerfield" data-action-change="schedule.every" value={form.everySec ?? 86400} disabled={busy}
onChange={(e) => draft({ everySec: Number(e.target.value) })}>
{EVERY.map(([sec, label]) => <option key={sec} value={sec} disabled={sec < board.policy.minIntervalMinutes * 60}>{t(label)}</option>)}
</select>
<span className="acts">
<button className="act ghost" data-action="schedule.cancel" disabled={busy} onClick={() => setForm(null)}>{t("取消")}</button>
<button className="act" data-action="schedule.preview" data-primary="" disabled={busy || !form.workspace.trim() || !form.prompt.trim()}
onClick={() => void run(async () => setPreview(await port.previewSchedule({ ...form, workspace: form.workspace.trim(), prompt: form.prompt.trim() })))}>{t("预览")}</button>
</span>
</div>
)}
{form && preview && (
<div className="peerform" role="group" aria-label={t("确认创建")}>
<div className="peernote"><b>{t("请确认")}</b></div>
<ul className="wa-list">
<li><span className="tx"><span className="lb">{t("第一次运行")}</span><span className="ds">{when(preview.firstRunUnix)}</span></span></li>
<li><span className="tx"><span className="lb">{t("文件夹")}</span><span className="ds">{preview.workspace} {t("(只读:它能读这里的文件,不写任何东西)")}</span></span></li>
<li><span className="tx"><span className="lb">{t("模型")}</span><span className="ds">{preview.model}</span></span></li>
<li><span className="tx"><span className="lb">{t("要做什么")}</span><span className="ds">{preview.prompt}</span></span></li>
<li><span className="tx"><span className="lb">{t("每次最多")}</span><span className="ds">{t("{a} 令牌,{b} 步,{c} 秒", { a: preview.perRunTokens.toLocaleString(), b: preview.perRunSteps, c: preview.perRunWallSec })}</span></span></li>
<li><span className="tx"><span className="lb">{t("总共最多")}</span><span className="ds">{t("每天 {a} 令牌,一共 {b} 令牌,{d} 自动到期", { a: preview.perDayMax.toLocaleString(), b: preview.lifetimeTokens.toLocaleString(), d: when(preview.expiresAtUnix) })}</span></span></li>
</ul>
<div className="peernote">{t("需要你批准的事会挂起等你,不会替你答复。")}</div>
<span className="acts">
<button className="act ghost" data-action="schedule.back" disabled={busy} onClick={() => setPreview(null)}>{t("返回修改")}</button>
<button className="act" data-action="schedule.create" data-primary="" disabled={locked}
onClick={() => void run(async () => { await port.createSchedule({ ...form, workspace: form.workspace.trim(), prompt: form.prompt.trim() }, preview.confirm); setForm(null); setPreview(null); })}>{t("确认创建")}</button>
</span>
</div>
)}
{readOnly && <div className="peernote" role="status">{t("这些设置现在是只读的:这个窗口没有被授予编辑权限。")}</div>}
{error && <div className="why" role="alert">{error}</div>}
</div>
</Group>
);
}
+10 -6
View File
@@ -30,6 +30,7 @@ import { Switch } from "./Switch";
import { ServerRow } from "./ServerRow";
import { SkillGroups } from "./SkillRow";
import { Hooks } from "./Hooks";
import { ScheduledRuns } from "./ScheduledRuns";
import { Network } from "./Network";
import { Shell as ShellPicker } from "./Shell";
import { ExternalOrigin } from "./ExternalOrigin";
@@ -619,12 +620,15 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
{at === "peers" && <PeersSection port={port} hub={hub} />}
{at === "hooks" && (
<Group id="hooks"
title={t("自动化")}
hint={t("在 agent 执行任务前后运行自定义命令。这些命令在本机以当前用户权限运行;可拦截 agent 的两个事件已在下方标出。")}
>
<Hooks port={port} onChanged={afterExtChange} />
</Group>
<>
<Group id="hooks"
title={t("自动化")}
hint={t("在 agent 执行任务前后运行自定义命令。这些命令在本机以当前用户权限运行;可拦截 agent 的两个事件已在下方标出。")}
>
<Hooks port={port} onChanged={afterExtChange} />
</Group>
<ScheduledRuns port={port} />
</>
)}
{at === "ext" && (
<>
@@ -276,6 +276,7 @@ export const SETTINGS: SettingEntry[] = [
// takes that choice as a parameter — hook.Scope, McpInstallScope, the skill
// activation scope, a memory's project/global flag. Naming one authority here
// would be wrong whenever the reader picks the other.
{ section: "hooks", anchor: "scheduled-runs", title: "定时运行", scope: "machine", apply: "immediate", keywords: ["定时", "计划", "任务", "每天", "schedule", "cron", "unattended", "无人值守"] },
{ section: "hooks", anchor: "hooks", title: "自动化", scope: "chosen", apply: "immediate", keywords: ["钩子", "hook", "触发"] },
{ section: "ext", anchor: "ext-runtime", title: "运行时", scope: "session", apply: "immediate", keywords: ["扩展", "沙盒"] },
@@ -0,0 +1,161 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { ScheduledRuns } from "./ScheduledRuns";
import { HttpError } from "../port/http_error";
import type { AgentPort, ScheduleBoard, SchedulePreview } from "../port/port";
afterEach(cleanup);
const board = (over: Partial<ScheduleBoard> = {}): ScheduleBoard => ({
pausedAll: false,
folders: [{ path: "/Users/you/src/app", name: "app" }, { path: "/Users/you/src/docs", name: "docs" }],
policy: { minIntervalMinutes: 60, maxSchedules: 8, globalTokensDay: 1_200_000, expiryDays: 30 },
schedules: [{ id: "sch_a1", label: "Morning briefing", status: "active", everySec: 86_400, workspace: "/Users/you/src/app", model: "deepseek/v4", prompt: "p",
perRunTokens: 250_000, lifetimeTokens: 3_000_000, spentLifetimeTokens: 410_000, expiresAtUnix: 1_793_000_000 }],
runs: [{ triggerId: "sch_a1/1", scheduleId: "sch_a1", slotUnix: 1_790_000_000, state: "succeeded", chargedTokens: 118_400 }],
...over,
});
const preview: SchedulePreview = { firstRunUnix: 1_790_100_000, workspace: "/Users/you/src/app", model: "deepseek/v4", prompt: "summarize", perRunTokens: 250_000, perRunSteps: 40,
perRunWallSec: 900, perDayMax: 1_000_000, lifetimeTokens: 3_000_000, expiresAtUnix: 1_792_700_000, confirm: "tok-1" };
function draw(over: Partial<AgentPort> = {}, b: ScheduleBoard = board()) {
const port = {
schedules: vi.fn(async () => b), previewSchedule: vi.fn(async () => preview), createSchedule: vi.fn(async () => b.schedules[0]),
scheduleAction: vi.fn(async () => b), setSchedulesPaused: vi.fn(async () => b),
scheduleResult: vi.fn(async () => ({ triggerId: "sch_a1/1", report: "all quiet", pending: [], enforcement: "tool-filter", recordedUnix: 1_790_000_260 })),
...over,
} as unknown as AgentPort;
render(<ScheduledRuns port={port} />);
return port;
}
describe("scheduled runs", () => {
it("lists what is scheduled with its trigger, status, spend and end", async () => {
draw();
expect(await screen.findByText("Morning briefing")).toBeTruthy();
expect(screen.getByText(/每天 · 运行中/)).toBeTruthy();
expect(screen.getByText(/已用 410,000 \/ 3,000,000 令牌/)).toBeTruthy();
expect(screen.getByText("/Users/you/src/app · deepseek/v4")).toBeTruthy();
});
it("pauses without asking, but asks twice before deleting", async () => {
const port = draw();
await userEvent.click(await screen.findByRole("button", { name: "暂停" }));
expect(port.scheduleAction).toHaveBeenCalledExactlyOnceWith("sch_a1", "pause");
await userEvent.click(screen.getByRole("button", { name: "删除" }));
expect(port.scheduleAction).toHaveBeenCalledTimes(1);
await userEvent.click(screen.getByRole("button", { name: "确认删除" }));
expect(port.scheduleAction).toHaveBeenLastCalledWith("sch_a1", "delete");
});
it("shows the preview and creates only with that preview's confirmation", async () => {
const port = draw();
await userEvent.click(await screen.findByRole("button", { name: "新建定时运行" }));
expect((screen.getByRole("button", { name: "预览" }) as HTMLButtonElement).disabled).toBe(true);
await userEvent.selectOptions(screen.getByLabelText("文件夹(它只能读这里)"), "/Users/you/src/app");
await userEvent.type(screen.getByLabelText("要做什么"), "summarize");
await userEvent.click(screen.getByRole("button", { name: "预览" }));
expect(port.previewSchedule).toHaveBeenCalledWith(expect.objectContaining({ workspace: "/Users/you/src/app", prompt: "summarize", everySec: 86400 }));
const facts = await screen.findByRole("group", { name: "确认创建" });
expect(within(facts).getByText(/每天 1,000,000 令牌,一共 3,000,000 令牌/)).toBeTruthy();
expect(port.createSchedule).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "确认创建" }));
expect(port.createSchedule).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ workspace: "/Users/you/src/app" }), "tok-1");
});
it("offers only the trusted projects, and says how to get one when there are none", async () => {
draw();
await userEvent.click(await screen.findByRole("button", { name: "新建定时运行" }));
const folder = screen.getByLabelText("文件夹(它只能读这里)") as HTMLSelectElement;
expect([...folder.options].map((o) => o.value)).toEqual(["", "/Users/you/src/app", "/Users/you/src/docs"]);
expect((screen.getByRole("button", { name: "预览" }) as HTMLButtonElement).disabled).toBe(true);
cleanup();
draw({}, board({ folders: [] }));
await userEvent.click(await screen.findByRole("button", { name: "新建定时运行" }));
expect(screen.getByText(/还没有受信任的项目/)).toBeTruthy();
expect((screen.getByRole("button", { name: "预览" }) as HTMLButtonElement).disabled).toBe(true);
});
it("goes back from the preview without creating anything", async () => {
const port = draw();
await userEvent.click(await screen.findByRole("button", { name: "新建定时运行" }));
await userEvent.selectOptions(screen.getByLabelText("文件夹(它只能读这里)"), "/Users/you/src/app");
await userEvent.type(screen.getByLabelText("要做什么"), "x");
await userEvent.click(screen.getByRole("button", { name: "预览" }));
await userEvent.click(await screen.findByRole("button", { name: "返回修改" }));
expect(screen.getByLabelText("要做什么")).toBeTruthy();
expect(port.createSchedule).not.toHaveBeenCalled();
});
it("shows the kernel's refusal by its code and goes read-only without the edit grant", async () => {
draw({ createSchedule: vi.fn(async () => { throw new HttpError(403, "x", { code: "schedule.editing_disabled", error: "no" }); }) });
await userEvent.click(await screen.findByRole("button", { name: "新建定时运行" }));
await userEvent.selectOptions(screen.getByLabelText("文件夹(它只能读这里)"), "/Users/you/src/app");
await userEvent.type(screen.getByLabelText("要做什么"), "x");
await userEvent.click(screen.getByRole("button", { name: "预览" }));
await userEvent.click(await screen.findByRole("button", { name: "确认创建" }));
expect((await screen.findByRole("alert")).textContent).toBeTruthy();
await waitFor(() => expect(screen.getByText("这些设置现在是只读的:这个窗口没有被授予编辑权限。")).toBeTruthy());
expect((screen.getByRole("button", { name: "确认创建" }) as HTMLButtonElement).disabled).toBe(true);
});
it("shows a report as labelled plain text, never as markup or a remote load", async () => {
draw({ scheduleResult: vi.fn(async () => ({ triggerId: "sch_a1/1", report: "<img src=x onerror=alert(1)> **bold** [link](https://evil.example)", pending: [{ kind: "approval", summary: "<img src=y onerror=alert(2)> Wanted a shell command", detail: "**git** [log](https://evil.example) <script>x</script>" }], enforcement: "tool-filter", recordedUnix: 1_790_000_260 })) });
await userEvent.click(await screen.findByRole("button", { name: "查看结果" }));
const region = await screen.findByRole("region", { name: "运行结果" });
expect(within(region).getAllByText("以下是模型写的内容,只作参考,不是 Reasonix 的说明。")).toHaveLength(2);
const report = within(region).getByTestId("schedule-report");
expect(report.textContent).toContain("<img src=x onerror=alert(1)> **bold** [link](https://evil.example)");
expect(region.querySelector("img, a, script")).toBeNull();
const pending = within(region).getByTestId("schedule-pending");
expect(pending.textContent).toContain("<img src=y onerror=alert(2)> Wanted a shell command");
expect(pending.textContent).toContain("**git** [log](https://evil.example) <script>x</script>");
expect(pending.querySelector("img, a, script")).toBeNull();
expect(within(region).getByText("这些都没有被执行,也没有被代答。")).toBeTruthy();
});
it("says why the first read failed and offers a retry instead of dropping the block", async () => {
const schedules = vi.fn().mockRejectedValueOnce(new HttpError(403, "x", { code: "schedule.editing_disabled", error: "no" })).mockResolvedValue(board());
draw({ schedules });
const alert = await screen.findByRole("alert");
expect(alert.textContent).toContain("无法读取定时运行:");
expect(screen.getByText("定时运行")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "重试" }));
expect(await screen.findByText("Morning briefing")).toBeTruthy();
expect(screen.queryByRole("alert")).toBeNull();
});
it("keeps the last good board when a later refresh fails, and says so", async () => {
const schedules = vi.fn().mockResolvedValueOnce(board()).mockRejectedValueOnce(new Error("down")).mockResolvedValue(board());
draw({ schedules });
await userEvent.click(await screen.findByRole("button", { name: "暂停" }));
expect(await screen.findByText("Morning briefing")).toBeTruthy();
expect((await screen.findByRole("alert")).textContent).toContain("无法刷新定时运行:");
await userEvent.click(screen.getByRole("button", { name: "重试" }));
await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
expect(screen.getByText("Morning briefing")).toBeTruthy();
});
it("offers to resume everything when all are paused, and pauses all otherwise", async () => {
const port = draw({}, board({ pausedAll: true }));
expect(await screen.findByText("全部定时运行已暂停")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "全部恢复" }));
expect(port.setSchedulesPaused).toHaveBeenCalledExactlyOnceWith(false);
cleanup();
const other = draw();
await userEvent.click(await screen.findByRole("button", { name: "全部暂停" }));
expect(other.setSchedulesPaused).toHaveBeenCalledExactlyOnceWith(true);
});
it("disables intervals under the policy's minimum", async () => {
draw({}, board({ policy: { minIntervalMinutes: 240, maxSchedules: 8, globalTokensDay: 1_200_000, expiryDays: 30 } }));
await userEvent.click(await screen.findByRole("button", { name: "新建定时运行" }));
const every = screen.getByLabelText("多久一次") as HTMLSelectElement;
const off = [...every.options].filter((o) => o.disabled).map((o) => o.textContent);
expect(off).toEqual(["每小时", "每 2 小时"]);
});
});
@@ -32,18 +32,19 @@ const REGISTRY = (() => {
// Anchored to the tree being scanned, like every other path here: a literal
// "src/actions.ts" would load the product's registry into a fixture run and
// report all 47 of its entries as having no production root.
const at = join(SRC, "actions.ts");
const src = existsSync(at) ? readFileSync(at, "utf8") : null;
if (!src) return null;
const tree = parse(src, { sourceType: "module", plugins: ["typescript"], errorRecovery: true });
const files = existsSync(SRC) ? readdirSync(SRC).filter((f) => /^actions(_\w+)?\.ts$/.test(f)).sort() : [];
if (!files.length) return null;
const out = new Map();
walk(tree, (n) => {
if (n.type !== "ObjectExpression") return;
const props = n.properties.filter((x) => x.type === "ObjectProperty" && x.key?.name);
const id = props.find((x) => x.key.name === "id");
const kind = props.find((x) => x.key.name === "kind");
if (id?.value?.type === "StringLiteral") out.set(id.value.value, kind?.value?.type === "StringLiteral" ? kind.value.value : null);
});
for (const f of files) {
const tree = parse(readFileSync(join(SRC, f), "utf8"), { sourceType: "module", plugins: ["typescript"], errorRecovery: true });
walk(tree, (n) => {
if (n.type !== "ObjectExpression") return;
const props = n.properties.filter((x) => x.type === "ObjectProperty" && x.key?.name);
const id = props.find((x) => x.key.name === "id");
const kind = props.find((x) => x.key.name === "kind");
if (id?.value?.type === "StringLiteral") out.set(id.value.value, kind?.value?.type === "StringLiteral" ? kind.value.value : null);
});
}
return out;
})();
+1 -1
View File
@@ -24,7 +24,7 @@ const ROUTES = [
"/changes", "/commit", "/attachments", "/drop", "/checkpoints", "/branches", "/compact", "/compaction", "/rewind", "/branch",
"/extensions", "/themes", "/plugins", "/market", "/surfaces", "/feedback",
"/fork", "/summarize", "/forget", "/bypass", "/auto-approve-tools",
"/permissions", "/subagent-steer-limits", "/sandbox", "/progress-watch", "/ask-timeout", "/session-ref-settings", "/changed-files-settings", "/feedback-level-notices", "/observability", "/fold-work-settings", "/turn-narration-settings", "/display-currency", "/auto-archive", "/persona", "/context", "/storage", "/tray", "/browser", "/browser-host", "/asks", "/update", "/receipt-settings", "/subagent-claims", "/write-lease", "/peers",
"/permissions", "/subagent-steer-limits", "/sandbox", "/progress-watch", "/ask-timeout", "/session-ref-settings", "/changed-files-settings", "/schedules", "/feedback-level-notices", "/observability", "/fold-work-settings", "/turn-narration-settings", "/display-currency", "/auto-archive", "/persona", "/context", "/storage", "/tray", "/browser", "/browser-host", "/asks", "/update", "/receipt-settings", "/subagent-claims", "/write-lease", "/peers",
"/mods", "/git",
"/host", "/notifications", "/share", "/pair", "/device",
"/web-access-settings", "/slash", "/workspaces", "/welcome", "/usage", "/config", "/studio",
+5 -2
View File
@@ -1896,8 +1896,8 @@ accepted for compatibility but are hidden from help and completion.
## Scheduled runs
A schedule makes Reasonix run a read-only task for you at a time you chose and leave the result waiting. It is
the command line only for now; Studio gets a list later.
A schedule makes Reasonix run a read-only task for you at a time you chose and leave the result waiting. Create
and manage them in Studio under Settings, Automation, Scheduled runs, or with the command line below.
```bash
reasonix schedule add --every 2h --prompt "Summarize what changed in the open TODOs" --workspace ~/src/app
@@ -1919,6 +1919,9 @@ reasonix schedule tick # claim and run what is due now, then exit
- Otherwise call `reasonix schedule tick` from the operating system's scheduler (cron, launchd, Task
Scheduler).
- Every tick goes through the store's own budget and expiry checks, so neither route can bypass them.
- **Trusted folders only.** A schedule's folder must be a project you have trusted in Studio or the terminal UI.
Anything else is refused by the kernel (`schedule.workspace_unknown`), and trust is checked again at every run:
withdrawing it stops the schedule's runs.
- **Read-only.** A run reads files in its folder and writes nothing. Anything that needs your approval is parked
for you and never answered on your behalf.
- **Ceilings.** `[schedule]` in your user config can change them; a repository's `reasonix.toml` can only
+5 -1
View File
@@ -77,7 +77,12 @@ type Messages struct {
RenameUsage string // /rename with no args
RenameNoSession string // /rename with no active session
RenameDoneFmt string // /rename succeeded (one %s = new title)
ResumePickTitle string // header in the interactive resume picker
ResumePickHint string // keyboard hint in the interactive resume picker
ResumeRecoveryBadgeFmt string // recovery-copy badge — %s = short parent session id
ResumePickFilter string // resume picker: typing narrows the list
ResumePickSearch string // resume picker: label before the typed query
ResumePickNoMatch string // resume picker: the query matches no session
// terminal transcript rows the kernel does not word.
TUIDeclinedFmt string // an approval the user refused — %s tool, %s subject
@@ -166,7 +171,6 @@ type Messages struct {
NotifyMessages
SlashHintMessages
QueueMessages
ResumePickerMessages
GoalMessages
SettingMessages
ApprovalNeededFmt string // notification text for a pending approval, tool only
+5 -1
View File
@@ -86,6 +86,11 @@ var English = Messages{
RenameUsage: "usage: /rename <new title> or /rename <n> <new title>",
RenameNoSession: "no active session to rename",
RenameDoneFmt: "session renamed to %q",
ResumePickTitle: "Resume a saved session",
ResumePickHint: "↑/↓ move · Enter resume · Esc cancel",
ResumePickFilter: "Type to filter",
ResumePickSearch: "Search: ",
ResumePickNoMatch: "No matches",
TUIDeclinedFmt: "declined %s %s",
NoticeUnappliedSteerFmt: "Guidance was not applied because the turn ended before it could be processed. Send it again if it is still needed:\n%s",
NoticeExtSkippedFmt: "Extension %s's sidecar is not running, so it was skipped at %s. Check it with /plugins and start it, or disable the extension.",
@@ -193,7 +198,6 @@ var English = Messages{
NotifyMessages: notifyEN,
SlashHintMessages: slashHintEN,
QueueMessages: queueEN,
ResumePickerMessages: resumePickerEN,
GoalMessages: goalEN,
SettingMessages: settingEN,
ApprovalNeededFmt: "approval needed: %s",
+5 -1
View File
@@ -87,6 +87,11 @@ var Chinese = Messages{
RenameUsage: "用法:/rename <新名称> 或 /rename <序号> <新名称>",
RenameNoSession: "当前没有活跃会话可重命名",
RenameDoneFmt: "会话已重命名为 %q",
ResumePickTitle: "选择要恢复的会话",
ResumePickHint: "↑/↓ 移动 · Enter 恢复 · Esc 取消",
ResumePickFilter: "输入以筛选",
ResumePickSearch: "搜索:",
ResumePickNoMatch: "没有匹配的会话",
TUIDeclinedFmt: "已拒绝 %s %s",
NoticeUnappliedSteerFmt: "引导没有生效:这一轮在处理它之前就结束了。如果仍然需要,请再发送一次:\n%s",
NoticeExtSkippedFmt: "扩展 %s 的配套后台程序没有运行,本次在 %s 已被跳过。用 /plugins 查看并启动它,或停用该扩展。",
@@ -193,7 +198,6 @@ var Chinese = Messages{
NotifyMessages: notifyZH,
SlashHintMessages: slashHintZH,
QueueMessages: queueZH,
ResumePickerMessages: resumePickerZH,
GoalMessages: goalZH,
SettingMessages: settingZH,
ApprovalNeededFmt: "需要审批:%s",
+5 -1
View File
@@ -87,6 +87,11 @@ var ChineseTraditional = Messages{
ResumeAlreadyActive: "已在該會話中",
ResumedTitle: "已恢復會話",
InterruptedAdjudicationPrefix: "上一輪在等待你的回答時被中斷,問題是:",
ResumePickTitle: "選擇要恢復的會話",
ResumePickHint: "↑/↓ 移動 · Enter 恢復 · Esc 取消",
ResumePickFilter: "輸入以篩選",
ResumePickSearch: "搜尋:",
ResumePickNoMatch: "沒有符合的會話",
TUIDeclinedFmt: "已拒絕 %s %s",
NoticeUnappliedSteerFmt: "引導沒有生效:這一輪在處理它之前就結束了。如果仍然需要,請再發送一次:\n%s",
NoticeExtSkippedFmt: "擴充 %s 的配套背景程式沒有執行,本次在 %s 已被跳過。用 /plugins 查看並啟動它,或停用該擴充。",
@@ -194,7 +199,6 @@ var ChineseTraditional = Messages{
NotifyMessages: notifyZHTW,
SlashHintMessages: slashHintZHTW,
QueueMessages: queueZHTW,
ResumePickerMessages: resumePickerZHTW,
GoalMessages: goalZHTW,
SettingMessages: settingZHTW,
ApprovalNeededFmt: "需要核准:%s",
@@ -1,31 +0,0 @@
package i18n
var resumePickerEN = ResumePickerMessages{
ResumePickTitle: "Resume a saved session",
ResumePickHint: "↑/↓ move · Enter resume · Ctrl+D delete · Esc cancel",
ResumePickFilter: "Type to filter",
ResumePickSearch: "Search: ",
ResumePickNoMatch: "No matches",
ResumeDeleteAskFmt: "Delete %q? Ctrl+D again to confirm, other key cancels",
ResumeDeleteFailedFmt: "Not deleted: %s",
}
var resumePickerZH = ResumePickerMessages{
ResumePickTitle: "选择要恢复的会话",
ResumePickHint: "↑/↓ 移动 · Enter 恢复 · Ctrl+D 删除 · Esc 取消",
ResumePickFilter: "输入以筛选",
ResumePickSearch: "搜索:",
ResumePickNoMatch: "没有匹配的会话",
ResumeDeleteAskFmt: "永久删除 %q?再按 Ctrl+D 确认,其他键取消",
ResumeDeleteFailedFmt: "未删除:%s",
}
var resumePickerZHTW = ResumePickerMessages{
ResumePickTitle: "選擇要恢復的會話",
ResumePickHint: "↑/↓ 移動 · Enter 恢復 · Ctrl+D 刪除 · Esc 取消",
ResumePickFilter: "輸入以篩選",
ResumePickSearch: "搜尋:",
ResumePickNoMatch: "沒有符合的會話",
ResumeDeleteAskFmt: "永久刪除 %q?再按 Ctrl+D 確認,其他鍵取消",
ResumeDeleteFailedFmt: "未刪除:%s",
}
@@ -1,13 +0,0 @@
package i18n
// ResumePickerMessages is the text of the terminal's interactive resume picker, including
// deleting a conversation from it; Messages embeds it.
type ResumePickerMessages struct {
ResumePickTitle string // header in the interactive resume picker
ResumePickHint string // keyboard hint in the interactive resume picker
ResumePickFilter string // typing narrows the list
ResumePickSearch string // label before the typed query
ResumePickNoMatch string // the query matches no session
ResumeDeleteAskFmt string // confirm deleting a conversation — %s its title
ResumeDeleteFailedFmt string // a delete was refused — %s the reason
}
@@ -21,6 +21,7 @@ import (
"reasonix/internal/frontend/schedulerun"
"reasonix/internal/runtime/schedrun"
"reasonix/internal/state/schedule"
"reasonix/internal/state/workspacelist"
)
const (
@@ -222,6 +223,7 @@ api_key_env = "SCHED_FAKE_KEY"
}
store := base.WithClock(clk.Now)
ws := testenv.TempDir(t)
trustSchedFolder(t, ws)
writeTestFile(t, filepath.Join(ws, "notes.txt"), "the TODO list is empty\n", 0o644)
writeTestFile(t, filepath.Join(ws, "reasonix.toml"), "default_model = \"evil/none\"\n", 0o644)
sc, err := store.Create(t.Context(), pol, schedule.CreateRequest{
@@ -273,3 +275,19 @@ func (w *execWorld) runRecord() schedule.Run {
w.t.Fatal("run vanished")
return schedule.Run{}
}
// trustSchedFolder remembers dir as a project and marks it trusted, the state a
// schedule needs its folder to be in both when it is created and when it runs.
func trustSchedFolder(t *testing.T, dir string) {
t.Helper()
list := filepath.Join(config.MemoryUserDir(), workspacelist.FileName)
if err := workspacelist.Update(t.Context(), list, false, func(l *workspacelist.List) error {
l.Paths = append(l.Paths, dir)
return nil
}); err != nil {
t.Fatal(err)
}
if err := config.NewProjectGrantStore(config.RootsForHome("").Home()).SetTrust(dir, config.WorkspaceTrusted); err != nil {
t.Fatal(err)
}
}
@@ -159,6 +159,22 @@ func TestScheduleExecRefusesAnEditedManifestAndSpendsNothing(t *testing.T) {
}
}
// Trust is the person's to withdraw: a schedule confirmed while its project was
// trusted does not read it once it is not.
func TestScheduleExecDoesNotReadAProjectThatIsNoLongerTrusted(t *testing.T) {
w := newExecWorld(t, "", "Look around.", fakeModelRef, fakeTurn{text: "x", prompt: 1, cl: 1})
if err := config.NewProjectGrantStore(config.RootsForHome("").Home()).SetTrust(w.ws, config.WorkspaceTrustDeclined); err != nil {
t.Fatal(err)
}
rep, err := w.supervisor().Run(t.Context(), w.run.TriggerID)
if err != nil || rep.Code != schedrun.CodeWorkspaceUntrust || rep.State != schedule.RunFailed {
t.Fatalf("Run = %+v, %v", rep, err)
}
if n := len(w.model.requests()); n != 0 {
t.Fatalf("provider saw %d requests from a run in a project nobody trusts", n)
}
}
func TestScheduleExecDoesNotSubstituteAModelThatIsNoLongerConfigured(t *testing.T) {
w := newExecWorld(t, "", "Look around.", schedule.Model{Provider: "gone", Model: "m"}, fakeTurn{text: "x", prompt: 1, cl: 1})
rep, err := w.supervisor().Run(t.Context(), w.run.TriggerID)
+36 -62
View File
@@ -19,7 +19,6 @@ import (
"golang.org/x/term"
"reasonix/internal/base/secrets"
"reasonix/internal/contract/config"
"reasonix/internal/runtime/schedrun"
"reasonix/internal/runtime/scheduler"
"reasonix/internal/state/schedule"
@@ -249,8 +248,7 @@ func scheduleAdd(args []string, store *schedule.Store, p schedule.Policy, sio sc
fmt.Fprintln(sio.err, "schedule:", err)
return scheduleExitUsage
}
budget := p.DefaultBudget()
plan := preview(req, budget, p, sio.now())
plan := preview(req, p, sio.now())
code := planCode(plan)
fmt.Fprint(sio.out, plan)
fmt.Fprintf(sio.out, "Type %s to create this schedule, anything else cancels: ", code)
@@ -269,48 +267,41 @@ func scheduleAdd(args []string, store *schedule.Store, p schedule.Policy, sio sc
}
func (f addFlags) request() (schedule.CreateRequest, error) {
req := schedule.CreateRequest{Label: strings.TrimSpace(f.label), Prompt: strings.TrimSpace(f.prompt), ConfirmedBy: schedule.ConfirmedByHuman, ConfirmedVia: schedule.ConfirmedViaTerminal}
d := scheduler.Draft{Label: f.label, Prompt: f.prompt, Workspace: f.workspace, Model: f.model}
wd, err := os.Getwd()
if err != nil {
return schedule.CreateRequest{}, err
}
if d.Workspace == "" {
d.Workspace = wd
}
switch {
case (f.every == "") == (f.at == ""):
return req, errors.New("give exactly one of --every and --at")
return schedule.CreateRequest{}, errors.New("give exactly one of --every and --at")
case f.every != "":
d, err := time.ParseDuration(f.every)
if err != nil || d <= 0 {
return req, fmt.Errorf("--every %q is not a duration such as 90m or 2h", f.every)
dur, err := time.ParseDuration(f.every)
if err != nil || dur <= 0 {
return schedule.CreateRequest{}, fmt.Errorf("--every %q is not a duration such as 90m or 2h", f.every)
}
req.Trigger = schedule.Trigger{Kind: schedule.TriggerEvery, EverySec: int64(d / time.Second)}
d.EverySec = int64(dur / time.Second)
default:
at, err := parseAt(f.at)
if err != nil {
return req, err
return schedule.CreateRequest{}, err
}
req.Trigger = schedule.Trigger{Kind: schedule.TriggerAt, At: at}
d.At = at
}
ws := f.workspace
if ws == "" {
wd, err := os.Getwd()
if err != nil {
return req, err
}
ws = wd
req, err := d.Request(scheduler.Folders(wd))
switch {
case errors.Is(err, scheduler.ErrDraftFolderUnknown):
return req, fmt.Errorf("%q is not a trusted project: open it in Studio or the terminal UI and trust it first, then run this again", d.Workspace)
case errors.Is(err, scheduler.ErrDraftWorkspace):
return req, fmt.Errorf("--workspace %q is not a folder", f.workspace)
case errors.Is(err, scheduler.ErrDraftModel):
return req, fmt.Errorf("%w; give one with --model", err)
}
abs, err := filepath.Abs(ws)
if err != nil {
return req, err
}
if real, err := filepath.EvalSymlinks(abs); err == nil {
abs = real
}
if info, err := os.Stat(abs); err != nil || !info.IsDir() {
return req, fmt.Errorf("--workspace %q is not a folder", ws)
}
req.Target = schedule.Target{Workspace: abs}
model, err := pinModel(f.model, abs)
if err != nil {
return req, err
}
req.Model = model
return req, nil
req.ConfirmedVia = schedule.ConfirmedViaTerminal
return req, err
}
func parseAt(s string) (time.Time, error) {
@@ -323,25 +314,6 @@ func parseAt(s string) (time.Time, error) {
return time.Time{}, fmt.Errorf("--at %q is not a time (RFC 3339, or 2006-01-02 15:04 in local time)", s)
}
// pinModel fixes the model at creation from the user's own configuration, so a
// repository cannot change which model, or whose key, a confirmed run uses.
func pinModel(ref, workspace string) (schedule.Model, error) {
cfg, err := config.RootsForHome("").LoadUserScopeForRoot(workspace)
if err != nil {
return schedule.Model{}, err
}
if ref == "" {
ref = cfg.DefaultModel
}
entry, ok := cfg.ResolveModel(ref)
if !ok || entry == nil {
return schedule.Model{}, fmt.Errorf("model %q is not a configured model; give one with --model", ref)
}
return schedule.Model{Provider: entry.Name, Model: entry.Model}, nil
}
// preview is what a person confirms: when it first runs, what it may do, and the
// most it can spend, in the policy's own numbers.
// planCode is the short id a person types back: it names the exact plan shown,
// so a confirmation typed for one plan cannot be replayed for another.
func planCode(plan string) string {
@@ -349,18 +321,20 @@ func planCode(plan string) string {
return hex.EncodeToString(sum[:3])
}
func preview(req schedule.CreateRequest, b schedule.Budget, p schedule.Policy, now time.Time) string {
// preview is what a person confirms: when it first runs, what it may do, and the
// most it can spend, in the policy's own numbers.
func preview(req schedule.CreateRequest, p schedule.Policy, now time.Time) string {
f := scheduler.FactsFor(req, p, now)
var sb strings.Builder
fmt.Fprintf(&sb, "Schedule %s\n", describeTrigger(req.Trigger))
if req.Trigger.Kind == schedule.TriggerEvery {
fmt.Fprintf(&sb, " first run: %s\n", now.Add(time.Duration(req.Trigger.EverySec)*time.Second).Local().Format("2006-01-02 15:04"))
fmt.Fprintf(&sb, " first run: %s\n", f.FirstRun.Local().Format("2006-01-02 15:04"))
}
fmt.Fprintf(&sb, " folder: %s (read-only: it can read files here and write nothing)\n", req.Target.Workspace)
fmt.Fprintf(&sb, " model: %s/%s\n", req.Model.Provider, req.Model.Model)
fmt.Fprintf(&sb, " prompt: %s\n", req.Prompt)
fmt.Fprintf(&sb, " each run: up to %d tokens, %d steps, %d s\n", b.PerRunTokens, b.PerRunSteps, b.PerRunWallSec)
perDay := min(b.PerRunTokens*b.MaxRunsPerDay, p.GlobalTokensDay)
fmt.Fprintf(&sb, " at most: %d tokens per day, %d over its lifetime; it ends on its own after %d days\n", perDay, b.LifetimeTokens, p.ExpiryDays)
fmt.Fprintf(&sb, " folder: %s (read-only: it can read files here and write nothing)\n", f.Folder)
fmt.Fprintf(&sb, " model: %s/%s\n", f.Model.Provider, f.Model.Model)
fmt.Fprintf(&sb, " prompt: %s\n", f.Prompt)
fmt.Fprintf(&sb, " each run: up to %d tokens, %d steps, %d s\n", f.Budget.PerRunTokens, f.Budget.PerRunSteps, f.Budget.PerRunWallSec)
fmt.Fprintf(&sb, " at most: %d tokens per day, %d over its lifetime; it ends on its own after %d days\n", f.PerDayMax, f.Lifetime, p.ExpiryDays)
sb.WriteString(" Anything that needs your approval is parked for you, never answered for you.\n")
return sb.String()
}
+44 -1
View File
@@ -50,7 +50,18 @@ api_key_env = "REASONIX_TEST_KEY"
t.Fatal(err)
}
_ = home
return &schedWorld{t: t, ws: t.TempDir(), now: time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC), human: true}
w := &schedWorld{t: t, ws: t.TempDir(), now: time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC), human: true}
w.trust(w.ws)
t.Chdir(w.ws)
return w
}
// trust marks dir a trusted project, which is the only kind a schedule may read.
func (w *schedWorld) trust(dir string) {
w.t.Helper()
if err := config.NewProjectGrantStore(config.RootsForHome("").Home()).SetTrust(dir, config.WorkspaceTrusted); err != nil {
w.t.Fatal(err)
}
}
func (w *schedWorld) run(args ...string) int {
@@ -267,3 +278,35 @@ func TestScheduleUsageErrors(t *testing.T) {
}
}
}
// A schedule reads one of the trusted projects and nothing else; every other
// spelling of a folder is refused before anything about it is looked at.
func TestScheduleAddOnlyAcceptsATrustedProject(t *testing.T) {
w := newSchedWorld(t)
parent := filepath.Dir(w.ws)
untrusted := t.TempDir()
missing := filepath.Join(w.ws, "no-such-folder")
for name, folder := range map[string]string{
"its parent directory": parent,
"a path through ..": filepath.Join(w.ws, "sub", ".."+string(filepath.Separator)+".."),
"a folder that is not there": missing,
"a relative path": ".",
"a project nobody trusted": untrusted,
"the filesystem root": string(filepath.Separator),
} {
w.input = "CONFIRM"
w.run("add", "--every", "2h", "--prompt", "x", "--workspace", folder)
if got := w.schedules(); len(got) != 0 {
t.Errorf("%s: a schedule was created: %+v", name, got)
}
if !strings.Contains(w.err.String(), "not a trusted project") {
t.Errorf("%s: the refusal does not say why: %q", name, w.err.String())
}
}
w.trust(untrusted)
t.Chdir(untrusted)
w.input = "CONFIRM"
if code := w.run("add", "--every", "2h", "--prompt", "x", "--workspace", untrusted); code != 0 || len(w.schedules()) != 1 {
t.Fatalf("a project trusted after the fact was refused: %d %s", code, w.err.String())
}
}
+6
View File
@@ -27,6 +27,7 @@ import (
"reasonix/internal/contract/tool"
"reasonix/internal/frontend/clisession"
"reasonix/internal/runtime/schedrun"
"reasonix/internal/runtime/scheduler"
"reasonix/internal/session/control"
"reasonix/internal/state/schedule"
)
@@ -181,6 +182,11 @@ func (r *scheduleRun) load(ctx context.Context) (code string) {
if r.sc.Confirmed.By != schedule.ConfirmedByHuman || r.sc.Confirmed.Digest != schedule.Digest(r.sc) {
return schedrun.CodeDigest
}
// Trust is the person's to withdraw: a project no longer on the trusted list
// is not read by a schedule that was confirmed while it was.
if _, ok := scheduler.Match(scheduler.Folders(""), r.sc.Target.Workspace); !ok {
return schedrun.CodeWorkspaceUntrust
}
return ""
}
+1
View File
@@ -59,6 +59,7 @@ func (s *Server) routes() http.Handler {
s.registerChangedFilesRoutes(mux)
s.registerFeedbackLevelNoticeRoutes(mux)
s.registerObservabilityRoutes(mux)
s.registerScheduleRoutes(mux)
mux.HandleFunc("POST /new", s.newSession)
mux.HandleFunc("POST /rewind", s.rewind)
mux.HandleFunc("POST /branch/at", s.branchAt)
+165
View File
@@ -0,0 +1,165 @@
package serve
import (
"encoding/json"
"errors"
"net/http"
"reasonix/internal/runtime/scheduler"
"reasonix/internal/session/control"
"reasonix/internal/state/schedule"
)
func (s *Server) registerScheduleRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /schedules", s.schedules)
mux.HandleFunc("GET /schedules/result", s.scheduleResult)
mux.HandleFunc("POST /schedules/preview", s.previewSchedule)
mux.HandleFunc("POST /schedules", s.createSchedule)
mux.HandleFunc("POST /schedules/pause-all", s.scheduleSwitch(true))
mux.HandleFunc("POST /schedules/resume-all", s.scheduleSwitch(false))
mux.HandleFunc("POST /schedules/{id}/pause", s.scheduleOne("pause"))
mux.HandleFunc("POST /schedules/{id}/resume", s.scheduleOne("resume"))
mux.HandleFunc("POST /schedules/{id}/renew", s.scheduleOne("renew"))
mux.HandleFunc("POST /schedules/{id}/delete", s.scheduleOne("delete"))
}
// allowSchedules gates what widens unattended spending, which a networked
// client or a paired phone must not decide for the machine's owner. Pausing and
// deleting only narrow it, and stay open to any reader.
func (s *Server) allowSchedules(w http.ResponseWriter, r *http.Request) bool {
if s.grants.at(r).providerEdit {
return true
}
refuse(w, http.StatusForbidden, "schedule.editing_disabled", "scheduled runs are not editable by this client", nil)
return false
}
func (s *Server) schedules(w http.ResponseWriter, r *http.Request) {
board, err := s.ctl().Schedules(r.Context())
if err != nil {
refuseSchedule(w, err)
return
}
writeJSON(w, board)
}
func (s *Server) scheduleResult(w http.ResponseWriter, r *http.Request) {
trigger := r.URL.Query().Get("trigger")
if trigger == "" {
missingField(w, "trigger")
return
}
res, err := s.ctl().ScheduleResult(r.Context(), trigger)
if err != nil {
refuseSchedule(w, err)
return
}
writeJSON(w, res)
}
func (s *Server) previewSchedule(w http.ResponseWriter, r *http.Request) {
var draft control.ScheduleDraft
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10)).Decode(&draft); err != nil {
badBody(w)
return
}
prev, err := s.ctl().PreviewSchedule(r.Context(), draft)
if err != nil {
refuseSchedule(w, err)
return
}
writeJSON(w, prev)
}
func (s *Server) createSchedule(w http.ResponseWriter, r *http.Request) {
if !s.allowSchedules(w, r) {
return
}
var body struct {
control.ScheduleDraft
Confirm string `json:"confirm"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10)).Decode(&body); err != nil {
badBody(w)
return
}
if body.Confirm == "" {
refuse(w, http.StatusConflict, "schedule.confirmation_required", "a schedule is created only from a preview the person confirmed", nil)
return
}
sc, err := s.ctl().CreateSchedule(r.Context(), body.ScheduleDraft, body.Confirm)
if err != nil {
refuseSchedule(w, err)
return
}
writeJSON(w, sc)
}
func (s *Server) scheduleSwitch(paused bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !paused && !s.allowSchedules(w, r) {
return
}
if err := s.ctl().SetSchedulesPaused(r.Context(), paused); err != nil {
refuseSchedule(w, err)
return
}
s.schedules(w, r)
}
}
func (s *Server) scheduleOne(verb string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if (verb == "resume" || verb == "renew") && !s.allowSchedules(w, r) {
return
}
id, ctx, ctl := r.PathValue("id"), r.Context(), s.ctl()
var err error
switch verb {
case "pause":
err = ctl.PauseSchedule(ctx, id)
case "resume":
err = ctl.ResumeSchedule(ctx, id)
case "renew":
err = ctl.RenewSchedule(ctx, id)
case "delete":
err = ctl.DeleteSchedule(ctx, id)
}
if err != nil {
refuseSchedule(w, err)
return
}
s.schedules(w, r)
}
}
// refuseSchedule gives each refusal its own code: the store and the controller
// answer by identity, and the sentence is the frontend's.
func refuseSchedule(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, schedule.ErrNotFound), errors.Is(err, schedule.ErrResultNotFound):
refuse(w, http.StatusNotFound, "schedule.not_found", "no such schedule or result", nil)
case errors.Is(err, control.ErrScheduleConfirmMismatch):
refuse(w, http.StatusConflict, "schedule.digest_mismatch", "what is being created is not what was previewed", nil)
case errors.Is(err, schedule.ErrIntervalBelowFloor):
refuse(w, http.StatusBadRequest, "schedule.interval_too_short", "the interval is under the minimum", nil)
case errors.Is(err, schedule.ErrTooMany):
refuse(w, http.StatusConflict, "schedule.limit_reached", "the schedule limit is reached", nil)
case errors.Is(err, scheduler.ErrDraftTrigger):
refuse(w, http.StatusBadRequest, "schedule.bad_trigger", "give exactly one of an interval and a time", nil)
case errors.Is(err, scheduler.ErrDraftFolderUnknown):
refuse(w, http.StatusBadRequest, "schedule.workspace_unknown", "the folder is not a trusted project", nil)
case errors.Is(err, scheduler.ErrDraftWorkspace):
refuse(w, http.StatusBadRequest, "schedule.no_folder", "the folder does not exist", nil)
case errors.Is(err, scheduler.ErrDraftModel):
refuse(w, http.StatusBadRequest, "schedule.model_unconfigured", "the model is not configured", nil)
case errors.Is(err, schedule.ErrInvalid):
refuse(w, http.StatusBadRequest, "schedule.invalid", "the schedule is not valid", nil)
case errors.Is(err, schedule.ErrSchemaReadonly):
refuse(w, http.StatusConflict, "schedule.schema_readonly", "the schedules were written by a newer version and are read-only here", nil)
case errors.Is(err, schedule.ErrNotActive), errors.Is(err, schedule.ErrExpired):
refuse(w, http.StatusConflict, "schedule.not_active", "the schedule is not active", nil)
default:
saveFailed(w, http.StatusInternalServerError, "schedule.save_failed", err)
}
}
+175
View File
@@ -0,0 +1,175 @@
package serve
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/session/control"
"reasonix/internal/state/workspacelist"
)
func schedulesServer(t *testing.T) (string, string) {
t.Helper()
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(operatorHandler(s))
t.Cleanup(srv.Close)
ws := testenv.TempDir(t)
trustServeFolder(t, ws)
return srv.URL, ws
}
// trustServeFolder remembers dir as a project and marks it trusted.
func trustServeFolder(t *testing.T, dir string) {
t.Helper()
list := filepath.Join(config.MemoryUserDir(), workspacelist.FileName)
if err := workspacelist.Update(context.Background(), list, false, func(l *workspacelist.List) error {
l.Paths = append(l.Paths, dir)
return nil
}); err != nil {
t.Fatal(err)
}
if err := config.NewProjectGrantStore(config.RootsForHome("").Home()).SetTrust(dir, config.WorkspaceTrusted); err != nil {
t.Fatal(err)
}
}
func scheduleDraft(ws string) map[string]any {
return map[string]any{"label": "morning", "everySec": 7200, "workspace": ws, "prompt": "summarize the TODOs"}
}
func TestAScheduleIsPreviewedConfirmedListedAndPaused(t *testing.T) {
base, ws := schedulesServer(t)
if got := readJSON[control.ScheduleBoard](t, base, "/schedules"); len(got.Schedules) != 0 || got.PausedAll {
t.Fatalf("empty board = %+v", got)
}
status, raw := postSide(t, base+"/schedules/preview", scheduleDraft(ws))
if status != http.StatusOK || !strings.Contains(string(raw), `"confirm"`) {
t.Fatalf("preview = %d %s", status, raw)
}
var prev control.SchedulePreview
mustDecode(t, raw, &prev)
if status, raw := postSide(t, base+"/schedules", scheduleDraft(ws)); status != http.StatusConflict || !strings.Contains(string(raw), "schedule.confirmation_required") {
t.Fatalf("no confirmation = %d %s", status, raw)
}
changed := scheduleDraft(ws)
changed["prompt"] = "something else"
changed["confirm"] = prev.Confirm
if status, raw := postSide(t, base+"/schedules", changed); status != http.StatusConflict || !strings.Contains(string(raw), "schedule.digest_mismatch") {
t.Fatalf("a changed draft = %d %s", status, raw)
}
ok := scheduleDraft(ws)
ok["confirm"] = prev.Confirm
status, raw = postSide(t, base+"/schedules", ok)
if status != http.StatusOK {
t.Fatalf("create = %d %s", status, raw)
}
var sc control.ScheduleView
mustDecode(t, raw, &sc)
if got := readJSON[control.ScheduleBoard](t, base, "/schedules"); len(got.Schedules) != 1 || got.Schedules[0].ID != sc.ID {
t.Fatalf("board = %+v", got)
}
if status, raw := postSide(t, base+"/schedules/"+sc.ID+"/pause", map[string]any{}); status != http.StatusOK || !strings.Contains(string(raw), `"paused"`) {
t.Fatalf("pause = %d %s", status, raw)
}
if status, _ := postSide(t, base+"/schedules/"+sc.ID+"/resume", map[string]any{}); status != http.StatusOK {
t.Fatalf("resume = %d", status)
}
if status, _ := postSide(t, base+"/schedules/pause-all", map[string]any{}); status != http.StatusOK {
t.Fatalf("pause-all = %d", status)
}
if got := readJSON[control.ScheduleBoard](t, base, "/schedules"); !got.PausedAll {
t.Fatal("the global switch did not show")
}
if status, _ := postSide(t, base+"/schedules/"+sc.ID+"/delete", map[string]any{}); status != http.StatusOK {
t.Fatalf("delete = %d", status)
}
}
func TestScheduleRefusalsCarryTheirOwnCodes(t *testing.T) {
base, ws := schedulesServer(t)
short := scheduleDraft(ws)
short["everySec"] = 60
_, raw := postSide(t, base+"/schedules/preview", short)
var prev control.SchedulePreview
mustDecode(t, raw, &prev)
short["confirm"] = prev.Confirm
for name, c := range map[string]struct {
path string
body map[string]any
code string
}{
"under the floor": {"/schedules", short, "schedule.interval_too_short"},
"no trigger": {"/schedules/preview", map[string]any{"workspace": ws, "prompt": "x"}, "schedule.bad_trigger"},
"unknown folder": {"/schedules/preview", map[string]any{"everySec": 7200, "workspace": ws + "/nope", "prompt": "x"}, "schedule.workspace_unknown"},
"parent folder": {"/schedules/preview", map[string]any{"everySec": 7200, "workspace": filepath.Dir(ws), "prompt": "x"}, "schedule.workspace_unknown"},
"relative folder": {"/schedules/preview", map[string]any{"everySec": 7200, "workspace": ".", "prompt": "x"}, "schedule.workspace_unknown"},
"unknown model": {"/schedules/preview", map[string]any{"everySec": 7200, "workspace": ws, "prompt": "x", "model": "nobody/none"}, "schedule.model_unconfigured"},
"unknown id": {"/schedules/sch_none/pause", map[string]any{}, "schedule.not_found"},
} {
if status, raw := postSide(t, base+c.path, c.body); status < 400 || !strings.Contains(string(raw), c.code) {
t.Errorf("%s = %d %s, want %s", name, status, raw, c.code)
}
}
gone := filepath.Join(t.TempDir(), "gone")
if err := os.MkdirAll(gone, 0o755); err != nil {
t.Fatal(err)
}
trustServeFolder(t, gone)
if err := os.RemoveAll(gone); err != nil {
t.Fatal(err)
}
if status, raw := postSide(t, base+"/schedules/preview", map[string]any{"everySec": 7200, "workspace": gone, "prompt": "x"}); status != http.StatusBadRequest || !strings.Contains(string(raw), "schedule.workspace_unknown") {
t.Errorf("a trusted folder that was deleted = %d %s, want schedule.workspace_unknown ", status, raw)
}
resp, err := http.Get(base + "/schedules/result?trigger=sch_x/1")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("an unrecorded result = %d", resp.StatusCode)
}
}
// What widens unattended spending is the machine owner's: a client without the
// edit grant, or a paired phone, can pause and delete but not create or resume.
func TestOnlyTheMachinesOwnerMayCreateOrResumeSchedules(t *testing.T) {
s := newProviderEditServer(t)
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
for _, p := range []string{"/schedules", "/schedules/sch_x/resume", "/schedules/sch_x/renew", "/schedules/resume-all"} {
if status, raw := postSide(t, srv.URL+p, map[string]any{"confirm": "x"}); status != http.StatusForbidden || !strings.Contains(string(raw), "schedule.editing_disabled") {
t.Errorf("%s without the grant = %d %s", p, status, raw)
}
}
for _, p := range []string{"/schedules/sch_x/pause", "/schedules/pause-all"} {
if status, _ := postSide(t, srv.URL+p, map[string]any{}); status == http.StatusForbidden {
t.Errorf("%s was refused, but only narrows", p)
}
}
s.AllowProviderEdit()
paired := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
operatorHandler(s).ServeHTTP(w, r.WithContext(withDeviceReach(r.Context(), "phone", 1)))
}))
defer paired.Close()
if status, _ := postSide(t, paired.URL+"/schedules", map[string]any{"confirm": "x", "everySec": 7200, "prompt": "x"}); status == http.StatusOK {
t.Fatal("a paired device created a schedule")
}
}
func mustDecode(t *testing.T, raw []byte, into any) {
t.Helper()
if err := json.Unmarshal(raw, into); err != nil {
t.Fatalf("%s: %v", raw, err)
}
}
-2
View File
@@ -468,8 +468,6 @@ func (m *model) onScreenMsg(msg tea.Msg) (tea.Cmd, bool) {
return m.onModelSwitched(msg), true
case sessionsMsg:
return m.onSessions(msg), true
case deletedMsg:
return m.onDeleted(msg), true
case resumedMsg:
return m.onResumed(msg), true
case checkpointsMsg:
-8
View File
@@ -134,13 +134,6 @@ func (k *recordingKernel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"planId": "p-1", "canFiles": true, "canConversation": true})
case "/rewind/commit":
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "conversationOk": true})
case "/delete-session":
if strings.Contains(string(body), `"a"`) {
w.WriteHeader(http.StatusConflict)
_ = json.NewEncoder(w).Encode(map[string]any{"code": "session.active", "message": "this session is the one open here"})
return
}
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusNoContent)
}
@@ -207,7 +200,6 @@ func press(m *model, k string) tea.Cmd {
"enter": {Code: tea.KeyEnter},
"esc": {Code: tea.KeyEscape},
"ctrl+s": {Code: 's', Mod: tea.ModCtrl},
"ctrl+d": {Code: 'd', Mod: tea.ModCtrl},
"ctrl+c": {Code: 'c', Mod: tea.ModCtrl},
"ctrl+o": {Code: 'o', Mod: tea.ModCtrl},
"ctrl+g": {Code: 'g', Mod: tea.ModCtrl},
+1 -56
View File
@@ -88,11 +88,6 @@ func (c *Client) RenameSession(ctx context.Context, name, title string) error {
return c.do(ctx, http.MethodPost, "/sessions/rename", map[string]string{"id": name, "title": title}, nil)
}
// DeleteSession erases the saved conversation listed under name.
func (c *Client) DeleteSession(ctx context.Context, name string) error {
return c.do(ctx, http.MethodPost, "/delete-session", map[string]string{"name": name}, nil)
}
// Resume binds the runtime to the saved conversation at path.
func (c *Client) Resume(ctx context.Context, path string) error {
return c.do(ctx, http.MethodPost, "/resume", map[string]string{"path": path}, nil)
@@ -104,9 +99,6 @@ type sessionPicker struct {
all []SessionInfo
query string
sel int
// arming is the path a first Ctrl+D marked for deletion; notice is the last refusal.
arming string
notice string
}
type (
@@ -115,10 +107,6 @@ type (
err error
}
resumedMsg struct{ err error }
deletedMsg struct {
path string
err error
}
)
func (m *model) openPicker() tea.Cmd {
@@ -185,19 +173,7 @@ func (m *model) pickerKey(msg tea.KeyPressMsg) (tea.Cmd, bool) {
return nil, false
}
items := p.shown()
k := msg.String()
arming := p.arming
p.arming, p.notice = "", ""
switch k {
case "ctrl+d":
if p.sel >= len(items) {
break
}
if s := items[p.sel]; arming != s.Path {
p.arming = s.Path
} else {
return m.deleteSession(s), true
}
switch k := msg.String(); k {
case "esc":
m.picker = nil
case "up", "ctrl+p":
@@ -221,26 +197,6 @@ func (m *model) pickerKey(msg tea.KeyPressMsg) (tea.Cmd, bool) {
return nil, true
}
func (m *model) deleteSession(s SessionInfo) tea.Cmd {
return func() tea.Msg { return deletedMsg{path: s.Path, err: m.client.DeleteSession(m.ctx, s.Name)} }
}
// onDeleted drops the erased row and keeps the picker open for the next one;
// a refusal stays on the picker, where the user is looking.
func (m *model) onDeleted(msg deletedMsg) tea.Cmd {
p := m.picker
if p == nil {
return nil
}
if msg.err != nil {
p.notice = fmt.Sprintf(i18n.M.ResumeDeleteFailedFmt, msg.err.Error())
return nil
}
p.all = slices.DeleteFunc(p.all, func(s SessionInfo) bool { return s.Path == msg.path })
p.sel = min(p.sel, max(len(p.shown())-1, 0))
return nil
}
func (m *model) pickSession(s SessionInfo) tea.Cmd {
m.picker = nil
switch {
@@ -313,17 +269,6 @@ func (m *model) pickerPanel() []string {
if end < len(items) {
lines = append(lines, termrender.Dim(" ↓ more"))
}
if p.arming != "" {
for _, s := range items {
if s.Path == p.arming {
room := max(width-termrender.VisibleWidth(fmt.Sprintf(i18n.M.ResumeDeleteAskFmt, "")), 1)
lines = append(lines, termrender.Accent(clipVisible(fmt.Sprintf(i18n.M.ResumeDeleteAskFmt, clipVisible(sessionLabel(s), room)), width)))
}
}
}
if p.notice != "" {
lines = append(lines, termrender.Accent(p.notice))
}
lines = append(lines, termrender.Dim(i18n.M.ResumePickFilter+" · "+i18n.M.ResumePickHint))
return panel(lines, m.width, accentEdge)
}
-57
View File
@@ -8,7 +8,6 @@ import (
tea "charm.land/bubbletea/v2"
"reasonix/internal/contract/eventwire"
"reasonix/internal/frontend/termrender"
)
// The picker opens on the first conversation that is not the open one, what
@@ -39,62 +38,6 @@ func TestPickerResumesTheChosenSession(t *testing.T) {
}
}
// Ctrl+D arms the row under the cursor and only a second Ctrl+D on the same row
// deletes it; any other key disarms, and a refusal stays on the picker.
func TestPickerDeletesOnlyAfterAConfirmingSecondPress(t *testing.T) {
m, k := testModel(t)
run(m, m.openPicker())
deleted := func() bool { return strings.Contains(strings.Join(k.seen(), "\n"), "POST /delete-session") }
run(m, press(m, "ctrl+d"))
if deleted() || !strings.Contains(m.View().Content, "Ctrl+D again") {
t.Fatalf("first press should only ask:\n%s", m.View().Content)
}
run(m, press(m, "down"))
run(m, press(m, "ctrl+d"))
if deleted() {
t.Fatal("a press after moving the cursor deleted a row that was never confirmed")
}
run(m, press(m, "ctrl+d"))
if !strings.Contains(strings.Join(k.seen(), "\n"), `POST /delete-session {"name":"c"}`) {
t.Fatalf("confirmed delete did not reach the kernel: %v", k.seen())
}
if m.picker == nil || len(m.picker.all) != 2 {
t.Fatalf("picker should stay open without the erased row: %+v", m.picker)
}
}
// The confirmation is the one line that says what a second press does, so it is the
// title that gives way on a narrow terminal, never the instruction.
func TestPickerConfirmationKeepsItsInstructionOnANarrowTerminal(t *testing.T) {
m, _ := testModel(t)
m.width = 70
run(m, m.openPicker())
m.picker.all[1].Title = strings.Repeat("a long conversation title ", 6)
m.picker.sel = 1
run(m, press(m, "ctrl+d"))
var line string
for l := range strings.SplitSeq(m.View().Content, "\n") {
if strings.Contains(l, "Delete") {
line = l
}
}
if !strings.Contains(line, "other key cancels") || termrender.VisibleWidth(strings.TrimRight(line, " ")) > m.width {
t.Fatalf("confirmation line = %q (width %d)", line, termrender.VisibleWidth(line))
}
}
func TestPickerShowsWhyTheOpenSessionWasNotDeleted(t *testing.T) {
m, _ := testModel(t)
run(m, m.openPicker())
m.picker.sel = 0
run(m, press(m, "ctrl+d"))
run(m, press(m, "ctrl+d"))
if m.picker == nil || len(m.picker.all) != 3 || !strings.Contains(m.View().Content, "Not deleted") {
t.Fatalf("refusal should stay on the picker:\n%s", m.View().Content)
}
}
func TestPickerIgnoresTheOpenSession(t *testing.T) {
m, k := testModel(t)
run(m, m.openPicker())
+2
View File
@@ -35,6 +35,8 @@ const (
CodeRunStarted = "schedule.run_started"
CodeRunSettled = "schedule.run_settled"
CodeDigest = "schedule.digest_mismatch"
CodeWorkspaceGone = "schedule.workspace_gone"
CodeWorkspaceUntrust = "schedule.workspace_untrusted"
CodeBuild = "schedule.build_failed"
CodePolicy = "schedule.policy_invalid"
CodeProviderAuth = "provider.auth"
+72
View File
@@ -0,0 +1,72 @@
package scheduler
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"reasonix/internal/state/schedule"
)
// Typed reasons a draft cannot become a request; a frontend maps them by
// identity and never by wording.
var (
ErrDraftTrigger = errors.New("schedule draft: give exactly one of an interval and a time")
ErrDraftWorkspace = errors.New("schedule draft: the folder does not exist")
// ErrDraftFolderUnknown: the folder is not one of the trusted projects, which
// is the only kind a schedule may read.
ErrDraftFolderUnknown = errors.New("schedule draft: the folder is not a trusted project")
ErrDraftModel = errors.New("schedule draft: the model is not configured")
)
// Draft is what a person typed or picked, before the host normalises it: the
// one shape every frontend builds a schedule from.
type Draft struct {
Label string
EverySec int64
At time.Time
Workspace string
Prompt string
// Model is a provider/model reference; empty is the user's default.
Model string
}
// Request normalises a draft into the store's request. The folder must be one
// of folders (see Folders); it is resolved from that entry, not from the draft.
// The model is pinned from the user's configuration, and the request is marked
// as a human's: a frontend only calls this for one.
func (d Draft) Request(folders []string) (schedule.CreateRequest, error) {
req := schedule.CreateRequest{Label: strings.TrimSpace(d.Label), Prompt: strings.TrimSpace(d.Prompt), ConfirmedBy: schedule.ConfirmedByHuman}
if (d.EverySec > 0) == !d.At.IsZero() {
return req, ErrDraftTrigger
}
if d.EverySec > 0 {
req.Trigger = schedule.Trigger{Kind: schedule.TriggerEvery, EverySec: d.EverySec}
} else {
req.Trigger = schedule.Trigger{Kind: schedule.TriggerAt, At: d.At}
}
ws, ok := Match(folders, d.Workspace)
if !ok {
return req, ErrDraftFolderUnknown
}
abs, err := filepath.Abs(ws)
if err != nil {
return req, err
}
if real, err := filepath.EvalSymlinks(abs); err == nil {
abs = real
}
if info, err := os.Stat(abs); err != nil || !info.IsDir() {
return req, fmt.Errorf("%w: %q", ErrDraftWorkspace, ws)
}
req.Target = schedule.Target{Workspace: abs}
model, err := PinModel(d.Model, abs)
if err != nil {
return req, fmt.Errorf("%w: %w", ErrDraftModel, err)
}
req.Model = model
return req, nil
}
+60
View File
@@ -0,0 +1,60 @@
package scheduler
import (
"fmt"
"time"
"reasonix/internal/contract/config"
"reasonix/internal/state/schedule"
)
// PinModel fixes the model a schedule will use from the user's own
// configuration, so a repository cannot change which model, or whose key, a
// confirmed run uses. An empty ref is the user's default model.
func PinModel(ref, workspace string) (schedule.Model, error) {
cfg, err := config.RootsForHome("").LoadUserScopeForRoot(workspace)
if err != nil {
return schedule.Model{}, err
}
if ref == "" {
ref = cfg.DefaultModel
}
entry, ok := cfg.ResolveModel(ref)
if !ok || entry == nil {
return schedule.Model{}, fmt.Errorf("model %q is not a configured model", ref)
}
return schedule.Model{Provider: entry.Name, Model: entry.Model}, nil
}
// Facts is what a person confirms before a schedule exists: when it first runs,
// where it works, which model, and the most it can spend, in the policy's own
// numbers. Every frontend shows these and none computes them itself.
type Facts struct {
Trigger schedule.Trigger
FirstRun time.Time
Folder string
Model schedule.Model
Prompt string
Budget schedule.Budget
PerDayMax int64
Lifetime int64
// ExpiresAt is when it ends on its own unless a person renews it.
ExpiresAt time.Time
}
// FactsFor computes the confirmation facts for a request under a policy.
func FactsFor(req schedule.CreateRequest, p schedule.Policy, now time.Time) Facts {
b := p.DefaultBudget()
if req.Budget != nil {
b = *req.Budget
}
f := Facts{Trigger: req.Trigger, Folder: req.Target.Workspace, Model: req.Model, Prompt: req.Prompt, Budget: b,
PerDayMax: min(b.PerRunTokens*b.MaxRunsPerDay, p.GlobalTokensDay), Lifetime: b.LifetimeTokens, ExpiresAt: now.Add(p.Expiry())}
switch req.Trigger.Kind {
case schedule.TriggerEvery:
f.FirstRun = now.Add(time.Duration(req.Trigger.EverySec) * time.Second)
case schedule.TriggerAt:
f.FirstRun = req.Trigger.At
}
return f
}
+77
View File
@@ -0,0 +1,77 @@
package scheduler
import (
"os"
"path/filepath"
"runtime"
"strings"
"reasonix/internal/contract/config"
"reasonix/internal/state/workspacelist"
)
// Folders are the project roots a schedule may read: the remembered projects,
// and current when it is one, that the person has marked trusted. The list
// comes from the person's own record, never from a request, and a request is
// only ever compared with it.
func Folders(current string) []string {
roots := config.RootsForHome("")
grants := config.NewProjectGrantStore(roots.Home())
var candidates []string
if dir := config.MemoryUserDir(); dir != "" {
if list, err := workspacelist.Read(filepath.Join(dir, workspacelist.FileName)); err == nil {
candidates = list.Paths
}
}
if current != "" {
candidates = append(candidates, current)
}
var out []string
for _, p := range candidates {
if trust, err := grants.Trust(p); err != nil || trust != config.WorkspaceTrusted || contains(out, p) {
continue
}
// A project that is no longer there is not one a schedule can read, and
// answering "gone" would tell a caller about paths it was never offered.
if info, err := os.Stat(p); err != nil || !info.IsDir() {
continue
}
out = append(out, p)
}
return out
}
func contains(list []string, p string) bool {
for _, q := range list {
if samePath(q, p) {
return true
}
}
return false
}
// Match returns the folder in folders that requested names, in the list's own
// spelling, and false when it names none of them. It compares the two paths as
// text, so a requested path is never followed or opened: a parent directory, a
// relative path, a path through "..", a missing folder and an untrusted project
// are all simply not in the list.
func Match(folders []string, requested string) (string, bool) {
requested = strings.TrimSpace(requested)
if requested == "" || !filepath.IsAbs(requested) {
return "", false
}
for _, f := range folders {
if samePath(f, requested) {
return f, true
}
}
return "", false
}
func samePath(a, b string) bool {
a, b = filepath.Clean(a), filepath.Clean(b)
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
return strings.EqualFold(a, b)
}
return a == b
}
+122
View File
@@ -0,0 +1,122 @@
package scheduler
import (
"errors"
"os"
"path/filepath"
"slices"
"testing"
"reasonix/internal/contract/config"
"reasonix/internal/state/workspacelist"
)
func TestARequestedFolderIsOnlyEverComparedWithTheTrustedList(t *testing.T) {
root := t.TempDir()
inner := filepath.Join(root, "app")
other := filepath.Join(root, "other")
for _, d := range []string{inner, other} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
list := []string{inner}
for name, tc := range map[string]struct {
in string
want bool
}{
"the project itself": {inner, true},
"with a trailing separator": {inner + string(filepath.Separator), true},
"through a dot segment": {filepath.Join(inner, ".") + string(filepath.Separator) + ".", true},
"its parent": {root, false},
"a sibling": {other, false},
"a path through ..": {filepath.Join(inner, "..", "other"), false},
"a child of the project": {filepath.Join(inner, "sub"), false},
"a relative path": {"app", false},
"a relative dot": {".", false},
"empty": {"", false},
"blank": {" ", false},
"not there": {filepath.Join(root, "missing"), false},
} {
got, ok := Match(list, tc.in)
if ok != tc.want || (ok && got != inner) {
t.Errorf("%s: Match(%q) = %q, %v; want %v", name, tc.in, got, ok, tc.want)
}
}
}
func TestADraftNamesATrustedFolderOrIsRefusedAsUnknown(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
inner := t.TempDir()
d := Draft{Prompt: "p", EverySec: 3600, Workspace: filepath.Join(inner, "..")}
if _, err := d.Request([]string{inner}); !errors.Is(err, ErrDraftFolderUnknown) {
t.Fatalf("a parent directory = %v, want ErrDraftFolderUnknown", err)
}
d.Workspace = inner
if _, err := d.Request(nil); !errors.Is(err, ErrDraftFolderUnknown) {
t.Fatalf("no trusted project at all = %v, want ErrDraftFolderUnknown", err)
}
}
func TestTheTrustedFoldersAreTheRememberedProjectsThePersonTrusted(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
remembered, declined, undecided, current := t.TempDir(), t.TempDir(), t.TempDir(), t.TempDir()
list := filepath.Join(config.MemoryUserDir(), workspacelist.FileName)
if err := workspacelist.Update(t.Context(), list, false, func(l *workspacelist.List) error {
l.Paths = append(l.Paths, remembered, declined, undecided)
return nil
}); err != nil {
t.Fatal(err)
}
grants := config.NewProjectGrantStore(config.RootsForHome("").Home())
for dir, trust := range map[string]config.WorkspaceTrust{remembered: config.WorkspaceTrusted, declined: config.WorkspaceTrustDeclined} {
if err := grants.SetTrust(dir, trust); err != nil {
t.Fatal(err)
}
}
if got := Folders(current); len(got) != 1 || got[0] != remembered {
t.Fatalf("Folders = %v, want only the remembered project that was trusted (an untrusted current one is not one)", got)
}
if err := grants.SetTrust(current, config.WorkspaceTrusted); err != nil {
t.Fatal(err)
}
if got := Folders(current); len(got) != 2 || got[1] != current {
t.Fatalf("Folders = %v, want the trusted current project added", got)
}
if got := Folders(remembered); len(got) != 1 {
t.Fatalf("a project listed and current appears twice: %v", got)
}
}
func TestATrustedProjectThatIsGoneOrIsNotAFolderIsNotListed(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
gone, file := t.TempDir(), filepath.Join(t.TempDir(), "afile")
if err := os.WriteFile(file, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
list := filepath.Join(config.MemoryUserDir(), workspacelist.FileName)
if err := workspacelist.Update(t.Context(), list, false, func(l *workspacelist.List) error {
l.Paths = append(l.Paths, gone, file)
return nil
}); err != nil {
t.Fatal(err)
}
grants := config.NewProjectGrantStore(config.RootsForHome("").Home())
for _, p := range []string{gone, file} {
if err := grants.SetTrust(p, config.WorkspaceTrusted); err != nil {
t.Fatal(err)
}
}
if got := Folders(""); !slices.Contains(got, gone) {
t.Fatalf("a present trusted project is missing: %v", got)
}
if got := Folders(""); slices.Contains(got, file) {
t.Fatalf("a file was listed as a project: %v", got)
}
if err := os.RemoveAll(gone); err != nil {
t.Fatal(err)
}
if got := Folders(""); slices.Contains(got, gone) {
t.Fatalf("a deleted project is still listed: %v", got)
}
}
+2
View File
@@ -578,6 +578,7 @@ type SessionAPI interface {
SubagentView
PeerMail
Mods
Schedules
MCPSignIn
LocalShell
DiffRendering
@@ -631,6 +632,7 @@ var (
_ SubagentView = (*Controller)(nil)
_ PeerMail = (*Controller)(nil)
_ Mods = (*Controller)(nil)
_ Schedules = (*Controller)(nil)
_ MCPSignIn = (*Controller)(nil)
_ SessionHistory = (*Controller)(nil)
_ MemoryControl = (*Controller)(nil)
+303
View File
@@ -0,0 +1,303 @@
package control
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"path/filepath"
"slices"
"time"
"reasonix/internal/runtime/scheduler"
"reasonix/internal/state/schedule"
)
// ErrScheduleConfirmMismatch is a creation whose confirmation is not the one the
// preview issued: what is stored is exactly what the person was shown.
var ErrScheduleConfirmMismatch = errors.New("schedule: the confirmation does not match the preview")
// Schedules is the machine-wide scheduled-runs surface. Every frontend that
// shows or edits schedules goes through it, so floors, defaults and the
// confirmation are enforced once and never by a frontend.
type Schedules interface {
Schedules(ctx context.Context) (ScheduleBoard, error)
PreviewSchedule(ctx context.Context, d ScheduleDraft) (SchedulePreview, error)
CreateSchedule(ctx context.Context, d ScheduleDraft, confirm string) (ScheduleView, error)
PauseSchedule(ctx context.Context, id string) error
ResumeSchedule(ctx context.Context, id string) error
RenewSchedule(ctx context.Context, id string) error
DeleteSchedule(ctx context.Context, id string) error
SetSchedulesPaused(ctx context.Context, paused bool) error
ScheduleResult(ctx context.Context, triggerID string) (ScheduleResultView, error)
}
// ScheduleDraft is what a person picked in a frontend. Exactly one of EverySec
// and AtUnix is set.
type ScheduleDraft struct {
Label string `json:"label"`
EverySec int64 `json:"everySec,omitempty"`
AtUnix int64 `json:"atUnix,omitempty"`
Workspace string `json:"workspace"`
Prompt string `json:"prompt"`
// Model is a provider/model reference; empty is the default model.
Model string `json:"model,omitempty"`
}
// ScheduleView is one stored schedule.
type ScheduleView struct {
ID string `json:"id"`
Label string `json:"label"`
Status string `json:"status"`
PausedReason string `json:"pausedReason,omitempty"`
EverySec int64 `json:"everySec,omitempty"`
AtUnix int64 `json:"atUnix,omitempty"`
Workspace string `json:"workspace"`
Model string `json:"model"`
Prompt string `json:"prompt"`
PerRunTokens int64 `json:"perRunTokens"`
LifetimeTokens int64 `json:"lifetimeTokens"`
SpentLifetimeTokens int64 `json:"spentLifetimeTokens"`
ConsecutiveFailures int `json:"consecutiveFailures,omitempty"`
ExpiresAtUnix int64 `json:"expiresAtUnix"`
}
// ScheduleRunView is one run record: a claimed slot, or one skipped on purpose.
type ScheduleRunView struct {
TriggerID string `json:"triggerId"`
ScheduleID string `json:"scheduleId"`
SlotUnix int64 `json:"slotUnix"`
State string `json:"state"`
SkipReason string `json:"skipReason,omitempty"`
Charged int64 `json:"chargedTokens"`
EndedUnix int64 `json:"endedUnix,omitempty"`
}
// SchedulePolicyView is the part of the policy a frontend states to the person.
type SchedulePolicyView struct {
MinIntervalMinutes int64 `json:"minIntervalMinutes"`
MaxSchedules int64 `json:"maxSchedules"`
GlobalTokensDay int64 `json:"globalTokensDay"`
ExpiryDays int64 `json:"expiryDays"`
}
// ScheduleBoard is everything the list shows.
type ScheduleBoard struct {
PausedAll bool `json:"pausedAll"`
PausedReason string `json:"pausedReason,omitempty"`
Schedules []ScheduleView `json:"schedules"`
Runs []ScheduleRunView `json:"runs"`
Policy SchedulePolicyView `json:"policy"`
// Folders are the trusted projects a schedule may be given. A creation names
// one of these and nothing else.
Folders []ScheduleFolder `json:"folders"`
}
// ScheduleFolder is one trusted project a schedule can read.
type ScheduleFolder struct {
Path string `json:"path"`
Name string `json:"name"`
}
// SchedulePreview is what a person confirms, with the token that proves a
// creation is the one they were shown.
type SchedulePreview struct {
FirstRunUnix int64 `json:"firstRunUnix"`
Workspace string `json:"workspace"`
Model string `json:"model"`
Prompt string `json:"prompt"`
PerRunTokens int64 `json:"perRunTokens"`
PerRunSteps int64 `json:"perRunSteps"`
PerRunWallSec int64 `json:"perRunWallSec"`
PerDayMax int64 `json:"perDayMax"`
Lifetime int64 `json:"lifetimeTokens"`
ExpiresAtUnix int64 `json:"expiresAtUnix"`
Confirm string `json:"confirm"`
}
// SchedulePendingView is something a run parked for a person. Detail is the
// model's own wording and is untrusted.
type SchedulePendingView struct {
Kind string `json:"kind"`
Summary string `json:"summary"`
Detail string `json:"detail,omitempty"`
}
// ScheduleResultView is what a finished run left. The report is model text: a
// frontend labels it and renders it without loading remote content.
type ScheduleResultView struct {
TriggerID string `json:"triggerId"`
Report string `json:"report"`
ReportTruncated bool `json:"reportTruncated,omitempty"`
Pending []SchedulePendingView `json:"pending"`
PendingDropped int `json:"pendingDropped,omitempty"`
Enforcement string `json:"enforcement"`
RecordedUnix int64 `json:"recordedUnix"`
}
func (c *Controller) scheduleStore() (*schedule.Store, schedule.Policy, error) {
return scheduler.OpenUserStore(nil)
}
func (c *Controller) Schedules(ctx context.Context) (ScheduleBoard, error) {
store, policy, err := c.scheduleStore()
if err != nil {
return ScheduleBoard{}, err
}
m, _, err := store.Snapshot(ctx)
if err != nil {
return ScheduleBoard{}, err
}
out := ScheduleBoard{PausedAll: m.PausedAll, PausedReason: string(m.PausedReason), Schedules: []ScheduleView{}, Runs: []ScheduleRunView{},
Policy: SchedulePolicyView{MinIntervalMinutes: policy.MinIntervalMinutes, MaxSchedules: policy.MaxSchedules, GlobalTokensDay: policy.GlobalTokensDay, ExpiryDays: policy.ExpiryDays}}
out.Folders = c.scheduleFolderViews()
for _, s := range m.Schedules {
out.Schedules = append(out.Schedules, scheduleView(s))
}
runs := m.Runs
if len(runs) > 20 {
runs = runs[len(runs)-20:]
}
for _, r := range slices.Backward(runs) {
out.Runs = append(out.Runs, scheduleRunView(r))
}
return out, nil
}
// scheduleFolders is the trusted-project list a draft is matched against.
func (c *Controller) scheduleFolders() []string { return scheduler.Folders(c.WorkspaceRoot()) }
func (c *Controller) scheduleFolderViews() []ScheduleFolder {
out := []ScheduleFolder{}
for _, p := range c.scheduleFolders() {
out = append(out, ScheduleFolder{Path: p, Name: filepath.Base(p)})
}
return out
}
func scheduleView(s schedule.Schedule) ScheduleView {
v := ScheduleView{ID: s.ID, Label: s.Label, Status: string(s.Status), PausedReason: string(s.PausedReason), Workspace: s.Target.Workspace,
Model: s.Model.Provider + "/" + s.Model.Model, Prompt: s.Prompt, PerRunTokens: s.Budget.PerRunTokens, LifetimeTokens: s.Budget.LifetimeTokens,
SpentLifetimeTokens: s.SpentLifetimeTokens, ConsecutiveFailures: s.ConsecutiveFailures, ExpiresAtUnix: s.ExpiresAt.Unix()}
if s.Trigger.Kind == schedule.TriggerEvery {
v.EverySec = s.Trigger.EverySec
} else {
v.AtUnix = s.Trigger.At.Unix()
}
return v
}
func scheduleRunView(r schedule.Run) ScheduleRunView {
v := ScheduleRunView{TriggerID: r.TriggerID, ScheduleID: r.ScheduleID, SlotUnix: r.SlotAt.Unix(), State: string(r.State), SkipReason: r.SkipReason, Charged: r.Charged}
if !r.EndedAt.IsZero() {
v.EndedUnix = r.EndedAt.Unix()
}
return v
}
func (d ScheduleDraft) draft() scheduler.Draft {
out := scheduler.Draft{Label: d.Label, EverySec: d.EverySec, Workspace: d.Workspace, Prompt: d.Prompt, Model: d.Model}
if d.AtUnix > 0 {
out.At = time.Unix(d.AtUnix, 0)
}
return out
}
func (c *Controller) PreviewSchedule(_ context.Context, d ScheduleDraft) (SchedulePreview, error) {
_, policy, err := c.scheduleStore()
if err != nil {
return SchedulePreview{}, err
}
req, err := d.draft().Request(c.scheduleFolders())
if err != nil {
return SchedulePreview{}, err
}
f := scheduler.FactsFor(req, policy, time.Now())
return SchedulePreview{FirstRunUnix: f.FirstRun.Unix(), Workspace: f.Folder, Model: f.Model.Provider + "/" + f.Model.Model, Prompt: f.Prompt,
PerRunTokens: f.Budget.PerRunTokens, PerRunSteps: f.Budget.PerRunSteps, PerRunWallSec: f.Budget.PerRunWallSec,
PerDayMax: f.PerDayMax, Lifetime: f.Lifetime, ExpiresAtUnix: f.ExpiresAt.Unix(), Confirm: confirmToken(req, f, policy.ExpiryDays)}, nil
}
// confirmToken fingerprints everything the person was shown except the clock, so
// a creation that differs in any shown fact is refused.
func confirmToken(req schedule.CreateRequest, f scheduler.Facts, expiryDays int64) string {
body, _ := json.Marshal(struct {
Label string
Trigger schedule.Trigger
Folder string
Model schedule.Model
Prompt string
Budget schedule.Budget
PerDayMax int64
Expiry int64
}{req.Label, schedule.Trigger{Kind: req.Trigger.Kind, EverySec: req.Trigger.EverySec, At: req.Trigger.At.UTC().Truncate(time.Second)}, f.Folder, f.Model, f.Prompt, f.Budget, f.PerDayMax, expiryDays})
sum := sha256.Sum256(body)
return hex.EncodeToString(sum[:])
}
func (c *Controller) CreateSchedule(ctx context.Context, d ScheduleDraft, confirm string) (ScheduleView, error) {
store, policy, err := c.scheduleStore()
if err != nil {
return ScheduleView{}, err
}
req, err := d.draft().Request(c.scheduleFolders())
if err != nil {
return ScheduleView{}, err
}
if confirm == "" || confirm != confirmToken(req, scheduler.FactsFor(req, policy, time.Now()), policy.ExpiryDays) {
return ScheduleView{}, ErrScheduleConfirmMismatch
}
sc, err := store.Create(ctx, policy, req)
if err != nil {
return ScheduleView{}, err
}
return scheduleView(sc), nil
}
func (c *Controller) editSchedule(ctx context.Context, do func(context.Context, *schedule.Store, schedule.Policy) error) error {
store, policy, err := c.scheduleStore()
if err != nil {
return err
}
return do(ctx, store, policy)
}
func (c *Controller) PauseSchedule(ctx context.Context, id string) error {
return c.editSchedule(ctx, func(ctx context.Context, s *schedule.Store, _ schedule.Policy) error { return s.Pause(ctx, id) })
}
func (c *Controller) ResumeSchedule(ctx context.Context, id string) error {
return c.editSchedule(ctx, func(ctx context.Context, s *schedule.Store, _ schedule.Policy) error { return s.Resume(ctx, id) })
}
func (c *Controller) RenewSchedule(ctx context.Context, id string) error {
return c.editSchedule(ctx, func(ctx context.Context, s *schedule.Store, p schedule.Policy) error { return s.Renew(ctx, p, id) })
}
func (c *Controller) DeleteSchedule(ctx context.Context, id string) error {
return c.editSchedule(ctx, func(ctx context.Context, s *schedule.Store, _ schedule.Policy) error { return s.Delete(ctx, id) })
}
func (c *Controller) SetSchedulesPaused(ctx context.Context, paused bool) error {
return c.editSchedule(ctx, func(ctx context.Context, s *schedule.Store, _ schedule.Policy) error {
return s.SetPausedAll(ctx, paused)
})
}
func (c *Controller) ScheduleResult(ctx context.Context, triggerID string) (ScheduleResultView, error) {
store, _, err := c.scheduleStore()
if err != nil {
return ScheduleResultView{}, err
}
r, err := store.GetResult(ctx, triggerID)
if err != nil {
return ScheduleResultView{}, err
}
out := ScheduleResultView{TriggerID: r.TriggerID, Report: r.Report, ReportTruncated: r.ReportTruncated, Pending: []SchedulePendingView{},
PendingDropped: r.PendingDropped, Enforcement: r.Posture.Enforcement, RecordedUnix: r.RecordedAt.Unix()}
for _, p := range r.Pending {
out.Pending = append(out.Pending, SchedulePendingView{Kind: string(p.Kind), Summary: p.Summary, Detail: p.Detail})
}
return out, nil
}
+189
View File
@@ -0,0 +1,189 @@
package control
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/runtime/scheduler"
"reasonix/internal/state/schedule"
"reasonix/internal/state/workspacelist"
)
func schedulesController(t *testing.T) (*Controller, string) {
t.Helper()
home := testenv.TempDir(t)
t.Setenv("REASONIX_HOME", home)
body := "default_model = \"local/fake-model\"\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://example.invalid\"\nmodel = \"fake-model\"\napi_key_env = \"REASONIX_TEST_KEY\"\n"
if err := os.WriteFile(config.UserConfigPath(), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
c := New(Options{WorkspaceRoot: testenv.TempDir(t)})
t.Cleanup(c.Close)
ws := testenv.TempDir(t)
trustProject(t, ws)
return c, ws
}
// trustProject remembers dir as a project and marks it trusted, which is what
// makes it one a schedule may read.
func trustProject(t *testing.T, dir string) {
t.Helper()
list := filepath.Join(config.MemoryUserDir(), workspacelist.FileName)
if err := workspacelist.Update(context.Background(), list, false, func(l *workspacelist.List) error {
l.Paths = append(l.Paths, dir)
return nil
}); err != nil {
t.Fatal(err)
}
if err := config.NewProjectGrantStore(config.RootsForHome("").Home()).SetTrust(dir, config.WorkspaceTrusted); err != nil {
t.Fatal(err)
}
}
func draftIn(ws string) ScheduleDraft {
return ScheduleDraft{Label: "morning", EverySec: 7200, Workspace: ws, Prompt: "summarize the TODOs"}
}
func TestAScheduleIsCreatedOnlyWithTheConfirmationOfItsOwnPreview(t *testing.T) {
c, ws := schedulesController(t)
ctx := context.Background()
prev, err := c.PreviewSchedule(ctx, draftIn(ws))
if err != nil || prev.Confirm == "" || prev.Model != "local/fake-model" || prev.PerRunTokens != 250_000 || prev.PerDayMax != 1_000_000 {
t.Fatalf("preview = %+v, %v", prev, err)
}
for name, mutate := range map[string]func(*ScheduleDraft){
"another prompt": func(d *ScheduleDraft) { d.Prompt = "something else" },
"another interval": func(d *ScheduleDraft) { d.EverySec = 3600 },
"another label": func(d *ScheduleDraft) { d.Label = "other" },
} {
d := draftIn(ws)
mutate(&d)
if _, err := c.CreateSchedule(ctx, d, prev.Confirm); !errors.Is(err, ErrScheduleConfirmMismatch) {
t.Errorf("%s: err = %v", name, err)
}
}
if _, err := c.CreateSchedule(ctx, draftIn(ws), ""); !errors.Is(err, ErrScheduleConfirmMismatch) {
t.Fatalf("no confirmation: %v", err)
}
board, _ := c.Schedules(ctx)
if len(board.Schedules) != 0 {
t.Fatal("a refused creation stored a schedule")
}
sc, err := c.CreateSchedule(ctx, draftIn(ws), prev.Confirm)
if err != nil || sc.Status != "active" || sc.EverySec != 7200 || sc.Model != "local/fake-model" {
t.Fatalf("created = %+v, %v", sc, err)
}
board, _ = c.Schedules(ctx)
if len(board.Schedules) != 1 || board.Policy.MinIntervalMinutes != 60 || board.Policy.MaxSchedules != 8 {
t.Fatalf("board = %+v", board)
}
}
func TestSchedulesAreRefusedWithTypedReasons(t *testing.T) {
c, ws := schedulesController(t)
ctx := context.Background()
d := draftIn(ws)
d.EverySec = 60
prev, err := c.PreviewSchedule(ctx, d)
if err != nil {
t.Fatal(err)
}
if _, err := c.CreateSchedule(ctx, d, prev.Confirm); !errors.Is(err, schedule.ErrIntervalBelowFloor) {
t.Fatalf("an interval under the floor: %v", err)
}
for name, d := range map[string]ScheduleDraft{
"no trigger": {Workspace: ws, Prompt: "x"},
"both": {EverySec: 7200, AtUnix: 4102444800, Workspace: ws, Prompt: "x"},
"no folder": {EverySec: 7200, Workspace: ws + "/nope", Prompt: "x"},
"unknown model": {EverySec: 7200, Workspace: ws, Prompt: "x", Model: "nobody/none"},
} {
if _, err := c.PreviewSchedule(ctx, d); err == nil {
t.Errorf("%s: accepted", name)
}
}
}
func TestSchedulesCanBePausedResumedRenewedAndDeleted(t *testing.T) {
c, ws := schedulesController(t)
ctx := context.Background()
prev, _ := c.PreviewSchedule(ctx, draftIn(ws))
sc, err := c.CreateSchedule(ctx, draftIn(ws), prev.Confirm)
if err != nil {
t.Fatal(err)
}
status := func() string {
b, _ := c.Schedules(ctx)
if len(b.Schedules) == 0 {
return "gone"
}
return b.Schedules[0].Status
}
if err := c.PauseSchedule(ctx, sc.ID); err != nil || status() != "paused" {
t.Fatalf("pause: %v %s", err, status())
}
if err := c.ResumeSchedule(ctx, sc.ID); err != nil || status() != "active" {
t.Fatalf("resume: %v %s", err, status())
}
if err := c.RenewSchedule(ctx, sc.ID); err != nil {
t.Fatalf("renew: %v", err)
}
if err := c.SetSchedulesPaused(ctx, true); err != nil {
t.Fatal(err)
}
if b, _ := c.Schedules(ctx); !b.PausedAll {
t.Fatal("the global switch did not show")
}
if err := c.DeleteSchedule(ctx, sc.ID); err != nil || status() != "gone" {
t.Fatalf("delete: %v %s", err, status())
}
if err := c.PauseSchedule(ctx, "sch_missing"); !errors.Is(err, schedule.ErrNotFound) {
t.Fatalf("an unknown id: %v", err)
}
}
func TestAResultThatWasNeverRecordedIsATypedNotFound(t *testing.T) {
c, _ := schedulesController(t)
if _, err := c.ScheduleResult(context.Background(), "sch_x/1"); !errors.Is(err, schedule.ErrResultNotFound) {
t.Fatalf("err = %v", err)
}
}
func TestAScheduleOnlyEverReadsATrustedProjectAndTheBoardListsThem(t *testing.T) {
c, ws := schedulesController(t)
ctx := context.Background()
board, _ := c.Schedules(ctx)
if len(board.Folders) != 1 || board.Folders[0].Path != ws || board.Folders[0].Name != filepath.Base(ws) {
t.Fatalf("folders = %+v, want the one trusted project", board.Folders)
}
untrusted := testenv.TempDir(t)
for name, folder := range map[string]string{
"a project nobody trusted": untrusted,
"the parent directory": filepath.Dir(ws),
"a path through ..": filepath.Join(ws, "..", filepath.Base(untrusted)),
"a relative path": ".",
"a missing folder": filepath.Join(ws, "nope"),
} {
if _, err := c.PreviewSchedule(ctx, draftIn(folder)); !errors.Is(err, scheduler.ErrDraftFolderUnknown) {
t.Errorf("%s: preview = %v, want ErrDraftFolderUnknown", name, err)
}
if _, err := c.CreateSchedule(ctx, draftIn(folder), "anything"); !errors.Is(err, scheduler.ErrDraftFolderUnknown) {
t.Errorf("%s: create = %v, want ErrDraftFolderUnknown", name, err)
}
}
if board, _ = c.Schedules(ctx); len(board.Schedules) != 0 {
t.Fatal("a refused folder stored a schedule")
}
declined := testenv.TempDir(t)
trustProject(t, declined)
if err := config.NewProjectGrantStore(config.RootsForHome("").Home()).SetTrust(declined, config.WorkspaceTrustDeclined); err != nil {
t.Fatal(err)
}
if _, err := c.PreviewSchedule(ctx, draftIn(declined)); !errors.Is(err, scheduler.ErrDraftFolderUnknown) {
t.Fatalf("a declined project = %v, want ErrDraftFolderUnknown", err)
}
}
@@ -0,0 +1,2 @@
- 新增:设置 → 自动化 → 定时运行:查看、预览确认后创建、暂停、续期、删除定时任务,并查看每次运行的结果。关联 #11406。
- Added: Settings, Automation, Scheduled runs: list, create after a confirmed preview, pause, renew and delete scheduled tasks, and read each run's result. Refs #11406.
@@ -1,2 +0,0 @@
- 终端的会话选择器(/resume)里按 Ctrl+D 询问、对同一行再按一次 Ctrl+D 才永久删除该会话;当前打开的会话会被拒绝并说明原因。见 #13954。
- In the terminal's session picker (/resume), Ctrl+D asks and a second Ctrl+D on the same row erases that conversation for good; the open session is refused with the reason. See #13954.
+10
View File
@@ -23,6 +23,7 @@ const (
tsVersionFile = "desktop/frontend-next/src/port/version.ts"
tsModelFile = "desktop/frontend-next/src/port/model.ts"
tsMcpFile = "desktop/frontend-next/src/port/mcp.ts"
tsScheduleFile = "desktop/frontend-next/src/port/schedule.ts"
tsReplayFile = "desktop/frontend-next/src/port/surface_replay.ts"
tsShellFile = "desktop/frontend-next/src/port/shell.ts"
tsShareFile = "desktop/frontend-next/src/port/share.ts"
@@ -179,6 +180,15 @@ var mirroredWireTypes = []wireMirror{
{"internal/session/control/progress_watch_settings.go", "ProgressWatchSettings", tsBoundaryFile, "ProgressWatchSettings"},
{"internal/session/control/changed_files_settings.go", "ChangedFilesSettings", tsBoundaryFile, "ChangedFilesSettings"},
{"internal/session/control/observability_settings.go", "ObservabilitySettings", tsBoundaryFile, "ObservabilitySettings"},
{"internal/session/control/schedules.go", "ScheduleDraft", tsScheduleFile, "ScheduleDraft"},
{"internal/session/control/schedules.go", "ScheduleView", tsScheduleFile, "ScheduleView"},
{"internal/session/control/schedules.go", "ScheduleRunView", tsScheduleFile, "ScheduleRunView"},
{"internal/session/control/schedules.go", "SchedulePolicyView", tsScheduleFile, "SchedulePolicyView"},
{"internal/session/control/schedules.go", "ScheduleBoard", tsScheduleFile, "ScheduleBoard"},
{"internal/session/control/schedules.go", "ScheduleFolder", tsScheduleFile, "ScheduleFolder"},
{"internal/session/control/schedules.go", "SchedulePreview", tsScheduleFile, "SchedulePreview"},
{"internal/session/control/schedules.go", "SchedulePendingView", tsScheduleFile, "SchedulePendingView"},
{"internal/session/control/schedules.go", "ScheduleResultView", tsScheduleFile, "ScheduleResultView"},
{"internal/session/control/fold_work_settings.go", "FoldWorkSettings", tsBoundaryFile, "FoldWorkSettings"},
{"internal/session/control/ask_timeout.go", "AskTimeoutSettings", tsBoundaryFile, "AskTimeoutSettings"},
{"internal/session/control/session_ref_settings.go", "SessionRefSettings", tsBoundaryFile, "SessionRefSettings"},