feat(studio): storage relocation, composer intake, and the checkpoint agreement

Accumulated work from the branch, carried across the rebase onto origin/studio
and merged with what landed there in the meantime.

Storage: a workspace can name where its state lives, and the server tracks the
one relocation it may be running (internal/storage, config/storage_roots,
serve/storage, the Storage panel).

Composer intake: what a drop or a long paste becomes is one pure function —
payload in, action out — so "what happens if I drop a folder" is a test rather
than a branch of an onDrop handler. It sits on the drop router that landed
upstream: that one answers which pane a drop hit, this one answers what it
means and says so before the pointer is released. A paste long enough to bury
the composer becomes a chip beside it instead.

Session: the checkpoint agreement and the lineage/torn-log recovery cases,
plus the conflict diagnostic learning where two transcripts parted and which
roles stood there — a position and a role tell a local reshape from a foreign
write without recording any content.

Model refs: the default-model write checks the live catalog, so an extension
model the runtime knows can be persisted as the default.
This commit is contained in:
yhh committed 2026-08-19 23:52:04 -07:00
1 parent 786d042f89
commit b18d8ef3aa
62 files changed
+4512 -410

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
# Reasonix
The project's standing instructions live in `REASONIX.md` — one file, read by
both Reasonix and Codex. Add rules there, never here.
@REASONIX.md
+1
View File
@@ -174,6 +174,7 @@ export const EN: Record<string, string> = {
"松手 → 插入到光标处": "Release to insert at the caret",
"工作区外的文件引用不到 · 这里放不下": "A file outside the workspace cannot be referenced — this drop has nowhere to go",
"图片": "image",
"文件": "file",
"粘贴的文本": "pasted text",
"{n} 行 · 展开到输入框": "{n} lines · put it back in the box",
"移除这张图":"Remove this image",
@@ -165,8 +165,8 @@ export const EN_SETTINGS: Record<string, string> = {
"卡住了,要你介入": "Stuck — needs you",
"端点要求的额外设置": "Extra settings this endpoint demands",
"上下文窗口(tokens)": "Context window (tokens)",
"留空 = 用内置的已知值;0 = 这个来源不自动压缩":
"Empty = use the built-in known value; 0 = never auto-compact on this source",
"留空 = 用内置的已知值;自己加的来源没有内置值,那就是不压缩":
"Empty = use the built-in known value; a source you added yourself has none, which means no compaction",
"填模型文档写的上下文上限,不是最大输出。填小了会一直压缩,填大了会在真到上限时被端点拒绝。":
"Use the context limit from the model's documentation, not its max output. Too small and it compacts constantly; too large and the endpoint refuses once the real limit is hit.",
"思考参数": "Thinking parameter",
+15 -31
View File
@@ -1,7 +1,9 @@
import type { AccountState, AgentPort, Completion, CompletionItem, DeviceGrant, ProviderCheck, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, CapabilityScope, McpCatalog, McpDraft, McpDraftServer, McpEntry, McpInstallResult, ScopeLayer, HookCatalog, HookDryRun, HookEntry, MemoryCatalog, MemoryEntry, NetworkProbe, NetworkSettings, McpRisk, WorkspaceInfo, WorkspaceChanges, Attachment, DroppedRef, ThemePack } from "./port";
import type { AccountState, AgentPort, Completion, CompletionItem, DeviceGrant, ProviderCheck, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, CapabilityScope, McpCatalog, McpDraft, McpDraftServer, McpEntry, McpInstallResult, ScopeLayer, HookCatalog, HookDryRun, HookEntry, MemoryCatalog, MemoryEntry, NetworkProbe, NetworkSettings, WorkspaceInfo, WorkspaceChanges, Attachment, DroppedRef, ThemePack } from "./port";
import type { WireEvent } from "./wire";
import { MockProvider } from "./mock_provider";
import { SCRIPT } from "./fixture";
import { mockStorage, mockStoragePlan } from "./mock_storage";
import { risksOf, toEntry } from "./mock_mcp";
export class MockPort extends MockProvider implements AgentPort {
@@ -756,6 +758,18 @@ export class MockPort extends MockProvider implements AgentPort {
async setPreset(preset: Preset) {
this.state.preset = preset;
}
async storage() {
return mockStorage();
}
async planStorageMove(root: string, dir: string) {
return mockStoragePlan(root, dir);
}
async moveStorage(root: string, dir: string) {
return mockStoragePlan(root, dir);
}
async setModel(ref: string) {
this.state.modelRef = ref;
this.state.label = ref.split("/").pop() ?? ref;
@@ -768,33 +782,3 @@ export class MockPort extends MockProvider implements AgentPort {
}
}
function toEntry(s: McpDraftServer): McpEntry {
return {
name: s.name,
state: "idle",
enabled: true,
transport: s.transport,
source: "user config",
tools: 0,
};
}
const SECRETY = /auth|token|secret|credential|api[-_]?key|cookie/i;
function risksOf(servers: McpDraftServer[]): McpRisk[] {
const out: McpRisk[] = [];
for (const s of servers) {
if (s.command) {
out.push({ server: s.name, kind: "shell", field: "command", detail: [s.command, ...(s.args ?? [])].join(" ") });
}
if (s.url) out.push({ server: s.name, kind: "unknown-host", field: "url", detail: s.url });
for (const [k, v] of Object.entries(s.env ?? {})) {
if (SECRETY.test(k) && !v.startsWith("$")) {
out.push({ server: s.name, kind: "secret", field: "env." + k,
detail: `明文写进配置文件;改成 \${${k.toUpperCase()}} 可以只留在环境变量里` });
}
}
}
return out;
}
@@ -0,0 +1,33 @@
// Fixtures for the add-a-server flow: what a draft looks like once accepted,
// and what the kernel would flag about it before it is.
import type { McpDraftServer, McpEntry, McpRisk } from "./port";
export function toEntry(s: McpDraftServer): McpEntry {
return {
name: s.name,
state: "idle",
enabled: true,
transport: s.transport,
source: "user config",
tools: 0,
};
}
const SECRETY = /auth|token|secret|credential|api[-_]?key|cookie/i;
export function risksOf(servers: McpDraftServer[]): McpRisk[] {
const out: McpRisk[] = [];
for (const s of servers) {
if (s.command) {
out.push({ server: s.name, kind: "shell", field: "command", detail: [s.command, ...(s.args ?? [])].join(" ") });
}
if (s.url) out.push({ server: s.name, kind: "unknown-host", field: "url", detail: s.url });
for (const [k, v] of Object.entries(s.env ?? {})) {
if (SECRETY.test(k) && !v.startsWith("$")) {
out.push({ server: s.name, kind: "secret", field: "env." + k,
detail: `明文写进配置文件;改成 \${${k.toUpperCase()}} 可以只留在环境变量里` });
}
}
}
return out;
}
@@ -1,4 +1,4 @@
import type { ProviderEdit, ProviderEntry, ProviderProbe } from "./port";
import type { Protocol, ProviderEdit, ProviderEntry, ProviderProbe } from "./port";
import { MockBoundary } from "./mock_boundary";
// The sources half of the fixture. Chained on for the reason the others are:
@@ -38,6 +38,15 @@ export class MockProvider extends MockBoundary {
return this.sources;
}
// Mirrors the kernel catalog: two wires answer one OpenAI model listing.
async protocols(): Promise<Protocol[]> {
return [
{ kind: "openai", discovery: "openai", serverWebSearch: false, reasoningParams: true },
{ kind: "responses", discovery: "openai", serverWebSearch: true, reasoningParams: false },
{ kind: "anthropic", discovery: "anthropic", serverWebSearch: true, reasoningParams: false },
];
}
async probeProvider(): Promise<ProviderProbe> {
throw new Error("演示模式不会真的去连端点");
}
@@ -0,0 +1,28 @@
import type { StoragePlan, StorageState } from "./storage";
// A C: drive with three gigabytes left and a session store that ate the rest —
// the situation the panel exists for, so the fixture shows it rather than a
// tidy empty install.
const VOL = { volume: "C:", volumeFree: 3_100_000_000, volumeTotal: 476_000_000_000 };
const ROAMING = "C:/Users/you/AppData/Roaming/reasonix";
const LOCAL = "C:/Users/you/AppData/Local/reasonix";
export function mockStorage(): StorageState {
return {
editable: true,
roots: [
{ id: "state", dir: ROAMING, bytes: 6_800_000_000, files: 9_612, relocatable: true, ...VOL },
{ id: "cache", dir: LOCAL, bytes: 1_900_000_000, files: 42, relocatable: true, ...VOL },
{ id: "worktrees", dir: `${LOCAL}/worktrees`, bytes: 412_000_000, files: 1_204, relocatable: true, ...VOL },
{ id: "home", dir: ROAMING, bytes: 12_288, files: 3, relocatable: false, ...VOL },
{ id: "locks", dir: LOCAL, bytes: 0, files: 0, relocatable: false, missing: true },
],
};
}
export function mockStoragePlan(root: string, dir: string): StoragePlan {
return {
root, from: ROAMING, to: dir, bytes: 6_800_000_000, files: 9_612,
need: 7_068_435_456, free: 214_000_000_000, ok: true,
};
}
+10 -14
View File
@@ -37,7 +37,8 @@ import type { WireEvent } from "./wire";
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan } from "./plugin";
import type { Appearance, ThemePack } from "./look";
import type { PermissionLists, PermissionRules, SandboxSettings } from "./boundary";
import type { ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderProbe } from "./provider";
import type { Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderProbe, ProviderSetup } from "./provider";
import type { StoragePlan, StorageState } from "./storage";
export type * from "./plugin";
@@ -53,12 +54,12 @@ export interface ContextBreakdown {
output: number;
}
// GET /history returns the provider conversation, not the event stream: the
// stream is live-only, so a reload rebuilds the transcript from these.
// GET /history: the event stream is live-only, so a reload rebuilds from these.
export interface HistoryMessage {
role: "system" | "user" | "assistant" | "tool";
content: string;
reasoning?: string;
images?: number; // attachments on a user turn; an image-only one has no text
toolCalls?: { id: string; name: string; arguments?: string }[];
toolCallId?: string;
toolName?: string;
@@ -152,17 +153,6 @@ export interface JobEntry {
// The UI depends on this and nothing else. SsePort talks to internal/serve;
// MockPort replays a fixture. Neither is allowed to leak transport details
// upward, which is what keeps the same UI usable in a browser and in Wails.
// GET /provider-setup 404s once a usable key exists, so null means "ready".
export interface ProviderSetup {
required: boolean;
provider?: string;
model?: string;
modelRef?: string;
keyEnv?: string;
error?: string;
activationPending?: boolean;
}
export interface SessionEntry {
name: string;
path: string;
@@ -548,7 +538,13 @@ export interface AgentPort {
// Persisted, then the runtime is rebuilt: boot reads every role model while
// assembling, so an assignment cannot reach a runtime that is already up.
setRole(role: string, ref: string): Promise<void>;
storage(): Promise<StorageState>;
planStorageMove(root: string, dir: string): Promise<StoragePlan>;
moveStorage(root: string, dir: string): Promise<StoragePlan>;
providers(): Promise<ProviderEntry[]>;
// The wire formats a source may be saved as. Read from the kernel so a
// protocol added there reaches this panel without a frontend release.
protocols(): Promise<Protocol[]>;
// Asks an endpoint what it is. Writes nothing — the answer is shown for
// confirmation, because only the person holding the key knows what they
// bought.
@@ -40,11 +40,28 @@ export interface ProviderEntry {
extraBody?: Record<string, unknown>;
}
// One wire format a source may be saved as, as the kernel declares it. The
// list is the kernel's and the words for it are ours: no label rides along.
export interface Protocol {
kind: string;
// Which model-listing shape this wire is discovered under. Protocols sharing
// one value answer the same listing and cannot be told apart by a probe.
discovery: string;
// Whether the wire has a format for a provider-executed web search tool, and
// whether thinking/effort fields ride it at all.
serverWebSearch: boolean;
reasoningParams: boolean;
}
// What an endpoint turned out to be. Every field is a guess the user confirms
// before anything is written — a model list cannot prove which protocol a
// gateway speaks, only which ones it answers.
export interface ProviderProbe {
kind: string;
// Every kind that listing may be driven with, kernel order. `kind` is the
// pre-selection among them, not the only answer: DeepSeek serves both the
// OpenAI chat wire and the Responses API off one model list.
kinds: string[];
authHeader: boolean;
models: string[];
default: string;
@@ -64,6 +81,9 @@ export interface ProviderProbe {
export interface ProviderCheck {
ok: boolean;
kind?: string;
// Whether that answer is consistent with the kind the entry records. A
// Responses source answers the OpenAI listing, so equality is the wrong test.
matches?: boolean;
models?: string[];
ambiguous?: boolean;
noProxy?: boolean;
@@ -103,3 +123,15 @@ export interface ProviderDraft {
effort: string;
vision: string[];
}
// What the opening sequence still owes a machine with no usable key. GET
// /provider-setup 404s once one exists, so null means "ready".
export interface ProviderSetup {
required: boolean;
provider?: string;
model?: string;
modelRef?: string;
keyEnv?: string;
error?: string;
activationPending?: boolean;
}
+20 -1
View File
@@ -1,7 +1,8 @@
import { download } from "./download";
import type { AccountState, AgentPort, Appearance, ContextBreakdown, Completion, DeviceGrant, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderProbe, UpdateProgress, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, HookCatalog, HookDryRun, HookEntry, MemoryCatalog, NetworkProbe, NetworkSettings, ShellSettings, PermissionLists, PermissionRules, SandboxSettings, McpCatalog, McpDraft, McpDraftServer, McpInstallResult, McpInstallScope, CapabilityScope, ScopeLayer, PluginExport, PluginInstallRequest, PluginPackage, PluginPlan, SkillCatalog, WorkspaceInfo, ThemePack } from "./port";
import type { AccountState, AgentPort, Appearance, ContextBreakdown, Completion, DeviceGrant, Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderProbe, UpdateProgress, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, HookCatalog, HookDryRun, HookEntry, MemoryCatalog, NetworkProbe, NetworkSettings, ShellSettings, PermissionLists, PermissionRules, SandboxSettings, McpCatalog, McpDraft, McpDraftServer, McpInstallResult, McpInstallScope, CapabilityScope, ScopeLayer, PluginExport, PluginInstallRequest, PluginPackage, PluginPlan, SkillCatalog, WorkspaceInfo, ThemePack } from "./port";
import { HttpError, type Attachment, type DroppedRef, type WorkspaceChanges } from "./port";
import { SseHttp } from "./sse_http";
import type { StoragePlan, StorageState } from "./storage";
import type { WireEvent } from "./wire";
// The running project is the default, so its requests stay the bare path they
@@ -317,6 +318,10 @@ export class SsePort extends SseHttp implements AgentPort {
return this.get<ProviderEntry[]>("/providers");
}
protocols() {
return this.get<Protocol[]>("/providers/protocols");
}
// The failure message is the answer here — a 401, a wrong path and "no chat
// models" send the user to three different fixes — so it is read out of the
// body rather than thrown away as a status code.
@@ -365,6 +370,20 @@ export class SsePort extends SseHttp implements AgentPort {
return this.post("/roles", { role, ref });
}
storage() {
return this.get<StorageState>("/storage");
}
// Both answer with a plan: a refused move is reported in the body rather
// than as a failed request, because its refusals are what the panel shows.
planStorageMove(root: string, dir: string) {
return this.post0<StoragePlan>("/storage/plan", { root, dir });
}
moveStorage(root: string, dir: string) {
return this.post0<StoragePlan>("/storage/move", { root, dir });
}
// Like the add-a-source probe, the interesting answer is in the body: a
// refused key and a moved endpoint are different fixes.
async checkProvider(name: string): Promise<ProviderCheck> {
+72
View File
@@ -0,0 +1,72 @@
// What the kernel keeps on disk, and moving it somewhere else. Sizes are the
// ones it measured — a person deletes things on the strength of these numbers,
// so nothing here is an estimate.
// One declared root. relocatable and pinnedBy come from the kernel's own root
// table: the panel never decides for itself which rows may be moved.
export interface StorageRoot {
id: string;
dir: string;
bytes: number;
files: number;
relocatable: boolean;
// The environment variable holding this root in place, when one does. A
// pinned root shows where it is and why it cannot be moved from here.
pinnedBy?: string;
// Never written yet. Not a failure — a fresh install has no worktrees.
missing?: boolean;
err?: string;
volume?: string;
volumeFree?: number;
volumeTotal?: number;
}
// A move in flight, or the one that just ended. Polled rather than pushed: it
// is a single operation someone is watching, on a panel that already refreshes.
export interface StorageMove {
root: string;
to: string;
phase: string;
bytes: number;
total: number;
detail?: string;
err?: string;
done: boolean;
}
// An unfinished move the kernel found recorded at launch.
export interface StoragePending {
root: string;
to: string;
phase: string;
}
export interface StorageState {
roots: StorageRoot[];
// False when the host has not opened up configuration writing; the panel then
// reports sizes and offers nothing it cannot carry out.
editable: boolean;
move?: StorageMove;
pending?: StoragePending;
}
// One objection to a move. code is what a client branches on, detail the
// sentence a person reads; both arrive together so neither has to be inferred.
export interface StorageRefusal {
code: string;
detail: string;
}
// What a move would do, answered before any of it is done. need is the bytes
// required including the headroom the kernel insists on leaving.
export interface StoragePlan {
root: string;
from: string;
to: string;
bytes: number;
files: number;
need: number;
free: number;
ok: boolean;
refusals?: StorageRefusal[];
}
+147 -7
View File
@@ -1033,6 +1033,40 @@
@keyframes blink { 0%, 100% { opacity: 1 } 55% { opacity: .15 } }
.tk .caret { width: 5px; height: 11px; background: var(--faint); margin-left: 2px }
/* 收工回执:它说的是「证据」,不是「进度」。所以整张卡不渲染 verdict 名词
—— 主机知道自己验到了什么、没验到什么,但完全不知道这一单还剩多少,
把「没验证」写成「没做完」就是拿一句它答不上来的话去否定用户刚看完的
那一屏。三档权重:干净的一轮沉成一行 ghost;这一轮自己申报的用 hair
起小节、不给色相(是主动说的,不是被查出来的);只有主机找出来的缺口
才配那道 2px warn。 */
.rcpt { padding: 7px 0 3px 31px }
/* 只淡入不位移:位移读作「又来了一步」,而这是这一轮落停。 */
.rcpt[data-lvl="ok"] {
display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap;
font: 400 11.5px var(--ui); color: var(--faint);
animation: settle .26s var(--ease-out) both;
}
.rcpt[data-lvl="ok"] .tick { font-size: 10px; color: var(--ok); opacity: .75 }
.rcpt[data-lvl="ok"] .src { font: 400 10.5px var(--mono); color: var(--ghost); min-width: 0; overflow-wrap: anywhere }
@keyframes settle { from { opacity: 0 } to { opacity: 1 } }
.rcpt-open { display: flex; flex-direction: column; gap: 10px; padding-top: 9px }
/* 干净那行落在展开卡里时不再吃一次左边距 */
.rcpt-open .rcpt { padding: 0 }
.rgroup { display: flex; flex-direction: column; gap: 4px }
.rgroup .hd { font: 500 10px var(--mono); letter-spacing: .07em; color: var(--warn) }
.rgroup[data-declared] .hd { color: var(--faint) }
/* 逐行错开 40ms:五行一起弹出来是一坨,错开才读得出这是一份清单 */
.rrow {
padding: 5px 0 5px 10px; border-left: 2px solid var(--warn);
animation: card var(--t-card) var(--ease-out) both;
animation-delay: calc(var(--i, 0) * 40ms);
}
.rgroup[data-declared] .rrow { border-left-color: var(--hair) }
.rrow .t { display: block; font: 400 12px var(--ui); color: var(--text) }
.rrow .d { display: block; margin-top: 2px; font: 400 11px var(--mono); color: var(--muted); overflow-wrap: anywhere }
.rgroup .more { padding-left: 12px; font: 400 11px var(--ui); color: var(--ghost) }
/* review findings:严重度用左侧 2px 色条编码,不新增文字标签 */
.finds { margin-top: 7px; display: flex; flex-direction: column; gap: 5px }
.find { padding: 6px 0 6px 10px; border-left: 2px solid var(--ghost); animation: card var(--t-card) var(--ease-out) both }
@@ -1041,6 +1075,13 @@
.find[data-lvl="err"] { border-left-color: var(--err) }
.find .t { display: block; font: 500 12.5px var(--ui); color: var(--text) }
.find .why { display: block; margin-top: 2px; font-size: 11.5px; line-height: 1.55; color: var(--muted) }
/* 通知的第二行是机器文本:系统原话、命令、来源路径。等宽 + 保留换行,长
路径断得开 —— 拿正文字体铺成一坨,就会和标题糊在一起,也就是用户看到
的那面墙。 */
.find .why.nwhy {
margin-top: 5px; font: 400 11px/1.6 var(--mono); color: var(--faint);
white-space: pre-wrap; overflow-wrap: anywhere;
}
.term { margin-top: 6px; padding: 8px 11px; background: var(--overlay); border-radius: var(--r-sm); font: 400 11.5px/19px var(--mono); color: var(--muted); overflow-x: auto }
.term-l { white-space: pre }
@@ -1314,15 +1355,79 @@
/* 减去 .flow 的内边距,输入框的可见边缘才跟卡片的左右边缘落在同一条线上 */
/* 附件是「这一轮要带上的东西」,所以它贴在输入框上沿,不在别处 */
.compose textarea[data-over] { outline: 2px dashed var(--accent); outline-offset: -3px }
/* 输入区整块都收拖放,但它本身不该占布局:display:contents 让盒子消失、
节点还在,拖放层就能问「这一下落在输入框里没有」。 */
.dropzone { display: contents }
.shots { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 0 2px 7px }
.shot { display: inline-flex; align-items: center; gap: 6px; padding: 3px 4px 3px 9px; border: 1px solid var(--border); border-radius: var(--r-pill); font: 400 11px var(--mono); color: var(--muted) }
.shot .nm { max-width: 22ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
.shot button { border: 0; background: none; color: var(--ghost); cursor: pointer; font: 400 12px/1 var(--mono); padding: 0 3px }
.shot button:hover { color: var(--err) }
/* 落点是整块面板,不是输入框那 40px —— 瞄一行高的框,就是在瞄 40px。
外壳把整窗的落点都报上来,落在哪一格由 DOM 说了算(见 filedrop.ts)。 */
.pane::after {
content: ""; position: absolute; inset: 0; z-index: 3; pointer-events: none;
background: color-mix(in srgb, var(--page) 74%, transparent);
opacity: 0; transition: opacity .12s var(--ease-out);
}
.pane[data-intake]::after { opacity: 1 }
/* 输入框自己抬起来换色,亮的是它而不是一个虚线矩形 */
.pane[data-intake] .compose {
position: relative; z-index: 4;
transform: translateY(-2px);
transition: transform .12s var(--ease-out), box-shadow .12s var(--ease-out);
}
.pane[data-intake="attach"] .compose { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent) }
.pane[data-intake="attach"] .compose::after { border-color: var(--accent) }
.pane[data-intake="ref"] .compose { box-shadow: 0 0 0 3px color-mix(in srgb, var(--net) 22%, transparent) }
.pane[data-intake="ref"] .compose::after { border-color: var(--net) }
.pane[data-intake="insert"] .compose { box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 20%, transparent) }
.pane[data-intake="insert"] .compose::after { border-color: var(--ok) }
/* 拒绝态不抖也不闪红:落点单纯不亮,那行字说明原因 */
.pane[data-intake="reject"] .compose { transform: none; box-shadow: none }
/* 说的是动词,不是「把文件拖到这里」—— 松手之前就知道会得到什么 */
.intake {
margin: 0 0 7px; padding: 0 2px; font: 500 11.5px var(--ui);
animation: intakein .12s var(--ease-out) both;
}
.intake[data-tone="attach"] { color: var(--accent) }
.intake[data-tone="ref"] { color: var(--net) }
.intake[data-tone="insert"] { color: var(--ok) }
.intake[data-tone="reject"] { color: var(--faint) }
@keyframes intakein { from { opacity: 0; transform: translateY(3px) } to { opacity: 1; transform: none } }
/* 附件是「这一轮要带上的东西」,所以它贴在输入框上沿,不在别处。
连着贴两张截图,只有文件名不一样 —— 而那正是卡片唯一要说清的事,
所以缩略图不是装饰。 */
.shots { list-style: none; margin: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 7px; padding: 0 2px 8px }
.shot {
display: inline-flex; align-items: center; gap: 8px; padding: 4px 4px 4px 5px;
border: 1px solid var(--border); border-radius: 7px; background: var(--raised);
animation: shotin .1s var(--ease-out) both; animation-delay: calc(var(--i, 0) * 45ms);
}
@keyframes shotin { from { opacity: 0; transform: scale(.94) } to { opacity: 1; transform: none } }
.shot .thumb {
width: 30px; height: 30px; flex: none; border-radius: 4px; background: var(--overlay);
background-size: cover; background-position: center;
}
.shot .glyph {
width: 30px; height: 30px; flex: none; border-radius: 4px; display: grid; place-items: center;
background: color-mix(in srgb, var(--net) 12%, transparent); color: var(--net);
font: 500 9.5px var(--mono); letter-spacing: .04em;
}
.shot .meta { display: flex; flex-direction: column; gap: 1px; min-width: 0; padding-right: 2px }
.shot .nm { max-width: 24ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 400 11.5px var(--mono); color: var(--text) }
.shot .sz { font: 400 10px var(--mono); color: var(--ghost) }
/* 收起来的粘贴要能原样放回去 —— 自动改掉用户手打的内容,代价比帮的忙大 */
.shot .undo {
align-self: flex-start; border: 0; background: none; padding: 0; cursor: pointer;
font: 400 10px var(--mono); color: var(--faint); text-decoration: underline;
text-decoration-style: dotted; text-underline-offset: 2px;
}
.shot .undo:hover { color: var(--accent) }
.shot .x {
width: 22px; height: 22px; flex: none; border: 0; border-radius: var(--r-sm); cursor: pointer;
background: transparent; color: var(--ghost); font: 400 13px/1 var(--mono);
display: grid; place-items: center; transition: color .12s, background .12s;
}
.shot .x:hover { color: var(--err); background: color-mix(in srgb, var(--err) 12%, transparent) }
.shot button:focus-visible, .intake:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px }
.shots .warn { font: 400 11px var(--ui); color: var(--accent) }
.compose {
@@ -1578,7 +1683,9 @@
.call, .nest, .ask, .hits > *, .steps > *, .term-l, .dl, .plan .s, .find,
.ag, .file, .jump, .menu, .nest-ret, .hl .cost, .sym, .pip[data-settled],
.big .v[data-flash], .plan .s[data-done] .b,
.rmark, .rmark path, .await i, .apv, .guard, .medit, .medit-r, .endbar { animation: none !important }
.rmark, .rmark path, .await i, .apv, .guard, .medit, .medit-r, .endbar,
.rcpt[data-lvl="ok"], .rrow, .shot, .intake { animation: none !important }
.pane[data-intake] .compose { transform: none !important }
.rmark path { stroke-dashoffset: 0 !important }
/* 三个状态本来就靠颜色和那行字说话,停掉动画不丢任何信息 */
.reload, .reload .rdot, .rnote { animation: none !important }
@@ -3577,3 +3684,36 @@
.ctxpop .row .v { font: 400 10.5px var(--mono); color: var(--text); font-variant-numeric: tabular-nums }
.ctxpop .row .p { width: 30px; text-align: right; font: 400 10px var(--mono); color: var(--ghost) }
.ctxpop .foot { margin: 3px 0 0; font: 400 10px/1.5 var(--ui); color: var(--ghost) }
/* 存储:一屏三段 —— 占了多少、放在哪、正在搬什么。条形按最大的那一项归一,
所以读的是相对关系而不是绝对像素;数字全部等宽对齐,因为它们是拿来比的。 */
.storage .vol { display: flex; flex-direction: column; gap: 5px; margin-bottom: 12px }
.storage .vol .row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px }
.storage .vol .nm { font: 500 12px var(--ui); color: var(--text) }
.storage .vol .sz { font: 400 10.5px var(--mono); color: var(--muted); font-variant-numeric: tabular-nums }
.storage .meter { height: 5px; border-radius: var(--r-xs); background: var(--hair); overflow: hidden }
.storage .meter > i { display: block; height: 100%; background: var(--accent); transition: width var(--t-line) var(--ease-out) }
.storage .drives { margin: 10px 0 0 }
.storage .drives p { margin: 0; font: 400 10.5px var(--mono); color: var(--muted); font-variant-numeric: tabular-nums }
.storage .item { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; flex-wrap: wrap;
padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--r-sm); margin-bottom: 8px }
.storage .item .l { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1 }
.storage .item .l b { font: 600 12px var(--ui); color: var(--text) }
.storage .item .hint { font: 400 11px var(--ui); color: var(--muted) }
.storage .item .p { font: 400 10.5px var(--mono); color: var(--ghost); word-break: break-all }
/* 被环境变量钉住、或本来就不可移动 —— 说明为什么这一行没有按钮 */
.storage .item .held { font: 400 10.5px var(--mono); color: var(--warn) }
.storage .mover { display: flex; flex-direction: column; gap: 8px; width: 100%; margin-top: 4px }
.storage .mover input { width: 100%; padding: 6px 9px; border: 1px solid var(--border); border-radius: var(--r-xs);
background: var(--surface); color: var(--text); font: 400 11px var(--mono) }
.storage .mover input:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px }
.storage .mover .acts { display: flex; gap: 8px; justify-content: flex-end }
.storage .refusals { margin: 0; padding-left: 16px; display: flex; flex-direction: column; gap: 3px }
.storage .refusals li { font: 400 11px/1.5 var(--ui); color: var(--err) }
.storage .ready { margin: 0; font: 400 11px/1.5 var(--ui); color: var(--muted) }
.storage .moving .failed { margin: 0; font: 400 11px/1.5 var(--ui); color: var(--err) }
.storage .moving .row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 5px }
.storage .moving .nm { font: 500 12px var(--ui); color: var(--text) }
.storage .moving .sz { font: 400 10.5px var(--mono); color: var(--muted); font-variant-numeric: tabular-nums }
+244 -79
View File
@@ -1,11 +1,13 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { t } from "../i18n";
import { useFileDrop, type Dropped } from "./filedrop";
import type { AgentPort, ApprovalMode, ModelEntry, SessionStatus, Attachment } from "../port/port";
import { Picker } from "./Menu";
import { modelMenu } from "./modelmenu";
import { CompletionMenu, useCompletion } from "./Completion";
import { useIme } from "./ime";
import { countLines, pasteIsLong, planTone, planVerb } from "./intake";
import { useIntake } from "./useIntake";
import type { Dropped } from "./filedrop";
// 从紧到松排,和闸门环的缺口一个方向。「不打扰」在内核是 permission.Deny:
// 它比「询问」更严,不是更松 —— 排在询问前面才不会被读反。
@@ -25,8 +27,13 @@ const APPROVALS: [ApprovalMode, string, string][] = [
const EFFORT_FALLBACK = ["auto", "low", "medium", "high", "xhigh", "max"];
function effortsFor(models: ModelEntry[], ref?: string): string[] {
const declared = models.find((m) => m.ref === ref)?.efforts;
return declared?.length ? declared : EFFORT_FALLBACK;
const model = models.find((m) => m.ref === ref);
// A model that is in the list and names no levels has none: the host omits
// the field exactly when it would refuse every level but auto. Filling that
// in from the fallback is what put a full ladder in front of a relay whose
// every rung came back refused. The fallback is for a list not answered yet.
if (model) return model.efforts ?? [];
return EFFORT_FALLBACK;
}
// 强度是有序的,批准是有序的 —— 一排全等的胶囊把这件事藏了起来。两个刻度把
@@ -43,13 +50,46 @@ interface Props {
onError: (e: unknown) => void;
}
// What is riding along with this turn. An image travels as bytes the kernel
// already saved; a long paste is the text itself, held out of the box so eight
// hundred lines of log do not become the composer.
type Chip =
| { k: "image"; id: string; a: Attachment; url?: string }
| { k: "paste"; id: string; body: string; lines: number; name?: string };
// Two screenshots pasted in a row are one filename apart, which is the one
// thing the chip has to tell them by. The preview comes off the blob that was
// just attached — the kernel keeps the bytes, this keeps a handle to look at.
function previewURL(blob: Blob): string | undefined {
try {
return URL.createObjectURL(blob);
} catch {
return undefined;
}
}
// A dropped file has no preview to stand behind: the host named it, it was
// never read. Its kind fills the square, because a blank one reads as an image
// that failed to load.
function kindOf(path: string): string {
const name = path.split(/[\\/]/).pop() ?? path;
const dot = name.lastIndexOf(".");
return dot > 0 ? name.slice(dot + 1).toUpperCase().slice(0, 4) : "FILE";
}
function releaseChip(c: Chip) {
if (c.k === "image" && c.url) URL.revokeObjectURL(c.url);
}
let chipSeq = 0;
const chipId = () => `c${++chipSeq}`;
export function Composer({ port, status, running, onSubmit, onChanged, onError }: Props) {
const [text, setText] = useState("");
// The caret decides which token is being completed, so it is state here
// rather than something read off the element when a menu happens to open.
const [caret, setCaret] = useState(0);
const [shots, setShots] = useState<Attachment[]>([]);
const [over, setOver] = useState(false);
const [shots, setShots] = useState<Chip[]>([]);
const picker = useRef<HTMLInputElement>(null);
const [models, setModels] = useState<ModelEntry[]>([]);
const [switching, setSwitching] = useState(false);
@@ -62,9 +102,13 @@ export function Composer({ port, status, running, onSubmit, onChanged, onError }
port.models().then(setModels).catch(() => setModels([]));
}, [port]);
// A drop lands where the caret is, and the handler that receives it was built
// once — so the position it reads has to be a ref, not the render's copy.
const caretRef = useRef(0);
const type = (next: string, at: number) => {
setText(next);
setCaret(at);
caretRef.current = at;
};
const menu = useCompletion(port, text, caret, (next, at) => {
@@ -93,50 +137,114 @@ export function Composer({ port, status, running, onSubmit, onChanged, onError }
}, [text]);
// Attachments ride into the turn as path references, exactly as they do from
// the CLI. What the reference points at is the difference: a dropped file is
// still where it was, and only pasted bytes were written into the workspace.
// the CLI — the host saved the bytes, the turn parser resolves the token. A
// held-back paste follows the typed text, which is where it reads as the
// material the message is about rather than as part of the sentence.
const send = () => {
const v = text.trim();
if (!v && shots.length === 0) return;
const line = [...shots.map((a) => a.ref), v].filter(Boolean).join(" ");
const refs = shots.flatMap((c) => (c.k === "image" ? [c.a.ref] : []));
const pastes = shots.flatMap((c) => (c.k === "paste" ? [c.body] : []));
const line = [[...refs, v].filter(Boolean).join(" "), ...pastes].filter(Boolean).join("\n\n");
type("", 0);
shots.forEach(releaseChip);
setShots([]);
onSubmit(line);
};
// Bytes, for the two cases that have nothing else to offer: the clipboard,
// which never says where what it holds came from, and the picker, which hands
// back a File and no path. The host writes them into the workspace and the
// turn references that copy — which is right for a screenshot and wrong for a
// file that already exists somewhere, so a drop does not come through here.
const grab = (files: File[]) => {
if (files.length === 0) return;
Promise.all(files.map((f) => port.attach(f, f.name)))
.then((added) => setShots((prev) => [...prev, ...added]))
.catch(onError);
};
const insert = useCallback((snippet: string) => {
const el = box.current;
const at = el ? el.selectionStart : caretRef.current;
setText((prev) => {
const cut = Math.min(at, prev.length);
const next = prev.slice(0, cut) + snippet + prev.slice(cut);
pending.current = cut + snippet.length;
return next;
});
}, []);
// A dropped file is referenced where it lives. Copying it into the workspace
// is what let a turn edit the copy and report the edit as done while the file
// the user pointed at never changed — and what filled the attachment
// directory with a second version of everything anybody dragged in.
const dropped = (d: Dropped) => {
if (d.paths.length === 0) {
// A tab, or a drag that carried a promise rather than a file on disk.
grab(d.files.filter((f) => f.size > 0));
return;
}
port
.dropRefs(d.paths)
.then((refs) => {
const took = refs.filter((r) => r.ref).map((r) => ({ path: r.path ?? "", ref: r.ref ?? "", image: r.image }));
if (took.length > 0) setShots((prev) => [...prev, ...took]);
const refused = refs.filter((r) => r.error).map((r) => r.error);
if (refused.length > 0) onError(new Error(refused.join("\n")));
})
.catch(onError);
};
const drop = useFileDrop(dropped, setOver);
const discard = useCallback((c: Chip) => {
releaseChip(c);
setShots((prev) => prev.filter((x) => x !== c));
}, []);
const attach = useCallback(
(blobs: File[]) => {
if (blobs.length === 0) return;
Promise.all(blobs.map((b) => port.attach(b, b.name).then((a) => ({ a, url: previewURL(b) }))))
.then((added) =>
setShots((prev) => [...prev, ...added.map(({ a, url }) => ({ k: "image" as const, id: chipId(), a, url }))]),
)
.catch(onError);
},
[port, onError],
);
// A dropped file is referenced where it lives. Copying it in is what let a
// turn edit the copy and report the edit as done while the file the user
// pointed at never changed. Only the host can mint the token: whether a path
// is inside the workspace compares two spellings of one location.
const refIn = useCallback(
(paths: string[]) => {
port
.dropRefs(paths)
.then((refs) => {
const took = refs.flatMap((r) =>
r.ref
? [{ k: "image" as const, id: chipId(), a: { path: r.path ?? "", ref: r.ref, image: r.image } }]
: [],
);
if (took.length > 0) setShots((prev) => [...prev, ...took]);
const refused = refs.flatMap((r) => (r.error ? [r.error] : []));
if (refused.length > 0) onError(new Error(refused.join("\n")));
})
.catch(onError);
},
[port, onError],
);
const readIn = useCallback(
(files: File[]) => {
for (const file of files) {
void file
.text()
.then((body) => {
if (!body.trim()) return;
setShots((prev) => [
...prev,
{ k: "paste", id: chipId(), body, lines: countLines(body), name: file.name },
]);
})
.catch(onError);
}
},
[onError],
);
// One place decides what a payload becomes, whichever channel carried it.
const receive = useCallback(
(d: Dropped) => {
if (d.paths.length > 0) refIn(d.paths);
else if (d.files.length > 0) {
const pictures = d.files.filter((f) => f.type.startsWith("image/"));
attach(pictures);
// Bytes that are not pixels have nowhere else to go: the kernel's
// attachment store keeps images only, and there is no path to point at.
// Reading them as text is the one answer left, and a good one for a
// dropped log.
readIn(d.files.filter((f) => !pictures.includes(f)));
}
if (d.text) insert(d.text + " ");
// A drop is the start of typing, not the end of it.
box.current?.focus();
},
[refIn, attach, readIn, insert],
);
const { plan: drag, ref: dropzone } = useIntake({
root: status?.workspaceRoot ?? status?.cwd,
onReceive: receive,
});
const apv = status?.toolApprovalMode ?? "ask";
const eff = status?.effort || "auto";
@@ -151,8 +259,8 @@ export function Composer({ port, status, running, onSubmit, onChanged, onError }
return (
// display:contents, so the box looks exactly as it did and the drop layer
// still has one node to ask "did this land in the composer" about.
<div className="dropzone" ref={drop}>
// still has one node to ask which pane a drop landed in.
<div className="dropzone" ref={dropzone}>
{menu.open && (
<CompletionMenu
items={menu.completion.items}
@@ -163,31 +271,73 @@ export function Composer({ port, status, running, onSubmit, onChanged, onError }
onHover={menu.hover}
/>
)}
{/* What letting go will do, said before it happens. A drop that only
reports afterwards is the pattern this replaces. */}
{drag && (
<p className="intake" data-tone={planTone(drag)} role="status" aria-live="polite">
{planVerb(drag)}
</p>
)}
{shots.length > 0 && (
<div className="shots">
{shots.map((a) => (
<span className="shot" key={a.ref} title={a.path}>
<span className="nm">{a.path.split("/").pop()}</span>
<button aria-label={t("移除这张图")} onClick={() => setShots((p) => p.filter((x) => x !== a))}>
<ul className="shots">
{shots.map((c, i) => (
<li className="shot" key={c.id} style={{ "--i": i } as React.CSSProperties}>
{c.k === "image" ? (
<>
{c.a.image === false ? (
<span className="glyph" aria-hidden="true">{kindOf(c.a.path)}</span>
) : (
<span
className="thumb"
aria-hidden="true"
style={c.url ? { backgroundImage: `url(${c.url})` } : undefined}
/>
)}
<span className="meta">
<span className="nm" title={c.a.path}>{c.a.path.split("/").pop()}</span>
<span className="sz">{c.a.image === false ? t("文件") : t("图片")}</span>
</span>
</>
) : (
<>
<span className="glyph" aria-hidden="true">TXT</span>
<span className="meta">
<span className="nm">{c.name ?? t("粘贴的文本")}</span>
<button
className="undo"
onClick={() => {
discard(c);
insert(c.body);
}}
>
{t("{n} 行 · 展开到输入框", { n: c.lines })}
</button>
</span>
</>
)}
<button
className="x"
aria-label={t("移除")}
onClick={() => discard(c)}
>
×
</button>
</span>
</li>
))}
{/* The kernel keeps the image either way, but a text-only model never
sees it — say so here rather than letting the paste vanish. */}
{status?.vision === false && shots.some((a) => a.image) && (
<span className="warn">
{status?.vision === false && shots.some((c) => c.k === "image" && c.a.image !== false) && (
<li className="warn">
{status?.visionDeclared === false
? t("没人说过这个模型读不读图 · 先按不读处理;在「连接」里勾上它就直接发")
: t("当前模型不读图 · 将交给能读图的子代理")}
</span>
</li>
)}
</div>
</ul>
)}
<textarea
ref={box}
rows={1}
data-over={over ? "" : undefined}
value={text}
placeholder={t("交待一个任务,回车发送… / 调用命令与技能,@ 引用文件")}
role="combobox"
@@ -200,12 +350,22 @@ export function Composer({ port, status, running, onSubmit, onChanged, onError }
// the caret is what decides which token the menu is completing.
onKeyUp={(e) => setCaret(e.currentTarget.selectionStart)}
onClick={(e) => setCaret(e.currentTarget.selectionStart)}
// Dropping is the pane's job — a one-row box is 40px to aim at, and the
// handler that used to live here prevented the default and then did
// nothing with text, which is worse than not handling it at all.
onPaste={(e) => {
const files = [...e.clipboardData.files];
if (files.some((f) => f.type.startsWith("image/"))) {
const images = [...e.clipboardData.files].filter((f) => f.type.startsWith("image/"));
if (images.length > 0) {
e.preventDefault();
grab(files);
attach(images);
return;
}
const body = e.clipboardData.getData("text/plain");
if (!pasteIsLong(body)) return;
// Long enough to bury the composer. It still goes with the turn — it
// is just held beside the box instead of becoming it.
e.preventDefault();
setShots((prev) => [...prev, { k: "paste", id: chipId(), body, lines: countLines(body) }]);
}}
{...ime.handlers}
onKeyDown={(e) => {
@@ -255,9 +415,8 @@ export function Composer({ port, status, running, onSubmit, onChanged, onError }
}}
/>
<div className="row" data-busy={switching ? "" : undefined}>
{/* 只挑图片:选出来的文件跟剪贴板一样只有字节没有路径,只能复制一份
进工作区。对截图这是对的,对一个已经躺在磁盘上的文件就不是——那条
路留给拖进来。 */}
{/* 拖进来和粘贴都走同一条路,但那两个都得先有一张图在手边。点开系统
选择器是唯一不需要预备动作的入口。 */}
<input
ref={picker}
type="file"
@@ -265,7 +424,7 @@ export function Composer({ port, status, running, onSubmit, onChanged, onError }
multiple
hidden
onChange={(e) => {
grab([...(e.target.files ?? [])]);
attach([...(e.target.files ?? [])].filter((f) => f.type.startsWith("image/")));
// 同一张图再选一次也要能进来,所以每次用完清空。
e.target.value = "";
}}
@@ -295,24 +454,30 @@ export function Composer({ port, status, running, onSubmit, onChanged, onError }
</>
}
/>
<span className="sep" aria-hidden="true" />
<Picker
className="mode plain"
place="bottom"
current={eff}
items={efforts.map((v) => ({ value: v, label: v, meter: LEVEL[v] }))}
onPick={(v) => change(port.setEffort(v))}
label={
<>
<span className="bars" data-lv={LEVEL[eff] ?? 0} aria-hidden="true">
<i /><i /><i /><i /><i />
</span>
<span className="lb">{t("强度")}</span>
{/* key 让值换一次就重挂载一次 —— 这是那半秒里唯一能看出「改动生效了」的地方 */}
<span className="vl" data-lv={LEVEL[eff] ?? 0} key={eff}>{eff}</span>
</>
}
/>
{/* An endpoint that names no levels gets no control: a picker whose
every rung is refused downstream reads as a dead one. */}
{efforts.length > 0 && (
<>
<span className="sep" aria-hidden="true" />
<Picker
className="mode plain"
place="bottom"
current={eff}
items={efforts.map((v) => ({ value: v, label: v, meter: LEVEL[v] }))}
onPick={(v) => change(port.setEffort(v))}
label={
<>
<span className="bars" data-lv={LEVEL[eff] ?? 0} aria-hidden="true">
<i /><i /><i /><i /><i />
</span>
<span className="lb">{t("强度")}</span>
{/* key 让值换一次就重挂载一次 —— 这是那半秒里唯一能看出「改动生效了」的地方 */}
<span className="vl" data-lv={LEVEL[eff] ?? 0} key={eff}>{eff}</span>
</>
}
/>
</>
)}
<button
className="mode tog"
aria-pressed={status?.plan ?? false}
+3 -1
View File
@@ -154,7 +154,9 @@ export function Onboarding({ port, setup, onDone }: Props) {
<span>{KIND_LABEL[found.kind] || found.kind}</span>
<span className="k">{found.models.length} 个模型 · key 存本机</span>
</div>
{found.ambiguous && (
{/* More than one wire answered, or one listing several may be
driven with: either way the line above is a preference. */}
{(found.ambiguous || (found.kinds?.length ?? 0) > 1) && (
<p className="onb-why">
{t("这个端点不止一种协议答应了,上面是偏好而不是事实。连上之后能在设置里换。")}
</p>
+24 -5
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { t } from "../i18n";
import type { ProviderCheck, ProviderEdit, ProviderEntry, ProviderProbe } from "../port/port";
import type { Protocol, ProviderCheck, ProviderEdit, ProviderEntry, ProviderProbe } from "../port/port";
import { KIND_LABEL, accountKey, accountLabel, disambiguate, hostOf, nameFrom, vendorLabel } from "./vendors";
// A connection is an account, not a config row. One endpoint answering two
@@ -12,6 +12,7 @@ import { KIND_LABEL, accountKey, accountLabel, disambiguate, hostOf, nameFrom, v
type Port = {
providers(): Promise<ProviderEntry[]>;
protocols(): Promise<Protocol[]>;
probeProvider(baseUrl: string, apiKey: string): Promise<ProviderProbe>;
saveProvider(draft: {
name: string; kind: string; baseUrl: string; apiKey: string; models: string[];
@@ -277,9 +278,9 @@ function Conn({
</span>
<span className="why">
{!found.ok && found.error}
{found.ok && found.kind !== entry.kind &&
{found.ok && found.matches === false &&
t("记的是 {had},但它答的是 {got}。", { had: t(KIND_LABEL[entry.kind] ?? entry.kind), got: t(KIND_LABEL[found.kind ?? ""] ?? found.kind ?? "") })}
{found.ok && found.kind === entry.kind && "key 有效,协议也对得上。"}
{found.ok && found.matches !== false && "key 有效,协议也对得上。"}
{found.ok && found.noProxy && " 走代理连不上、直连可以。"}
</span>
</div>
@@ -490,9 +491,19 @@ function AddProvider({
const [baseUrl, setBaseUrl] = useState("");
const [apiKey, setApiKey] = useState("");
const [probe, setProbe] = useState<ProviderProbe | null>(null);
const [catalog, setCatalog] = useState<Protocol[]>([]);
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
// The endpoint says which wires are on the table and the kernel says what
// each one is, so neither list is written here.
useEffect(() => {
port.protocols().then(setCatalog).catch(() => setCatalog([]));
}, [port]);
const choices = probe ? (probe.kinds?.length ? probe.kinds : [probe.kind]) : [];
const searchOn = choices.filter((k) => catalog.find((p) => p.kind === k)?.serverWebSearch);
const searchSplit = searchOn.length > 0 && searchOn.length < choices.length;
// Everything below is editable after the probe, because every one of these
// is something the endpoint could not tell us for certain.
const [name, setName] = useState("");
@@ -602,11 +613,19 @@ function AddProvider({
<label className="grow">
<span>{t("接入方式")}</span>
<select value={kind} onChange={(e) => setKind(e.target.value)}>
<option value="openai">{t("OpenAI 兼容")}</option>
<option value="anthropic">{t("Anthropic 兼容")}</option>
{choices.map((k) => (
<option key={k} value={k}>{t(KIND_LABEL[k] ?? k)}</option>
))}
</select>
</label>
</div>
{searchSplit && (
<p className="acct-note">
{t("这个地址两条线都答得上来。{on} 那条上联网搜索由供应商自己跑,另一条没有 —— 那是协议的差别,不是设置。", {
on: searchOn.map((k) => t(KIND_LABEL[k] ?? k)).join("、"),
})}
</p>
)}
{probe.ambiguous && (
<p className="acct-note">
{t("两种接入方式的模型列表它都答得上来,光看列表分不出来 —— 聊天入口通常不在同一个路径下,选错了聊天会报错。要两条都用就再添加一次、选另一个。")}
+8 -1
View File
@@ -20,6 +20,7 @@ import { Roles } from "./Roles";
import { Boundary } from "./Boundary";
import { Versions } from "./Versions";
import { Memory } from "./Memory";
import { Storage } from "./Storage";
import { Appearance, SCHEMES } from "./Appearance";
import { Exception, ScopeBar } from "./CapabilityScope";
@@ -35,7 +36,7 @@ const APPROVALS: [ApprovalMode, string, string][] = [
["yolo", "全放行", "不问了。只在你完全信任这个工作区时用"],
];
type Section = "session" | "model" | "tools" | "hooks" | "ext" | "network" | "memory" | "account" | "versions" | "appearance" | "advanced";
type Section = "session" | "model" | "tools" | "hooks" | "ext" | "network" | "memory" | "storage" | "account" | "versions" | "appearance" | "advanced";
// What still lives in the old desktop app. Bots are not on the roadmap, so
// they are not a promise to keep here either. Signing in and reading
@@ -54,6 +55,7 @@ const NAV: [Section, string][] = [
["ext", "扩展"],
["network", "网络"],
["memory", "记忆"],
["storage", "存储"],
["account", "账号"],
["versions", "版本"],
["appearance", "外观"],
@@ -244,6 +246,7 @@ export function Settings({ port, status, theme, onTheme, contrast, onContrast, l
ext: broken ? t("{n} 个异常", { n: broken }) : packages.length ? t("{n} 个包", { n: packages.length }) : `${looseMcp.length + looseOn}`,
network: netMode,
memory: memCount ? t("{n} 条", { n: memCount }) : "",
storage: "",
account: acct === null ? "" : acct.signedIn ? (acct.user?.label ?? t("已登录")) : t("未登录"),
versions: "",
appearance: t(SCHEMES.find(([id]) => id === theme)?.[1] ?? ""),
@@ -561,6 +564,10 @@ export function Settings({ port, status, theme, onTheme, contrast, onContrast, l
</Group>
)}
{at === "storage" && (
<Group title={t("存储")} hint={t("它把数据写在哪、占了多少。会话和索引会一直长,配置和凭据不会 —— 所以只有前者能搬走,搬迁在重启后生效。")}><Storage port={port} /></Group>
)}
{at === "appearance" && (
<Appearance port={port} theme={theme} onTheme={onTheme} contrast={contrast} onContrast={onContrast} reloadThemes={reloadThemes} look={look} onLook={onLook} />
)}
+309
View File
@@ -0,0 +1,309 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { t } from "../i18n";
import type { AgentPort } from "../port/port";
import type { StoragePlan, StorageRoot, StorageState } from "../port/storage";
// Three questions in the order a person asks them: how much is there, where is
// it, and what can go. The rows are whatever the kernel declares — a root added
// there shows up here without this panel being told about it.
// The rows a person recognises. A root the kernel declares but this list has no
// name for still renders, under its own id: showing an unnamed row is better
// than hiding storage someone is looking for.
const NAMED: Record<string, [string, string]> = {
state: ["会话与归档", "转录、压缩归档、用量统计、回溯快照"],
cache: ["索引与缓存", "搜索索引与派生数据,删掉会自动重建"],
worktrees: ["隔离工作区", "交付模式检出的独立副本"],
home: ["配置与凭据", "设置和 API key,始终随用户配置文件走"],
locks: ["进程锁", "多个实例互斥用,必须留在本机固定位置"],
};
function bytes(n: number): string {
if (n < 1024) return `${n} B`;
const units = ["KB", "MB", "GB", "TB"];
let value = n / 1024;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit++;
}
return `${value >= 100 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`;
}
export function Storage({ port }: { port: AgentPort }) {
const [state, setState] = useState<StorageState | null>(null);
const [error, setError] = useState("");
const [picking, setPicking] = useState<string | null>(null);
const [target, setTarget] = useState("");
const [plan, setPlan] = useState<StoragePlan | null>(null);
const input = useRef<HTMLInputElement>(null);
const read = useCallback(() => {
port
.storage()
.then(setState)
.catch(() => setError(t("读不到存储占用。")));
}, [port]);
useEffect(read, [read]);
// A move outlives the request that started it, so the panel follows it the
// way it follows a running turn: by asking again until it says it is done.
const running = state?.move && !state.move.done;
useEffect(() => {
if (!running) return;
const timer = setInterval(read, 500);
return () => clearInterval(timer);
}, [running, read]);
useEffect(() => {
if (picking) input.current?.focus();
}, [picking]);
const check = useCallback(
(root: string, dir: string) => {
setPlan(null);
if (!dir.trim()) return;
port.planStorageMove(root, dir).then(setPlan).catch(() => setPlan(null));
},
[port],
);
const start = useCallback(
(root: string, dir: string) => {
port
.moveStorage(root, dir)
.then((accepted) => {
if (!accepted.ok) {
setPlan(accepted);
return;
}
setPicking(null);
setTarget("");
setPlan(null);
read();
})
.catch(() => setError(t("搬迁没能开始。")));
},
[port, read],
);
if (error) return <div className="empty">{error}</div>;
if (!state) return <div className="empty">{t("正在统计…")}</div>;
const measured = state.roots.filter((r) => !r.missing || r.relocatable);
const largest = Math.max(1, ...measured.map((r) => r.bytes));
return (
<div className="storage">
<section className="grp">
<h3 className="lbl">{t("占用")}</h3>
{measured.map((root) => (
<Bar key={root.id} root={root} largest={largest} />
))}
<Drives roots={state.roots} />
</section>
<section className="grp">
<h3 className="lbl">{t("位置")}</h3>
{state.roots.map((root) => (
<Row
key={root.id}
root={root}
editable={state.editable}
busy={Boolean(running)}
open={picking === root.id}
target={target}
plan={plan}
inputRef={input}
onOpen={() => {
setPicking(root.id);
setTarget("");
setPlan(null);
}}
onCancel={() => {
setPicking(null);
setPlan(null);
}}
onTarget={(dir) => {
setTarget(dir);
check(root.id, dir);
}}
onStart={() => start(root.id, target)}
/>
))}
</section>
{state.move && <Moving move={state.move} />}
</div>
);
}
function Bar({ root, largest }: { root: StorageRoot; largest: number }) {
const [name] = NAMED[root.id] ?? [root.id, ""];
return (
<div className="vol">
<div className="row">
<span className="nm">{t(name)}</span>
<span className="sz">
{bytes(root.bytes)}
{root.files > 0 && ` · ${t("{n} 个文件", { n: root.files })}`}
</span>
</div>
<div className="meter">
<i style={{ width: `${Math.max(1, Math.round((root.bytes / largest) * 100))}%` }} />
</div>
</div>
);
}
// Which disks this adds up on. Roots sharing a volume are one line, because
// moving one of them off a full drive only helps if the others go too.
function Drives({ roots }: { roots: StorageRoot[] }) {
const seen = new Map<string, StorageRoot>();
for (const root of roots) {
if (root.volume && root.volumeTotal && !seen.has(root.volume)) seen.set(root.volume, root);
}
if (seen.size === 0) return null;
return (
<div className="drives">
{[...seen.values()].map((root) => (
<p key={root.volume}>
{t("{drive} 剩余 {free} / {total}", {
drive: root.volume ?? "",
free: bytes(root.volumeFree ?? 0),
total: bytes(root.volumeTotal ?? 0),
})}
</p>
))}
</div>
);
}
function Row({
root,
editable,
busy,
open,
target,
plan,
inputRef,
onOpen,
onCancel,
onTarget,
onStart,
}: {
root: StorageRoot;
editable: boolean;
busy: boolean;
open: boolean;
target: string;
plan: StoragePlan | null;
inputRef: React.RefObject<HTMLInputElement | null>;
onOpen: () => void;
onCancel: () => void;
onTarget: (dir: string) => void;
onStart: () => void;
}) {
const [name, hint] = NAMED[root.id] ?? [root.id, ""];
// Every path on screen is either actionable or says why it is not. A row that
// simply had no button would read as a bug.
const held = root.pinnedBy
? t("由 {env} 指定", { env: root.pinnedBy })
: !root.relocatable
? t("不可移动")
: "";
return (
<div className="item">
<div className="l">
<b>{t(name)}</b>
{hint && <span className="hint">{t(hint)}</span>}
<span className="p">{root.dir}</span>
{held && <span className="held">{held}</span>}
{root.err && <span className="held">{root.err}</span>}
</div>
{root.relocatable && !root.pinnedBy && editable && !open && (
<button className="btn" disabled={busy} onClick={onOpen}>
{t("移动…")}
</button>
)}
{open && (
<div className="mover">
<input
ref={inputRef}
value={target}
spellCheck={false}
placeholder={t("目标文件夹的完整路径,例如 D:/reasonix/sessions")}
onChange={(e) => onTarget(e.target.value)}
/>
{plan && <Verdict plan={plan} />}
<div className="acts">
<button className="btn" onClick={onCancel}>
{t("取消")}
</button>
<button className="btn pri" disabled={!plan?.ok} onClick={onStart}>
{t("开始搬迁")}
</button>
</div>
</div>
)}
</div>
);
}
// The preflight's answer, verbatim. Refusals arrive with their own sentences,
// so nothing here reconstructs an explanation from a code.
function Verdict({ plan }: { plan: StoragePlan }) {
if (!plan.ok) {
return (
<ul className="refusals">
{(plan.refusals ?? []).map((r) => (
<li key={r.code}>{r.detail}</li>
))}
</ul>
);
}
return (
<p className="ready">
{t("将搬走 {size}({n} 个文件),目标盘剩余 {free}。完成后需要重启才会生效。", {
size: bytes(plan.bytes),
n: plan.files,
free: bytes(plan.free),
})}
</p>
);
}
const PHASES: Record<string, string> = {
copying: "正在复制",
verifying: "正在校验",
committed: "已提交,正在清理原位置",
done: "已完成",
};
function Moving({ move }: { move: NonNullable<StorageState["move"]> }) {
const pct = move.total > 0 ? Math.min(100, Math.round((move.bytes / move.total) * 100)) : 0;
return (
<section className="grp moving">
<h3 className="lbl">{t("搬迁")}</h3>
{move.err ? (
<p className="failed">{move.err}</p>
) : (
<>
<div className="row">
<span className="nm">{t(PHASES[move.phase] ?? move.phase)}</span>
<span className="sz">
{bytes(move.bytes)} / {bytes(move.total)}
</span>
</div>
<div className="meter">
<i style={{ width: `${pct}%` }} />
</div>
{move.done && <p className="ready">{t("已搬完。重启后生效。")}</p>}
{move.detail && <p className="ready">{move.detail}</p>}
</>
)}
</section>
);
}
+34 -16
View File
@@ -8,6 +8,9 @@ import type { HubPort } from "../port/hub";
export interface Dropped {
paths: string[];
files: File[];
// A drag that carried no file at all — a link, a selection. Absent for every
// drop that did, so the two never have to be told apart downstream.
text?: string;
}
// How long a window's drop waits for the paths before it settles for the bytes
@@ -29,14 +32,14 @@ interface Clock {
// disk, and answering that with nothing is how a drop looks broken.
export interface DropRouter<Z> {
over(zone: Z | null): void;
dropped(zone: Z, files: File[]): void;
dropped(zone: Z, files: File[], text?: string): void;
paths(list: string[]): void;
forget(zone: Z): void;
}
export function createDropRouter<Z>(deliver: (zone: Z, d: Dropped) => void, clock: Clock): DropRouter<Z> {
let hover: Z | null = null;
let pending: { id: number; zone: Z; files: File[] } | null = null;
let pending: { id: number; zone: Z; files: File[]; text?: string } | null = null;
let seq = 0;
let settled = -Infinity;
@@ -50,21 +53,28 @@ export function createDropRouter<Z>(deliver: (zone: Z, d: Dropped) => void, cloc
over(zone) {
hover = zone;
},
dropped(zone, files) {
dropped(zone, files, text) {
// The paths beat the DOM event to it, and this is the same drop.
if (clock.now() - settled < clock.grace) return;
// No file was dropped, so no host will ever name one: waiting out the
// grace here would only make a dropped link feel broken.
if (files.length === 0 && text) {
settle(zone, { paths: [], files, text });
return;
}
const id = ++seq;
pending = { id, zone, files };
pending = { id, zone, files, text };
clock.wait(() => {
if (pending?.id === id) settle(zone, { paths: [], files });
if (pending?.id === id) settle(zone, { paths: [], files, text });
}, clock.grace);
},
paths(list) {
const zone = pending?.zone ?? hover;
const files = pending?.files ?? [];
const text = pending?.text;
pending = null;
if (!zone || list.length === 0) return;
settle(zone, { paths: list, files });
settle(zone, { paths: list, files, text });
},
forget(zone) {
if (hover === zone) hover = null;
@@ -76,7 +86,10 @@ export function createDropRouter<Z>(deliver: (zone: Z, d: Dropped) => void, cloc
interface Zone {
el: HTMLElement;
onDrop: (d: Dropped) => void;
onOver: (over: boolean) => void;
// The transfer rides along so a zone can say what letting go will do while
// the drag is still in the air; it names kinds and MIME types only, never a
// path and never bytes.
onOver: (over: boolean, dt: DataTransfer | null) => void;
}
const zones = new Set<Zone>();
@@ -84,7 +97,8 @@ let router: DropRouter<Zone> | null = null;
let lit: Zone | null = null;
function carriesFiles(e: DragEvent): boolean {
return !!e.dataTransfer && [...e.dataTransfer.types].includes("Files");
const types = e.dataTransfer ? [...e.dataTransfer.types] : [];
return types.includes("Files") || types.includes("text/uri-list") || types.includes("text/plain");
}
function zoneAt(target: EventTarget | null): Zone | null {
@@ -95,11 +109,11 @@ function zoneAt(target: EventTarget | null): Zone | null {
return null;
}
function light(next: Zone | null) {
function light(next: Zone | null, dt: DataTransfer | null = null) {
if (lit === next) return;
lit?.onOver(false);
lit?.onOver(false, null);
lit = next;
lit?.onOver(true);
lit?.onOver(true, dt);
router?.over(next);
}
@@ -121,7 +135,7 @@ export function install(hub: HubPort) {
if (!carriesFiles(e)) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
light(zoneAt(e.target));
light(zoneAt(e.target), e.dataTransfer);
},
true,
);
@@ -142,7 +156,11 @@ export function install(hub: HubPort) {
e.preventDefault();
const zone = zoneAt(e.target) ?? lit;
light(null);
if (zone) router?.dropped(zone, [...(e.dataTransfer?.files ?? [])]);
if (!zone) return;
const dt = e.dataTransfer;
const files = [...(dt?.files ?? [])];
const text = files.length > 0 ? undefined : dt?.getData("text/uri-list") || dt?.getData("text/plain") || undefined;
router?.dropped(zone, files, text);
},
true,
);
@@ -162,8 +180,8 @@ export function install(hub: HubPort) {
// ref object would keep the old one registered.
export function useFileDrop(
onDrop: (d: Dropped) => void,
onOver: (over: boolean) => void,
): (node: HTMLElement | null) => void {
onOver: (over: boolean, dt: DataTransfer | null) => void,
): (node: HTMLElement | null) => (() => void) | void {
// Read through a ref so a caller does not have to memoize either callback to
// avoid re-registering the zone on every render.
const live = useRef({ onDrop, onOver });
@@ -173,7 +191,7 @@ export function useFileDrop(
const zone: Zone = {
el: node,
onDrop: (d) => live.current.onDrop(d),
onOver: (over) => live.current.onOver(over),
onOver: (over, dt) => live.current.onOver(over, dt),
};
zones.add(zone);
return () => {
+132
View File
@@ -0,0 +1,132 @@
import { describe, expect, it } from "vitest";
import { actionFor, countLines, pasteIsLong, planIntake, planTone, planVerb, relativize } from "./intake";
const ROOT = "D:/DeepSeek-Reasonix";
const file = (path: string, mime = "") => ({ kind: "file" as const, path, mime });
const dir = (path: string) => ({ kind: "dir" as const, path });
const text = (body: string) => ({ kind: "text" as const, text: body });
describe("what a dropped thing turns into", () => {
it("carries pixels and points at everything else", () => {
expect(actionFor(file(`${ROOT}/shot.png`, "image/png"), ROOT)).toBe("attach");
expect(actionFor(file(`${ROOT}/internal/hook/runner.go`), ROOT)).toBe("ref");
expect(actionFor(dir(`${ROOT}/internal/hook`), ROOT)).toBe("ref");
expect(actionFor(text("hello"), ROOT)).toBe("insert");
});
// A reference that does not resolve is worse than refusing the drop: the
// agent reads it, finds nothing, and the turn is spent on the lookup.
it("refuses a file it could not reference and has no bytes for", () => {
expect(actionFor(file("C:/elsewhere/notes.md"), ROOT)).toBe("reject");
expect(actionFor(file(`${ROOT}/a.go`), undefined)).toBe("reject");
});
// A browser tab is never told a path. Bytes in hand are still worth carrying,
// which is the only answer available there.
it("carries bytes when no path was reported", () => {
const dropped = { kind: "file" as const, name: "notes.md", blob: new Blob(["x"]) };
expect(actionFor(dropped, ROOT)).toBe("attach");
});
// A dragover reports kinds and never payloads, so the preview has to commit
// before it can resolve anything — otherwise every drag previews as refused.
it("previews a file still in the air as a reference", () => {
expect(actionFor({ kind: "file", mime: "", pending: true }, ROOT)).toBe("ref");
expect(actionFor({ kind: "file", mime: "image/png", pending: true }, ROOT)).toBe("attach");
});
// An image is carried wherever it came from — bytes travel, paths do not.
it("still attaches an image from outside the workspace", () => {
expect(actionFor(file("C:/elsewhere/shot.png", "image/png"), ROOT)).toBe("attach");
});
});
describe("relativize", () => {
it("takes the workspace-relative form an @ref uses", () => {
expect(relativize(`${ROOT}/internal/hook/runner.go`, ROOT)).toBe("internal/hook/runner.go");
expect(relativize(`${ROOT}\\internal\\hook`, ROOT)).toBe("internal/hook");
expect(relativize(ROOT, ROOT)).toBe(".");
});
it("does not mistake a sibling directory for a child", () => {
expect(relativize("D:/DeepSeek-Reasonix-old/x.go", ROOT)).toBeNull();
});
// Windows hands back whatever case the user typed; the same file must not
// become unreferenceable because the drive letter arrived lowercased.
it("matches the root case-insensitively", () => {
expect(relativize(`d:/deepseek-reasonix/main.go`, ROOT)).toBe("main.go");
});
});
describe("the plan and the sentence it says", () => {
it("splits a mixed drop and says both halves", () => {
const plan = planIntake(
[file(`${ROOT}/a.png`, "image/png"), file(`${ROOT}/b.go`), file(`${ROOT}/c.go`)],
ROOT,
);
expect(plan.attach).toHaveLength(1);
expect(plan.ref).toEqual(["b.go", "c.go"]);
expect(planTone(plan)).toBe("attach");
expect(planVerb(plan)).toContain("1");
expect(planVerb(plan)).toContain("2");
});
it("names the one file it is about to reference", () => {
const plan = planIntake([file(`${ROOT}/internal/hook/runner.go`)], ROOT);
expect(planVerb(plan)).toContain("runner.go");
});
it("marks a directory so the reference reads as one", () => {
expect(planIntake([dir(`${ROOT}/internal/hook`)], ROOT).ref).toEqual(["internal/hook/"]);
});
// The unit was right and the composition threw the answer away: actionFor
// said "ref", then the plan asked for a path the drag does not have yet and
// dropped it. An empty plan shows no hint, so a source file dragged over the
// pane looked exactly like a dead feature.
it("counts a reference it cannot name yet instead of losing it", () => {
const plan = planIntake([{ kind: "file", mime: "", pending: true }], ROOT);
expect(plan.pendingRef).toBe(1);
expect(planTone(plan)).toBe("ref");
expect(planVerb(plan)).not.toBe("");
});
// The desktop channel reports a path and never the bytes. Filtering the plan
// by blob alone left the drop with nothing to do and no way to say so.
it("keeps a host-named image in the plan though it carries no bytes", () => {
const plan = planIntake([file(`${ROOT}/shot.png`, "image/png")], ROOT);
expect(plan.attach).toHaveLength(1);
expect(plan.attach[0].blob).toBeUndefined();
expect(plan.attach[0].path).toContain("shot.png");
});
it("says why nothing will happen rather than going quiet", () => {
const plan = planIntake([file("C:/elsewhere/notes.md")], ROOT);
expect(planTone(plan)).toBe("reject");
expect(planVerb(plan)).not.toBe("");
});
// The regression this whole change exists for: dragging a selection used to
// reach a handler that prevented the default and then did nothing.
it("keeps dropped text", () => {
const plan = planIntake([text("func main() {}")], ROOT);
expect(plan.insert).toBe("func main() {}");
expect(planTone(plan)).toBe("insert");
});
});
describe("a paste long enough to bury the composer", () => {
it("counts lines and characters, whichever comes first", () => {
expect(pasteIsLong("short")).toBe(false);
expect(pasteIsLong("x".repeat(4001))).toBe(true);
// 800 narrow lines are well under the character cap and have still eaten
// the window, which is the case a character-only threshold misses.
expect(pasteIsLong(Array.from({ length: 800 }, () => "ok").join("\n"))).toBe(true);
});
it("counts an empty paste as no lines", () => {
expect(countLines("")).toBe(0);
expect(countLines("a\nb")).toBe(2);
});
});
+171
View File
@@ -0,0 +1,171 @@
// intake.ts — what the composer does with something dropped or pasted on it.
//
// The whole decision is one pure function: payload in, action out. "What
// happens if I drop a folder" is then a test rather than the third branch of an
// onDrop handler. The rule it encodes: this agent reads files itself, so a
// dropped source file becomes a reference it can open at its own budget, not a
// copy of the bytes it must swallow whole. Only pixels have to be carried.
import { t } from "../i18n";
export type IntakeAction = "attach" | "ref" | "insert" | "reject";
// One thing being dropped, described as much as the platform will say. The
// desktop host reports a path, a browser tab reports bytes and never a path,
// and a drag still in the air reports neither — pending marks that third case,
// where the kind is known and the payload only arrives on release.
export interface IntakeItem {
kind: "file" | "dir" | "text";
mime?: string;
path?: string;
name?: string;
text?: string;
blob?: Blob;
pending?: boolean;
}
export interface IntakePlan {
// Files whose bytes have to travel, in the order they were dropped.
attach: IntakeItem[];
// Workspace-relative paths, ready to become @refs.
ref: string[];
// References the drop will produce once the host names them. A preview knows
// how many files are coming and not yet what they are called.
pendingRef: number;
// Text to put at the caret.
insert: string;
// Dropped things nothing can be done with — a file outside the workspace
// cannot be referenced, and copying it in is not what the drop asked for.
reject: number;
}
const SEP = /[\\/]/;
export function actionFor(item: IntakeItem, root?: string): IntakeAction {
if (item.kind === "text") return item.text ? "insert" : "reject";
if (isImage(item)) return "attach";
// Still in the air: the kind is all a dragover reports, and a file that is
// not an image is one this agent would rather be handed a name for.
if (item.pending) return "ref";
if (relativize(item.path, root) !== null) return "ref";
// No path to point at. Bytes in hand are still worth carrying; nothing in
// hand is a drop there is no honest answer for.
if (item.kind === "file" && item.blob) return "attach";
return "reject";
}
function isImage(item: IntakeItem): boolean {
return item.kind === "file" && (item.mime ?? item.blob?.type ?? "").startsWith("image/");
}
// relativize turns an absolute path into the workspace-relative form an @ref
// uses, or null when it is outside — the reference would not resolve, and a
// reference that does not resolve is worse than refusing the drop.
export function relativize(path?: string, root?: string): string | null {
const p = normalize(path);
const r = normalize(root);
if (!p) return null;
if (!r) return null;
const base = r.endsWith("/") ? r : r + "/";
if (p === r) return ".";
if (!p.toLowerCase().startsWith(base.toLowerCase())) return null;
return p.slice(base.length) || ".";
}
function normalize(value?: string): string {
return (value ?? "").trim().replace(/[\\]/g, "/").replace(/\/+$/, "");
}
export function planIntake(items: IntakeItem[], root?: string): IntakePlan {
const plan: IntakePlan = { attach: [], ref: [], pendingRef: 0, insert: "", reject: 0 };
const text: string[] = [];
for (const item of items) {
switch (actionFor(item, root)) {
case "attach":
plan.attach.push(item);
break;
case "ref": {
// A drag still in the air has no path to resolve yet. It is counted, not
// dropped: a preview that resolved nothing used to come out empty, so a
// file that was about to become a reference showed no hint at all.
const rel = relativize(item.path, root);
if (!rel) {
plan.pendingRef++;
break;
}
plan.ref.push(item.kind === "dir" && !rel.endsWith("/") ? rel + "/" : rel);
break;
}
case "insert":
text.push(item.text ?? "");
break;
default:
plan.reject++;
}
}
plan.insert = text.join("\n");
return plan;
}
export function planIsEmpty(plan: IntakePlan): boolean {
return (
plan.attach.length === 0 &&
plan.ref.length === 0 &&
plan.pendingRef === 0 &&
plan.insert === "" &&
plan.reject === 0
);
}
function refCount(plan: IntakePlan): number {
return plan.ref.length + plan.pendingRef;
}
// tone is which of the three actions the drop leads with, so the composer can
// colour itself by what it is about to do rather than by "a drag is happening".
export function planTone(plan: IntakePlan): IntakeAction {
if (plan.attach.length > 0) return "attach";
if (refCount(plan) > 0) return "ref";
if (plan.insert !== "") return "insert";
return "reject";
}
// planVerb says what letting go will do, before it happens. A drop that only
// reports afterwards is the pattern this replaces.
export function planVerb(plan: IntakePlan): string {
const images = plan.attach.length;
const refs = refCount(plan);
if (images > 0 && refs > 0) return t("松手 → 附上 {a} 张图,引用 {r} 个文件", { a: images, r: refs });
if (images > 0) return t("松手 → 附上 {n} 张图", { n: images });
if (refs === 1 && plan.ref.length === 1) return t("松手 → 引用 {name}", { name: basename(plan.ref[0]) });
if (refs === 1) return t("松手 → 引用 1 个文件");
if (refs > 1) return t("松手 → 引用 {n} 个文件", { n: refs });
if (plan.insert !== "") return t("松手 → 插入到光标处");
return t("工作区外的文件引用不到 · 这里放不下");
}
function basename(path: string): string {
const parts = path.replace(/\/$/, "").split(SEP);
return parts[parts.length - 1] || path;
}
// refToken is the same token the @ completion produces, so a dropped file and a
// completed one leave the composer identical.
export function refToken(rel: string): string {
return "@" + rel;
}
// A paste long enough to bury the composer becomes a chip instead. Lines and
// characters both count, whichever comes first: eight hundred lines of narrow
// log is under the character cap and has already eaten the window.
export const PASTE_CHARS = 4000;
export const PASTE_LINES = 80;
export function pasteIsLong(text: string): boolean {
return text.length > PASTE_CHARS || countLines(text) > PASTE_LINES;
}
export function countLines(text: string): number {
if (text === "") return 0;
return text.split("\n").length;
}
+75
View File
@@ -0,0 +1,75 @@
// useIntake.ts — the conversation pane is the drop target, not the forty
// pixels of textarea inside it.
//
// Aiming at a one-row input is aiming at 40px, so the whole pane lights up and
// the composer says what letting go will do. Which pane a drop belongs to, and
// whether the payload is a path or bytes, is filedrop.ts's answer; what is left
// here is meaning — and saying it while the drag is still in the air.
import { useCallback, useEffect, useRef, useState } from "react";
import { useFileDrop, type Dropped } from "./filedrop";
import { planIntake, planIsEmpty, planTone, type IntakeItem, type IntakePlan } from "./intake";
interface Options {
root?: string;
onReceive: (d: Dropped) => void;
}
export function useIntake({ root, onReceive }: Options): {
plan: IntakePlan | null;
ref: (node: HTMLElement | null) => (() => void) | void;
} {
const [plan, setPlan] = useState<IntakePlan | null>(null);
// The pane behind the composer, marked so the transcript can recede. Held as
// a ref rather than looked up per render: the zone node is what the drop
// system hands over, and the pane is that node's ancestor.
const pane = useRef<HTMLElement | null>(null);
const state = useRef({ root, onReceive });
state.current = { root, onReceive };
const zone = useFileDrop(
(d) => {
setPlan(null);
state.current.onReceive(d);
},
(over, dt) => setPlan(over ? preview(dt, state.current.root) : null),
);
const ref = useCallback(
(node: HTMLElement | null) => {
pane.current = node?.closest<HTMLElement>(".pane") ?? null;
const off = zone(node);
return () => {
pane.current?.removeAttribute("data-intake");
pane.current = null;
off?.();
};
},
[zone],
);
useEffect(() => {
const el = pane.current;
if (!el) return;
if (plan && !planIsEmpty(plan)) el.setAttribute("data-intake", planTone(plan));
else el.removeAttribute("data-intake");
}, [plan]);
return { plan: plan && !planIsEmpty(plan) ? plan : null, ref };
}
// preview reads what a drag in the air will say: kinds and MIME types, never a
// path and never bytes. Everything it produces is marked pending.
function preview(dt: DataTransfer | null, root?: string): IntakePlan | null {
if (!dt) return null;
const items: IntakeItem[] = [];
for (const item of Array.from(dt.items ?? [])) {
if (item.kind === "file") items.push({ kind: "file", mime: item.type, pending: true });
}
if (items.length === 0 && carriesText(dt)) items.push({ kind: "text", text: " ", pending: true });
return planIntake(items, root);
}
function carriesText(dt: DataTransfer): boolean {
return Array.from(dt.types ?? []).some((type) => type === "text/plain" || type === "text/uri-list");
}
+1 -1
View File
@@ -21,7 +21,7 @@ const ROUTES = [
"/changes", "/attachments", "/drop", "/checkpoints", "/branches", "/compact", "/rewind",
"/extensions", "/themes", "/plugins", "/surfaces",
"/fork", "/summarize", "/forget", "/bypass", "/auto-approve-tools",
"/permissions", "/sandbox", "/context",
"/permissions", "/sandbox", "/context", "/storage",
"/slash", "/workspaces", "/welcome",
];
+2 -2
View File
@@ -69,7 +69,7 @@ var apiPaths = map[string]bool{
"/changes": true, "/attachments": true, "/drop": true, "/roles": true,
"/themes": true, "/extensions": true, "/plugins": true, "/surfaces": true,
"/welcome": true, "/appearance": true,
"/permissions": true, "/sandbox": true,
"/permissions": true, "/sandbox": true, "/storage": true,
}
// A sub-path belongs to the resource it hangs off: /mcp/reconnect is the same
@@ -77,7 +77,7 @@ var apiPaths = map[string]bool{
// endpoint from silently answering with index.html instead of JSON — and
// TestEveryPathTheFrontendCallsIsRouted is what keeps this list honest, because
// the comment alone did not.
var apiPrefixes = []string{"/mcp/", "/skills/", "/inbox/", "/account/", "/hooks/", "/memory/", "/network/", "/providers/", "/rewind/", "/extensions/", "/themes/", "/plugins/", "/appearance/"}
var apiPrefixes = []string{"/mcp/", "/skills/", "/inbox/", "/account/", "/hooks/", "/memory/", "/network/", "/providers/", "/rewind/", "/extensions/", "/themes/", "/plugins/", "/appearance/", "/storage/"}
// splitRuntimePath separates a pane's address from the route it is asking for:
// /rt/r2/status is runtime r2 asking for /status. An unprefixed path belongs to
+9 -74
View File
@@ -125,48 +125,6 @@ type snapshotWriteDecision struct {
ledgerStale bool
}
type SessionSnapshotConflictKind string
const (
SessionSnapshotConflictStalePrefix SessionSnapshotConflictKind = "stale_prefix"
SessionSnapshotConflictDiverged SessionSnapshotConflictKind = "diverged"
)
type SessionSnapshotConflictError struct {
Path string
Kind SessionSnapshotConflictKind
ExistingMessages int
SnapshotMessages int
BaseRevision int64
DiskRevision int64
}
func (e *SessionSnapshotConflictError) Error() string {
if e == nil {
return ErrSessionSnapshotConflict.Error()
}
switch e.Kind {
case SessionSnapshotConflictStalePrefix:
return fmt.Sprintf("%s: %s has %d messages at revision %d; stale snapshot has %d messages from revision %d",
ErrSessionSnapshotConflict, e.Path, e.ExistingMessages, e.DiskRevision, e.SnapshotMessages, e.BaseRevision)
default:
return fmt.Sprintf("%s: %s diverged on disk (%d messages, revision %d) from snapshot (%d messages, revision %d)",
ErrSessionSnapshotConflict, e.Path, e.ExistingMessages, e.DiskRevision, e.SnapshotMessages, e.BaseRevision)
}
}
func (e *SessionSnapshotConflictError) Unwrap() error {
return ErrSessionSnapshotConflict
}
func SnapshotConflictKind(err error) (SessionSnapshotConflictKind, bool) {
var conflict *SessionSnapshotConflictError
if errors.As(err, &conflict) && conflict != nil {
return conflict.Kind, true
}
return "", false
}
const RecoveryBranchDefaultName = "Recovered unsaved changes from stale runtime"
type RecoveryBranchOptions struct {
@@ -397,6 +355,10 @@ func (s *Session) saveLocked(path string, mode sessionSaveMode) error {
if err != nil {
slog.Warn("session: keeping save after display read-model append failure", "path", path, "err", err)
}
// A declined in-place append must not leave the checkpoint describing
// the transcript from before this save; republishing it also re-arms
// the append fast path for every later save.
displayModelCurrent, indexFrom := settleDisplayReadModel(path, msgs, displayModelCurrent, decision.appendFrom)
revision, err := recordSessionContentRevision(path, digest, decision.revision)
if err != nil {
return err
@@ -410,7 +372,7 @@ func (s *Session) saveLocked(path string, mode sessionSaveMode) error {
slog.Warn("session: keeping save after event index write failure", "path", path, "err", err)
}
if displayModelCurrent {
if err := refreshSessionDisplayIndex(path, msgs, digest, revision, decision.appendFrom); err != nil {
if err := refreshSessionDisplayIndex(path, msgs, digest, revision, indexFrom); err != nil {
// The append boundary lets the refresh extend the previous index
// instead of re-encoding the whole transcript.
slog.Warn("session: keeping save after display index write failure", "path", path, "err", err)
@@ -631,38 +593,11 @@ func (s *Session) checkSnapshotWrite(path string, next []provider.Message, nextD
}
if messagesHavePrefix(existing, next) || messagesHavePrefixWithCompatibleSystem(existing, next) ||
(rawDiffers && (messagesHavePrefix(raw, next) || messagesHavePrefixWithCompatibleSystem(raw, next))) {
return snapshotWriteDecision{}, &SessionSnapshotConflictError{
Path: path,
Kind: SessionSnapshotConflictStalePrefix,
ExistingMessages: len(existing),
SnapshotMessages: len(next),
BaseRevision: baseState.revision,
DiskRevision: currentRevision,
}
}
return snapshotWriteDecision{}, &SessionSnapshotConflictError{
Path: path,
Kind: SessionSnapshotConflictDiverged,
ExistingMessages: len(existing),
SnapshotMessages: len(next),
BaseRevision: baseState.revision,
DiskRevision: currentRevision,
}
}
func snapshotConflict(path string, existing, next []provider.Message, baseRevision, diskRevision int64) error {
kind := SessionSnapshotConflictDiverged
if messagesHavePrefix(existing, next) || messagesHavePrefixWithCompatibleSystem(existing, next) {
kind = SessionSnapshotConflictStalePrefix
}
return &SessionSnapshotConflictError{
Path: path,
Kind: kind,
ExistingMessages: len(existing),
SnapshotMessages: len(next),
BaseRevision: baseRevision,
DiskRevision: diskRevision,
return snapshotWriteDecision{}, snapshotConflictOfKind(path, SessionSnapshotConflictStalePrefix,
existing, next, baseState.revision, currentRevision)
}
return snapshotWriteDecision{}, snapshotConflictOfKind(path, SessionSnapshotConflictDiverged,
existing, next, baseState.revision, currentRevision)
}
func (s *Session) SaveRecoveryBranch(opts RecoveryBranchOptions) (RecoveryBranchInfo, error) {
+95
View File
@@ -0,0 +1,95 @@
// Snapshot conflicts: what a refused save reports about the two transcripts.
package agent
import (
"errors"
"fmt"
"reasonix/internal/provider"
)
type SessionSnapshotConflictKind string
const (
SessionSnapshotConflictStalePrefix SessionSnapshotConflictKind = "stale_prefix"
SessionSnapshotConflictDiverged SessionSnapshotConflictKind = "diverged"
)
type SessionSnapshotConflictError struct {
Path string
Kind SessionSnapshotConflictKind
ExistingMessages int
SnapshotMessages int
BaseRevision int64
DiskRevision int64
// DivergedAt is the first index the two disagree on with the roles standing
// there, -1 when one is a prefix of the other. A position and a role tell a
// local reshape from a foreign write; content never leaves in a diagnostic.
DivergedAt int
DiskRole string
SnapshotRole string
}
func (e *SessionSnapshotConflictError) Error() string {
if e == nil {
return ErrSessionSnapshotConflict.Error()
}
switch e.Kind {
case SessionSnapshotConflictStalePrefix:
return fmt.Sprintf("%s: %s has %d messages at revision %d; stale snapshot has %d messages from revision %d",
ErrSessionSnapshotConflict, e.Path, e.ExistingMessages, e.DiskRevision, e.SnapshotMessages, e.BaseRevision)
default:
return fmt.Sprintf("%s: %s diverged on disk (%d messages, revision %d) from snapshot (%d messages, revision %d)",
ErrSessionSnapshotConflict, e.Path, e.ExistingMessages, e.DiskRevision, e.SnapshotMessages, e.BaseRevision)
}
}
func (e *SessionSnapshotConflictError) Unwrap() error {
return ErrSessionSnapshotConflict
}
func SnapshotConflictKind(err error) (SessionSnapshotConflictKind, bool) {
var conflict *SessionSnapshotConflictError
if errors.As(err, &conflict) && conflict != nil {
return conflict.Kind, true
}
return "", false
}
func snapshotConflict(path string, existing, next []provider.Message, baseRevision, diskRevision int64) error {
kind := SessionSnapshotConflictDiverged
if messagesHavePrefix(existing, next) || messagesHavePrefixWithCompatibleSystem(existing, next) {
kind = SessionSnapshotConflictStalePrefix
}
return snapshotConflictOfKind(path, kind, existing, next, baseRevision, diskRevision)
}
func snapshotConflictOfKind(path string, kind SessionSnapshotConflictKind,
existing, next []provider.Message, baseRevision, diskRevision int64,
) *SessionSnapshotConflictError {
at, diskRole, snapshotRole := firstStorageDivergence(existing, next)
return &SessionSnapshotConflictError{
Path: path,
Kind: kind,
DivergedAt: at,
DiskRole: diskRole,
SnapshotRole: snapshotRole,
ExistingMessages: len(existing),
SnapshotMessages: len(next),
BaseRevision: baseRevision,
DiskRevision: diskRevision,
}
}
// firstStorageDivergence reports where two transcripts stop agreeing and the
// roles standing there: the shape of a conflict without any of its content.
// -1 means neither contradicts the other and one is simply shorter.
func firstStorageDivergence(disk, snapshot []provider.Message) (int, string, string) {
for i := range min(len(disk), len(snapshot)) {
if messagesEqualForStorage(disk[i], snapshot[i]) {
continue
}
return i, string(disk[i].Role), string(snapshot[i].Role)
}
return -1, "", ""
}
@@ -102,3 +102,65 @@ func TestRepeatedExternalRewritesShareOneBranch(t *testing.T) {
t.Fatalf("six external rewrites over the same unsaved turn produced %d branches, want 1", len(branches))
}
}
// What a conflict cannot say today is why it happened, and the answer lives in
// where the two transcripts parted: a turn whose role changed under us is this
// process reshaping its own history, while a different turn at the same index
// is another writer. The error carries the position and the two roles — never
// the content, which a diagnostic must not carry off the machine.
func TestSnapshotConflictNamesWhereTheTranscriptsParted(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
seed := []provider.Message{
{Role: provider.RoleUser, Content: "你好"},
{Role: provider.RoleAssistant, Content: "hi"},
}
rewriteTranscriptOutsideReasonix(t, path, seed...)
// The live runtime reshaped the assistant turn into a local-only row, the
// way cancel cleanup does, and added one more.
live := NewSession("sys")
live.Add(seed[0])
live.Add(provider.Message{Role: provider.RoleTool, ToolCallID: provider.LocalOnlyToolID,
Name: provider.LocalOnlyToolName, LocalOnly: true, Content: "hi"})
live.Add(provider.Message{Role: provider.RoleUser, Content: "还没落盘"})
err := live.SaveSnapshot(path)
var conflict *SessionSnapshotConflictError
if !errors.As(err, &conflict) {
t.Fatalf("save = %v, want a snapshot conflict", err)
}
if conflict.DivergedAt != 2 {
t.Fatalf("diverged at %d, want index 2 (the reshaped turn, after the system prompt)", conflict.DivergedAt)
}
if conflict.DiskRole != string(provider.RoleAssistant) || conflict.SnapshotRole != string(provider.RoleTool) {
t.Fatalf("roles = %q/%q, want assistant on disk and tool in memory", conflict.DiskRole, conflict.SnapshotRole)
}
}
// A transcript the other side has merely not caught up with parted nowhere:
// reporting an index there would send a reader looking for a rewrite that
// never happened.
func TestFirstStorageDivergenceTreatsAPrefixAsAgreement(t *testing.T) {
shared := []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "你好"},
}
longer := append(append([]provider.Message(nil), shared...),
provider.Message{Role: provider.RoleAssistant, Content: "hi"})
for _, tc := range []struct {
name string
disk, snap []provider.Message
}{
{name: "snapshot ahead", disk: shared, snap: longer},
{name: "disk ahead", disk: longer, snap: shared},
{name: "identical", disk: longer, snap: longer},
} {
t.Run(tc.name, func(t *testing.T) {
if at, diskRole, snapRole := firstStorageDivergence(tc.disk, tc.snap); at != -1 || diskRole != "" || snapRole != "" {
t.Fatalf("divergence = %d %q/%q, want none", at, diskRole, snapRole)
}
})
}
}
@@ -0,0 +1,56 @@
// Keeping the .jsonl checkpoint and the event log describing one transcript:
// which one answers a torn replay, and how a declined in-place append is undone.
package agent
import (
"log/slog"
"reasonix/internal/provider"
)
// resolveReplayedMessages answers a replay that applied at least one record.
// A torn log is met with the checkpoint beside it when that checkpoint carries
// the same history further: the turns the torn record dropped are usually
// already durable there, and taking the shorter log alone is what let an
// unclean exit lose them. damaged still rides out so the next save heals.
func resolveReplayedMessages(sessionPath string, replay sessionEventReplay) ([]provider.Message, bool, bool, error) {
if replay.damaged {
if healed, ok := checkpointExtendingReplay(sessionPath, replay.msgs); ok {
return healed, false, true, nil
}
}
return replay.msgs, true, replay.damaged, nil
}
// checkpointExtendingReplay reports the checkpoint when it continues the prefix
// the log still replays. One that is shorter is a read model that has not
// caught up; one that contradicts what survived is another writer's file.
func checkpointExtendingReplay(sessionPath string, replayed []provider.Message) ([]provider.Message, bool) {
msgs, err := loadSessionMessagesFromJSONL(sessionPath)
if err != nil || len(msgs) <= len(replayed) {
return nil, false
}
if !messagesHavePrefix(msgs, replayed) && !messagesHavePrefixWithCompatibleSystem(msgs, replayed) {
return nil, false
}
return msgs, true
}
// settleDisplayReadModel republishes the read model whole when the in-place
// append declined it. Leaving it is what let the checkpoint keep the transcript
// from before this save — a second lineage beside the log, which a fallback
// reader would take as the session. It reports whether the read model is
// current and the boundary the display index refresh may extend from.
func settleDisplayReadModel(path string, msgs []provider.Message, current bool, appendFrom int) (bool, int) {
if current {
return true, appendFrom
}
if err := writeSessionMessages(path, msgs); err != nil {
// The transcript and its revision are already durable; a read model
// that could not be republished stays for background repair.
slog.Warn("session: keeping save after display read-model rewrite failure", "path", path, "err", err)
return false, appendFrom
}
// Rebuilt whole, so there is no previous index left to extend.
return true, -1
}
@@ -0,0 +1,86 @@
package agent
import (
"os"
"testing"
"reasonix/internal/provider"
"reasonix/internal/store"
)
// The checkpoint is what a reader falls back to when the log cannot answer, so
// the two must never describe different transcripts. An append the read model
// declines used to leave it on the previous save's history: a second lineage
// beside the log, waiting for the first fallback to pick it.
func TestDeclinedReadModelAppendStillLeavesOneLineage(t *testing.T) {
dir := t.TempDir()
path := dir + "/session.jsonl"
live := NewSession("sys")
live.Add(provider.Message{Role: provider.RoleUser, Content: "第一句话", RawContent: "第一句话"})
live.Add(provider.Message{Role: provider.RoleAssistant, Content: "hi"})
if err := live.SaveSnapshot(path); err != nil {
t.Fatal(err)
}
// The index a save is allowed to lose (it is warn-only there) is exactly
// what the in-place append needs to prove the checkpoint is the log's
// prefix, so losing it is how the decline happens in the field.
if err := os.Remove(store.SessionDisplayIndex(path)); err != nil {
t.Fatalf("drop display index: %v", err)
}
live.Add(provider.Message{Role: provider.RoleUser, Content: "第二句话", RawContent: "第二句话"})
if err := live.SaveSnapshot(path); err != nil {
t.Fatal(err)
}
logged, _, damaged, err := loadSessionMessages(path)
if err != nil || damaged {
t.Fatalf("replay = %v damaged=%v", err, damaged)
}
checkpoint, err := loadSessionMessagesFromJSONL(path)
if err != nil {
t.Fatal(err)
}
if !messagesEqualForStorageList(logged, checkpoint) {
t.Fatalf("checkpoint %q disagrees with the log %q",
sessionUserTexts(checkpoint), sessionUserTexts(logged))
}
if got := sessionUserTexts(checkpoint); len(got) != 2 {
t.Fatalf("checkpoint users = %q, want both turns", got)
}
}
// Republishing the read model also republishes the index that proves it, so a
// session that lost one recovers the in-place append instead of paying a whole
// rewrite on every later save.
func TestRepublishedReadModelRearmsTheAppendPath(t *testing.T) {
dir := t.TempDir()
path := dir + "/session.jsonl"
live := NewSession("sys")
live.Add(provider.Message{Role: provider.RoleUser, Content: "第一句话", RawContent: "第一句话"})
if err := live.SaveSnapshot(path); err != nil {
t.Fatal(err)
}
if err := os.Remove(store.SessionDisplayIndex(path)); err != nil {
t.Fatal(err)
}
live.Add(provider.Message{Role: provider.RoleAssistant, Content: "hi"})
if err := live.SaveSnapshot(path); err != nil {
t.Fatal(err)
}
idx, err := LoadSessionDisplayIndex(store.SessionDisplayIndex(path))
if err != nil {
t.Fatalf("index was not republished: %v", err)
}
if idx.MessageCount != 3 {
t.Fatalf("index message count = %d, want 3", idx.MessageCount)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if idx.TranscriptSize != info.Size() {
t.Fatalf("index describes %d bytes of a %d-byte checkpoint", idx.TranscriptSize, info.Size())
}
}
+1 -1
View File
@@ -573,7 +573,7 @@ func loadSessionMessagesWithLimits(sessionPath string, limits sessionReplayLimit
return nil, true, false, replayErr
}
if replay.records > 0 {
return replay.msgs, true, replay.damaged, nil
return resolveReplayedMessages(sessionPath, replay)
}
// Defensive: the probe saw a native head but nothing replayed; fall
// back to the checkpoint and let the next save rebuild the log.
+39
View File
@@ -7,6 +7,7 @@ import (
"log/slog"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"sync/atomic"
@@ -371,5 +372,43 @@ func observeUnleasedSessionWrite(path string, mode sessionSaveMode) {
"path", filepath.Base(path),
"mode", int(mode),
"writer", SessionWriterID(),
"origin", unleasedWriteOrigin(),
)
}
// unleasedWriteOrigin names the first caller outside this package: the writer
// the probe exists to enumerate. Reading it off the stack keeps the label out
// of every save signature, where a new writer would have to remember to pass
// one — and the writer that forgets is the one being hunted.
func unleasedWriteOrigin() string {
var pcs [24]uintptr
frames := runtime.CallersFrames(pcs[:runtime.Callers(2, pcs[:])])
for {
frame, more := frames.Next()
if frame.Function == "" && !more {
break
}
if frame.Function != "" && !strings.HasPrefix(frame.Function, sessionPackageFramePrefix()) {
return fmt.Sprintf("%s (%s:%d)", frame.Function, filepath.Base(frame.File), frame.Line)
}
if !more {
break
}
}
return "unknown"
}
// sessionPackageFramePrefix reads this package's own frame prefix off a local
// function, so moving the package cannot quietly turn the filter above into a
// no-op the way a written-out import path would.
func sessionPackageFramePrefix() string {
pc, _, _, ok := runtime.Caller(0)
if !ok {
return ""
}
name := runtime.FuncForPC(pc).Name()
if cut := strings.LastIndex(name, "."); cut > 0 {
return name[:cut+1]
}
return ""
}
+19
View File
@@ -4,6 +4,7 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -436,3 +437,21 @@ func TestSessionLeaseReleaseRetiresLockSidecars(t *testing.T) {
}
first.Release()
}
// The probe exists to name the writers that still save without a lease, so its
// origin has to point past this package — a report that says "the session
// package saved it" names nobody. The frame filter is derived, not written out,
// so this also catches a package move quietly turning it into a no-op.
func TestUnleasedWriteOriginPointsPastTheSessionPackage(t *testing.T) {
prefix := sessionPackageFramePrefix()
if prefix == "" || !strings.HasSuffix(prefix, ".") {
t.Fatalf("frame prefix = %q, want this package's own, dot-terminated", prefix)
}
origin := unleasedWriteOrigin()
if origin == "" || origin == "unknown" {
t.Fatalf("origin = %q, want a caller", origin)
}
if strings.HasPrefix(origin, prefix) {
t.Fatalf("origin = %q, want a frame outside %s", origin, prefix)
}
}
@@ -0,0 +1,185 @@
package agent
import (
"os"
"path/filepath"
"testing"
"reasonix/internal/provider"
"reasonix/internal/store"
)
// tearEventLogTail is what an unclean exit leaves behind: the last record never
// finished being written, so replay stops before the turn it described.
func tearEventLogTail(t *testing.T, path string, bytes int) {
t.Helper()
logPath := store.SessionEventLog(path)
b, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("read event log: %v", err)
}
if bytes >= len(b) {
t.Fatalf("tear of %d bytes exceeds the %d-byte log", bytes, len(b))
}
if err := os.WriteFile(logPath, b[:len(b)-bytes], 0o600); err != nil {
t.Fatalf("tear event log: %v", err)
}
}
func sessionUserTexts(msgs []provider.Message) []string {
var out []string
for _, m := range msgs {
if m.Role == provider.RoleUser {
out = append(out, UserMessageText(m))
}
}
return out
}
// A torn record costs the turns it described only if nothing else recorded
// them. The checkpoint beside the log is written from the same save, so an
// exit that tore the log usually left the turn durable there — and opening the
// session on the shorter log alone is what made a crash lose a whole exchange.
func TestTornEventLogKeepsTheTurnsTheCheckpointStillHolds(t *testing.T) {
for _, tear := range []int{5, 20, 100} {
t.Run("", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
live := NewSession("sys")
live.Add(provider.Message{Role: provider.RoleUser, Content: "第一句话", RawContent: "第一句话"})
live.Add(provider.Message{Role: provider.RoleAssistant, Content: "hi"})
if err := live.SaveSnapshot(path); err != nil {
t.Fatal(err)
}
live.Add(provider.Message{Role: provider.RoleUser, Content: "第二句话", RawContent: "第二句话"})
live.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"})
if err := live.SaveSnapshot(path); err != nil {
t.Fatal(err)
}
tearEventLogTail(t, path, tear)
msgs, _, damaged, err := loadSessionMessages(path)
if err != nil {
t.Fatalf("open after the tear: %v", err)
}
if !damaged {
t.Fatal("a torn log must still report damage so the next save heals it")
}
if got := sessionUserTexts(msgs); len(got) != 2 || got[1] != "第二句话" {
t.Fatalf("users after a %d-byte tear = %q, want the checkpoint's two turns", tear, got)
}
})
}
}
// The checkpoint only speaks for turns the surviving log agrees with. One that
// contradicts it — another writer's file, a restored backup — is not a longer
// version of this transcript and must not be adopted as one.
func TestTornEventLogRefusesACheckpointThatContradictsIt(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
live := NewSession("sys")
live.Add(provider.Message{Role: provider.RoleUser, Content: "第一句话", RawContent: "第一句话"})
live.Add(provider.Message{Role: provider.RoleAssistant, Content: "hi"})
if err := live.SaveSnapshot(path); err != nil {
t.Fatal(err)
}
live.Add(provider.Message{Role: provider.RoleUser, Content: "第二句话", RawContent: "第二句话"})
if err := live.SaveSnapshot(path); err != nil {
t.Fatal(err)
}
tearEventLogTail(t, path, 20)
if err := writeSessionMessages(path, []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "别处写进来的一句", RawContent: "别处写进来的一句"},
{Role: provider.RoleAssistant, Content: "no"},
{Role: provider.RoleUser, Content: "以及另一句", RawContent: "以及另一句"},
}); err != nil {
t.Fatal(err)
}
msgs, _, damaged, err := loadSessionMessages(path)
if err != nil {
t.Fatal(err)
}
if !damaged {
t.Fatal("want the torn log still reported as damaged")
}
if got := sessionUserTexts(msgs); len(got) != 1 || got[0] != "第一句话" {
t.Fatalf("users = %q, want only what the surviving log replays", got)
}
}
// A shorter checkpoint is a read model that has not caught up, never a longer
// history: adopting it would drop the turns the log still replays.
func TestTornEventLogRefusesAShorterCheckpoint(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
live := NewSession("sys")
live.Add(provider.Message{Role: provider.RoleUser, Content: "第一句话", RawContent: "第一句话"})
live.Add(provider.Message{Role: provider.RoleAssistant, Content: "hi"})
if err := live.SaveSnapshot(path); err != nil {
t.Fatal(err)
}
live.Add(provider.Message{Role: provider.RoleUser, Content: "第二句话", RawContent: "第二句话"})
live.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"})
if err := live.SaveSnapshot(path); err != nil {
t.Fatal(err)
}
tearEventLogTail(t, path, 5)
if err := writeSessionMessages(path, []provider.Message{{Role: provider.RoleSystem, Content: "sys"}}); err != nil {
t.Fatal(err)
}
msgs, _, _, err := loadSessionMessages(path)
if err != nil {
t.Fatal(err)
}
if got := sessionUserTexts(msgs); len(got) != 1 || got[0] != "第一句话" {
t.Fatalf("users = %q, want the log's own prefix", got)
}
}
// An unclean exit leaves the log torn and the checkpoint ahead of it. Healing
// that on open is only half the job: the turn has to survive the next save,
// which rewrites the log from what was opened.
func TestHealedTornLogSurvivesTheNextSave(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
live := NewSession("sys")
live.Add(provider.Message{Role: provider.RoleUser, Content: "第一句话", RawContent: "第一句话"})
live.Add(provider.Message{Role: provider.RoleAssistant, Content: "hi"})
if err := live.SaveSnapshot(path); err != nil {
t.Fatal(err)
}
live.Add(provider.Message{Role: provider.RoleUser, Content: "第二句话", RawContent: "第二句话"})
live.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"})
if err := live.SaveSnapshot(path); err != nil {
t.Fatal(err)
}
tearEventLogTail(t, path, 20)
reopened, err := LoadSession(path)
if err != nil {
t.Fatalf("reopen: %v", err)
}
reopened.Add(provider.Message{Role: provider.RoleUser, Content: "第三句话", RawContent: "第三句话"})
if err := reopened.SaveSnapshot(path); err != nil {
t.Fatalf("save after reopen: %v", err)
}
final, err := LoadSession(path)
if err != nil {
t.Fatal(err)
}
got := sessionUserTexts(final.Snapshot())
want := []string{"第一句话", "第二句话", "第三句话"}
if len(got) != len(want) {
t.Fatalf("users = %q, want %q", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("users = %q, want %q", got, want)
}
}
}
+7 -1
View File
@@ -148,7 +148,13 @@ func (m *chatTUI) persistModel(ref string) {
unlock := config.LockUserConfigEdits()
defer unlock()
edit := config.LoadForEdit(path)
if err := edit.SetDefaultModel(ref); err != nil {
// An extension model is only in the live catalog, so the runtime has to be
// asked; before one is bound the configuration is the only authority there is.
var catalog []provider.Descriptor
if m.ctrl != nil {
catalog = m.ctrl.ProviderCatalog()
}
if err := edit.SetDefaultModel(ref, catalog); err != nil {
m.notice(fmt.Sprintf("model: persist refused: %v (ref=%s)", err, ref))
return
}
+36 -2
View File
@@ -8,6 +8,7 @@ import (
tea "charm.land/bubbletea/v2"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/provider"
)
@@ -124,15 +125,18 @@ func TestPersistModelRejectsUnknownRef(t *testing.T) {
}
// TestPersistModelAcceptsPluginRef: a plugin-namespaced ref picked in /model
// persists like any config ref — the config catalog cannot vouch for it, but
// boot's merged resolver gates it at the next launch.
// persists like any config ref, on the live catalog's word rather than its own
// spelling — the config catalog never holds an extension-hosted model.
func TestPersistModelAcceptsPluginRef(t *testing.T) {
isolateUserConfig(t)
if _, err := config.SetCredential("DEEPSEEK_API_KEY", "test-key"); err != nil {
t.Fatalf("SetCredential: %v", err)
}
// The live catalog is what vouches for an extension model: no config file
// holds one, and the picker offered it from here in the first place.
m := newTestChatTUI()
m.ctrl = newPluginCatalogController(t)
m.persistModel("plugin/demo/fake/x")
body, err := os.ReadFile(config.UserConfigPath())
@@ -144,6 +148,36 @@ func TestPersistModelAcceptsPluginRef(t *testing.T) {
}
}
// newPluginCatalogController is a runtime whose merged catalog carries one
// extension-hosted model, the state a plugin install leaves behind.
func newPluginCatalogController(t *testing.T) *control.Controller {
t.Helper()
ctrl := control.New(control.Options{ProviderResolver: &provider.StaticResolver{
Descriptors: []provider.Descriptor{{Ref: "plugin/demo/fake/x", Model: "x"}},
}})
t.Cleanup(ctrl.Close)
return ctrl
}
// The namespace is not the permission it used to stand in for: a plugin-shaped
// ref no catalog carries is a typo, and persisting it strands the next launch
// on a model nothing can resolve.
func TestPersistModelRejectsAPluginRefNoCatalogCarries(t *testing.T) {
isolateUserConfig(t)
if _, err := config.SetCredential("DEEPSEEK_API_KEY", "test-key"); err != nil {
t.Fatalf("SetCredential: %v", err)
}
m := newTestChatTUI()
m.ctrl = newPluginCatalogController(t)
m.persistModel("plugin/ghost/fake/nothing")
if body, err := os.ReadFile(config.UserConfigPath()); err == nil &&
strings.Contains(string(body), "plugin/ghost/fake/nothing") {
t.Fatalf("persisted a ref no catalog carries:\n%s", body)
}
}
// TestMergeExtensionModelRefs pins the /model picker merge: extension
// descriptors join the config-backed list with their plugin/... refs intact,
// duplicates collapse, and a nil catalog leaves the base list untouched.
+2 -2
View File
@@ -353,7 +353,7 @@ func (s *providerSetupSession) setDefaultModel(model string) error {
before := s.cfg.DefaultModel
// Setup runs before any runtime exists, so the configuration is the only
// catalog there is to check against.
if err := s.cfg.SetDefaultModel(model); err != nil {
if err := s.cfg.SetDefaultModel(model, nil); err != nil {
return err
}
if before != s.cfg.DefaultModel {
@@ -754,7 +754,7 @@ func (s *providerSetupSession) replayOperations(cfg *config.Config, accessDeclar
if cfg.DefaultModel != operation.beforeString {
return &providerSetupConflictError{field: "default_model"}
}
if err := cfg.SetDefaultModel(operation.afterString); err != nil {
if err := cfg.SetDefaultModel(operation.afterString, nil); err != nil {
return fmt.Errorf("replay default_model: %w", err)
}
case setupOpLanguage:
+2 -19
View File
@@ -13,8 +13,6 @@ import (
"net/url"
"os"
"path/filepath"
"regexp"
"runtime"
"slices"
"strings"
@@ -23,23 +21,6 @@ import (
"reasonix/internal/provider"
)
var validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
// IsValidSkillName reports whether name is a usable skill identifier.
func IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }
// SkillNameKey normalizes a skill identifier for config comparisons.
func SkillNameKey(name string) string {
name = strings.TrimSpace(name)
if !IsValidSkillName(name) {
return ""
}
if runtime.GOOS == "windows" {
return strings.ToLower(name)
}
return name
}
// Config is Reasonix's runtime configuration.
type Config struct {
ConfigVersion int `toml:"config_version"`
@@ -68,6 +49,8 @@ type Config struct {
Serve ServeConfig `toml:"serve"`
Secrets SecretsConfig `toml:"secrets"`
Remote RemoteConfig `toml:"remote"`
// Storage relocates the movable roots, keyed by RootID. User/global only.
Storage map[string]string `toml:"storage"`
systemPromptFileSource promptFileSource
providerSources map[string]providerSourceScope
+28 -27
View File
@@ -15,12 +15,12 @@ import (
"github.com/BurntSushi/toml"
"reasonix/internal/extension/protocol"
"reasonix/internal/fileutil"
fileencoding "reasonix/internal/fileutil/encoding"
"reasonix/internal/mcpdiag"
"reasonix/internal/netclient"
"reasonix/internal/permission"
"reasonix/internal/provider"
)
var validDesktopExternalOpenerID = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`)
@@ -41,42 +41,43 @@ const (
listDeny = "deny"
)
// SetDefaultModel points default_model at an existing model. It accepts both
// forms used by the runtime resolver:
// - "provider" — the provider's own default model;
// - "provider/model" — that specific model under that provider.
//
// Either is rejected when the target does not exist, so a UI can't strand
// the config on a model that doesn't exist. Plugin-namespaced refs
// (plugin/<plugin>/<provider>/<model>) are the exception: they belong to
// extension sidecars, so the config catalog cannot vouch for them — boot's
// merged resolver gates them at the next launch instead.
func (c *Config) SetDefaultModel(name string) error {
// ModelRefSelectable reports whether ref names a model this installation can be
// pointed at, in any role: one the configuration carries, or one only the live
// catalog vouches for — extension-hosted models never reach a config file. A nil
// catalog asks the configuration alone. Every surface that offers a model decides
// with this, so what it offers and what it accepts cannot drift into two sets.
func (c *Config) ModelRefSelectable(ref string, catalog []provider.Descriptor) bool {
ref = strings.TrimSpace(ref)
if ref == "" {
return false
}
if _, ok := c.ResolveModel(ref); ok {
return true
}
for _, d := range catalog {
if strings.TrimSpace(d.Ref) == ref {
return true
}
}
return false
}
// SetDefaultModel points default_model at any model ModelRefSelectable accepts:
// a provider name, a "provider/model" ref, or a ref the live catalog carries.
// Anything else is refused, so a UI cannot strand the config on a model that
// will not resolve at the next launch.
func (c *Config) SetDefaultModel(name string, catalog []provider.Descriptor) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("set default: empty name")
}
if _, ok := c.ResolveModel(name); !ok && protocol.PluginRefOwner(name) == "" {
if !c.ModelRefSelectable(name, catalog) {
return fmt.Errorf("set default: no such model %q (configured: %s)", name, c.providerNames())
}
c.DefaultModel = name
return nil
}
// SetPlannerModel sets (or, with "", clears) agent.planner_model for two-model
// collaboration. A non-empty name must be a configured provider.
func (c *Config) SetPlannerModel(name string) error {
if name == "" {
c.Agent.PlannerModel = ""
return nil
}
if _, ok := c.Provider(name); !ok {
return fmt.Errorf("set planner: no provider %q (configured: %s)", name, c.providerNames())
}
c.Agent.PlannerModel = name
return nil
}
// SetAutoPlan is retained for source compatibility with older desktop clients.
// Automatic plan mode is retired: "off" is an idempotent compatibility write,
// while every attempt to enable it is rejected explicitly.
+40 -27
View File
@@ -15,32 +15,63 @@ import (
"time"
"github.com/BurntSushi/toml"
"reasonix/internal/provider"
)
// The configuration is one catalog of two. An extension-hosted model lives in
// a sidecar and never appears in a config file, so a caller holding the live
// catalog is what lets it qualify — and a ref no catalog carries still cannot,
// which is what keeps this from degrading into "anything with a slash".
func TestSelectableModelRefsComeFromBothCatalogs(t *testing.T) {
c := Default()
const hosted = "plugin/demo/cloud/extension-chat"
catalog := []provider.Descriptor{{Ref: hosted, Model: "extension-chat"}}
if c.ModelRefSelectable(hosted, nil) {
t.Fatal("the configuration alone must not vouch for an extension model")
}
if !c.ModelRefSelectable(hosted, catalog) {
t.Fatal("the live catalog must vouch for the model it carries")
}
if c.ModelRefSelectable("plugin/ghost/cloud/nothing", catalog) {
t.Fatal("a ref no catalog carries is not selectable")
}
if err := c.SetDefaultModel(hosted, catalog); err != nil {
t.Fatalf("set extension default: %v", err)
}
if c.DefaultModel != hosted {
t.Errorf("default = %q, want %q", c.DefaultModel, hosted)
}
if err := c.SetDefaultModel("plugin/ghost/cloud/nothing", catalog); err == nil {
t.Error("expected a refusal for a ref neither catalog carries")
}
}
func TestSetDefaultModel(t *testing.T) {
c := Default()
if err := c.SetDefaultModel("deepseek-pro"); err != nil {
if err := c.SetDefaultModel("deepseek-pro", nil); err != nil {
t.Fatalf("set valid default: %v", err)
}
if c.DefaultModel != "deepseek-pro" {
t.Errorf("default = %q, want deepseek-pro", c.DefaultModel)
}
if err := c.SetDefaultModel("nope"); err == nil {
if err := c.SetDefaultModel("nope", nil); err == nil {
t.Error("expected error for unknown provider")
}
// "provider/model" form is also accepted: the /model picker stores the
// full ref so a user can land on a non-default model under the same
// provider across restarts.
if err := c.SetDefaultModel("deepseek-pro/deepseek-v4-pro"); err != nil {
if err := c.SetDefaultModel("deepseek-pro/deepseek-v4-pro", nil); err != nil {
t.Fatalf("set provider/model default: %v", err)
}
if c.DefaultModel != "deepseek-pro/deepseek-v4-pro" {
t.Errorf("default = %q, want deepseek-pro/deepseek-v4-pro", c.DefaultModel)
}
if err := c.SetDefaultModel("deepseek-pro/missing"); err == nil {
if err := c.SetDefaultModel("deepseek-pro/missing", nil); err == nil {
t.Error("expected error for unknown model under known provider")
}
if err := c.SetDefaultModel(""); err == nil {
if err := c.SetDefaultModel("", nil); err == nil {
t.Error("expected error for empty name")
}
}
@@ -417,22 +448,6 @@ func TestSetUICloseBehavior(t *testing.T) {
}
}
func TestSetPlannerModel(t *testing.T) {
c := Default()
if err := c.SetPlannerModel("deepseek-pro"); err != nil {
t.Fatalf("set planner: %v", err)
}
if c.Agent.PlannerModel != "deepseek-pro" {
t.Errorf("planner = %q", c.Agent.PlannerModel)
}
if err := c.SetPlannerModel(""); err != nil || c.Agent.PlannerModel != "" {
t.Errorf("clearing planner failed: err=%v planner=%q", err, c.Agent.PlannerModel)
}
if err := c.SetPlannerModel("ghost"); err == nil {
t.Error("expected error for unknown planner")
}
}
func TestSetAutoPlanRejectsRetiredModes(t *testing.T) {
c := Default()
if err := c.SetAutoPlan("off"); err != nil {
@@ -1254,12 +1269,10 @@ func TestClearPluginAuthentication(t *testing.T) {
// re-decodes the file to confirm the changes survived a write/read cycle.
func TestSaveToRoundTrips(t *testing.T) {
c := Default()
if err := c.SetDefaultModel("deepseek-pro"); err != nil {
t.Fatal(err)
}
if err := c.SetPlannerModel("deepseek-pro"); err != nil {
if err := c.SetDefaultModel("deepseek-pro", nil); err != nil {
t.Fatal(err)
}
c.Agent.PlannerModel = "deepseek-pro"
if err := c.UpsertProvider(ProviderEntry{Name: "local", Kind: "openai", BaseURL: "http://localhost:1234/v1", Model: "llama"}); err != nil {
t.Fatal(err)
}
@@ -2038,7 +2051,7 @@ func TestSaveToExistingProjectPersistsTopLevelDelta(t *testing.T) {
}
cfg := Default()
cfg.ConfigVersion = 2
if err := cfg.SetDefaultModel("deepseek-pro"); err != nil {
if err := cfg.SetDefaultModel("deepseek-pro", nil); err != nil {
t.Fatal(err)
}
if err := cfg.SaveTo(projectPath); err != nil {
@@ -2162,7 +2175,7 @@ func TestUnrelatedProjectSavePreservesExplicitDefaultSkillOverride(t *testing.T)
if err != nil {
t.Fatal(err)
}
if err := cfg.SetDefaultModel("deepseek-pro"); err != nil {
if err := cfg.SetDefaultModel("deepseek-pro", nil); err != nil {
t.Fatal(err)
}
if err := cfg.SaveTo(projectPath); err != nil {
+5 -5
View File
@@ -130,7 +130,7 @@ func loadForRoot(root string, migrateOnDisk bool) (*Config, error) {
userDefaultModel := cfg.DefaultModel
globalCLI := cfg.CLI
globalSecrets := cfg.Secrets
globalRemote := cfg.Remote.Clone()
globalRemote, globalStorage := cfg.Remote.Clone(), maps.Clone(cfg.Storage)
globalDesktopLanguage := cfg.Desktop.Language
globalPricingCurrency := cfg.Desktop.Currency
globalBillingDisplayCurrency := cfg.Billing.DisplayCurrency
@@ -158,10 +158,10 @@ func loadForRoot(root string, migrateOnDisk bool) (*Config, error) {
// reasonix.toml must not be able to flip on the workflow-breaking env/path
// protections.
cfg.Secrets = globalSecrets
// Remote SSH hosts are equally user-global: a cloned repo's reasonix.toml
// must not be able to inject hosts, jump chains, or port forwards that
// steer where Reasonix opens connections.
cfg.Remote = globalRemote
// Remote SSH hosts and storage locations are equally user-global: a cloned
// repo must not inject hosts, jump chains, or port forwards, nor redirect
// where this machine keeps its transcripts, catalogs, and checkouts.
cfg.Remote, cfg.Storage = globalRemote, globalStorage
// Desktop language and pricing currency are user-level regional preferences.
// A repository must not be able to alter how the user's spend is shown.
cfg.Desktop.Language = globalDesktopLanguage
+14 -76
View File
@@ -44,27 +44,7 @@ func userConfigDir() string {
return reasonixHomeDir()
}
func reasonixHomeDir() string {
if dir := cleanEnvDir("REASONIX_HOME"); dir != "" {
return dir
}
if runtimeGOOS == "windows" {
if dir := osUserConfigDir(); dir != "" {
return filepath.Join(dir, "reasonix")
}
if home, err := osUserHomeDir(); err == nil && home != "" {
return filepath.Join(home, "AppData", "Roaming", "reasonix")
}
return ""
}
if home, err := osUserHomeDir(); err == nil && home != "" {
return filepath.Join(home, ".reasonix")
}
if dir := osUserConfigDir(); dir != "" {
return filepath.Join(dir, "reasonix")
}
return ""
}
func reasonixHomeDir() string { return storageRootDir(RootHome) }
func userConfigLoadPath() string {
primary := userConfigPath()
@@ -143,12 +123,7 @@ func legacyXDGConfigPaths() []string {
return paths
}
func userSupportDir() string {
if dir := cleanEnvDir("REASONIX_STATE_HOME"); dir != "" {
return dir
}
return reasonixHomeDir()
}
func userSupportDir() string { return storageRootDir(RootState) }
func legacyOSSupportDir() string {
if IsolatedHomeDir() != "" {
@@ -165,22 +140,17 @@ func legacyOSSupportDir() string {
return path
}
func userCacheDir() string {
if dir := cleanEnvDir("REASONIX_CACHE_HOME"); dir != "" {
return dir
}
if dir := cleanEnvDir("REASONIX_HOME"); dir != "" {
return filepath.Join(dir, "cache")
}
dir := osUserCacheDir()
if dir == "" {
return ""
}
return filepath.Join(dir, "reasonix")
}
func userCacheDir() string { return storageRootDir(RootCache) }
func cleanEnvDir(name string) string {
dir := strings.TrimSpace(os.Getenv(name))
return expandDirValue(os.Getenv(name))
}
// expandDirValue turns a directory someone wrote — in the environment or in
// config.toml — into an absolute path. Both layers of the root chain share it,
// so a location is spelled the same way wherever it is set.
func expandDirValue(dir string) string {
dir = strings.TrimSpace(dir)
if dir == "" {
return ""
}
@@ -351,53 +321,21 @@ func MissingReasoningWarnStateDir() string {
// workspaces. It intentionally follows the cache root rather than project or
// session state: taking a lease must never dirty the repository it protects.
func WorkspaceLeaseDir() string {
// Deliberately ignore REASONIX_HOME/REASONIX_CACHE_HOME here. Two app
// instances with different state profiles can still open the same user
// workspace, so their safety lock must converge on one OS-user cache root.
dir := osUserCacheDir()
if strings.TrimSpace(dir) == "" {
return ""
}
return filepath.Join(dir, "reasonix", "workspace-leases")
return joinRoot(storageRootDir(RootLocks), "workspace-leases")
}
// RepairMutationLockDir stores target-path repair locks in the OS-user cache.
// It deliberately ignores Reasonix home/cache overrides: isolated instances
// can still repair the same project reasonix.toml, so their locks must converge.
func RepairMutationLockDir() string {
dir := osUserCacheDir()
if strings.TrimSpace(dir) == "" {
return ""
}
return filepath.Join(dir, "reasonix", "repair-mutation-locks")
return joinRoot(storageRootDir(RootLocks), "repair-mutation-locks")
}
// DeliveryWorktreeDir is durable storage for user-visible isolated Delivery
// workspaces. Explicit state/home overrides remain authoritative. Windows uses
// LocalAppData by default so large Git worktrees do not roam with the user's
// profile; other platforms keep using Reasonix state storage.
func DeliveryWorktreeDir() string {
if dir := cleanEnvDir("REASONIX_STATE_HOME"); dir != "" {
return filepath.Join(dir, "worktrees")
}
if dir := cleanEnvDir("REASONIX_HOME"); dir != "" {
return filepath.Join(dir, "worktrees")
}
if runtimeGOOS == "windows" {
if dir := osUserCacheDir(); dir != "" {
return filepath.Join(dir, "reasonix", "worktrees")
}
if home, err := osUserHomeDir(); err == nil && home != "" {
return filepath.Join(home, "AppData", "Local", "reasonix", "worktrees")
}
return ""
}
dir := userSupportDir()
if dir == "" {
return ""
}
return filepath.Join(dir, "worktrees")
}
func DeliveryWorktreeDir() string { return storageRootDir(RootWorktrees) }
// UserCredentialsPath is the reasonix-owned global .env file under Reasonix
// home. It is the single source for provider credentials saved by Reasonix, so
+19
View File
@@ -611,6 +611,8 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string {
b.WriteString("\n")
}
renderStorage(&b, c, scope)
// [secrets] is user/global only: LoadForRoot discards project values, so
// the project scope never renders it. Rendering it here is what lets a
// user's saved toggles survive config rewrites (WriteFile re-renders the
@@ -1363,6 +1365,23 @@ func renderLSPConfig(b *strings.Builder, cfg LSPConfig) {
}
}
// renderStorage writes the relocated roots, from the struct so a move survives
// a full rewrite. User/global only: a cloned repo must not redirect this machine.
func renderStorage(b *strings.Builder, c *Config, scope RenderScope) {
if scope == RenderScopeProject || len(c.Storage) == 0 {
return
}
b.WriteString("[storage] # where data lives; user/global only, ./reasonix.toml cannot override\n")
for _, id := range RootIDs() {
dir := strings.TrimSpace(c.Storage[string(id)])
if dir == "" || !RootRelocatable(id) {
continue
}
fmt.Fprintf(b, "%s = %q\n", renderTOMLKeyPart(string(id)), dir)
}
b.WriteString("\n")
}
func renderTOMLKeyPart(key string) string {
if isBareTOMLKey(key) {
return key
+25
View File
@@ -0,0 +1,25 @@
// skill_name.go — what counts as a skill identifier, and how one is compared.
package config
import (
"regexp"
"runtime"
"strings"
)
var validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
// IsValidSkillName reports whether name is a usable skill identifier.
func IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }
// SkillNameKey normalizes a skill identifier for config comparisons.
func SkillNameKey(name string) string {
name = strings.TrimSpace(name)
if !IsValidSkillName(name) {
return ""
}
if runtime.GOOS == "windows" {
return strings.ToLower(name)
}
return name
}
+141
View File
@@ -0,0 +1,141 @@
// storage_config.go — the configured layer of the root precedence chain.
package config
import (
"fmt"
"path/filepath"
"strings"
"sync"
"github.com/BurntSushi/toml"
fileencoding "reasonix/internal/fileutil/encoding"
)
// storageSection is the [storage] table: root id to directory. A map rather
// than a field per root, so declaring a new root in storageRoots is the only
// edit a new relocatable root needs.
type storageSection struct {
Storage map[string]string `toml:"storage"`
}
// storageDirCache holds the [storage] table for one home root. Roots resolve on
// nearly every path call, so the file is read once — but keyed by the home that
// produced it, because an isolated instance (and every test) moves home out
// from under a process that has already resolved paths.
var storageDirCache struct {
mu sync.Mutex
home string
read bool
dirs map[RootID]string
}
// configuredRootDir answers what the configuration chose for a root, "" when it
// chose nothing. An immovable root always answers "": home cannot be named by
// the file it holds, and the locks root must not follow a profile at all.
func configuredRootDir(id RootID) string {
if !RootRelocatable(id) {
return ""
}
home := storageRootDir(RootHome)
if home == "" {
return ""
}
storageDirCache.mu.Lock()
defer storageDirCache.mu.Unlock()
if !storageDirCache.read || storageDirCache.home != home {
storageDirCache.home = home
storageDirCache.read = true
storageDirCache.dirs = readStorageDirs(filepath.Join(home, "config.toml"))
}
return storageDirCache.dirs[id]
}
// InvalidateStorageDirs drops the cached [storage] table. A process that just
// wrote one calls this so a later read sees it; the resolved roots a running
// runtime already handed out do not change, which is why relocation takes
// effect on the next launch rather than mid-session.
func InvalidateStorageDirs() {
storageDirCache.mu.Lock()
defer storageDirCache.mu.Unlock()
storageDirCache.read = false
storageDirCache.dirs = nil
}
// readStorageDirs decodes only the [storage] table. A malformed or unreadable
// config answers an empty set rather than an error: paths must still resolve
// for a user whose config file is broken, or they could not reach the tooling
// that repairs it.
func readStorageDirs(path string) map[RootID]string {
b, err := fileencoding.ReadFileUTF8(path)
if err != nil {
return nil
}
var section storageSection
if _, err := toml.Decode(string(b), &section); err != nil {
return nil
}
if len(section.Storage) == 0 {
return nil
}
out := make(map[RootID]string, len(section.Storage))
for key, dir := range section.Storage {
id := RootID(strings.TrimSpace(key))
if !RootRelocatable(id) {
// A configuration naming an immovable root is not an error worth
// failing a launch over; it simply does not get to move it.
continue
}
if dir := cleanConfiguredDir(dir); dir != "" {
out[id] = dir
}
}
return out
}
// cleanConfiguredDir expands and absolutises a configured directory the same
// way an environment override is treated, so the two layers of the chain accept
// the same spellings — "~/x", "%LOCALAPPDATA%\x", a relative path.
func cleanConfiguredDir(dir string) string {
dir = strings.TrimSpace(dir)
if dir == "" {
return ""
}
return expandDirValue(dir)
}
// SetStorageDir relocates one root, or with "" returns it to its default. It
// rules on what the configuration can rule on — that the root exists, may be
// moved, and is not held by the environment — and leaves whether the target
// fits, is reachable, and is not inside the source to the move that acts on it.
func (c *Config) SetStorageDir(id RootID, dir string) error {
root, ok := lookupRoot(id)
if !ok {
return fmt.Errorf("set storage: no root named %q", id)
}
if !root.relocatable {
return fmt.Errorf("set storage: %s cannot be moved", id)
}
if pin := RootPinnedBy(id); pin != "" {
return fmt.Errorf("set storage: %s is held by %s; unset it to choose a location here", id, pin)
}
dir = expandDirValue(dir)
if dir == "" {
delete(c.Storage, string(id))
if len(c.Storage) == 0 {
c.Storage = nil
}
return nil
}
if c.Storage == nil {
c.Storage = map[string]string{}
}
c.Storage[string(id)] = dir
return nil
}
// StorageDir reports the configured location for a root, "" when the
// configuration names none and the root falls back to its default.
func (c *Config) StorageDir(id RootID) string {
return strings.TrimSpace(c.Storage[string(id)])
}
+245
View File
@@ -0,0 +1,245 @@
// storage_roots.go — the closed set of directory roots the runtime writes into.
package config
import (
"path/filepath"
"slices"
"sync"
)
// RootID names one storage root. The set is closed: every directory the runtime
// writes belongs to exactly one, so usage accounting, the storage panel, and
// relocation can enumerate roots instead of each keeping its own list of places
// to look.
type RootID string
const (
// RootHome holds config.toml and the credentials .env — kilobytes that must
// stay findable, so it is the one root a user cannot move: nothing can name
// the location of the file that names locations.
RootHome RootID = "home"
// RootState holds transcripts, archives, stats, and per-project memory.
RootState RootID = "state"
// RootCache holds the listing catalogs and every other derived artefact.
RootCache RootID = "cache"
// RootWorktrees holds the isolated git worktrees Delivery checks out.
RootWorktrees RootID = "worktrees"
// RootLocks holds the cross-process locks two isolated runtimes must both
// find, which is why it is pinned to the OS user rather than to a profile.
RootLocks RootID = "locks"
)
// storageRoot declares one root. What separates roots — the variable that pins
// it, whether a user may move it, where it lands when nothing says — is data
// here, so resolveRoot below is the process's only precedence chain and a new
// root costs one entry rather than another resolver carrying its own fallbacks.
type storageRoot struct {
id RootID
// env pins this root from the environment. "" when no variable names it.
env string
// relocatable reports whether a user may move this root. False is a
// contract, not an omission: locks must converge across instances, and home
// cannot be named by the configuration it holds.
relocatable bool
// fallback answers when nothing overrides. It may read other roots; the
// graph stays acyclic because home resolves without reading any.
fallback func() string
// owns names what this root may claim inside a directory it shares — state
// falls back to home, so on Windows they are one folder and a move of one
// must not carry the other's credentials away. Empty means sole occupancy.
owns []string
}
// storageRootTable is the declaration. Order is stable so listings and
// diagnostics read the same way every time. It is built inside a function
// because entries resolve each other — a root that derives from another names
// it here instead of repeating its chain, and a package-level initializer
// cannot hold a table whose values reach back into it.
var (
rootsOnce sync.Once
rootsTable []storageRoot
)
func storageRootTable() []storageRoot {
rootsOnce.Do(func() {
rootsTable = []storageRoot{
{id: RootHome, env: "REASONIX_HOME", relocatable: false, fallback: defaultHomeDir},
{id: RootState, env: "REASONIX_STATE_HOME", relocatable: true,
owns: []string{"sessions", "archive", "stats", "projects"},
fallback: func() string {
return storageRootDir(RootHome)
}},
{id: RootCache, env: "REASONIX_CACHE_HOME", relocatable: true, fallback: defaultCacheDir},
{id: RootWorktrees, env: "", relocatable: true, fallback: defaultWorktreeDir},
{id: RootLocks, env: "", relocatable: false, fallback: osCacheBase,
owns: []string{"workspace-leases", "repair-mutation-locks"}},
}
})
return rootsTable
}
// storageRootDir is the whole precedence chain: what the environment pins, then
// what the configuration chose, then the root's own default. Every root runs it,
// so relocation, isolation, and the OS defaults compose the same way for all of
// them instead of each resolver spelling out its own order.
func storageRootDir(id RootID) string {
root, ok := lookupRoot(id)
if !ok {
return ""
}
if root.env != "" {
if dir := cleanEnvDir(root.env); dir != "" {
return dir
}
}
if dir := configuredRootDir(id); dir != "" {
return dir
}
return root.fallback()
}
func lookupRoot(id RootID) (storageRoot, bool) {
table := storageRootTable()
if i := slices.IndexFunc(table, func(r storageRoot) bool { return r.id == id }); i >= 0 {
return table[i], true
}
return storageRoot{}, false
}
// RootIDs lists every declared root in table order. Usage accounting, the
// storage panel, and diagnostics enumerate this rather than each carrying a
// list, so declaring a root is the only edit a new one needs.
func RootIDs() []RootID {
table := storageRootTable()
out := make([]RootID, 0, len(table))
for _, root := range table {
out = append(out, root.id)
}
return out
}
// RootOwns names the entries a root may claim inside its directory, empty when
// it has the directory to itself. A move and a size report read this rather
// than assuming a root owns everything beneath it, because state shares a
// folder with home on a default Windows install.
func RootOwns(id RootID) []string {
root, ok := lookupRoot(id)
if !ok || len(root.owns) == 0 {
return nil
}
return append([]string(nil), root.owns...)
}
// RootRelocatable reports whether a user may move this root. Callers that offer
// relocation read it rather than listing the immovable roots themselves.
func RootRelocatable(id RootID) bool {
root, ok := lookupRoot(id)
return ok && root.relocatable
}
// RootPinnedBy names the environment variable holding this root in place, or ""
// when none does. A surface that offers to move a root shows this instead of
// accepting an edit the environment would silently override.
func RootPinnedBy(id RootID) string {
root, ok := lookupRoot(id)
if !ok || root.env == "" {
return ""
}
if cleanEnvDir(root.env) == "" {
return ""
}
return root.env
}
// RootDir resolves one root's absolute directory, "" when it cannot be resolved
// (a machine with no usable home or cache location) — callers degrade rather
// than write to a relative path.
func RootDir(id RootID) string { return storageRootDir(id) }
// joinRoot appends sub to the first base that resolves, and answers "" when
// none does. The guard matters: filepath.Join("", "x") is the relative path
// "x", which would land runtime state in the working directory.
func joinRoot(base, sub string, more ...func() string) string {
if base == "" {
for _, next := range more {
if base = next(); base != "" {
break
}
}
}
if base == "" {
return ""
}
return filepath.Join(base, sub)
}
// defaultHomeDir is where configuration lands when REASONIX_HOME is unset:
// the roaming profile on Windows, a dotfile directory elsewhere.
func defaultHomeDir() string {
if runtimeGOOS == "windows" {
if dir := osUserConfigDir(); dir != "" {
return filepath.Join(dir, "reasonix")
}
if home, err := osUserHomeDir(); err == nil && home != "" {
return filepath.Join(home, "AppData", "Roaming", "reasonix")
}
return ""
}
if home, err := osUserHomeDir(); err == nil && home != "" {
return filepath.Join(home, ".reasonix")
}
if dir := osUserConfigDir(); dir != "" {
return filepath.Join(dir, "reasonix")
}
return ""
}
// defaultCacheDir is where derived data lands when no variable names it. A
// pinned home takes its cache along as a subdirectory; otherwise the cache is
// the OS location itself, which on Windows is the profile that does not roam.
func defaultCacheDir() string {
if home := cleanEnvDir("REASONIX_HOME"); home != "" {
return filepath.Join(home, "cache")
}
return osCacheBase()
}
// osCacheBase is the OS user's cache location for this app. It reads no
// Reasonix variable on purpose: the locks root has to converge there even when
// two instances run under different profiles.
func osCacheBase() string {
if dir := osUserCacheDir(); dir != "" {
return filepath.Join(dir, "reasonix")
}
if runtimeGOOS == "windows" {
if home, err := osUserHomeDir(); err == nil && home != "" {
return filepath.Join(home, "AppData", "Local", "reasonix")
}
}
return ""
}
// defaultWorktreeDir places checkouts that are as large as the repositories
// they mirror. A state root someone pinned deliberately takes them along;
// otherwise Windows keeps them out of the roaming profile, where they would be
// copied between machines.
func defaultWorktreeDir() string {
if dir := pinnedStateDir(); dir != "" {
return filepath.Join(dir, "worktrees")
}
if runtimeGOOS == "windows" {
return joinRoot(osCacheBase(), "worktrees")
}
return joinRoot(storageRootDir(RootState), "worktrees")
}
// pinnedStateDir is the state root only insofar as a variable holds it there:
// its own, or the home root it otherwise follows. Roots that inherit a
// deliberate relocation consult this rather than the resolved value, which
// cannot tell a chosen location from a default one.
func pinnedStateDir() string {
if dir := cleanEnvDir("REASONIX_STATE_HOME"); dir != "" {
return dir
}
return cleanEnvDir("REASONIX_HOME")
}
+271
View File
@@ -0,0 +1,271 @@
package config
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
func writeStorageSection(t *testing.T, home, body string) {
t.Helper()
if err := os.MkdirAll(home, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
InvalidateStorageDirs()
t.Cleanup(InvalidateStorageDirs)
}
// The chain is the point: a variable outranks the configuration, the
// configuration outranks the default, and every root runs the same order. A
// root that resolved by its own rules is how the old resolvers drifted apart.
func TestRootChainPrefersEnvironmentThenConfigurationThenDefault(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
t.Setenv("REASONIX_STATE_HOME", "")
t.Setenv("REASONIX_CACHE_HOME", "")
if got := RootDir(RootState); got != home {
t.Fatalf("default state = %q, want the home root %q", got, home)
}
configured := filepath.Join(t.TempDir(), "moved-state")
writeStorageSection(t, home, "[storage]\nstate = "+quote(configured)+"\n")
if got := RootDir(RootState); got != configured {
t.Fatalf("configured state = %q, want %q", got, configured)
}
pinned := t.TempDir()
t.Setenv("REASONIX_STATE_HOME", pinned)
if got := RootDir(RootState); got != pinned {
t.Fatalf("state = %q, want the environment to outrank configuration (%q)", got, pinned)
}
}
// Immovable is a contract with a reason behind it, so it is enforced rather
// than documented: two isolated runtimes opening one workspace have to find the
// same lock, and nothing may name the location of the file that names locations.
func TestConfigurationCannotMoveAnImmovableRoot(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
elsewhere := t.TempDir()
writeStorageSection(t, home,
"[storage]\nhome = "+quote(elsewhere)+"\nlocks = "+quote(elsewhere)+"\n")
if got := RootDir(RootHome); got != home {
t.Fatalf("home = %q, want it to stay at %q", got, home)
}
if got := RootDir(RootLocks); got == elsewhere {
t.Fatalf("locks followed the configuration to %q", got)
}
for _, id := range []RootID{RootHome, RootLocks} {
if RootRelocatable(id) {
t.Fatalf("%s reports itself relocatable", id)
}
}
for _, id := range []RootID{RootState, RootCache, RootWorktrees} {
if !RootRelocatable(id) {
t.Fatalf("%s reports itself immovable", id)
}
}
}
// A surface offering to move a root has to say when the environment already
// holds it, instead of accepting an edit that resolution would ignore.
func TestPinnedRootNamesTheVariableHoldingIt(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
t.Setenv("REASONIX_STATE_HOME", "")
if got := RootPinnedBy(RootState); got != "" {
t.Fatalf("unpinned state reported %q", got)
}
t.Setenv("REASONIX_STATE_HOME", t.TempDir())
if got := RootPinnedBy(RootState); got != "REASONIX_STATE_HOME" {
t.Fatalf("pinned state reported %q", got)
}
if got := RootPinnedBy(RootWorktrees); got != "" {
t.Fatalf("worktrees have no variable of their own, got %q", got)
}
}
// Both layers accept the same spellings, or a location would mean one thing in
// the environment and another in the file.
func TestConfiguredRootExpandsLikeAnEnvironmentOverride(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
t.Setenv("REASONIX_CACHE_HOME", "")
target := t.TempDir()
t.Setenv("STORAGE_TEST_TARGET", target)
writeStorageSection(t, home, "[storage]\ncache = \"${STORAGE_TEST_TARGET}/derived\"\n")
want := filepath.Join(target, "derived")
if got := RootDir(RootCache); got != want {
t.Fatalf("cache = %q, want the expanded %q", got, want)
}
}
// A broken config must not take the paths down with it: a user whose file
// cannot be parsed still has to reach the tooling that repairs it.
func TestUnreadableStorageSectionFallsBackInsteadOfFailing(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
t.Setenv("REASONIX_STATE_HOME", "")
writeStorageSection(t, home, "[storage\nstate = broken")
if got := RootDir(RootState); got != home {
t.Fatalf("state = %q, want the default %q despite the broken file", got, home)
}
}
func quote(s string) string {
out := make([]rune, 0, len(s)+2)
out = append(out, '"')
for _, r := range s {
if r == '\\' || r == '"' {
out = append(out, '\\')
}
out = append(out, r)
}
return string(append(out, '"'))
}
// A relocation has to survive the save: the renderer rewrites the whole file
// from the struct, so a section it has no line for is silently dropped.
func TestStorageSectionSurvivesASaveAndReload(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
t.Setenv("REASONIX_STATE_HOME", "")
t.Setenv("REASONIX_CACHE_HOME", "")
t.Cleanup(InvalidateStorageDirs)
target := filepath.Join(t.TempDir(), "moved")
c := Default()
if err := c.SetStorageDir(RootState, target); err != nil {
t.Fatalf("set storage: %v", err)
}
path := filepath.Join(home, "config.toml")
if err := c.SaveTo(path); err != nil {
t.Fatalf("save: %v", err)
}
InvalidateStorageDirs()
if got := RootDir(RootState); got != target {
t.Fatalf("state resolved to %q after save, want %q", got, target)
}
body, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(body), "[storage]") {
t.Fatalf("saved config has no [storage] section:\n%s", body)
}
}
// Clearing a root returns it to its default rather than writing an empty
// location nothing can resolve.
func TestClearingAStorageDirRestoresTheDefault(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
t.Setenv("REASONIX_STATE_HOME", "")
t.Cleanup(InvalidateStorageDirs)
c := Default()
if err := c.SetStorageDir(RootState, filepath.Join(t.TempDir(), "moved")); err != nil {
t.Fatal(err)
}
if err := c.SetStorageDir(RootState, ""); err != nil {
t.Fatalf("clear: %v", err)
}
if got := c.StorageDir(RootState); got != "" {
t.Fatalf("cleared root still configured as %q", got)
}
if err := c.SaveTo(filepath.Join(home, "config.toml")); err != nil {
t.Fatal(err)
}
InvalidateStorageDirs()
if got := RootDir(RootState); got != home {
t.Fatalf("state = %q after clearing, want the default %q", got, home)
}
}
// The setter rules on what configuration can rule on, and refuses the rest
// with a reason rather than writing a line resolution would ignore.
func TestSetStorageDirRefusesWhatItCannotHonour(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
c := Default()
if err := c.SetStorageDir(RootLocks, t.TempDir()); err == nil {
t.Fatal("locks accepted a relocation")
}
if err := c.SetStorageDir(RootHome, t.TempDir()); err == nil {
t.Fatal("home accepted a relocation")
}
if err := c.SetStorageDir(RootID("nowhere"), t.TempDir()); err == nil {
t.Fatal("an undeclared root accepted a relocation")
}
t.Setenv("REASONIX_CACHE_HOME", t.TempDir())
if err := c.SetStorageDir(RootCache, t.TempDir()); err == nil {
t.Fatal("a root the environment pins accepted a relocation the environment would override")
}
}
// A project checkout must not be able to redirect where this machine keeps its
// data — the same rule [secrets] and [remote] carry.
func TestProjectConfigCannotRelocateAnything(t *testing.T) {
c := Default()
c.Storage = map[string]string{string(RootState): "/from/the/repo"}
if body := RenderTOMLForScope(c, RenderScopeProject); strings.Contains(body, "[storage]") {
t.Fatalf("project scope rendered a storage section:\n%s", body)
}
}
// The defaults are a compatibility contract, not a preference: a root that
// resolves one directory deeper than it used to orphans everything the last
// release wrote there. Pinned here so a refactor of the chain cannot move them
// quietly — this caught the cache picking up a "cache" suffix it never had.
func TestDefaultRootDirectoriesAreWhereTheyAlwaysWere(t *testing.T) {
for _, name := range []string{"REASONIX_HOME", "REASONIX_STATE_HOME", "REASONIX_CACHE_HOME"} {
t.Setenv(name, "")
}
t.Cleanup(InvalidateStorageDirs)
InvalidateStorageDirs()
cacheBase := osUserCacheDir()
if cacheBase == "" {
t.Skip("no OS cache directory on this machine")
}
if got, want := RootDir(RootCache), filepath.Join(cacheBase, "reasonix"); got != want {
t.Errorf("cache = %q, want %q", got, want)
}
if got, want := RootDir(RootLocks), filepath.Join(cacheBase, "reasonix"); got != want {
t.Errorf("locks = %q, want %q", got, want)
}
if got, want := RootDir(RootState), RootDir(RootHome); got != want {
t.Errorf("state = %q, want the home root %q", got, want)
}
// A pinned home is the one case that takes the cache along, one level down.
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
if got, want := RootDir(RootCache), filepath.Join(home, "cache"); got != want {
t.Errorf("cache under a pinned home = %q, want %q", got, want)
}
}
// The locks root shares the OS cache directory with the cache root, so it owns
// only its own two subdirectories — otherwise a size report counts the whole
// cache twice and a reader sees storage that is not there.
func TestLocksRootOwnsOnlyItsLockDirectories(t *testing.T) {
owned := RootOwns(RootLocks)
if len(owned) != 2 {
t.Fatalf("locks owns %v, want its two lock directories", owned)
}
base := RootDir(RootLocks)
for _, dir := range []string{WorkspaceLeaseDir(), RepairMutationLockDir()} {
rel, err := filepath.Rel(base, dir)
if err != nil || !slices.Contains(owned, rel) {
t.Fatalf("%q is not among the declared entries %v", dir, owned)
}
}
}
@@ -26,6 +26,12 @@ type snapshotConflictDiagnostic struct {
DiskRevision int64 `json:"disk_revision,omitempty"`
RecoveryBranchID string `json:"recovery_branch_id,omitempty"`
ExistingRecovery bool `json:"existing_recovery,omitempty"`
// DivergedAt and the two roles say where the transcripts parted and what
// stood there: an assistant turn that became a tool row is a local reshape,
// two different assistant turns are two writers. No content is recorded.
DivergedAt int `json:"diverged_at"`
DiskRole string `json:"disk_role,omitempty"`
SnapshotRole string `json:"snapshot_role,omitempty"`
}
// conflictDiagDedup bounds repeated conflict event log lines for the same
@@ -47,10 +53,11 @@ func appendSnapshotConflictDiagnostic(path, mode, outcome string, saveErr error,
return
}
rec := snapshotConflictDiagnostic{
At: time.Now(),
BranchID: agent.BranchID(path),
Mode: mode,
Outcome: outcome,
At: time.Now(),
BranchID: agent.BranchID(path),
Mode: mode,
Outcome: outcome,
DivergedAt: -1,
}
if conflict != nil {
rec.Kind = string(conflict.Kind)
@@ -58,6 +65,9 @@ func appendSnapshotConflictDiagnostic(path, mode, outcome string, saveErr error,
rec.SnapshotMessages = conflict.SnapshotMessages
rec.BaseRevision = conflict.BaseRevision
rec.DiskRevision = conflict.DiskRevision
rec.DivergedAt = conflict.DivergedAt
rec.DiskRole = conflict.DiskRole
rec.SnapshotRole = conflict.SnapshotRole
}
if recoveryPath != "" {
rec.RecoveryBranchID = agent.BranchID(recoveryPath)
+33 -2
View File
@@ -3,7 +3,10 @@ package serve
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
@@ -17,6 +20,33 @@ import (
// control enforces the real per-kind limit once these are decoded.
const maxAttachmentUpload = 25 << 20
// attachmentBytes takes the bytes from whichever half the client had. A desktop
// drop reports a path and never the bytes, so the host reads the file it was
// pointed at — bounded to a regular file under the size cap, and still handed to
// the same saver, which admits nothing that does not sniff as a real image.
func attachmentBytes(path, data string) ([]byte, error) {
if strings.TrimSpace(path) == "" {
raw, err := base64.StdEncoding.DecodeString(data)
if err != nil {
return nil, errors.New("attachment data must be base64")
}
return raw, nil
}
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("read attachment: %w", err)
}
// A directory or a device would read as something no image sniff would
// admit, but refusing here says what was wrong instead of what it was not.
if !info.Mode().IsRegular() {
return nil, errors.New("attachment path is not a regular file")
}
if info.Size() > maxAttachmentUpload {
return nil, errors.New("attachment is larger than 10 MB")
}
return os.ReadFile(path)
}
func (s *Server) attachments(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, (maxAttachmentUpload/3)*4+1024)
var body struct {
@@ -25,14 +55,15 @@ func (s *Server) attachments(w http.ResponseWriter, r *http.Request) {
// stored name is generated either way.
Name string `json:"name"`
Data string `json:"data"`
Path string `json:"path"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "read attachment: "+err.Error(), http.StatusBadRequest)
return
}
raw, err := base64.StdEncoding.DecodeString(body.Data)
raw, err := attachmentBytes(body.Path, body.Data)
if err != nil {
http.Error(w, "attachment data must be base64", http.StatusBadRequest)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
root := s.ctl().WorkspaceRoot()
+131
View File
@@ -0,0 +1,131 @@
package serve
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/provider"
)
const extensionModelRef = "plugin/demo/cloud/extension-chat"
// A server whose picker stocks both catalogs: the configured provider and an
// extension-hosted one, which is the pairing every plugin install produces.
func newRoleServerWithExtensionModel(t *testing.T) *Server {
t.Helper()
t.Setenv("REASONIX_HOME", t.TempDir())
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
configPath := config.UserConfigPath()
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
body := `default_model = "existing/model-a"
[[providers]]
name = "existing"
kind = "openai"
base_url = "https://example.invalid/v1"
models = ["model-a"]
default = "model-a"
api_key = "k"
`
if err := os.WriteFile(configPath, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
bc := NewBroadcaster()
ctrl := control.New(control.Options{
Sink: bc, Label: "model-a", ModelRef: "existing/model-a", SessionDir: t.TempDir(),
ProviderResolver: &provider.StaticResolver{Descriptors: []provider.Descriptor{{
Ref: extensionModelRef, Model: "extension-chat", DisplayName: "Extension Chat",
}}},
})
t.Cleanup(ctrl.Close)
closeSharedCatalogsOnCleanup(t)
s := New(ctrl, bc, config.ServeConfig{})
s.AllowProviderEdit()
return s
}
func offeredModelRefs(t *testing.T, base string) []string {
t.Helper()
resp, err := http.Get(base + "/models")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var body struct {
Models []struct {
Ref string `json:"ref"`
} `json:"models"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
refs := make([]string, 0, len(body.Models))
for _, m := range body.Models {
refs = append(refs, m.Ref)
}
if len(refs) == 0 {
t.Fatal("the picker offered nothing to assign")
}
return refs
}
// The picker and the role form read one list, so a model the picker offers has
// to be one the assignment accepts. Validating against the configuration alone
// broke exactly here: an extension-hosted model switched fine as the main model
// and was refused as a role, because no config file ever holds one.
func TestEveryModelThePickerOffersIsAssignableToARole(t *testing.T) {
var offered []string
func() {
srv := httptest.NewServer(newRoleServerWithExtensionModel(t).Handler())
defer srv.Close()
offered = offeredModelRefs(t, srv.URL)
}()
// A fresh server per ref: assigning a role rebuilds the runtime, and a
// later ref must be judged by the picker's offer rather than by what an
// earlier assignment left behind.
for _, ref := range offered {
t.Run(ref, func(t *testing.T) {
srv := httptest.NewServer(newRoleServerWithExtensionModel(t).Handler())
defer srv.Close()
payload, err := json.Marshal(map[string]string{"role": "planner", "ref": ref})
if err != nil {
t.Fatal(err)
}
resp := postProvider(t, srv.URL, "/roles", string(payload))
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
got, _ := readAllString(resp)
t.Fatalf("POST /roles %s = %d: %s", ref, resp.StatusCode, got)
}
if got := readRoles(t, srv.URL)["planner"]; got != ref {
t.Fatalf("planner = %q after assigning %q", got, ref)
}
})
}
}
// Neither catalog vouching for a ref is still a refusal: the guard reads the
// catalogs, so it must not have widened into accepting anything with slashes.
func TestRoleStillRefusesARefNoCatalogCarries(t *testing.T) {
s := newRoleServerWithExtensionModel(t)
srv := httptest.NewServer(s.Handler())
defer srv.Close()
for _, ref := range []string{"plugin/ghost/cloud/nothing", "nosuch/model", "plugin/two"} {
resp := postProvider(t, srv.URL, "/roles", `{"role":"planner","ref":"`+ref+`"}`)
status := resp.StatusCode
resp.Body.Close()
if status != http.StatusBadRequest {
t.Fatalf("POST /roles %s = %d, want 400", ref, status)
}
}
}
+2 -2
View File
@@ -172,7 +172,7 @@ func TestSwitchingModelSurvivesARestart(t *testing.T) {
t.Fatal(err)
}
persistDefaultModel("mixed-anthropic/text-only")
persistDefaultModel("mixed-anthropic/text-only", nil)
// A fresh load is what a restart sees.
cfg, err := config.Load()
@@ -199,7 +199,7 @@ func TestPersistingAnUnknownModelLeavesTheDefaultAlone(t *testing.T) {
t.Fatal(err)
}
persistDefaultModel("nobody/nothing")
persistDefaultModel("nobody/nothing", nil)
cfg, err := config.Load()
if err != nil {
+1 -1
View File
@@ -72,7 +72,7 @@ func (s *Server) setRole(w http.ResponseWriter, r *http.Request) {
}
// Naming a model that does not resolve would strand the role on the next
// build, and the failure would surface as a broken turn rather than here.
if _, found := cfg.ResolveModel(ref); !found {
if !cfg.ModelRefSelectable(ref, s.ctl().ProviderCatalog()) {
http.Error(w, fmt.Sprintf("no configured model matches %q", ref), http.StatusBadRequest)
return
}
+10 -3
View File
@@ -64,6 +64,7 @@ type Server struct {
buildWorkspaceController func(ctx context.Context, dir, ref string) (*control.Controller, error)
lastBuild *boot.BuildResult // serving generation, guarded by bindMu; see reuseFromLastBuild
grants hostGrants // what the embedding host has opened up
moves moveTracker // the one storage relocation this server may be running
titleProv provider.Provider // best-effort provider for session titles
titlePrice *provider.Pricing
titleModelRef string
@@ -508,6 +509,7 @@ func (s *Server) routes() http.Handler {
s.registerInboxRoutes(mux)
s.registerProviderRoutes(mux)
s.registerRoleRoutes(mux)
s.registerStorageRoutes(mux)
s.registerWelcomeRoutes(mux)
mux.HandleFunc("POST /cancel", s.cancel)
mux.HandleFunc("POST /approve", s.approve)
@@ -801,9 +803,13 @@ type historyToolCall struct {
}
type historyMessage struct {
Role string `json:"role"`
Content string `json:"content"`
Reasoning string `json:"reasoning,omitempty"`
Role string `json:"role"`
Content string `json:"content"`
Reasoning string `json:"reasoning,omitempty"`
// Images is how many attachments the turn carried, not the attachments
// themselves: a turn that was only an image has no text to rebuild from,
// and a reader that sees zero of both drops it as host chrome.
Images int `json:"images,omitempty"`
ToolCalls []historyToolCall `json:"toolCalls,omitempty"`
ToolCallID string `json:"toolCallId,omitempty"`
ToolName string `json:"toolName,omitempty"`
@@ -824,6 +830,7 @@ func historyMessages(msgs []provider.Message) []historyMessage {
// Content is what the model saw, and one @-reference expands into a
// whole file. A reopened session has to show what was typed.
hm.Content = agent.UserMessageText(m)
hm.Images = len(m.Images)
}
if m.Role == provider.RoleAssistant {
hm.Reasoning = m.ReasoningContent
+21
View File
@@ -295,6 +295,27 @@ func TestHistoryMessagesPreserveToolDetails(t *testing.T) {
}
}
// A turn sent as an attachment and nothing else has no text left once the
// control blocks come off, and /history carries no attachments. The count is
// what tells a rebuilding client the turn happened at all.
func TestHistoryMessagesCountAttachmentsOnATextlessTurn(t *testing.T) {
got := historyMessages([]provider.Message{
{Role: provider.RoleUser, Content: "<reasoning-language>zh</reasoning-language>",
Images: []string{"data:image/png;base64,aa", "data:image/png;base64,bb"}},
{Role: provider.RoleUser, Content: "看看这个", RawContent: "看看这个"},
})
if len(got) != 2 {
t.Fatalf("history length = %d, want 2", len(got))
}
if got[0].Content != "" || got[0].Images != 2 {
t.Fatalf("attachment-only turn = %+v, want empty text and two images", got[0])
}
if got[1].Content != "看看这个" || got[1].Images != 0 {
t.Fatalf("ordinary turn = %+v, want its text and no image count", got[1])
}
}
func TestSessionsListPreviewStripsTransientReasoningLanguageBlock(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
+5 -3
View File
@@ -8,6 +8,7 @@ import (
"reasonix/internal/agentpreset"
"reasonix/internal/config"
"reasonix/internal/provider"
)
func (s *Server) preset(w http.ResponseWriter, r *http.Request) {
@@ -39,18 +40,19 @@ func (s *Server) model(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
catalog := s.ctl().ProviderCatalog()
// The switch only rebuilt the running controller. Without this the next
// launch boots from default_model and lands back on whatever was there
// before, which reads as the choice not having been saved at all — the CLI
// and the old desktop have both persisted it since they had a picker.
persistDefaultModel(ref)
persistDefaultModel(ref, catalog)
w.WriteHeader(http.StatusNoContent)
}
// persistDefaultModel records the choice in the user config. A refusal is worth
// a log and nothing more: the live switch already succeeded, and failing the
// request would say the model did not change when it did.
func persistDefaultModel(ref string) {
func persistDefaultModel(ref string, catalog []provider.Descriptor) {
path := config.UserConfigPath()
if path == "" {
return
@@ -60,7 +62,7 @@ func persistDefaultModel(ref string) {
unlock := config.LockUserConfigEdits()
defer unlock()
edit := config.LoadForEdit(path)
if err := edit.SetDefaultModel(ref); err != nil {
if err := edit.SetDefaultModel(ref, catalog); err != nil {
slog.Warn("serve: persist default model", "ref", ref, "err", err)
return
}
+219
View File
@@ -0,0 +1,219 @@
// storage.go — what the runtime keeps on disk, and moving it somewhere else.
package serve
import (
"context"
"encoding/json"
"net/http"
"strings"
"sync"
"reasonix/internal/config"
"reasonix/internal/storage"
)
// storageRoot is one root as the panel reads it. Sizes are the measured ones;
// pinnedBy and relocatable travel with them so the client never decides for
// itself which rows may be moved.
type storageRoot struct {
ID string `json:"id"`
Dir string `json:"dir"`
Bytes int64 `json:"bytes"`
Files int64 `json:"files"`
Relocatable bool `json:"relocatable"`
PinnedBy string `json:"pinnedBy,omitempty"`
Missing bool `json:"missing,omitempty"`
Err string `json:"err,omitempty"`
Volume string `json:"volume,omitempty"`
VolumeFree int64 `json:"volumeFree,omitempty"`
VolumeTotal int64 `json:"volumeTotal,omitempty"`
}
// storageMove is a move in flight or the one that just finished. It is polled
// rather than pushed: a move is one operation a person is watching, and the
// panel already refreshes while it runs.
type storageMove struct {
Root string `json:"root"`
To string `json:"to"`
Phase string `json:"phase"`
Bytes int64 `json:"bytes"`
Total int64 `json:"total"`
Detail string `json:"detail,omitempty"`
Err string `json:"err,omitempty"`
Done bool `json:"done"`
}
// storageRefusal is one preflight objection, carrying both the code a client
// branches on and the sentence a person reads.
type storageRefusal struct {
Code string `json:"code"`
Detail string `json:"detail"`
}
type storagePlan struct {
Root string `json:"root"`
From string `json:"from"`
To string `json:"to"`
Bytes int64 `json:"bytes"`
Files int64 `json:"files"`
Need int64 `json:"need"`
Free int64 `json:"free"`
OK bool `json:"ok"`
Refusals []storageRefusal `json:"refusals,omitempty"`
}
// moveTracker holds the one move this server may be running. One at a time is
// not a simplification: two moves could name each other's targets, and the
// journal records a single operation.
type moveTracker struct {
mu sync.Mutex
running bool
state storageMove
}
func (m *moveTracker) snapshot() (storageMove, bool) {
m.mu.Lock()
defer m.mu.Unlock()
if m.state.Phase == "" {
return storageMove{}, false
}
return m.state, true
}
func (m *moveTracker) begin(state storageMove) bool {
m.mu.Lock()
defer m.mu.Unlock()
if m.running {
return false
}
m.running = true
m.state = state
return true
}
func (m *moveTracker) update(fn func(*storageMove)) {
m.mu.Lock()
defer m.mu.Unlock()
fn(&m.state)
}
func (m *moveTracker) finish(fn func(*storageMove)) {
m.mu.Lock()
defer m.mu.Unlock()
m.running = false
fn(&m.state)
m.state.Done = true
}
func (s *Server) registerStorageRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /storage", s.storage)
mux.HandleFunc("POST /storage/plan", s.storagePlan)
mux.HandleFunc("POST /storage/move", s.storageMove)
}
// storage reports every root with its measured size. The walk runs on the
// request's own context, so a client that navigates away stops paying for it.
func (s *Server) storage(w http.ResponseWriter, r *http.Request) {
roots := storage.Survey(r.Context())
out := make([]storageRoot, 0, len(roots))
for _, root := range roots {
out = append(out, storageRoot{
ID: string(root.ID), Dir: root.Dir, Bytes: root.Bytes, Files: root.Files,
Relocatable: root.Relocatable, PinnedBy: root.PinnedBy, Missing: root.Missing,
Err: root.Err, Volume: root.Volume.Path,
VolumeFree: root.Volume.Free, VolumeTotal: root.Volume.Total,
})
}
body := map[string]any{"roots": out, "editable": s.grants.providerEdit}
if move, ok := s.moves.snapshot(); ok {
body["move"] = move
}
if pending, ok := storage.PendingMove(); ok {
body["pending"] = map[string]any{
"root": string(pending.Root), "to": pending.To, "phase": string(pending.Phase),
}
}
writeJSON(w, body)
}
// storagePlan answers what a move would do without doing any of it, so the
// panel can show the cost and every objection before a person commits.
func (s *Server) storagePlan(w http.ResponseWriter, r *http.Request) {
root, dir, ok := decodeStorageTarget(w, r)
if !ok {
return
}
writeJSON(w, planPayload(storage.PlanMove(r.Context(), root, dir)))
}
// storageMove starts one. It answers immediately with the plan it accepted and
// reports the rest through GET /storage: a move copies gigabytes, and holding
// the request open for it would leave the panel unable to say anything.
func (s *Server) storageMove(w http.ResponseWriter, r *http.Request) {
if !s.grants.providerEdit {
http.Error(w, "moving data is not enabled on this server", http.StatusForbidden)
return
}
root, dir, ok := decodeStorageTarget(w, r)
if !ok {
return
}
plan := storage.PlanMove(r.Context(), root, dir)
if !plan.OK() {
writeJSON(w, planPayload(plan))
return
}
if !s.moves.begin(storageMove{Root: string(plan.Root), To: plan.To, Phase: string(storage.PhaseCopying), Total: plan.Bytes}) {
http.Error(w, "a move is already running", http.StatusConflict)
return
}
// Detached from the request: the copy outlives the client that asked for
// it, and cancelling it is a decision this server does not offer mid-flight
// — an abandoned copy is resumable, a half-cancelled one is a surprise.
go func() {
err := storage.Move(context.WithoutCancel(r.Context()), plan, func(p storage.Progress) {
s.moves.update(func(state *storageMove) {
state.Phase, state.Bytes, state.Total = string(p.Phase), p.Bytes, p.Total
if p.Detail != "" {
state.Detail = p.Detail
}
})
})
s.moves.finish(func(state *storageMove) {
if err != nil {
state.Err = err.Error()
return
}
state.Phase = string(storage.PhaseDone)
})
}()
writeJSON(w, planPayload(plan))
}
func planPayload(plan storage.Plan) storagePlan {
out := storagePlan{
Root: string(plan.Root), From: plan.From, To: plan.To,
Bytes: plan.Bytes, Files: plan.Files, Need: plan.Need, Free: plan.Free, OK: plan.OK(),
}
for _, refusal := range plan.Refusals {
out.Refusals = append(out.Refusals, storageRefusal{Code: refusal.Code, Detail: refusal.Detail})
}
return out
}
func decodeStorageTarget(w http.ResponseWriter, r *http.Request) (config.RootID, string, bool) {
var body struct {
Root string `json:"root"`
Dir string `json:"dir"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&body); err != nil {
http.Error(w, "invalid storage request", http.StatusBadRequest)
return "", "", false
}
root := config.RootID(strings.TrimSpace(body.Root))
if root == "" {
http.Error(w, "missing root", http.StatusBadRequest)
return "", "", false
}
return root, strings.TrimSpace(body.Dir), true
}
+167
View File
@@ -0,0 +1,167 @@
package serve
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"reasonix/internal/config"
)
func readStorage(t *testing.T, base string) map[string]any {
t.Helper()
resp, err := http.Get(base + "/storage")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET /storage = %d", resp.StatusCode)
}
var out map[string]any
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatal(err)
}
return out
}
func storageRootsOf(t *testing.T, body map[string]any) map[string]map[string]any {
t.Helper()
rows, ok := body["roots"].([]any)
if !ok {
t.Fatalf("no roots in %v", body)
}
out := map[string]map[string]any{}
for _, row := range rows {
entry, ok := row.(map[string]any)
if !ok {
t.Fatalf("root row = %T", row)
}
out[entry["id"].(string)] = entry
}
return out
}
// The panel enumerates whatever the kernel declares, so every root has to
// arrive — including the ones it may only show and never offer to move.
func TestStorageListsEveryRootWithItsRelocationFacts(t *testing.T) {
s := newProviderEditServer(t)
srv := httptest.NewServer(s.Handler())
defer srv.Close()
roots := storageRootsOf(t, readStorage(t, srv.URL))
for _, id := range config.RootIDs() {
row, ok := roots[string(id)]
if !ok {
t.Fatalf("root %q missing from /storage", id)
}
if row["relocatable"] != config.RootRelocatable(id) {
t.Fatalf("root %q reported relocatable=%v", id, row["relocatable"])
}
}
if roots["locks"]["relocatable"] != false {
t.Fatal("the locks root was offered as movable over the wire")
}
}
// A plan is the answer to "what would this cost", and it must be reachable
// without doing anything: the panel shows the cost before a person commits.
func TestStoragePlanReportsCostAndRefusalsWithoutMoving(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
defer srv.Close()
before := config.RootDir(config.RootState)
resp := postProvider(t, srv.URL, "/storage/plan", `{"root":"state","dir":""}`)
defer resp.Body.Close()
var plan map[string]any
if err := json.NewDecoder(resp.Body).Decode(&plan); err != nil {
t.Fatal(err)
}
if plan["ok"] != false {
t.Fatalf("an empty target planned as ok: %v", plan)
}
refusals, _ := plan["refusals"].([]any)
if len(refusals) == 0 {
t.Fatal("a refused plan carried no reason")
}
first := refusals[0].(map[string]any)
if first["code"] == "" || first["detail"] == "" {
t.Fatalf("refusal missing a code or a sentence: %v", first)
}
if got := config.RootDir(config.RootState); got != before {
t.Fatalf("planning moved the root to %q", got)
}
}
// Relocation writes the configuration of the machine running the kernel, which
// is the one thing a server reachable over a network must not let a client do.
func TestStorageMoveIsRefusedUntilTheHostGrantsIt(t *testing.T) {
s := newProviderEditServer(t)
srv := httptest.NewServer(s.Handler())
defer srv.Close()
resp := postProvider(t, srv.URL, "/storage/move", `{"root":"state","dir":"`+storagePathLiteral(t.TempDir())+`"}`)
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("POST /storage/move without the grant = %d, want 403", resp.StatusCode)
}
}
// The move runs past the request that started it and reports through the same
// listing the panel already reads.
func TestStorageMoveRunsAndReportsThroughTheListing(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(s.Handler())
defer srv.Close()
home := config.RootDir(config.RootState)
if err := os.MkdirAll(filepath.Join(home, "sessions"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(home, "sessions", "a.jsonl"), make([]byte, 512), 0o600); err != nil {
t.Fatal(err)
}
target := filepath.Join(t.TempDir(), "moved")
t.Cleanup(config.InvalidateStorageDirs)
resp := postProvider(t, srv.URL, "/storage/move", `{"root":"state","dir":"`+storagePathLiteral(target)+`"}`)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := readAllString(resp)
t.Fatalf("POST /storage/move = %d: %s", resp.StatusCode, body)
}
deadline := time.Now().Add(10 * time.Second)
for {
move, _ := readStorage(t, srv.URL)["move"].(map[string]any)
if move != nil && move["done"] == true {
if errText, _ := move["err"].(string); errText != "" {
t.Fatalf("move failed: %s", errText)
}
break
}
if time.Now().After(deadline) {
t.Fatalf("move never finished: %v", move)
}
time.Sleep(20 * time.Millisecond)
}
if _, err := os.Stat(filepath.Join(target, "sessions", "a.jsonl")); err != nil {
t.Fatalf("the transcript did not arrive: %v", err)
}
}
// storagePathLiteral escapes a Windows path for embedding in a JSON string.
func storagePathLiteral(p string) string {
b, err := json.Marshal(p)
if err != nil {
return p
}
return string(b[1 : len(b)-1])
}
+371
View File
@@ -0,0 +1,371 @@
// move.go — relocating one root: copy, verify, commit, reclaim.
package storage
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"time"
"reasonix/internal/config"
"reasonix/internal/fileutil"
)
// Phase is where a move got to. The order is the guarantee: everything before
// Committed can be abandoned and leaves the runtime on its original data;
// everything from Committed on can only be retried, because the configuration
// already points at the copy.
type Phase string
const (
PhaseCopying Phase = "copying"
PhaseVerifying Phase = "verifying"
PhaseCommitted Phase = "committed"
PhaseDone Phase = "done"
)
// Journal is the record a move leaves behind so an interrupted one can be
// finished rather than guessed at. It lives in the home root, the one root a
// move never touches.
type Journal struct {
Root config.RootID `json:"root"`
From string `json:"from"`
To string `json:"to"`
Phase Phase `json:"phase"`
Started time.Time `json:"started"`
Updated time.Time `json:"updated"`
}
// Progress reports a move as it runs. Bytes copied against bytes planned is
// what a person watches; Phase is what tells them whether cancelling is still
// free.
type Progress struct {
Phase Phase
Bytes int64
Total int64
Files int64
Detail string
}
// ErrMoveInterrupted means the caller cancelled before the commit. Nothing was
// changed: the runtime still reads the original location.
var ErrMoveInterrupted = errors.New("storage: move interrupted before commit")
// Move relocates a root: copy, prove the copy, then point the configuration at
// it — a failure before that last step costs only bytes the next attempt reuses.
// The running process keeps reading the old location, because open catalogs and
// session leases hold handles that cannot be moved underneath them; the new one
// takes effect on the next launch.
func Move(ctx context.Context, plan Plan, report func(Progress)) error {
if !plan.OK() {
return fmt.Errorf("storage: move refused: %s", plan.Refusals[0].Detail)
}
notify := func(p Progress) {
if report != nil {
report(p)
}
}
journal := Journal{Root: plan.Root, From: plan.From, To: plan.To, Phase: PhaseCopying, Started: time.Now()}
if err := writeJournal(journal); err != nil {
return err
}
owned := presentEntries(plan.From, movedEntries(plan.Root))
copied, err := copyOwned(ctx, owned, plan, notify)
if err != nil {
return err
}
journal.Phase = PhaseVerifying
notify(Progress{Phase: PhaseVerifying, Bytes: copied, Total: plan.Bytes})
if err := writeJournal(journal); err != nil {
return err
}
for _, entry := range owned {
if err := verifyTree(ctx, filepath.Join(plan.From, entry), filepath.Join(plan.To, entry)); err != nil {
return err
}
}
// The point of no return, and the only write that changes what the next
// launch reads. Everything above this line is a copy nobody depends on.
journal.Phase = PhaseCommitted
if err := writeJournal(journal); err != nil {
return err
}
if err := commitLocation(plan.Root, plan.To); err != nil {
return fmt.Errorf("storage: copy verified but the location could not be recorded: %w", err)
}
notify(Progress{Phase: PhaseCommitted, Bytes: copied, Total: plan.Bytes})
// Reclaiming is the one step allowed to fail quietly: the move already
// succeeded, and a source that could not be deleted is wasted space rather
// than lost data.
if err := reclaimOwned(plan.From, owned); err != nil {
notify(Progress{Phase: PhaseDone, Bytes: copied, Total: plan.Bytes,
Detail: "moved; the old folder could not be removed and can be deleted by hand"})
journal.Phase = PhaseDone
_ = writeJournal(journal)
return nil
}
journal.Phase = PhaseDone
_ = writeJournal(journal)
notify(Progress{Phase: PhaseDone, Bytes: copied, Total: plan.Bytes})
return clearJournal()
}
// movedEntries is what this move may touch: the root's declared entries, or a
// single empty name meaning the directory itself when the root has it alone.
func movedEntries(id config.RootID) []string {
if owned := config.RootOwns(id); len(owned) > 0 {
return owned
}
return []string{""}
}
// presentEntries keeps the ones that actually exist, so every later step works
// on one list: a root that never grew a stats folder has nothing to copy,
// verify, or reclaim there.
func presentEntries(from string, entries []string) []string {
out := make([]string, 0, len(entries))
for _, entry := range entries {
if _, err := os.Stat(filepath.Join(from, entry)); err == nil {
out = append(out, entry)
}
}
return out
}
// copyOwned runs the copy over each entry the root owns, reporting one running
// total across them so progress reads as a single operation.
func copyOwned(ctx context.Context, owned []string, plan Plan, notify func(Progress)) (int64, error) {
var copied int64
for _, entry := range owned {
from := filepath.Join(plan.From, entry)
n, err := copyTree(ctx, from, filepath.Join(plan.To, entry), plan.Bytes, copied, notify)
copied += n
if err != nil {
return copied, err
}
}
return copied, nil
}
// reclaimOwned removes only what was copied. A root sharing its directory
// leaves the neighbour's files exactly where they were.
func reclaimOwned(from string, owned []string) error {
for _, entry := range owned {
if err := os.RemoveAll(filepath.Join(from, entry)); err != nil {
return err
}
}
return nil
}
// copyTree mirrors from into to. A file already present at the right size is
// left alone, which is what makes a second run finish an interrupted first one
// instead of starting over.
func copyTree(ctx context.Context, from, to string, total, already int64, notify func(Progress)) (int64, error) {
var copied int64
var files int64
err := filepath.WalkDir(from, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if ctxErr := ctx.Err(); ctxErr != nil {
return errors.Join(ErrMoveInterrupted, ctxErr)
}
rel, relErr := filepath.Rel(from, path)
if relErr != nil {
return relErr
}
dest := filepath.Join(to, rel)
if entry.IsDir() {
return os.MkdirAll(dest, 0o755)
}
info, infoErr := entry.Info()
if infoErr != nil {
return infoErr
}
if existing, statErr := os.Stat(dest); statErr == nil && existing.Size() == info.Size() {
copied += info.Size()
files++
return nil
}
if err := copyFile(path, dest, info); err != nil {
return err
}
copied += info.Size()
files++
notify(Progress{Phase: PhaseCopying, Bytes: already + copied, Total: total, Files: files})
return nil
})
return copied, err
}
func copyFile(from, to string, info fs.FileInfo) error {
if err := os.MkdirAll(filepath.Dir(to), 0o755); err != nil {
return err
}
src, err := os.Open(from)
if err != nil {
return err
}
defer src.Close()
// Written beside the target and renamed into place, so an interrupted copy
// never leaves a short file the size check above would accept as done.
tmp, err := os.CreateTemp(filepath.Dir(to), ".reasonix-move-*")
if err != nil {
return err
}
tmpName := tmp.Name()
if _, err := io.Copy(tmp, src); err != nil {
tmp.Close()
os.Remove(tmpName)
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
os.Remove(tmpName)
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
return err
}
if err := os.Chmod(tmpName, info.Mode().Perm()); err != nil {
os.Remove(tmpName)
return err
}
if err := fileutil.ReplaceFile(tmpName, to); err != nil {
os.Remove(tmpName)
return err
}
return nil
}
// verifyTree proves every source file arrived byte for byte. Size alone would
// accept a torn copy that happened to land on the same length, and this is the
// last check before the source is deleted.
func verifyTree(ctx context.Context, from, to string) error {
return filepath.WalkDir(from, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if ctxErr := ctx.Err(); ctxErr != nil {
return errors.Join(ErrMoveInterrupted, ctxErr)
}
if entry.IsDir() {
return nil
}
rel, relErr := filepath.Rel(from, path)
if relErr != nil {
return relErr
}
srcSum, err := fileDigest(path)
if err != nil {
return err
}
dstSum, err := fileDigest(filepath.Join(to, rel))
if err != nil {
return fmt.Errorf("verify %s: %w", rel, err)
}
if srcSum != dstSum {
return fmt.Errorf("verify %s: the copy does not match the original", rel)
}
return nil
})
}
func fileDigest(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
sum := sha256.New()
if _, err := io.Copy(sum, f); err != nil {
return "", err
}
return hex.EncodeToString(sum.Sum(nil)), nil
}
// commitLocation records the new location through the ordinary config editor,
// so a relocation is an edit like any other and survives later rewrites.
func commitLocation(root config.RootID, dir string) error {
path := config.UserConfigPath()
if path == "" {
return errors.New("no user config to record the location in")
}
unlock := config.LockUserConfigEdits()
defer unlock()
edit := config.LoadForEdit(path)
if err := edit.SetStorageDir(root, dir); err != nil {
return err
}
if err := edit.SaveTo(path); err != nil {
return err
}
config.InvalidateStorageDirs()
return nil
}
// journalPath is in the home root on purpose: it has to survive the move of
// every root that can move, and home is the one that cannot.
func journalPath() string {
home := config.RootDir(config.RootHome)
if home == "" {
return ""
}
return filepath.Join(home, "storage-move.json")
}
func writeJournal(j Journal) error {
path := journalPath()
if path == "" {
return errors.New("storage: nowhere to record the move")
}
j.Updated = time.Now()
body, err := json.MarshalIndent(j, "", " ")
if err != nil {
return err
}
return fileutil.AtomicWriteFile(path, append(body, '\n'), 0o600)
}
func clearJournal() error {
path := journalPath()
if path == "" {
return nil
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// PendingMove reports a move that did not reach done, so a launch can finish or
// report it instead of leaving the user with two half-populated folders and no
// account of which one is live.
func PendingMove() (Journal, bool) {
path := journalPath()
if path == "" {
return Journal{}, false
}
body, err := os.ReadFile(path)
if err != nil {
return Journal{}, false
}
var j Journal
if err := json.Unmarshal(body, &j); err != nil || j.Phase == "" || j.Phase == PhaseDone {
return Journal{}, false
}
return j, true
}
+235
View File
@@ -0,0 +1,235 @@
package storage
import (
"context"
"errors"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"reasonix/internal/config"
)
func stateHome(t *testing.T) string {
t.Helper()
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
t.Setenv("REASONIX_STATE_HOME", "")
t.Setenv("REASONIX_CACHE_HOME", "")
t.Cleanup(config.InvalidateStorageDirs)
config.InvalidateStorageDirs()
return home
}
func seedState(t *testing.T, home string) {
t.Helper()
write(t, filepath.Join(home, "sessions", "a.jsonl"), 4096)
write(t, filepath.Join(home, "sessions", "a.events.jsonl"), 2048)
write(t, filepath.Join(home, "archive", "old.jsonl"), 1024)
}
// The whole point of the sequence: the copy is proved, the configuration then
// points at it, and the original is only reclaimed after both.
func TestMoveCopiesVerifiesCommitsAndReclaims(t *testing.T) {
home := stateHome(t)
seedState(t, home)
target := filepath.Join(t.TempDir(), "moved")
plan := PlanMove(t.Context(), config.RootState, target)
if !plan.OK() {
t.Fatalf("plan refused: %+v", plan.Refusals)
}
var phases []Phase
if err := Move(t.Context(), plan, func(p Progress) {
if len(phases) == 0 || phases[len(phases)-1] != p.Phase {
phases = append(phases, p.Phase)
}
}); err != nil {
t.Fatalf("move: %v", err)
}
if got := config.RootDir(config.RootState); got != target {
t.Fatalf("state resolves to %q after the move, want %q", got, target)
}
if _, err := os.Stat(filepath.Join(target, "sessions", "a.jsonl")); err != nil {
t.Fatalf("moved transcript missing: %v", err)
}
if _, err := os.Stat(filepath.Join(home, "sessions")); !os.IsNotExist(err) {
t.Fatalf("source survived the move: %v", err)
}
if len(phases) == 0 || phases[len(phases)-1] != PhaseDone {
t.Fatalf("phases = %v, want it to end at done", phases)
}
}
// Before the commit nothing has changed. A cancelled move must leave the
// runtime reading exactly what it read before, whatever bytes were copied.
func TestCancelBeforeCommitChangesNothing(t *testing.T) {
home := stateHome(t)
seedState(t, home)
target := filepath.Join(t.TempDir(), "moved")
plan := PlanMove(t.Context(), config.RootState, target)
ctx, cancel := context.WithCancel(t.Context())
cancel()
err := Move(ctx, plan, nil)
if err == nil {
t.Fatal("a cancelled move reported success")
}
if !errors.Is(err, ErrMoveInterrupted) && !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled move failed with %v", err)
}
config.InvalidateStorageDirs()
if got := config.RootDir(config.RootState); got != home {
t.Fatalf("state moved to %q despite the cancel, want %q", got, home)
}
if _, err := os.Stat(filepath.Join(home, "sessions", "a.jsonl")); err != nil {
t.Fatalf("source was touched before the commit: %v", err)
}
}
// A target with files in it is refused — unless the journal says an
// interrupted move of this very root put them there, which is what makes the
// copy restartable without letting it merge into a stranger's folder.
func TestOnlyAJournalledMoveMayResumeIntoANonEmptyTarget(t *testing.T) {
home := stateHome(t)
seedState(t, home)
target := filepath.Join(t.TempDir(), "moved")
write(t, filepath.Join(target, "sessions", "a.jsonl"), 4096)
plan := PlanMove(t.Context(), config.RootState, target)
if plan.OK() {
t.Fatal("a target with files already in it was accepted without a journal")
}
if !slices.Contains(refusalCodes(plan), "target.not_empty") {
t.Fatalf("refusals = %v, want target.not_empty", refusalCodes(plan))
}
// The journal an interrupted first run would have left.
if err := writeJournal(Journal{
Root: config.RootState, From: home, To: target, Phase: PhaseCopying,
}); err != nil {
t.Fatal(err)
}
resumed := PlanMove(t.Context(), config.RootState, target)
if !resumed.OK() {
t.Fatalf("the journalled move was refused: %+v", resumed.Refusals)
}
if err := Move(t.Context(), resumed, nil); err != nil {
t.Fatalf("resume: %v", err)
}
if got := config.RootDir(config.RootState); got != target {
t.Fatalf("state = %q after the resume, want %q", got, target)
}
}
// A move only takes what its root owns. State shares a folder with home on a
// default Windows install, so a move of the transcripts must leave the
// configuration and the credentials exactly where they were.
func TestMovingASharedRootLeavesItsNeighbourAlone(t *testing.T) {
home := stateHome(t)
seedState(t, home)
if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte("default_model = \"a/b\"\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(home, ".env"), []byte("KEY=secret\n"), 0o600); err != nil {
t.Fatal(err)
}
target := filepath.Join(t.TempDir(), "moved")
plan := PlanMove(t.Context(), config.RootState, target)
if err := Move(t.Context(), plan, nil); err != nil {
t.Fatalf("move: %v", err)
}
for _, name := range []string{"config.toml", ".env"} {
if _, err := os.Stat(filepath.Join(home, name)); err != nil {
t.Fatalf("%s left home during a state move: %v", name, err)
}
if _, err := os.Stat(filepath.Join(target, name)); !os.IsNotExist(err) {
t.Fatalf("%s was carried into the state target", name)
}
}
if _, err := os.Stat(filepath.Join(target, "sessions", "a.jsonl")); err != nil {
t.Fatalf("the transcripts did not move: %v", err)
}
}
func refusalCodes(p Plan) []string {
var out []string
for _, r := range p.Refusals {
out = append(out, r.Code)
}
return out
}
// Every refusal the preflight can see is reported at once: a person choosing a
// folder should not have to discover them one attempt at a time.
func TestPreflightReportsEveryReasonItCanSee(t *testing.T) {
home := stateHome(t)
seedState(t, home)
inside := filepath.Join(home, "sessions")
plan := PlanMove(t.Context(), config.RootState, inside)
if plan.OK() {
t.Fatal("moving a root into itself was allowed")
}
for _, r := range plan.Refusals {
if strings.TrimSpace(r.Detail) == "" {
t.Fatalf("refusal %q carries no sentence for a reader", r.Code)
}
}
if !slices.Contains(refusalCodes(plan), "target.inside_source") {
t.Fatalf("refusals = %v, want target.inside_source", refusalCodes(plan))
}
}
// The immovable roots are refused by the same preflight the panel calls, so a
// surface cannot route around the contract by asking for a move directly.
func TestPreflightRefusesTheImmovableRoots(t *testing.T) {
stateHome(t)
for _, id := range []config.RootID{config.RootHome, config.RootLocks} {
plan := PlanMove(t.Context(), id, t.TempDir())
if plan.OK() {
t.Fatalf("%s was allowed to move", id)
}
if plan.Refusals[0].Code != "root.immovable" {
t.Fatalf("%s refused with %q", id, plan.Refusals[0].Code)
}
}
}
// A root the environment pins is refused with the variable named, rather than
// accepted into a configuration the environment would then override.
func TestPreflightNamesTheVariableThatPinsARoot(t *testing.T) {
stateHome(t)
t.Setenv("REASONIX_STATE_HOME", t.TempDir())
plan := PlanMove(t.Context(), config.RootState, t.TempDir())
if plan.OK() {
t.Fatal("a pinned root was allowed to move")
}
if !strings.Contains(plan.Refusals[0].Detail, "REASONIX_STATE_HOME") {
t.Fatalf("refusal %q does not name the variable", plan.Refusals[0].Detail)
}
}
// A move that reached the commit leaves a journal saying so, which is what
// lets a later launch tell "interrupted, nothing happened" from "committed,
// finish the cleanup".
func TestJournalIsClearedOnlyWhenTheMoveIsDone(t *testing.T) {
home := stateHome(t)
seedState(t, home)
target := filepath.Join(t.TempDir(), "moved")
if _, pending := PendingMove(); pending {
t.Fatal("a fresh home reports a pending move")
}
plan := PlanMove(t.Context(), config.RootState, target)
if err := Move(t.Context(), plan, nil); err != nil {
t.Fatalf("move: %v", err)
}
if j, pending := PendingMove(); pending {
t.Fatalf("a finished move left a journal: %+v", j)
}
}
+189
View File
@@ -0,0 +1,189 @@
// preflight.go — what has to be true before a root may be moved.
package storage
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"reasonix/internal/config"
)
// Refusal is one reason a move cannot proceed. Code is stable for the caller to
// branch on; Detail is the sentence a person reads. Both travel together so a
// surface never has to reconstruct the explanation from the code.
type Refusal struct {
Code string
Detail string
}
// Plan is what a move would do, decided before anything is written. A plan with
// refusals is not a failure to compute — it is the answer, and the caller shows
// it rather than starting work that cannot finish.
type Plan struct {
Root config.RootID
From string
To string
Bytes int64
Files int64
// Need and Free are the same units the refusal below is decided on, so a
// surface can show the shortfall without recomputing it.
Need int64
Free int64
Refusals []Refusal
}
// OK reports whether the plan may be committed.
func (p Plan) OK() bool { return len(p.Refusals) == 0 }
// headroom is the slack a move leaves on the target beyond the bytes it copies.
// A move that fills the destination to the last byte leaves a machine that
// cannot write the transcript it was moved to protect.
const headroom = 256 << 20
// PlanMove decides whether root may move to target and what it would cost. It
// writes nothing. Every refusal it can find is collected rather than the first:
// a user picking a drive wants both "too small" and "not writable" at once, not
// one per attempt.
func PlanMove(ctx context.Context, root config.RootID, target string) Plan {
plan := Plan{Root: root, To: strings.TrimSpace(target)}
if !config.RootRelocatable(root) {
plan.Refusals = append(plan.Refusals, Refusal{
Code: "root.immovable",
Detail: fmt.Sprintf("%s has to stay where every instance can find it", root),
})
return plan
}
if pin := config.RootPinnedBy(root); pin != "" {
plan.Refusals = append(plan.Refusals, Refusal{
Code: "root.pinned",
Detail: fmt.Sprintf("%s is set in the environment; unset it to choose a location here", pin),
})
return plan
}
plan.From = config.RootDir(root)
if plan.From == "" {
plan.Refusals = append(plan.Refusals, Refusal{
Code: "source.unresolved",
Detail: "this machine has no resolvable location for that data",
})
return plan
}
if plan.To == "" {
plan.Refusals = append(plan.Refusals, Refusal{Code: "target.empty", Detail: "choose a folder to move it to"})
return plan
}
plan.To = filepath.Clean(plan.To)
if samePath(plan.From, plan.To) {
plan.Refusals = append(plan.Refusals, Refusal{Code: "target.same", Detail: "that is where it already is"})
return plan
}
// A target inside the source would have the copy walking into its own
// output; a source inside the target would delete the destination when the
// move reclaims it. Both are the same mistake seen from two sides.
if within(plan.To, plan.From) {
plan.Refusals = append(plan.Refusals, Refusal{
Code: "target.inside_source", Detail: "that folder is inside the one being moved",
})
}
if within(plan.From, plan.To) {
plan.Refusals = append(plan.Refusals, Refusal{
Code: "source.inside_target", Detail: "the folder being moved is inside that one",
})
}
plan.Bytes, plan.Files, _, _ = measure(ctx, plan.From)
plan.Need = plan.Bytes + headroom
if err := probeWritable(plan.To); err != nil {
plan.Refusals = append(plan.Refusals, Refusal{
Code: "target.unwritable", Detail: "that folder cannot be written to: " + err.Error(),
})
return plan
}
// A folder with something already in it is refused, unless an interrupted
// move of this very root put it there — that is what the journal records,
// and continuing it is the whole reason the copy is restartable.
if entries, err := os.ReadDir(plan.To); err == nil && len(entries) > 0 && !resumes(plan) {
plan.Refusals = append(plan.Refusals, Refusal{
Code: "target.not_empty", Detail: "choose an empty folder, so nothing already there is mixed in",
})
}
vol := readVolume(plan.To)
plan.Free = vol.Free
if vol.Free > 0 && vol.Free < plan.Need {
plan.Refusals = append(plan.Refusals, Refusal{
Code: "target.too_small",
Detail: fmt.Sprintf("that drive has %s free; the move needs %s",
HumanBytes(vol.Free), HumanBytes(plan.Need)),
})
}
return plan
}
// resumes reports whether an unfinished move already claimed this exact
// destination for this exact root.
func resumes(plan Plan) bool {
pending, ok := PendingMove()
return ok && pending.Root == plan.Root && samePath(pending.To, plan.To)
}
// probeWritable creates the target if it is missing and proves a file can be
// written in it. Checking the mode bits instead would pass on a read-only
// network share and fail halfway through the copy.
func probeWritable(dir string) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
probe, err := os.CreateTemp(dir, ".reasonix-write-probe-*")
if err != nil {
return err
}
name := probe.Name()
closeErr := probe.Close()
rmErr := os.Remove(name)
if closeErr != nil {
return closeErr
}
return rmErr
}
// within reports whether path sits inside base, comparing cleaned absolute
// paths so "D:\a\..\b" cannot pose as a different tree.
func within(path, base string) bool {
rel, err := filepath.Rel(base, path)
if err != nil {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
func samePath(a, b string) bool {
if a == "" || b == "" {
return false
}
return strings.EqualFold(filepath.Clean(a), filepath.Clean(b))
}
// HumanBytes renders a size the way the storage panel shows it. It rounds down
// so a number never claims more room than the disk has.
func HumanBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for v := n / unit; v >= unit && exp < 4; v /= unit {
div *= unit
exp++
}
value := float64(n) / float64(div)
if value >= 100 {
return fmt.Sprintf("%.0f %cB", value, "KMGTP"[exp])
}
return fmt.Sprintf("%.1f %cB", value, "KMGTP"[exp])
}
+166
View File
@@ -0,0 +1,166 @@
// Package storage measures what the runtime keeps on disk: how much each
// declared root holds, and what the volume under it has left. It reads the root
// table rather than a list of its own, so a root declared in internal/config is
// accounted for here without this package being told about it.
package storage
import (
"context"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"reasonix/internal/config"
)
// Root is one root as measured: where it resolved to, whether a user may move
// it, and what it costs. Bytes and Files are what the walk actually counted —
// a surface that offers to delete things reports measured sizes, never
// estimates, because that number is what the decision is made on.
type Root struct {
ID config.RootID
Dir string
Relocatable bool
// PinnedBy names the environment variable holding this root, "" when none
// does. A root that is pinned cannot be moved from the application.
PinnedBy string
Bytes int64
Files int64
// Missing is a root that has never been written. It is not an error: a
// fresh install has no worktrees, and reporting zero is the honest answer.
Missing bool
// Err is set when the walk could not finish. Bytes and Files then describe
// what was reachable, so a permission-denied subtree understates rather
// than blanks the report.
Err string
// Volume describes the filesystem Dir sits on. Roots sharing a volume
// report the same one, which is what lets a reader see that moving one of
// them buys nothing.
Volume Volume
}
// Volume is the free/total pair for the filesystem under a path, plus the
// mount point (a drive on Windows) so a reader recognises which disk it is.
type Volume struct {
Path string
Free int64
Total int64
}
// Survey measures every declared root. It never fails as a whole: a root that
// cannot be read carries its own Err, because a single unreadable directory
// must not deny the user the numbers for the rest.
func Survey(ctx context.Context) []Root {
ids := config.RootIDs()
out := make([]Root, 0, len(ids))
volumes := map[string]Volume{}
for _, id := range ids {
root := Root{
ID: id,
Dir: config.RootDir(id),
Relocatable: config.RootRelocatable(id),
PinnedBy: config.RootPinnedBy(id),
}
if root.Dir == "" {
root.Missing = true
out = append(out, root)
continue
}
root.Bytes, root.Files, root.Missing, root.Err = measureRoot(ctx, id, root.Dir)
key := volumeKey(root.Dir)
vol, ok := volumes[key]
if !ok {
vol = readVolume(root.Dir)
volumes[key] = vol
}
root.Volume = vol
out = append(out, root)
}
return out
}
// measureRoot sizes what a root actually owns. A root sharing its directory
// with another counts only its declared entries, or the report would credit
// state with the configuration sitting beside it.
func measureRoot(ctx context.Context, id config.RootID, dir string) (bytes, files int64, missing bool, errText string) {
owned := config.RootOwns(id)
if len(owned) == 0 {
return measure(ctx, dir)
}
missing = true
for _, name := range owned {
b, f, miss, err := measure(ctx, filepath.Join(dir, name))
bytes += b
files += f
if !miss {
missing = false
}
if err != "" && errText == "" {
errText = err
}
}
return bytes, files, missing, errText
}
// measure walks dir. It counts what it can reach and reports the first refusal
// rather than aborting: an unreadable subtree is a smaller number plus a
// reason, which is more use than no number at all.
func measure(ctx context.Context, dir string) (bytes, files int64, missing bool, errText string) {
// Asked before the walk rather than inferred from it: a root nothing has
// written yet and a root that refused to be read are different answers,
// and the walk reports both as the same first callback error.
if _, err := os.Stat(dir); err != nil {
if os.IsNotExist(err) {
return 0, 0, true, ""
}
return 0, 0, false, err.Error()
}
walkErr := filepath.WalkDir(dir, func(_ string, entry fs.DirEntry, err error) error {
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
if err != nil {
if errText == "" {
errText = err.Error()
}
return nil
}
if entry.IsDir() {
return nil
}
info, infoErr := entry.Info()
if infoErr != nil {
if errText == "" {
errText = infoErr.Error()
}
return nil
}
files++
bytes += info.Size()
return nil
})
if walkErr != nil && errText == "" {
errText = walkErr.Error()
}
return bytes, files, false, errText
}
// volumeKey groups roots that sit on one filesystem, so the free-space probe
// runs once per disk instead of once per root.
func volumeKey(dir string) string {
vol := filepath.VolumeName(dir)
if vol != "" {
return strings.ToLower(vol)
}
return "/"
}
// SortedByBytes orders a survey largest first, which is the order a reader
// deciding what to move or delete wants to read it in.
func SortedByBytes(roots []Root) []Root {
out := append([]Root(nil), roots...)
sort.SliceStable(out, func(i, j int) bool { return out[i].Bytes > out[j].Bytes })
return out
}
+133
View File
@@ -0,0 +1,133 @@
package storage
import (
"context"
"os"
"path/filepath"
"testing"
"reasonix/internal/config"
)
func write(t *testing.T, path string, size int) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, make([]byte, size), 0o600); err != nil {
t.Fatal(err)
}
}
func find(t *testing.T, roots []Root, id config.RootID) Root {
t.Helper()
for _, r := range roots {
if r.ID == id {
return r
}
}
t.Fatalf("root %q missing from the survey", id)
return Root{}
}
// The survey reads the root table, so every declared root appears without this
// package being told the set — that is what keeps a newly declared root from
// silently going unaccounted for.
func TestSurveyCoversEveryDeclaredRoot(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
t.Setenv("REASONIX_STATE_HOME", "")
t.Setenv("REASONIX_CACHE_HOME", "")
roots := Survey(t.Context())
if len(roots) != len(config.RootIDs()) {
t.Fatalf("survey has %d roots, table declares %d", len(roots), len(config.RootIDs()))
}
for _, id := range config.RootIDs() {
find(t, roots, id)
}
}
// Bytes and files are counted, not estimated: the number is what a user decides
// to delete on.
func TestSurveyCountsWhatIsActuallyThere(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
t.Setenv("REASONIX_STATE_HOME", "")
write(t, filepath.Join(home, "sessions", "a.jsonl"), 1200)
write(t, filepath.Join(home, "sessions", "a.events.jsonl"), 800)
write(t, filepath.Join(home, "archive", "old.jsonl"), 4000)
state := find(t, Survey(t.Context()), config.RootState)
if state.Bytes != 6000 || state.Files != 3 {
t.Fatalf("state = %d bytes / %d files, want 6000 / 3", state.Bytes, state.Files)
}
if state.Missing || state.Err != "" {
t.Fatalf("state reported missing=%v err=%q", state.Missing, state.Err)
}
}
// A root nothing has written yet is not a failure. A fresh install has no
// worktrees, and zero is the honest answer rather than an error.
func TestNeverWrittenRootReportsMissingRatherThanFailing(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
t.Setenv("REASONIX_STATE_HOME", filepath.Join(t.TempDir(), "never-created"))
state := find(t, Survey(t.Context()), config.RootState)
if !state.Missing {
t.Fatalf("unwritten root reported missing=%v", state.Missing)
}
if state.Bytes != 0 || state.Err != "" {
t.Fatalf("unwritten root = %d bytes, err %q", state.Bytes, state.Err)
}
}
// The survey carries whether a root may be moved and what pins it, so a
// surface offering relocation never has to decide that for itself.
func TestSurveyCarriesRelocationFacts(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
pinned := t.TempDir()
t.Setenv("REASONIX_STATE_HOME", pinned)
roots := Survey(t.Context())
state := find(t, roots, config.RootState)
if !state.Relocatable || state.PinnedBy != "REASONIX_STATE_HOME" {
t.Fatalf("state relocatable=%v pinnedBy=%q", state.Relocatable, state.PinnedBy)
}
locks := find(t, roots, config.RootLocks)
if locks.Relocatable {
t.Fatal("the locks root must never report itself movable")
}
}
// Free space is what decides whether a move fits, so a surveyed root has to
// carry the volume under it.
func TestSurveyReadsTheVolumeUnderEachRoot(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
t.Setenv("REASONIX_STATE_HOME", "")
state := find(t, Survey(t.Context()), config.RootState)
if state.Volume.Total <= 0 || state.Volume.Free <= 0 {
t.Fatalf("volume = %+v, want a real free/total pair", state.Volume)
}
if state.Volume.Free > state.Volume.Total {
t.Fatalf("volume free %d exceeds total %d", state.Volume.Free, state.Volume.Total)
}
}
// A cancelled survey stops where it is and says so, rather than running a walk
// nobody is waiting for any more.
func TestCancelledSurveyStops(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
t.Setenv("REASONIX_STATE_HOME", "")
write(t, filepath.Join(home, "sessions", "a.jsonl"), 10)
ctx, cancel := context.WithCancel(t.Context())
cancel()
state := find(t, Survey(ctx), config.RootState)
if state.Err == "" {
t.Fatal("a cancelled walk must report why it stopped")
}
}
+19
View File
@@ -0,0 +1,19 @@
//go:build !windows
package storage
import "golang.org/x/sys/unix"
// readVolume asks the filesystem holding dir for its free and total bytes.
// Bavail, not Bfree: the reserved blocks a filesystem keeps for root are not
// space a move can use.
func readVolume(dir string) Volume {
vol := Volume{Path: dir}
var st unix.Statfs_t
if err := unix.Statfs(dir, &st); err != nil {
return vol
}
vol.Free = int64(st.Bavail) * int64(st.Bsize)
vol.Total = int64(st.Blocks) * int64(st.Bsize)
return vol
}
+30
View File
@@ -0,0 +1,30 @@
//go:build windows
package storage
import (
"path/filepath"
"golang.org/x/sys/windows"
)
// readVolume asks Windows for the free and total bytes available to this user
// on the volume holding dir. A quota-limited account gets its own allowance
// rather than the disk's, which is the number that decides whether a move fits.
func readVolume(dir string) Volume {
vol := Volume{Path: filepath.VolumeName(dir)}
if vol.Path == "" {
vol.Path = dir
}
pathp, err := windows.UTF16PtrFromString(dir)
if err != nil {
return vol
}
var free, total, totalFree uint64
if err := windows.GetDiskFreeSpaceEx(pathp, &free, &total, &totalFree); err != nil {
return vol
}
vol.Free = int64(free)
vol.Total = int64(total)
return vol
}