mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-08 08:04:50 -10:00
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:
45 files changed
+2044
-257
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 ?? [])] };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
})();
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"},
|
||||
|
||||
Reference in new issue
Block a user