mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
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:
1 parent
786d042f89
commit
b18d8ef3aa
62 files changed
+4512
-410
No files matched your search
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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("两种接入方式的模型列表它都答得上来,光看列表分不出来 —— 聊天入口通常不在同一个路径下,选错了聊天会报错。要两条都用就再添加一次、选另一个。")}
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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), §ion); 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)])
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user