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",
|
"松手 → 插入到光标处": "Release to insert at the caret",
|
||||||
"工作区外的文件引用不到 · 这里放不下": "A file outside the workspace cannot be referenced — this drop has nowhere to go",
|
"工作区外的文件引用不到 · 这里放不下": "A file outside the workspace cannot be referenced — this drop has nowhere to go",
|
||||||
"图片": "image",
|
"图片": "image",
|
||||||
|
"文件": "file",
|
||||||
"粘贴的文本": "pasted text",
|
"粘贴的文本": "pasted text",
|
||||||
"{n} 行 · 展开到输入框": "{n} lines · put it back in the box",
|
"{n} 行 · 展开到输入框": "{n} lines · put it back in the box",
|
||||||
"移除这张图":"Remove this image",
|
"移除这张图":"Remove this image",
|
||||||
|
|||||||
@@ -165,8 +165,8 @@ export const EN_SETTINGS: Record<string, string> = {
|
|||||||
"卡住了,要你介入": "Stuck — needs you",
|
"卡住了,要你介入": "Stuck — needs you",
|
||||||
"端点要求的额外设置": "Extra settings this endpoint demands",
|
"端点要求的额外设置": "Extra settings this endpoint demands",
|
||||||
"上下文窗口(tokens)": "Context window (tokens)",
|
"上下文窗口(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.",
|
"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",
|
"思考参数": "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 type { WireEvent } from "./wire";
|
||||||
import { MockProvider } from "./mock_provider";
|
import { MockProvider } from "./mock_provider";
|
||||||
import { SCRIPT } from "./fixture";
|
import { SCRIPT } from "./fixture";
|
||||||
|
import { mockStorage, mockStoragePlan } from "./mock_storage";
|
||||||
|
import { risksOf, toEntry } from "./mock_mcp";
|
||||||
|
|
||||||
|
|
||||||
export class MockPort extends MockProvider implements AgentPort {
|
export class MockPort extends MockProvider implements AgentPort {
|
||||||
@@ -756,6 +758,18 @@ export class MockPort extends MockProvider implements AgentPort {
|
|||||||
async setPreset(preset: Preset) {
|
async setPreset(preset: Preset) {
|
||||||
this.state.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) {
|
async setModel(ref: string) {
|
||||||
this.state.modelRef = ref;
|
this.state.modelRef = ref;
|
||||||
this.state.label = ref.split("/").pop() ?? 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";
|
import { MockBoundary } from "./mock_boundary";
|
||||||
|
|
||||||
// The sources half of the fixture. Chained on for the reason the others are:
|
// 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;
|
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> {
|
async probeProvider(): Promise<ProviderProbe> {
|
||||||
throw new Error("演示模式不会真的去连端点");
|
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 { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan } from "./plugin";
|
||||||
import type { Appearance, ThemePack } from "./look";
|
import type { Appearance, ThemePack } from "./look";
|
||||||
import type { PermissionLists, PermissionRules, SandboxSettings } from "./boundary";
|
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";
|
export type * from "./plugin";
|
||||||
|
|
||||||
@@ -53,12 +54,12 @@ export interface ContextBreakdown {
|
|||||||
output: number;
|
output: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /history returns the provider conversation, not the event stream: the
|
// GET /history: the event stream is live-only, so a reload rebuilds from these.
|
||||||
// stream is live-only, so a reload rebuilds the transcript from these.
|
|
||||||
export interface HistoryMessage {
|
export interface HistoryMessage {
|
||||||
role: "system" | "user" | "assistant" | "tool";
|
role: "system" | "user" | "assistant" | "tool";
|
||||||
content: string;
|
content: string;
|
||||||
reasoning?: string;
|
reasoning?: string;
|
||||||
|
images?: number; // attachments on a user turn; an image-only one has no text
|
||||||
toolCalls?: { id: string; name: string; arguments?: string }[];
|
toolCalls?: { id: string; name: string; arguments?: string }[];
|
||||||
toolCallId?: string;
|
toolCallId?: string;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
@@ -152,17 +153,6 @@ export interface JobEntry {
|
|||||||
// The UI depends on this and nothing else. SsePort talks to internal/serve;
|
// The UI depends on this and nothing else. SsePort talks to internal/serve;
|
||||||
// MockPort replays a fixture. Neither is allowed to leak transport details
|
// 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.
|
// 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 {
|
export interface SessionEntry {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -548,7 +538,13 @@ export interface AgentPort {
|
|||||||
// Persisted, then the runtime is rebuilt: boot reads every role model while
|
// Persisted, then the runtime is rebuilt: boot reads every role model while
|
||||||
// assembling, so an assignment cannot reach a runtime that is already up.
|
// assembling, so an assignment cannot reach a runtime that is already up.
|
||||||
setRole(role: string, ref: string): Promise<void>;
|
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[]>;
|
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
|
// Asks an endpoint what it is. Writes nothing — the answer is shown for
|
||||||
// confirmation, because only the person holding the key knows what they
|
// confirmation, because only the person holding the key knows what they
|
||||||
// bought.
|
// bought.
|
||||||
|
|||||||
@@ -40,11 +40,28 @@ export interface ProviderEntry {
|
|||||||
extraBody?: Record<string, unknown>;
|
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
|
// 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
|
// before anything is written — a model list cannot prove which protocol a
|
||||||
// gateway speaks, only which ones it answers.
|
// gateway speaks, only which ones it answers.
|
||||||
export interface ProviderProbe {
|
export interface ProviderProbe {
|
||||||
kind: string;
|
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;
|
authHeader: boolean;
|
||||||
models: string[];
|
models: string[];
|
||||||
default: string;
|
default: string;
|
||||||
@@ -64,6 +81,9 @@ export interface ProviderProbe {
|
|||||||
export interface ProviderCheck {
|
export interface ProviderCheck {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
kind?: string;
|
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[];
|
models?: string[];
|
||||||
ambiguous?: boolean;
|
ambiguous?: boolean;
|
||||||
noProxy?: boolean;
|
noProxy?: boolean;
|
||||||
@@ -103,3 +123,15 @@ export interface ProviderDraft {
|
|||||||
effort: string;
|
effort: string;
|
||||||
vision: 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 { 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 { HttpError, type Attachment, type DroppedRef, type WorkspaceChanges } from "./port";
|
||||||
import { SseHttp } from "./sse_http";
|
import { SseHttp } from "./sse_http";
|
||||||
|
import type { StoragePlan, StorageState } from "./storage";
|
||||||
import type { WireEvent } from "./wire";
|
import type { WireEvent } from "./wire";
|
||||||
|
|
||||||
// The running project is the default, so its requests stay the bare path they
|
// 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");
|
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
|
// 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
|
// models" send the user to three different fixes — so it is read out of the
|
||||||
// body rather than thrown away as a status code.
|
// 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 });
|
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
|
// Like the add-a-source probe, the interesting answer is in the body: a
|
||||||
// refused key and a moved endpoint are different fixes.
|
// refused key and a moved endpoint are different fixes.
|
||||||
async checkProvider(name: string): Promise<ProviderCheck> {
|
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 } }
|
@keyframes blink { 0%, 100% { opacity: 1 } 55% { opacity: .15 } }
|
||||||
.tk .caret { width: 5px; height: 11px; background: var(--faint); margin-left: 2px }
|
.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 色条编码,不新增文字标签 */
|
/* review findings:严重度用左侧 2px 色条编码,不新增文字标签 */
|
||||||
.finds { margin-top: 7px; display: flex; flex-direction: column; gap: 5px }
|
.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 }
|
.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[data-lvl="err"] { border-left-color: var(--err) }
|
||||||
.find .t { display: block; font: 500 12.5px var(--ui); color: var(--text) }
|
.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 { 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 { 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 }
|
.term-l { white-space: pre }
|
||||||
@@ -1314,15 +1355,79 @@
|
|||||||
|
|
||||||
/* 减去 .flow 的内边距,输入框的可见边缘才跟卡片的左右边缘落在同一条线上 */
|
/* 减去 .flow 的内边距,输入框的可见边缘才跟卡片的左右边缘落在同一条线上 */
|
||||||
/* 附件是「这一轮要带上的东西」,所以它贴在输入框上沿,不在别处 */
|
/* 附件是「这一轮要带上的东西」,所以它贴在输入框上沿,不在别处 */
|
||||||
.compose textarea[data-over] { outline: 2px dashed var(--accent); outline-offset: -3px }
|
|
||||||
/* 输入区整块都收拖放,但它本身不该占布局:display:contents 让盒子消失、
|
/* 输入区整块都收拖放,但它本身不该占布局:display:contents 让盒子消失、
|
||||||
节点还在,拖放层就能问「这一下落在输入框里没有」。 */
|
节点还在,拖放层就能问「这一下落在输入框里没有」。 */
|
||||||
.dropzone { display: contents }
|
.dropzone { display: contents }
|
||||||
.shots { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 0 2px 7px }
|
/* 落点是整块面板,不是输入框那 40px —— 瞄一行高的框,就是在瞄 40px。
|
||||||
.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) }
|
外壳把整窗的落点都报上来,落在哪一格由 DOM 说了算(见 filedrop.ts)。 */
|
||||||
.shot .nm { max-width: 22ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
.pane::after {
|
||||||
.shot button { border: 0; background: none; color: var(--ghost); cursor: pointer; font: 400 12px/1 var(--mono); padding: 0 3px }
|
content: ""; position: absolute; inset: 0; z-index: 3; pointer-events: none;
|
||||||
.shot button:hover { color: var(--err) }
|
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) }
|
.shots .warn { font: 400 11px var(--ui); color: var(--accent) }
|
||||||
|
|
||||||
.compose {
|
.compose {
|
||||||
@@ -1578,7 +1683,9 @@
|
|||||||
.call, .nest, .ask, .hits > *, .steps > *, .term-l, .dl, .plan .s, .find,
|
.call, .nest, .ask, .hits > *, .steps > *, .term-l, .dl, .plan .s, .find,
|
||||||
.ag, .file, .jump, .menu, .nest-ret, .hl .cost, .sym, .pip[data-settled],
|
.ag, .file, .jump, .menu, .nest-ret, .hl .cost, .sym, .pip[data-settled],
|
||||||
.big .v[data-flash], .plan .s[data-done] .b,
|
.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 }
|
.rmark path { stroke-dashoffset: 0 !important }
|
||||||
/* 三个状态本来就靠颜色和那行字说话,停掉动画不丢任何信息 */
|
/* 三个状态本来就靠颜色和那行字说话,停掉动画不丢任何信息 */
|
||||||
.reload, .reload .rdot, .rnote { animation: none !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 .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 .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) }
|
.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 { t } from "../i18n";
|
||||||
import { useFileDrop, type Dropped } from "./filedrop";
|
|
||||||
import type { AgentPort, ApprovalMode, ModelEntry, SessionStatus, Attachment } from "../port/port";
|
import type { AgentPort, ApprovalMode, ModelEntry, SessionStatus, Attachment } from "../port/port";
|
||||||
import { Picker } from "./Menu";
|
import { Picker } from "./Menu";
|
||||||
import { modelMenu } from "./modelmenu";
|
import { modelMenu } from "./modelmenu";
|
||||||
import { CompletionMenu, useCompletion } from "./Completion";
|
import { CompletionMenu, useCompletion } from "./Completion";
|
||||||
import { useIme } from "./ime";
|
import { useIme } from "./ime";
|
||||||
|
import { countLines, pasteIsLong, planTone, planVerb } from "./intake";
|
||||||
|
import { useIntake } from "./useIntake";
|
||||||
|
import type { Dropped } from "./filedrop";
|
||||||
|
|
||||||
// 从紧到松排,和闸门环的缺口一个方向。「不打扰」在内核是 permission.Deny:
|
// 从紧到松排,和闸门环的缺口一个方向。「不打扰」在内核是 permission.Deny:
|
||||||
// 它比「询问」更严,不是更松 —— 排在询问前面才不会被读反。
|
// 它比「询问」更严,不是更松 —— 排在询问前面才不会被读反。
|
||||||
@@ -25,8 +27,13 @@ const APPROVALS: [ApprovalMode, string, string][] = [
|
|||||||
const EFFORT_FALLBACK = ["auto", "low", "medium", "high", "xhigh", "max"];
|
const EFFORT_FALLBACK = ["auto", "low", "medium", "high", "xhigh", "max"];
|
||||||
|
|
||||||
function effortsFor(models: ModelEntry[], ref?: string): string[] {
|
function effortsFor(models: ModelEntry[], ref?: string): string[] {
|
||||||
const declared = models.find((m) => m.ref === ref)?.efforts;
|
const model = models.find((m) => m.ref === ref);
|
||||||
return declared?.length ? declared : EFFORT_FALLBACK;
|
// 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;
|
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) {
|
export function Composer({ port, status, running, onSubmit, onChanged, onError }: Props) {
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
// The caret decides which token is being completed, so it is state here
|
// 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.
|
// rather than something read off the element when a menu happens to open.
|
||||||
const [caret, setCaret] = useState(0);
|
const [caret, setCaret] = useState(0);
|
||||||
const [shots, setShots] = useState<Attachment[]>([]);
|
const [shots, setShots] = useState<Chip[]>([]);
|
||||||
const [over, setOver] = useState(false);
|
|
||||||
const picker = useRef<HTMLInputElement>(null);
|
const picker = useRef<HTMLInputElement>(null);
|
||||||
const [models, setModels] = useState<ModelEntry[]>([]);
|
const [models, setModels] = useState<ModelEntry[]>([]);
|
||||||
const [switching, setSwitching] = useState(false);
|
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.models().then(setModels).catch(() => setModels([]));
|
||||||
}, [port]);
|
}, [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) => {
|
const type = (next: string, at: number) => {
|
||||||
setText(next);
|
setText(next);
|
||||||
setCaret(at);
|
setCaret(at);
|
||||||
|
caretRef.current = at;
|
||||||
};
|
};
|
||||||
|
|
||||||
const menu = useCompletion(port, text, caret, (next, at) => {
|
const menu = useCompletion(port, text, caret, (next, at) => {
|
||||||
@@ -93,50 +137,114 @@ export function Composer({ port, status, running, onSubmit, onChanged, onError }
|
|||||||
}, [text]);
|
}, [text]);
|
||||||
|
|
||||||
// Attachments ride into the turn as path references, exactly as they do from
|
// 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
|
// the CLI — the host saved the bytes, the turn parser resolves the token. A
|
||||||
// still where it was, and only pasted bytes were written into the workspace.
|
// 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 send = () => {
|
||||||
const v = text.trim();
|
const v = text.trim();
|
||||||
if (!v && shots.length === 0) return;
|
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);
|
type("", 0);
|
||||||
|
shots.forEach(releaseChip);
|
||||||
setShots([]);
|
setShots([]);
|
||||||
onSubmit(line);
|
onSubmit(line);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Bytes, for the two cases that have nothing else to offer: the clipboard,
|
const insert = useCallback((snippet: string) => {
|
||||||
// which never says where what it holds came from, and the picker, which hands
|
const el = box.current;
|
||||||
// back a File and no path. The host writes them into the workspace and the
|
const at = el ? el.selectionStart : caretRef.current;
|
||||||
// turn references that copy — which is right for a screenshot and wrong for a
|
setText((prev) => {
|
||||||
// file that already exists somewhere, so a drop does not come through here.
|
const cut = Math.min(at, prev.length);
|
||||||
const grab = (files: File[]) => {
|
const next = prev.slice(0, cut) + snippet + prev.slice(cut);
|
||||||
if (files.length === 0) return;
|
pending.current = cut + snippet.length;
|
||||||
Promise.all(files.map((f) => port.attach(f, f.name)))
|
return next;
|
||||||
.then((added) => setShots((prev) => [...prev, ...added]))
|
});
|
||||||
.catch(onError);
|
}, []);
|
||||||
};
|
|
||||||
|
|
||||||
// A dropped file is referenced where it lives. Copying it into the workspace
|
const discard = useCallback((c: Chip) => {
|
||||||
// is what let a turn edit the copy and report the edit as done while the file
|
releaseChip(c);
|
||||||
// the user pointed at never changed — and what filled the attachment
|
setShots((prev) => prev.filter((x) => x !== c));
|
||||||
// directory with a second version of everything anybody dragged in.
|
}, []);
|
||||||
const dropped = (d: Dropped) => {
|
|
||||||
if (d.paths.length === 0) {
|
const attach = useCallback(
|
||||||
// A tab, or a drag that carried a promise rather than a file on disk.
|
(blobs: File[]) => {
|
||||||
grab(d.files.filter((f) => f.size > 0));
|
if (blobs.length === 0) return;
|
||||||
return;
|
Promise.all(blobs.map((b) => port.attach(b, b.name).then((a) => ({ a, url: previewURL(b) }))))
|
||||||
}
|
.then((added) =>
|
||||||
port
|
setShots((prev) => [...prev, ...added.map(({ a, url }) => ({ k: "image" as const, id: chipId(), a, url }))]),
|
||||||
.dropRefs(d.paths)
|
)
|
||||||
.then((refs) => {
|
.catch(onError);
|
||||||
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]);
|
[port, onError],
|
||||||
const refused = refs.filter((r) => r.error).map((r) => r.error);
|
);
|
||||||
if (refused.length > 0) onError(new Error(refused.join("\n")));
|
|
||||||
})
|
// A dropped file is referenced where it lives. Copying it in is what let a
|
||||||
.catch(onError);
|
// 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
|
||||||
const drop = useFileDrop(dropped, setOver);
|
// 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 apv = status?.toolApprovalMode ?? "ask";
|
||||||
const eff = status?.effort || "auto";
|
const eff = status?.effort || "auto";
|
||||||
@@ -151,8 +259,8 @@ export function Composer({ port, status, running, onSubmit, onChanged, onError }
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
// display:contents, so the box looks exactly as it did and the drop layer
|
// 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.
|
// still has one node to ask which pane a drop landed in.
|
||||||
<div className="dropzone" ref={drop}>
|
<div className="dropzone" ref={dropzone}>
|
||||||
{menu.open && (
|
{menu.open && (
|
||||||
<CompletionMenu
|
<CompletionMenu
|
||||||
items={menu.completion.items}
|
items={menu.completion.items}
|
||||||
@@ -163,31 +271,73 @@ export function Composer({ port, status, running, onSubmit, onChanged, onError }
|
|||||||
onHover={menu.hover}
|
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 && (
|
{shots.length > 0 && (
|
||||||
<div className="shots">
|
<ul className="shots">
|
||||||
{shots.map((a) => (
|
{shots.map((c, i) => (
|
||||||
<span className="shot" key={a.ref} title={a.path}>
|
<li className="shot" key={c.id} style={{ "--i": i } as React.CSSProperties}>
|
||||||
<span className="nm">{a.path.split("/").pop()}</span>
|
{c.k === "image" ? (
|
||||||
<button aria-label={t("移除这张图")} onClick={() => setShots((p) => p.filter((x) => x !== a))}>
|
<>
|
||||||
|
{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>
|
</button>
|
||||||
</span>
|
</li>
|
||||||
))}
|
))}
|
||||||
{/* The kernel keeps the image either way, but a text-only model never
|
{/* The kernel keeps the image either way, but a text-only model never
|
||||||
sees it — say so here rather than letting the paste vanish. */}
|
sees it — say so here rather than letting the paste vanish. */}
|
||||||
{status?.vision === false && shots.some((a) => a.image) && (
|
{status?.vision === false && shots.some((c) => c.k === "image" && c.a.image !== false) && (
|
||||||
<span className="warn">
|
<li className="warn">
|
||||||
{status?.visionDeclared === false
|
{status?.visionDeclared === false
|
||||||
? t("没人说过这个模型读不读图 · 先按不读处理;在「连接」里勾上它就直接发")
|
? t("没人说过这个模型读不读图 · 先按不读处理;在「连接」里勾上它就直接发")
|
||||||
: t("当前模型不读图 · 将交给能读图的子代理")}
|
: t("当前模型不读图 · 将交给能读图的子代理")}
|
||||||
</span>
|
</li>
|
||||||
)}
|
)}
|
||||||
</div>
|
</ul>
|
||||||
)}
|
)}
|
||||||
<textarea
|
<textarea
|
||||||
ref={box}
|
ref={box}
|
||||||
rows={1}
|
rows={1}
|
||||||
data-over={over ? "" : undefined}
|
|
||||||
value={text}
|
value={text}
|
||||||
placeholder={t("交待一个任务,回车发送… / 调用命令与技能,@ 引用文件")}
|
placeholder={t("交待一个任务,回车发送… / 调用命令与技能,@ 引用文件")}
|
||||||
role="combobox"
|
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.
|
// the caret is what decides which token the menu is completing.
|
||||||
onKeyUp={(e) => setCaret(e.currentTarget.selectionStart)}
|
onKeyUp={(e) => setCaret(e.currentTarget.selectionStart)}
|
||||||
onClick={(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) => {
|
onPaste={(e) => {
|
||||||
const files = [...e.clipboardData.files];
|
const images = [...e.clipboardData.files].filter((f) => f.type.startsWith("image/"));
|
||||||
if (files.some((f) => f.type.startsWith("image/"))) {
|
if (images.length > 0) {
|
||||||
e.preventDefault();
|
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}
|
{...ime.handlers}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -255,9 +415,8 @@ export function Composer({ port, status, running, onSubmit, onChanged, onError }
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="row" data-busy={switching ? "" : undefined}>
|
<div className="row" data-busy={switching ? "" : undefined}>
|
||||||
{/* 只挑图片:选出来的文件跟剪贴板一样只有字节没有路径,只能复制一份
|
{/* 拖进来和粘贴都走同一条路,但那两个都得先有一张图在手边。点开系统
|
||||||
进工作区。对截图这是对的,对一个已经躺在磁盘上的文件就不是——那条
|
选择器是唯一不需要预备动作的入口。 */}
|
||||||
路留给拖进来。 */}
|
|
||||||
<input
|
<input
|
||||||
ref={picker}
|
ref={picker}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -265,7 +424,7 @@ export function Composer({ port, status, running, onSubmit, onChanged, onError }
|
|||||||
multiple
|
multiple
|
||||||
hidden
|
hidden
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
grab([...(e.target.files ?? [])]);
|
attach([...(e.target.files ?? [])].filter((f) => f.type.startsWith("image/")));
|
||||||
// 同一张图再选一次也要能进来,所以每次用完清空。
|
// 同一张图再选一次也要能进来,所以每次用完清空。
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
}}
|
}}
|
||||||
@@ -295,24 +454,30 @@ export function Composer({ port, status, running, onSubmit, onChanged, onError }
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span className="sep" aria-hidden="true" />
|
{/* An endpoint that names no levels gets no control: a picker whose
|
||||||
<Picker
|
every rung is refused downstream reads as a dead one. */}
|
||||||
className="mode plain"
|
{efforts.length > 0 && (
|
||||||
place="bottom"
|
<>
|
||||||
current={eff}
|
<span className="sep" aria-hidden="true" />
|
||||||
items={efforts.map((v) => ({ value: v, label: v, meter: LEVEL[v] }))}
|
<Picker
|
||||||
onPick={(v) => change(port.setEffort(v))}
|
className="mode plain"
|
||||||
label={
|
place="bottom"
|
||||||
<>
|
current={eff}
|
||||||
<span className="bars" data-lv={LEVEL[eff] ?? 0} aria-hidden="true">
|
items={efforts.map((v) => ({ value: v, label: v, meter: LEVEL[v] }))}
|
||||||
<i /><i /><i /><i /><i />
|
onPick={(v) => change(port.setEffort(v))}
|
||||||
</span>
|
label={
|
||||||
<span className="lb">{t("强度")}</span>
|
<>
|
||||||
{/* key 让值换一次就重挂载一次 —— 这是那半秒里唯一能看出「改动生效了」的地方 */}
|
<span className="bars" data-lv={LEVEL[eff] ?? 0} aria-hidden="true">
|
||||||
<span className="vl" data-lv={LEVEL[eff] ?? 0} key={eff}>{eff}</span>
|
<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
|
<button
|
||||||
className="mode tog"
|
className="mode tog"
|
||||||
aria-pressed={status?.plan ?? false}
|
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>{KIND_LABEL[found.kind] || found.kind}</span>
|
||||||
<span className="k">{found.models.length} 个模型 · key 存本机</span>
|
<span className="k">{found.models.length} 个模型 · key 存本机</span>
|
||||||
</div>
|
</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">
|
<p className="onb-why">
|
||||||
{t("这个端点不止一种协议答应了,上面是偏好而不是事实。连上之后能在设置里换。")}
|
{t("这个端点不止一种协议答应了,上面是偏好而不是事实。连上之后能在设置里换。")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { t } from "../i18n";
|
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";
|
import { KIND_LABEL, accountKey, accountLabel, disambiguate, hostOf, nameFrom, vendorLabel } from "./vendors";
|
||||||
|
|
||||||
// A connection is an account, not a config row. One endpoint answering two
|
// 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 = {
|
type Port = {
|
||||||
providers(): Promise<ProviderEntry[]>;
|
providers(): Promise<ProviderEntry[]>;
|
||||||
|
protocols(): Promise<Protocol[]>;
|
||||||
probeProvider(baseUrl: string, apiKey: string): Promise<ProviderProbe>;
|
probeProvider(baseUrl: string, apiKey: string): Promise<ProviderProbe>;
|
||||||
saveProvider(draft: {
|
saveProvider(draft: {
|
||||||
name: string; kind: string; baseUrl: string; apiKey: string; models: string[];
|
name: string; kind: string; baseUrl: string; apiKey: string; models: string[];
|
||||||
@@ -277,9 +278,9 @@ function Conn({
|
|||||||
</span>
|
</span>
|
||||||
<span className="why">
|
<span className="why">
|
||||||
{!found.ok && found.error}
|
{!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 ?? "") })}
|
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 && " 走代理连不上、直连可以。"}
|
{found.ok && found.noProxy && " 走代理连不上、直连可以。"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -490,9 +491,19 @@ function AddProvider({
|
|||||||
const [baseUrl, setBaseUrl] = useState("");
|
const [baseUrl, setBaseUrl] = useState("");
|
||||||
const [apiKey, setApiKey] = useState("");
|
const [apiKey, setApiKey] = useState("");
|
||||||
const [probe, setProbe] = useState<ProviderProbe | null>(null);
|
const [probe, setProbe] = useState<ProviderProbe | null>(null);
|
||||||
|
const [catalog, setCatalog] = useState<Protocol[]>([]);
|
||||||
const [err, setErr] = useState("");
|
const [err, setErr] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
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
|
// Everything below is editable after the probe, because every one of these
|
||||||
// is something the endpoint could not tell us for certain.
|
// is something the endpoint could not tell us for certain.
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
@@ -602,11 +613,19 @@ function AddProvider({
|
|||||||
<label className="grow">
|
<label className="grow">
|
||||||
<span>{t("接入方式")}</span>
|
<span>{t("接入方式")}</span>
|
||||||
<select value={kind} onChange={(e) => setKind(e.target.value)}>
|
<select value={kind} onChange={(e) => setKind(e.target.value)}>
|
||||||
<option value="openai">{t("OpenAI 兼容")}</option>
|
{choices.map((k) => (
|
||||||
<option value="anthropic">{t("Anthropic 兼容")}</option>
|
<option key={k} value={k}>{t(KIND_LABEL[k] ?? k)}</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
{searchSplit && (
|
||||||
|
<p className="acct-note">
|
||||||
|
{t("这个地址两条线都答得上来。{on} 那条上联网搜索由供应商自己跑,另一条没有 —— 那是协议的差别,不是设置。", {
|
||||||
|
on: searchOn.map((k) => t(KIND_LABEL[k] ?? k)).join("、"),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{probe.ambiguous && (
|
{probe.ambiguous && (
|
||||||
<p className="acct-note">
|
<p className="acct-note">
|
||||||
{t("两种接入方式的模型列表它都答得上来,光看列表分不出来 —— 聊天入口通常不在同一个路径下,选错了聊天会报错。要两条都用就再添加一次、选另一个。")}
|
{t("两种接入方式的模型列表它都答得上来,光看列表分不出来 —— 聊天入口通常不在同一个路径下,选错了聊天会报错。要两条都用就再添加一次、选另一个。")}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { Roles } from "./Roles";
|
|||||||
import { Boundary } from "./Boundary";
|
import { Boundary } from "./Boundary";
|
||||||
import { Versions } from "./Versions";
|
import { Versions } from "./Versions";
|
||||||
import { Memory } from "./Memory";
|
import { Memory } from "./Memory";
|
||||||
|
import { Storage } from "./Storage";
|
||||||
import { Appearance, SCHEMES } from "./Appearance";
|
import { Appearance, SCHEMES } from "./Appearance";
|
||||||
import { Exception, ScopeBar } from "./CapabilityScope";
|
import { Exception, ScopeBar } from "./CapabilityScope";
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ const APPROVALS: [ApprovalMode, string, string][] = [
|
|||||||
["yolo", "全放行", "不问了。只在你完全信任这个工作区时用"],
|
["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
|
// 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
|
// they are not a promise to keep here either. Signing in and reading
|
||||||
@@ -54,6 +55,7 @@ const NAV: [Section, string][] = [
|
|||||||
["ext", "扩展"],
|
["ext", "扩展"],
|
||||||
["network", "网络"],
|
["network", "网络"],
|
||||||
["memory", "记忆"],
|
["memory", "记忆"],
|
||||||
|
["storage", "存储"],
|
||||||
["account", "账号"],
|
["account", "账号"],
|
||||||
["versions", "版本"],
|
["versions", "版本"],
|
||||||
["appearance", "外观"],
|
["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}`,
|
ext: broken ? t("{n} 个异常", { n: broken }) : packages.length ? t("{n} 个包", { n: packages.length }) : `${looseMcp.length + looseOn}`,
|
||||||
network: netMode,
|
network: netMode,
|
||||||
memory: memCount ? t("{n} 条", { n: memCount }) : "",
|
memory: memCount ? t("{n} 条", { n: memCount }) : "",
|
||||||
|
storage: "",
|
||||||
account: acct === null ? "" : acct.signedIn ? (acct.user?.label ?? t("已登录")) : t("未登录"),
|
account: acct === null ? "" : acct.signedIn ? (acct.user?.label ?? t("已登录")) : t("未登录"),
|
||||||
versions: "",
|
versions: "",
|
||||||
appearance: t(SCHEMES.find(([id]) => id === theme)?.[1] ?? ""),
|
appearance: t(SCHEMES.find(([id]) => id === theme)?.[1] ?? ""),
|
||||||
@@ -561,6 +564,10 @@ export function Settings({ port, status, theme, onTheme, contrast, onContrast, l
|
|||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{at === "storage" && (
|
||||||
|
<Group title={t("存储")} hint={t("它把数据写在哪、占了多少。会话和索引会一直长,配置和凭据不会 —— 所以只有前者能搬走,搬迁在重启后生效。")}><Storage port={port} /></Group>
|
||||||
|
)}
|
||||||
|
|
||||||
{at === "appearance" && (
|
{at === "appearance" && (
|
||||||
<Appearance port={port} theme={theme} onTheme={onTheme} contrast={contrast} onContrast={onContrast} reloadThemes={reloadThemes} look={look} onLook={onLook} />
|
<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 {
|
export interface Dropped {
|
||||||
paths: string[];
|
paths: string[];
|
||||||
files: File[];
|
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
|
// 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.
|
// disk, and answering that with nothing is how a drop looks broken.
|
||||||
export interface DropRouter<Z> {
|
export interface DropRouter<Z> {
|
||||||
over(zone: Z | null): void;
|
over(zone: Z | null): void;
|
||||||
dropped(zone: Z, files: File[]): void;
|
dropped(zone: Z, files: File[], text?: string): void;
|
||||||
paths(list: string[]): void;
|
paths(list: string[]): void;
|
||||||
forget(zone: Z): void;
|
forget(zone: Z): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDropRouter<Z>(deliver: (zone: Z, d: Dropped) => void, clock: Clock): DropRouter<Z> {
|
export function createDropRouter<Z>(deliver: (zone: Z, d: Dropped) => void, clock: Clock): DropRouter<Z> {
|
||||||
let hover: Z | null = null;
|
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 seq = 0;
|
||||||
let settled = -Infinity;
|
let settled = -Infinity;
|
||||||
|
|
||||||
@@ -50,21 +53,28 @@ export function createDropRouter<Z>(deliver: (zone: Z, d: Dropped) => void, cloc
|
|||||||
over(zone) {
|
over(zone) {
|
||||||
hover = zone;
|
hover = zone;
|
||||||
},
|
},
|
||||||
dropped(zone, files) {
|
dropped(zone, files, text) {
|
||||||
// The paths beat the DOM event to it, and this is the same drop.
|
// The paths beat the DOM event to it, and this is the same drop.
|
||||||
if (clock.now() - settled < clock.grace) return;
|
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;
|
const id = ++seq;
|
||||||
pending = { id, zone, files };
|
pending = { id, zone, files, text };
|
||||||
clock.wait(() => {
|
clock.wait(() => {
|
||||||
if (pending?.id === id) settle(zone, { paths: [], files });
|
if (pending?.id === id) settle(zone, { paths: [], files, text });
|
||||||
}, clock.grace);
|
}, clock.grace);
|
||||||
},
|
},
|
||||||
paths(list) {
|
paths(list) {
|
||||||
const zone = pending?.zone ?? hover;
|
const zone = pending?.zone ?? hover;
|
||||||
const files = pending?.files ?? [];
|
const files = pending?.files ?? [];
|
||||||
|
const text = pending?.text;
|
||||||
pending = null;
|
pending = null;
|
||||||
if (!zone || list.length === 0) return;
|
if (!zone || list.length === 0) return;
|
||||||
settle(zone, { paths: list, files });
|
settle(zone, { paths: list, files, text });
|
||||||
},
|
},
|
||||||
forget(zone) {
|
forget(zone) {
|
||||||
if (hover === zone) hover = null;
|
if (hover === zone) hover = null;
|
||||||
@@ -76,7 +86,10 @@ export function createDropRouter<Z>(deliver: (zone: Z, d: Dropped) => void, cloc
|
|||||||
interface Zone {
|
interface Zone {
|
||||||
el: HTMLElement;
|
el: HTMLElement;
|
||||||
onDrop: (d: Dropped) => void;
|
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>();
|
const zones = new Set<Zone>();
|
||||||
@@ -84,7 +97,8 @@ let router: DropRouter<Zone> | null = null;
|
|||||||
let lit: Zone | null = null;
|
let lit: Zone | null = null;
|
||||||
|
|
||||||
function carriesFiles(e: DragEvent): boolean {
|
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 {
|
function zoneAt(target: EventTarget | null): Zone | null {
|
||||||
@@ -95,11 +109,11 @@ function zoneAt(target: EventTarget | null): Zone | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function light(next: Zone | null) {
|
function light(next: Zone | null, dt: DataTransfer | null = null) {
|
||||||
if (lit === next) return;
|
if (lit === next) return;
|
||||||
lit?.onOver(false);
|
lit?.onOver(false, null);
|
||||||
lit = next;
|
lit = next;
|
||||||
lit?.onOver(true);
|
lit?.onOver(true, dt);
|
||||||
router?.over(next);
|
router?.over(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +135,7 @@ export function install(hub: HubPort) {
|
|||||||
if (!carriesFiles(e)) return;
|
if (!carriesFiles(e)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
|
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
|
||||||
light(zoneAt(e.target));
|
light(zoneAt(e.target), e.dataTransfer);
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
@@ -142,7 +156,11 @@ export function install(hub: HubPort) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const zone = zoneAt(e.target) ?? lit;
|
const zone = zoneAt(e.target) ?? lit;
|
||||||
light(null);
|
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,
|
true,
|
||||||
);
|
);
|
||||||
@@ -162,8 +180,8 @@ export function install(hub: HubPort) {
|
|||||||
// ref object would keep the old one registered.
|
// ref object would keep the old one registered.
|
||||||
export function useFileDrop(
|
export function useFileDrop(
|
||||||
onDrop: (d: Dropped) => void,
|
onDrop: (d: Dropped) => void,
|
||||||
onOver: (over: boolean) => void,
|
onOver: (over: boolean, dt: DataTransfer | null) => void,
|
||||||
): (node: HTMLElement | null) => void {
|
): (node: HTMLElement | null) => (() => void) | void {
|
||||||
// Read through a ref so a caller does not have to memoize either callback to
|
// Read through a ref so a caller does not have to memoize either callback to
|
||||||
// avoid re-registering the zone on every render.
|
// avoid re-registering the zone on every render.
|
||||||
const live = useRef({ onDrop, onOver });
|
const live = useRef({ onDrop, onOver });
|
||||||
@@ -173,7 +191,7 @@ export function useFileDrop(
|
|||||||
const zone: Zone = {
|
const zone: Zone = {
|
||||||
el: node,
|
el: node,
|
||||||
onDrop: (d) => live.current.onDrop(d),
|
onDrop: (d) => live.current.onDrop(d),
|
||||||
onOver: (over) => live.current.onOver(over),
|
onOver: (over, dt) => live.current.onOver(over, dt),
|
||||||
};
|
};
|
||||||
zones.add(zone);
|
zones.add(zone);
|
||||||
return () => {
|
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",
|
"/changes", "/attachments", "/drop", "/checkpoints", "/branches", "/compact", "/rewind",
|
||||||
"/extensions", "/themes", "/plugins", "/surfaces",
|
"/extensions", "/themes", "/plugins", "/surfaces",
|
||||||
"/fork", "/summarize", "/forget", "/bypass", "/auto-approve-tools",
|
"/fork", "/summarize", "/forget", "/bypass", "/auto-approve-tools",
|
||||||
"/permissions", "/sandbox", "/context",
|
"/permissions", "/sandbox", "/context", "/storage",
|
||||||
"/slash", "/workspaces", "/welcome",
|
"/slash", "/workspaces", "/welcome",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ var apiPaths = map[string]bool{
|
|||||||
"/changes": true, "/attachments": true, "/drop": true, "/roles": true,
|
"/changes": true, "/attachments": true, "/drop": true, "/roles": true,
|
||||||
"/themes": true, "/extensions": true, "/plugins": true, "/surfaces": true,
|
"/themes": true, "/extensions": true, "/plugins": true, "/surfaces": true,
|
||||||
"/welcome": true, "/appearance": 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
|
// 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
|
// endpoint from silently answering with index.html instead of JSON — and
|
||||||
// TestEveryPathTheFrontendCallsIsRouted is what keeps this list honest, because
|
// TestEveryPathTheFrontendCallsIsRouted is what keeps this list honest, because
|
||||||
// the comment alone did not.
|
// 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:
|
// 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
|
// /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
|
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"
|
const RecoveryBranchDefaultName = "Recovered unsaved changes from stale runtime"
|
||||||
|
|
||||||
type RecoveryBranchOptions struct {
|
type RecoveryBranchOptions struct {
|
||||||
@@ -397,6 +355,10 @@ func (s *Session) saveLocked(path string, mode sessionSaveMode) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("session: keeping save after display read-model append failure", "path", path, "err", err)
|
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)
|
revision, err := recordSessionContentRevision(path, digest, decision.revision)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
slog.Warn("session: keeping save after event index write failure", "path", path, "err", err)
|
||||||
}
|
}
|
||||||
if displayModelCurrent {
|
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
|
// The append boundary lets the refresh extend the previous index
|
||||||
// instead of re-encoding the whole transcript.
|
// instead of re-encoding the whole transcript.
|
||||||
slog.Warn("session: keeping save after display index write failure", "path", path, "err", err)
|
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) ||
|
if messagesHavePrefix(existing, next) || messagesHavePrefixWithCompatibleSystem(existing, next) ||
|
||||||
(rawDiffers && (messagesHavePrefix(raw, next) || messagesHavePrefixWithCompatibleSystem(raw, next))) {
|
(rawDiffers && (messagesHavePrefix(raw, next) || messagesHavePrefixWithCompatibleSystem(raw, next))) {
|
||||||
return snapshotWriteDecision{}, &SessionSnapshotConflictError{
|
return snapshotWriteDecision{}, snapshotConflictOfKind(path, SessionSnapshotConflictStalePrefix,
|
||||||
Path: path,
|
existing, next, baseState.revision, currentRevision)
|
||||||
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, SessionSnapshotConflictDiverged,
|
||||||
|
existing, next, baseState.revision, currentRevision)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) SaveRecoveryBranch(opts RecoveryBranchOptions) (RecoveryBranchInfo, error) {
|
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))
|
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
|
return nil, true, false, replayErr
|
||||||
}
|
}
|
||||||
if replay.records > 0 {
|
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
|
// Defensive: the probe saw a native head but nothing replayed; fall
|
||||||
// back to the checkpoint and let the next save rebuild the log.
|
// back to the checkpoint and let the next save rebuild the log.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -371,5 +372,43 @@ func observeUnleasedSessionWrite(path string, mode sessionSaveMode) {
|
|||||||
"path", filepath.Base(path),
|
"path", filepath.Base(path),
|
||||||
"mode", int(mode),
|
"mode", int(mode),
|
||||||
"writer", SessionWriterID(),
|
"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"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -436,3 +437,21 @@ func TestSessionLeaseReleaseRetiresLockSidecars(t *testing.T) {
|
|||||||
}
|
}
|
||||||
first.Release()
|
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()
|
unlock := config.LockUserConfigEdits()
|
||||||
defer unlock()
|
defer unlock()
|
||||||
edit := config.LoadForEdit(path)
|
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))
|
m.notice(fmt.Sprintf("model: persist refused: %v (ref=%s)", err, ref))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
"reasonix/internal/config"
|
"reasonix/internal/config"
|
||||||
|
"reasonix/internal/control"
|
||||||
"reasonix/internal/provider"
|
"reasonix/internal/provider"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -124,15 +125,18 @@ func TestPersistModelRejectsUnknownRef(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestPersistModelAcceptsPluginRef: a plugin-namespaced ref picked in /model
|
// TestPersistModelAcceptsPluginRef: a plugin-namespaced ref picked in /model
|
||||||
// persists like any config ref — the config catalog cannot vouch for it, but
|
// persists like any config ref, on the live catalog's word rather than its own
|
||||||
// boot's merged resolver gates it at the next launch.
|
// spelling — the config catalog never holds an extension-hosted model.
|
||||||
func TestPersistModelAcceptsPluginRef(t *testing.T) {
|
func TestPersistModelAcceptsPluginRef(t *testing.T) {
|
||||||
isolateUserConfig(t)
|
isolateUserConfig(t)
|
||||||
if _, err := config.SetCredential("DEEPSEEK_API_KEY", "test-key"); err != nil {
|
if _, err := config.SetCredential("DEEPSEEK_API_KEY", "test-key"); err != nil {
|
||||||
t.Fatalf("SetCredential: %v", err)
|
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 := newTestChatTUI()
|
||||||
|
m.ctrl = newPluginCatalogController(t)
|
||||||
m.persistModel("plugin/demo/fake/x")
|
m.persistModel("plugin/demo/fake/x")
|
||||||
|
|
||||||
body, err := os.ReadFile(config.UserConfigPath())
|
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
|
// TestMergeExtensionModelRefs pins the /model picker merge: extension
|
||||||
// descriptors join the config-backed list with their plugin/... refs intact,
|
// descriptors join the config-backed list with their plugin/... refs intact,
|
||||||
// duplicates collapse, and a nil catalog leaves the base list untouched.
|
// 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
|
before := s.cfg.DefaultModel
|
||||||
// Setup runs before any runtime exists, so the configuration is the only
|
// Setup runs before any runtime exists, so the configuration is the only
|
||||||
// catalog there is to check against.
|
// 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
|
return err
|
||||||
}
|
}
|
||||||
if before != s.cfg.DefaultModel {
|
if before != s.cfg.DefaultModel {
|
||||||
@@ -754,7 +754,7 @@ func (s *providerSetupSession) replayOperations(cfg *config.Config, accessDeclar
|
|||||||
if cfg.DefaultModel != operation.beforeString {
|
if cfg.DefaultModel != operation.beforeString {
|
||||||
return &providerSetupConflictError{field: "default_model"}
|
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)
|
return fmt.Errorf("replay default_model: %w", err)
|
||||||
}
|
}
|
||||||
case setupOpLanguage:
|
case setupOpLanguage:
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
"runtime"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -23,23 +21,6 @@ import (
|
|||||||
"reasonix/internal/provider"
|
"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.
|
// Config is Reasonix's runtime configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ConfigVersion int `toml:"config_version"`
|
ConfigVersion int `toml:"config_version"`
|
||||||
@@ -68,6 +49,8 @@ type Config struct {
|
|||||||
Serve ServeConfig `toml:"serve"`
|
Serve ServeConfig `toml:"serve"`
|
||||||
Secrets SecretsConfig `toml:"secrets"`
|
Secrets SecretsConfig `toml:"secrets"`
|
||||||
Remote RemoteConfig `toml:"remote"`
|
Remote RemoteConfig `toml:"remote"`
|
||||||
|
// Storage relocates the movable roots, keyed by RootID. User/global only.
|
||||||
|
Storage map[string]string `toml:"storage"`
|
||||||
|
|
||||||
systemPromptFileSource promptFileSource
|
systemPromptFileSource promptFileSource
|
||||||
providerSources map[string]providerSourceScope
|
providerSources map[string]providerSourceScope
|
||||||
|
|||||||
+28
-27
@@ -15,12 +15,12 @@ import (
|
|||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
"github.com/BurntSushi/toml"
|
||||||
|
|
||||||
"reasonix/internal/extension/protocol"
|
|
||||||
"reasonix/internal/fileutil"
|
"reasonix/internal/fileutil"
|
||||||
fileencoding "reasonix/internal/fileutil/encoding"
|
fileencoding "reasonix/internal/fileutil/encoding"
|
||||||
"reasonix/internal/mcpdiag"
|
"reasonix/internal/mcpdiag"
|
||||||
"reasonix/internal/netclient"
|
"reasonix/internal/netclient"
|
||||||
"reasonix/internal/permission"
|
"reasonix/internal/permission"
|
||||||
|
"reasonix/internal/provider"
|
||||||
)
|
)
|
||||||
|
|
||||||
var validDesktopExternalOpenerID = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`)
|
var validDesktopExternalOpenerID = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`)
|
||||||
@@ -41,42 +41,43 @@ const (
|
|||||||
listDeny = "deny"
|
listDeny = "deny"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetDefaultModel points default_model at an existing model. It accepts both
|
// ModelRefSelectable reports whether ref names a model this installation can be
|
||||||
// forms used by the runtime resolver:
|
// pointed at, in any role: one the configuration carries, or one only the live
|
||||||
// - "provider" — the provider's own default model;
|
// catalog vouches for — extension-hosted models never reach a config file. A nil
|
||||||
// - "provider/model" — that specific model under that provider.
|
// 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.
|
||||||
// Either is rejected when the target does not exist, so a UI can't strand
|
func (c *Config) ModelRefSelectable(ref string, catalog []provider.Descriptor) bool {
|
||||||
// the config on a model that doesn't exist. Plugin-namespaced refs
|
ref = strings.TrimSpace(ref)
|
||||||
// (plugin/<plugin>/<provider>/<model>) are the exception: they belong to
|
if ref == "" {
|
||||||
// extension sidecars, so the config catalog cannot vouch for them — boot's
|
return false
|
||||||
// merged resolver gates them at the next launch instead.
|
}
|
||||||
func (c *Config) SetDefaultModel(name string) error {
|
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)
|
name = strings.TrimSpace(name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return fmt.Errorf("set default: empty 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())
|
return fmt.Errorf("set default: no such model %q (configured: %s)", name, c.providerNames())
|
||||||
}
|
}
|
||||||
c.DefaultModel = name
|
c.DefaultModel = name
|
||||||
return nil
|
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.
|
// SetAutoPlan is retained for source compatibility with older desktop clients.
|
||||||
// Automatic plan mode is retired: "off" is an idempotent compatibility write,
|
// Automatic plan mode is retired: "off" is an idempotent compatibility write,
|
||||||
// while every attempt to enable it is rejected explicitly.
|
// while every attempt to enable it is rejected explicitly.
|
||||||
|
|||||||
@@ -15,32 +15,63 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
"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) {
|
func TestSetDefaultModel(t *testing.T) {
|
||||||
c := Default()
|
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)
|
t.Fatalf("set valid default: %v", err)
|
||||||
}
|
}
|
||||||
if c.DefaultModel != "deepseek-pro" {
|
if c.DefaultModel != "deepseek-pro" {
|
||||||
t.Errorf("default = %q, want deepseek-pro", c.DefaultModel)
|
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")
|
t.Error("expected error for unknown provider")
|
||||||
}
|
}
|
||||||
// "provider/model" form is also accepted: the /model picker stores the
|
// "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
|
// full ref so a user can land on a non-default model under the same
|
||||||
// provider across restarts.
|
// 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)
|
t.Fatalf("set provider/model default: %v", err)
|
||||||
}
|
}
|
||||||
if c.DefaultModel != "deepseek-pro/deepseek-v4-pro" {
|
if c.DefaultModel != "deepseek-pro/deepseek-v4-pro" {
|
||||||
t.Errorf("default = %q, want deepseek-pro/deepseek-v4-pro", c.DefaultModel)
|
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")
|
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")
|
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) {
|
func TestSetAutoPlanRejectsRetiredModes(t *testing.T) {
|
||||||
c := Default()
|
c := Default()
|
||||||
if err := c.SetAutoPlan("off"); err != nil {
|
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.
|
// re-decodes the file to confirm the changes survived a write/read cycle.
|
||||||
func TestSaveToRoundTrips(t *testing.T) {
|
func TestSaveToRoundTrips(t *testing.T) {
|
||||||
c := Default()
|
c := Default()
|
||||||
if err := c.SetDefaultModel("deepseek-pro"); err != nil {
|
if err := c.SetDefaultModel("deepseek-pro", nil); err != nil {
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := c.SetPlannerModel("deepseek-pro"); err != nil {
|
|
||||||
t.Fatal(err)
|
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 {
|
if err := c.UpsertProvider(ProviderEntry{Name: "local", Kind: "openai", BaseURL: "http://localhost:1234/v1", Model: "llama"}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -2038,7 +2051,7 @@ func TestSaveToExistingProjectPersistsTopLevelDelta(t *testing.T) {
|
|||||||
}
|
}
|
||||||
cfg := Default()
|
cfg := Default()
|
||||||
cfg.ConfigVersion = 2
|
cfg.ConfigVersion = 2
|
||||||
if err := cfg.SetDefaultModel("deepseek-pro"); err != nil {
|
if err := cfg.SetDefaultModel("deepseek-pro", nil); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := cfg.SaveTo(projectPath); err != nil {
|
if err := cfg.SaveTo(projectPath); err != nil {
|
||||||
@@ -2162,7 +2175,7 @@ func TestUnrelatedProjectSavePreservesExplicitDefaultSkillOverride(t *testing.T)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := cfg.SetDefaultModel("deepseek-pro"); err != nil {
|
if err := cfg.SetDefaultModel("deepseek-pro", nil); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := cfg.SaveTo(projectPath); err != nil {
|
if err := cfg.SaveTo(projectPath); err != nil {
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ func loadForRoot(root string, migrateOnDisk bool) (*Config, error) {
|
|||||||
userDefaultModel := cfg.DefaultModel
|
userDefaultModel := cfg.DefaultModel
|
||||||
globalCLI := cfg.CLI
|
globalCLI := cfg.CLI
|
||||||
globalSecrets := cfg.Secrets
|
globalSecrets := cfg.Secrets
|
||||||
globalRemote := cfg.Remote.Clone()
|
globalRemote, globalStorage := cfg.Remote.Clone(), maps.Clone(cfg.Storage)
|
||||||
globalDesktopLanguage := cfg.Desktop.Language
|
globalDesktopLanguage := cfg.Desktop.Language
|
||||||
globalPricingCurrency := cfg.Desktop.Currency
|
globalPricingCurrency := cfg.Desktop.Currency
|
||||||
globalBillingDisplayCurrency := cfg.Billing.DisplayCurrency
|
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
|
// reasonix.toml must not be able to flip on the workflow-breaking env/path
|
||||||
// protections.
|
// protections.
|
||||||
cfg.Secrets = globalSecrets
|
cfg.Secrets = globalSecrets
|
||||||
// Remote SSH hosts are equally user-global: a cloned repo's reasonix.toml
|
// Remote SSH hosts and storage locations are equally user-global: a cloned
|
||||||
// must not be able to inject hosts, jump chains, or port forwards that
|
// repo must not inject hosts, jump chains, or port forwards, nor redirect
|
||||||
// steer where Reasonix opens connections.
|
// where this machine keeps its transcripts, catalogs, and checkouts.
|
||||||
cfg.Remote = globalRemote
|
cfg.Remote, cfg.Storage = globalRemote, globalStorage
|
||||||
// Desktop language and pricing currency are user-level regional preferences.
|
// 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.
|
// A repository must not be able to alter how the user's spend is shown.
|
||||||
cfg.Desktop.Language = globalDesktopLanguage
|
cfg.Desktop.Language = globalDesktopLanguage
|
||||||
|
|||||||
+14
-76
@@ -44,27 +44,7 @@ func userConfigDir() string {
|
|||||||
return reasonixHomeDir()
|
return reasonixHomeDir()
|
||||||
}
|
}
|
||||||
|
|
||||||
func reasonixHomeDir() string {
|
func reasonixHomeDir() string { return storageRootDir(RootHome) }
|
||||||
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 userConfigLoadPath() string {
|
func userConfigLoadPath() string {
|
||||||
primary := userConfigPath()
|
primary := userConfigPath()
|
||||||
@@ -143,12 +123,7 @@ func legacyXDGConfigPaths() []string {
|
|||||||
return paths
|
return paths
|
||||||
}
|
}
|
||||||
|
|
||||||
func userSupportDir() string {
|
func userSupportDir() string { return storageRootDir(RootState) }
|
||||||
if dir := cleanEnvDir("REASONIX_STATE_HOME"); dir != "" {
|
|
||||||
return dir
|
|
||||||
}
|
|
||||||
return reasonixHomeDir()
|
|
||||||
}
|
|
||||||
|
|
||||||
func legacyOSSupportDir() string {
|
func legacyOSSupportDir() string {
|
||||||
if IsolatedHomeDir() != "" {
|
if IsolatedHomeDir() != "" {
|
||||||
@@ -165,22 +140,17 @@ func legacyOSSupportDir() string {
|
|||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
func userCacheDir() string {
|
func userCacheDir() string { return storageRootDir(RootCache) }
|
||||||
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 cleanEnvDir(name string) string {
|
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 == "" {
|
if dir == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -351,53 +321,21 @@ func MissingReasoningWarnStateDir() string {
|
|||||||
// workspaces. It intentionally follows the cache root rather than project or
|
// workspaces. It intentionally follows the cache root rather than project or
|
||||||
// session state: taking a lease must never dirty the repository it protects.
|
// session state: taking a lease must never dirty the repository it protects.
|
||||||
func WorkspaceLeaseDir() string {
|
func WorkspaceLeaseDir() string {
|
||||||
// Deliberately ignore REASONIX_HOME/REASONIX_CACHE_HOME here. Two app
|
return joinRoot(storageRootDir(RootLocks), "workspace-leases")
|
||||||
// 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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RepairMutationLockDir stores target-path repair locks in the OS-user cache.
|
// RepairMutationLockDir stores target-path repair locks in the OS-user cache.
|
||||||
// It deliberately ignores Reasonix home/cache overrides: isolated instances
|
// It deliberately ignores Reasonix home/cache overrides: isolated instances
|
||||||
// can still repair the same project reasonix.toml, so their locks must converge.
|
// can still repair the same project reasonix.toml, so their locks must converge.
|
||||||
func RepairMutationLockDir() string {
|
func RepairMutationLockDir() string {
|
||||||
dir := osUserCacheDir()
|
return joinRoot(storageRootDir(RootLocks), "repair-mutation-locks")
|
||||||
if strings.TrimSpace(dir) == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return filepath.Join(dir, "reasonix", "repair-mutation-locks")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeliveryWorktreeDir is durable storage for user-visible isolated Delivery
|
// DeliveryWorktreeDir is durable storage for user-visible isolated Delivery
|
||||||
// workspaces. Explicit state/home overrides remain authoritative. Windows uses
|
// workspaces. Explicit state/home overrides remain authoritative. Windows uses
|
||||||
// LocalAppData by default so large Git worktrees do not roam with the user's
|
// LocalAppData by default so large Git worktrees do not roam with the user's
|
||||||
// profile; other platforms keep using Reasonix state storage.
|
// profile; other platforms keep using Reasonix state storage.
|
||||||
func DeliveryWorktreeDir() string {
|
func DeliveryWorktreeDir() string { return storageRootDir(RootWorktrees) }
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserCredentialsPath is the reasonix-owned global .env file under Reasonix
|
// UserCredentialsPath is the reasonix-owned global .env file under Reasonix
|
||||||
// home. It is the single source for provider credentials saved by Reasonix, so
|
// 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")
|
b.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderStorage(&b, c, scope)
|
||||||
|
|
||||||
// [secrets] is user/global only: LoadForRoot discards project values, so
|
// [secrets] is user/global only: LoadForRoot discards project values, so
|
||||||
// the project scope never renders it. Rendering it here is what lets a
|
// the project scope never renders it. Rendering it here is what lets a
|
||||||
// user's saved toggles survive config rewrites (WriteFile re-renders the
|
// 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 {
|
func renderTOMLKeyPart(key string) string {
|
||||||
if isBareTOMLKey(key) {
|
if isBareTOMLKey(key) {
|
||||||
return 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"`
|
DiskRevision int64 `json:"disk_revision,omitempty"`
|
||||||
RecoveryBranchID string `json:"recovery_branch_id,omitempty"`
|
RecoveryBranchID string `json:"recovery_branch_id,omitempty"`
|
||||||
ExistingRecovery bool `json:"existing_recovery,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
|
// conflictDiagDedup bounds repeated conflict event log lines for the same
|
||||||
@@ -47,10 +53,11 @@ func appendSnapshotConflictDiagnostic(path, mode, outcome string, saveErr error,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
rec := snapshotConflictDiagnostic{
|
rec := snapshotConflictDiagnostic{
|
||||||
At: time.Now(),
|
At: time.Now(),
|
||||||
BranchID: agent.BranchID(path),
|
BranchID: agent.BranchID(path),
|
||||||
Mode: mode,
|
Mode: mode,
|
||||||
Outcome: outcome,
|
Outcome: outcome,
|
||||||
|
DivergedAt: -1,
|
||||||
}
|
}
|
||||||
if conflict != nil {
|
if conflict != nil {
|
||||||
rec.Kind = string(conflict.Kind)
|
rec.Kind = string(conflict.Kind)
|
||||||
@@ -58,6 +65,9 @@ func appendSnapshotConflictDiagnostic(path, mode, outcome string, saveErr error,
|
|||||||
rec.SnapshotMessages = conflict.SnapshotMessages
|
rec.SnapshotMessages = conflict.SnapshotMessages
|
||||||
rec.BaseRevision = conflict.BaseRevision
|
rec.BaseRevision = conflict.BaseRevision
|
||||||
rec.DiskRevision = conflict.DiskRevision
|
rec.DiskRevision = conflict.DiskRevision
|
||||||
|
rec.DivergedAt = conflict.DivergedAt
|
||||||
|
rec.DiskRole = conflict.DiskRole
|
||||||
|
rec.SnapshotRole = conflict.SnapshotRole
|
||||||
}
|
}
|
||||||
if recoveryPath != "" {
|
if recoveryPath != "" {
|
||||||
rec.RecoveryBranchID = agent.BranchID(recoveryPath)
|
rec.RecoveryBranchID = agent.BranchID(recoveryPath)
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ package serve
|
|||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -17,6 +20,33 @@ import (
|
|||||||
// control enforces the real per-kind limit once these are decoded.
|
// control enforces the real per-kind limit once these are decoded.
|
||||||
const maxAttachmentUpload = 25 << 20
|
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) {
|
func (s *Server) attachments(w http.ResponseWriter, r *http.Request) {
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, (maxAttachmentUpload/3)*4+1024)
|
r.Body = http.MaxBytesReader(w, r.Body, (maxAttachmentUpload/3)*4+1024)
|
||||||
var body struct {
|
var body struct {
|
||||||
@@ -25,14 +55,15 @@ func (s *Server) attachments(w http.ResponseWriter, r *http.Request) {
|
|||||||
// stored name is generated either way.
|
// stored name is generated either way.
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Data string `json:"data"`
|
Data string `json:"data"`
|
||||||
|
Path string `json:"path"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
http.Error(w, "read attachment: "+err.Error(), http.StatusBadRequest)
|
http.Error(w, "read attachment: "+err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
raw, err := base64.StdEncoding.DecodeString(body.Data)
|
raw, err := attachmentBytes(body.Path, body.Data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "attachment data must be base64", http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
root := s.ctl().WorkspaceRoot()
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
persistDefaultModel("mixed-anthropic/text-only")
|
persistDefaultModel("mixed-anthropic/text-only", nil)
|
||||||
|
|
||||||
// A fresh load is what a restart sees.
|
// A fresh load is what a restart sees.
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
@@ -199,7 +199,7 @@ func TestPersistingAnUnknownModelLeavesTheDefaultAlone(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
persistDefaultModel("nobody/nothing")
|
persistDefaultModel("nobody/nothing", nil)
|
||||||
|
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
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
|
// 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.
|
// 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)
|
http.Error(w, fmt.Sprintf("no configured model matches %q", ref), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -64,6 +64,7 @@ type Server struct {
|
|||||||
buildWorkspaceController func(ctx context.Context, dir, ref string) (*control.Controller, error)
|
buildWorkspaceController func(ctx context.Context, dir, ref string) (*control.Controller, error)
|
||||||
lastBuild *boot.BuildResult // serving generation, guarded by bindMu; see reuseFromLastBuild
|
lastBuild *boot.BuildResult // serving generation, guarded by bindMu; see reuseFromLastBuild
|
||||||
grants hostGrants // what the embedding host has opened up
|
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
|
titleProv provider.Provider // best-effort provider for session titles
|
||||||
titlePrice *provider.Pricing
|
titlePrice *provider.Pricing
|
||||||
titleModelRef string
|
titleModelRef string
|
||||||
@@ -508,6 +509,7 @@ func (s *Server) routes() http.Handler {
|
|||||||
s.registerInboxRoutes(mux)
|
s.registerInboxRoutes(mux)
|
||||||
s.registerProviderRoutes(mux)
|
s.registerProviderRoutes(mux)
|
||||||
s.registerRoleRoutes(mux)
|
s.registerRoleRoutes(mux)
|
||||||
|
s.registerStorageRoutes(mux)
|
||||||
s.registerWelcomeRoutes(mux)
|
s.registerWelcomeRoutes(mux)
|
||||||
mux.HandleFunc("POST /cancel", s.cancel)
|
mux.HandleFunc("POST /cancel", s.cancel)
|
||||||
mux.HandleFunc("POST /approve", s.approve)
|
mux.HandleFunc("POST /approve", s.approve)
|
||||||
@@ -801,9 +803,13 @@ type historyToolCall struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type historyMessage struct {
|
type historyMessage struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Reasoning string `json:"reasoning,omitempty"`
|
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"`
|
ToolCalls []historyToolCall `json:"toolCalls,omitempty"`
|
||||||
ToolCallID string `json:"toolCallId,omitempty"`
|
ToolCallID string `json:"toolCallId,omitempty"`
|
||||||
ToolName string `json:"toolName,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
|
// Content is what the model saw, and one @-reference expands into a
|
||||||
// whole file. A reopened session has to show what was typed.
|
// whole file. A reopened session has to show what was typed.
|
||||||
hm.Content = agent.UserMessageText(m)
|
hm.Content = agent.UserMessageText(m)
|
||||||
|
hm.Images = len(m.Images)
|
||||||
}
|
}
|
||||||
if m.Role == provider.RoleAssistant {
|
if m.Role == provider.RoleAssistant {
|
||||||
hm.Reasoning = m.ReasoningContent
|
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) {
|
func TestSessionsListPreviewStripsTransientReasoningLanguageBlock(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "session.jsonl")
|
path := filepath.Join(dir, "session.jsonl")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"reasonix/internal/agentpreset"
|
"reasonix/internal/agentpreset"
|
||||||
"reasonix/internal/config"
|
"reasonix/internal/config"
|
||||||
|
"reasonix/internal/provider"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) preset(w http.ResponseWriter, r *http.Request) {
|
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)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
catalog := s.ctl().ProviderCatalog()
|
||||||
// The switch only rebuilt the running controller. Without this the next
|
// The switch only rebuilt the running controller. Without this the next
|
||||||
// launch boots from default_model and lands back on whatever was there
|
// 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
|
// 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.
|
// and the old desktop have both persisted it since they had a picker.
|
||||||
persistDefaultModel(ref)
|
persistDefaultModel(ref, catalog)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// persistDefaultModel records the choice in the user config. A refusal is worth
|
// 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
|
// a log and nothing more: the live switch already succeeded, and failing the
|
||||||
// request would say the model did not change when it did.
|
// 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()
|
path := config.UserConfigPath()
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return
|
return
|
||||||
@@ -60,7 +62,7 @@ func persistDefaultModel(ref string) {
|
|||||||
unlock := config.LockUserConfigEdits()
|
unlock := config.LockUserConfigEdits()
|
||||||
defer unlock()
|
defer unlock()
|
||||||
edit := config.LoadForEdit(path)
|
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)
|
slog.Warn("serve: persist default model", "ref", ref, "err", err)
|
||||||
return
|
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