Merge studio into fix/gitignore-escaped-leading-bang

This commit is contained in:
esengine committed 2026-10-19 00:41:49 -07:00
commit 9552fbbfdc
52 files changed
+1941 -84

No files matched your search

+1
View File
@@ -177,6 +177,7 @@ jobs:
node perf/web-access.mjs
node perf/peers-settings.mjs
node perf/trust-wait.mjs
node perf/nest-nodes.mjs
node perf/provider-ports.mjs
node perf/peer-threads.mjs
node perf/text-size.mjs
+2
View File
@@ -32,6 +32,7 @@ const { groundFor, themeSourceFor } = require("./ground");
const { loadPrefs, prefsFile, registerPrefs } = require("./prefs");
const { stateFile, openWindow } = require("./windowstate");
const { accelerationOff, shouldDisableGpu, graphicsHandler } = require("./graphics");
const { processMemoryHandler } = require("./procmem");
const { captureHandler } = require("./capture");
const { createPowerGuard, keepsAwake } = require("./powerguard");
const { openLogs, redactArgv, failStartup } = require("./shelllog");
@@ -392,6 +393,7 @@ nativeTheme.on("updated", () => {
});
ipcMain.handle("shell:graphics", (event) => graphicsHandler(app, fromWindow, gpuLaunch)(event));
ipcMain.handle("shell:process-memory", (event) => processMemoryHandler(app, fromWindow)(event));
ipcMain.handle("window:unread-badge", (event, unread) => {
const target = fromWindow(event);
+2
View File
@@ -30,6 +30,8 @@ contextBridge.exposeInMainWorld("reasonixHost", {
captureWindow: () => ipcRenderer.invoke("window:capture"),
// What the shell's graphics are running as this launch.
graphics: () => ipcRenderer.invoke("shell:graphics"),
// The window's processes as type and two sizes, for the memory a report may carry.
processMemory: () => ipcRenderer.invoke("shell:process-memory"),
openExternal: (url) => ipcRenderer.invoke("shell:open-external", String(url)),
// A pane and a path inside its workspace; main asks the kernel where that is.
revealPath: (base, rel) => ipcRenderer.invoke("shell:reveal", String(base), String(rel)),
+31
View File
@@ -0,0 +1,31 @@
"use strict";
const TYPES = { Tab: "renderer", Browser: "browser", GPU: "gpu" };
// The kernel adds its own row, and a report holds a dozen.
const MAX_ROWS = 11;
const kb = (v) => (Number.isFinite(v) && v > 0 ? Math.min(Math.round(v), 2 ** 31) : 0);
// What app.getAppMetrics says, cut down to what a report may carry: a fixed
// type per process and two whole numbers. No pid, name, path or command line
// is read, so none can be sent.
function processMemoryRows(metrics) {
return (Array.isArray(metrics) ? metrics : []).slice(0, MAX_ROWS).map((m) => ({
process: TYPES[m?.type] ?? "utility",
workingSetKB: kb(m?.memory?.workingSetSize),
privateKB: kb(m?.memory?.privateBytes),
}));
}
function processMemoryHandler(app, fromWindow) {
return (event) => {
if (!fromWindow(event)) return [];
try {
return processMemoryRows(app.getAppMetrics());
} catch {
return [];
}
};
}
module.exports = { processMemoryRows, processMemoryHandler };
+36
View File
@@ -2868,3 +2868,39 @@ test("a window that fails to raise is given its opacity and mouse back", async (
await assert.rejects(wakeOf(win).run(async () => {}), /no window/);
assert.deepEqual(calls.slice(-3), ["ignore false", "opacity 1", "minimize"]);
});
const { processMemoryRows, processMemoryHandler } = require("../src/procmem.js");
test("the shell's process memory is only a fixed type and two whole numbers per process", () => {
const metrics = [
{ pid: 4740, type: "Tab", name: "C:\\Users\\someone\\x", cmd: "--user-data-dir=/home/someone", memory: { workingSetSize: 3992908, privateBytes: 3900000, peakWorkingSetSize: 9 } },
{ pid: 11, type: "Browser", memory: { workingSetSize: 120000, privateBytes: 90000 } },
{ pid: 12, type: "GPU", memory: { workingSetSize: 76000 } },
{ pid: 13, type: "Utility", memory: { workingSetSize: 6000, privateBytes: 5000 } },
{ pid: 14, type: "Zygote", memory: { workingSetSize: 1000, privateBytes: 900 } },
{ pid: 15, type: "Tab", memory: { workingSetSize: Number.NaN, privateBytes: -4 } },
];
const rows = processMemoryRows(metrics);
assert.deepEqual(rows, [
{ process: "renderer", workingSetKB: 3992908, privateKB: 3900000 },
{ process: "browser", workingSetKB: 120000, privateKB: 90000 },
{ process: "gpu", workingSetKB: 76000, privateKB: 0 },
{ process: "utility", workingSetKB: 6000, privateKB: 5000 },
{ process: "utility", workingSetKB: 1000, privateKB: 900 },
{ process: "renderer", workingSetKB: 0, privateKB: 0 },
]);
assert.ok(rows.every((r) => Object.keys(r).sort().join() === "privateKB,process,workingSetKB"));
assert.doesNotMatch(JSON.stringify(rows), /Users|pid|user-data|cmd/i);
});
test("the shell reports at most eleven processes, since the kernel adds its own row to a dozen", () => {
const many = Array.from({ length: 30 }, (_, i) => ({ pid: i, type: "Utility", memory: { workingSetSize: 1, privateBytes: 1 } }));
assert.equal(processMemoryRows(many).length, 11);
});
test("only the app's own window is answered, and nothing is read from the asker", () => {
const app = { getAppMetrics: () => [{ type: "Tab", memory: { workingSetSize: 5, privateBytes: 4 } }] };
assert.deepEqual(processMemoryHandler(app, () => true)({}), [{ process: "renderer", workingSetKB: 5, privateKB: 4 }]);
assert.deepEqual(processMemoryHandler(app, () => null)({}), []);
assert.deepEqual(processMemoryHandler({ getAppMetrics: () => { throw new Error("x"); } }, () => true)({}), []);
});
+6 -1
View File
@@ -430,6 +430,7 @@ const WORKSPACES = Number(query.get("ws") ?? 0);
const PREF = query.get("pref");
const ONBOARDING = query.has("onboarding");
const SESSIONS = Number(query.get("sess") ?? 0);
const ROW_TITLES = ["rx home目录里有没有 obsidian 的笔记仓库,帮我整理一下", "obsidian 插件同步方案怎么选", "查一下 studio 最近一次发布改了什么", "阅读一下最近的提交记录并总结"];
// ?turns= is how long the conversation being opened already is.
const TURNS = Number(query.get("turns") ?? 0);
const STATUS_MS = Number(query.get("statusms") ?? 0);
@@ -520,8 +521,12 @@ class BenchHub extends MockHub {
sessions: this.placed(`~/projects/workspace-${w}`, Array.from({ length: SESSIONS }, (_, s) => ({
path: `/sessions/w${w}-s${s}.jsonl`,
name: `w${w}-s${s}`,
title: `第 ${s} 次会话,标题是内核起的一句话`,
title: query.has("rows") ? ROW_TITLES[s % ROW_TITLES.length] : `第 ${s} 次会话,标题是内核起的一句话`,
turns: (s % 40) + 1,
...(query.has("rows") ? {
lastActiveAt: Date.now() - (9 + s) * 86_400_000,
versions: s % 2 === 1 ? [{ path: `/sessions/w${w}-s${s}-v1.jsonl`, name: `w${w}-s${s}-v1`, title: ROW_TITLES[s % ROW_TITLES.length], turns: 1 }] : undefined,
} : {}),
}))),
})),
);
+70
View File
@@ -0,0 +1,70 @@
// A long session of delegated work keeps the page light: a finished sub-agent's child calls are
// not in the document until its card is opened, so node count follows what is open, not what ran.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const AGENTS = 120;
const MAX_NODES = Number(process.env.PERF_NODES_MAX ?? 25000);
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const shotDir = process.env.SHOTS;
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
const ctx = await browser.newContext({ locale: "zh-CN", colorScheme: scheme, viewport: { width: 1400, height: 900 }, reducedMotion: "reduce" });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(`${BASE}?turns=3&pref=zh`, { waitUntil: "networkidle" });
await page.waitForSelector(".compose");
await page.waitForTimeout(600);
const before = await page.evaluate(() => document.querySelectorAll("*").length);
await page.evaluate((n) => {
window.__feed({ kind: "turn_started" });
const big = (tag) => Array.from({ length: 60 }, (_, k) => `${tag} line ${k}: matched in internal/pkg${k % 40}/file${k}.go`).join("\n");
for (let a = 0; a < n; a++) {
const pid = `a${a}`;
window.__feed({ kind: "tool_dispatch", tool: { id: pid, name: "task", args: JSON.stringify({ description: `子代理 ${a}`, prompt: "查" }) } });
for (let c = 0; c < 15; c++) {
const id = `${pid}c${c}`, name = c % 3 === 0 ? "bash" : c % 3 === 1 ? "grep" : "read_file";
window.__feed({ kind: "tool_dispatch", tool: { id, name, args: JSON.stringify({ command: "x" }), parentId: pid } });
window.__feed({ kind: "tool_result", tool: { id, name, args: JSON.stringify({ command: "x" }), output: big(id), parentId: pid, durationMs: 300 } });
}
window.__feed({ kind: "tool_result", tool: { id: pid, name: "task", args: "", output: "report " + a, durationMs: 9000 } });
}
}, AGENTS);
await page.waitForTimeout(800);
const after = await page.evaluate(() => document.querySelectorAll("*").length);
const kids = await page.evaluate(() => document.querySelectorAll(".nest-bd .call").length);
check(`${scheme}: ${AGENTS} finished sub-agents add ${after - before} nodes, under the ${MAX_NODES} limit`, after < MAX_NODES, `${after} in the page`);
check(`${scheme}: none of their child calls is in the document while the cards are closed`, kids === 0, `${kids}`);
await page.evaluate(() => { const g = [...document.querySelectorAll("details")].filter((d) => d.querySelector('[data-call="a0"]')).pop(); if (g) g.open = true; });
await page.waitForTimeout(400);
const shoot = async (name) => {
if (!shotDir) return;
await page.evaluate(() => document.querySelector('[data-call="a0"]')?.scrollIntoView({ block: "start" }));
await page.waitForTimeout(300);
await page.screenshot({ path: `${shotDir}/${scheme}-nest-${name}.png`, clip: { x: 360, y: 40, width: 1000, height: 420 } });
};
await shoot("closed");
const opened = await page.evaluate(() => {
const d = document.querySelector('[data-call="a0"] details.tool-disclosure');
if (!d) return -1;
d.open = true;
d.dispatchEvent(new Event("toggle"));
return 1;
});
await page.waitForTimeout(300);
const nowKids = await page.evaluate(() => document.querySelectorAll('[data-call="a0"] .nest-bd .call').length);
check(`${scheme}: opening one card mounts exactly its own 15 child calls`, opened === 1 && nowKids === 15, `${nowKids}`);
await shoot("open");
await ctx.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed:\n ` + fails.join("\n "));
process.exit(1);
}
console.log("\nall passed");
+32 -2
View File
@@ -24,6 +24,18 @@ try {
<div class="sessrow sessrow-remote">
<span class="sesstitle" title="${title}"><span>${title}</span></span><span class="sessmeta">3 轮</span>
</div>
<div class="sessrow sessrow-context" data-aged>
<button class="sesspin-action" data-action="session.pin">置顶</button>
<span class="sesstitle" title="${title}"><span>${title}</span></span>
<time class="sessmeta sessage">9 天前</time><button class="sesscopies">+1</button>
<span class="sessquick"><button data-action="session.archive">归档</button></span>
</div>
<div class="sessrow sessrow-context" data-aged-only>
<button class="sesspin-action" data-action="session.pin">置顶</button>
<span class="sesstitle" title="${title}"><span>${title}</span></span>
<time class="sessmeta sessage">9 天前</time>
<span class="sessquick"><button data-action="session.archive">归档</button></span>
</div>
<div class="sessrow sesscopy">
<span class="sesstitle" title="早先版本 · 4 轮"><span>早先版本 · 4 轮</span></span>
</div>
@@ -55,10 +67,28 @@ try {
}
assert.equal(rows[1].top - rows[0].bottom, 1, "only the compact row margin separates sessions");
assert.equal(rows[2].top - rows[1].bottom, 1, "the remote row has the same compact spacing");
assert.equal(rows[3].top - rows[2].bottom, 1, "the earlier version has the same compact spacing");
assert.equal(rows[3].top - rows[2].bottom, 1, "the aged row has the same compact spacing");
assert.equal(rows[4].top - rows[3].bottom, 1, "the age-only row has the same compact spacing");
assert.equal(rows[5].top - rows[4].bottom, 1, "the earlier version has the same compact spacing");
assert.ok(rows[0].truncated && rows[2].truncated, "long local and remote titles truncate");
if (state === "idle") {
for (const [selector, allowed] of [[".sessrow[data-aged]", 8], [".sessrow[data-aged-only]", 2]]) {
const gap = await page.evaluate((sel) => {
const row = document.querySelector(sel);
const box = row.getBoundingClientRect();
const inner = box.right - parseFloat(getComputedStyle(row).paddingRight);
const shown = [...row.children].filter((el) => {
const cs = getComputedStyle(el);
return cs.position !== "absolute" && cs.visibility !== "hidden" && el.getBoundingClientRect().width > 0 && !el.matches(".sessquick");
});
return { free: inner - Math.max(...shown.map((el) => el.getBoundingClientRect().right)), quick: row.querySelector(".sessquick").getBoundingClientRect().width };
}, selector);
assert.ok(gap.free <= allowed, `${scheme}/${width} ${selector}: ${gap.free}px of the row's right side is held back while no button is showing; the title should have it`);
assert.equal(gap.quick, 0, `${scheme}/${width}: the hover buttons take no room until the row is hovered or focused`);
}
}
if (state !== "idle") {
const contained = await page.locator(".sessquick > button, .sesspin-action").evaluateAll((buttons) => buttons.every((button) => {
const contained = await page.locator(".sessquick > button, .sesspin-action").evaluateAll((buttons) => buttons.filter((button) => (button.parentElement.matches(".sessquick") ? button.parentElement.getBoundingClientRect().width > 0 : getComputedStyle(button).opacity !== "0")).every((button) => {
const rect = button.getBoundingClientRect();
const row = button.closest(".sessrow").getBoundingClientRect();
return rect.top >= row.top && rect.bottom <= row.bottom && rect.left >= row.left && rect.right <= row.right;
@@ -85,7 +85,6 @@ export const EN_FEEDBACK: Record<string, string> = {
"随反馈一起发送的环境信息": "Environment sent with your report",
"入口": "Surface",
"渠道": "Channel",
"只读。不包含密钥、地址或任何文件内容。": "Read-only. It contains no keys, addresses or file contents.",
"打开日志文件夹": "Open logs folder",
"维护者向你要日志时,打开这个文件夹,把最新的 shell.log 和 host.log 发给我们。发之前可以先自己看一眼。": "When a maintainer asks for logs, open this folder and send us the newest shell.log and host.log. You can read them first.",
"无法打开日志文件夹": "The logs folder could not be opened.",
@@ -227,6 +226,14 @@ export const EN_FEEDBACK: Record<string, string> = {
"榜上显示的名字": "Name shown on the board",
"给反馈被采纳并发布的人。按版本看有名次,全部版本只按名字排序,不比高低。只有自己选择加入的人才会出现。": "For people whose feedback was adopted and shipped. A release has a ranking; all releases together are listed by name, not ranked. Only people who choose to join appear.",
"贡献榜": "Contribution board",
"内存": "Memory",
"只读。不包含密钥、地址或任何文件内容。内存一项只有进程类型和上面这些数字,用来判断是哪一部分占用高。": "Read-only. It holds no key, address or file content. The memory line is only the process type and the numbers above, to tell which part is using a lot.",
"{name}:{set}(私有 {own})": "{name}: {set} (private {own})",
"主进程": "Main process",
"界面进程": "Interface process",
"图形进程": "Graphics process",
"辅助进程": "Helper process",
"内核": "Kernel",
"要不要加入贡献榜": "Join the contribution board?",
"要不要加入贡献榜?": "Join the contribution board?",
"这台电脑已关联账号。加入后榜上公开显示你选的名字和被采纳的条数,不显示邮箱、安装 ID 或路径;这个提示只出现一次,随时可以退出。": "This computer is linked to your account. If you join, the board publicly shows the name you choose and how many of your suggestions were adopted, never an email, install ID or path. This is asked only once, and you can leave any time.",
@@ -400,3 +400,62 @@ describe("a request too large for one message", () => {
expect(again[0].id).toBe(partsOf(first.socket)[0].id);
});
});
describe("a slow answer over a live link", () => {
function chunk(socket: FakeSocket, id: string, index: number, done: boolean) {
socket.dispatchEvent(new MessageEvent("message", { data: JSON.stringify({
type: "desktop.response", id, index, done, status: 200,
body: linkCodec.bytesToBase64(new TextEncoder().encode(`part${index};`)),
}) }));
}
it("is not failed while its own chunks keep arriving, however long the whole takes", async () => {
const first = connection();
const link = new RemoteLink(first.conn, vi.fn(async () => first.conn), (end) => ends.push(end), { requestTimeoutMs: 30_000 });
const answer = link.fetch(request());
const settled = answer.then(() => "answered", (error: Error) => error.message);
await vi.advanceTimersByTimeAsync(0);
const id = String(first.socket.sent[0]?.id);
for (let index = 0; index < 5; index++) {
await vi.advanceTimersByTimeAsync(20_000);
chunk(first.socket, id, index, index === 4);
}
await expect(settled).resolves.toBe("answered");
await expect((await answer).text()).resolves.toBe("part0;part1;part2;part3;part4;");
});
it("keeps waiting for a request queued behind others while the desktop is still answering them", async () => {
const first = connection();
const link = new RemoteLink(first.conn, vi.fn(async () => first.conn), (end) => ends.push(end), { requestTimeoutMs: 30_000 });
const queued = link.fetch(request("GET", "/rt/two/events/replay"));
const settled = queued.then(() => "answered", (error: Error) => error.message);
await vi.advanceTimersByTimeAsync(0);
const id = String(first.socket.sent[0]?.id);
for (let beat = 0; beat < 6; beat++) {
await vi.advanceTimersByTimeAsync(20_000);
chunk(first.socket, "someone-elses-request", beat, false);
}
first.socket.reply(id, "finally");
await expect(settled).resolves.toBe("answered");
});
it("still gives up on a request nothing has answered, so a silent desktop is not waited on forever", async () => {
const first = connection();
const second = connection();
const link = new RemoteLink(first.conn, vi.fn(async () => second.conn), (end) => ends.push(end), { requestTimeoutMs: 30_000 });
const answer = link.fetch(request());
const settled = answer.then(() => "answered", (error: Error) => error.message);
await vi.advanceTimersByTimeAsync(0);
for (let beat = 0; beat < 40; beat++) {
await vi.advanceTimersByTimeAsync(10_000);
chunk(first.socket, "someone-elses-request", beat, false);
}
await expect(settled).resolves.toMatch(/Remote Studio|远程 Studio/);
});
});
+21 -1
View File
@@ -62,6 +62,8 @@ interface Pending {
contentType?: string;
etag?: string;
timer?: ReturnType<typeof setTimeout>;
// When this request last made progress: sent whole, or one more chunk of its answer.
progressAt?: number;
resolve: (value: Response) => void;
reject: (reason: unknown) => void;
}
@@ -73,6 +75,11 @@ export interface RemoteLinkOptions {
const MAX_RETRY_AFTER_MS = 2 * 60 * 1000;
// The desktop answers one request at a time, so a request can wait behind others
// it knows nothing about. While the link is delivering their answers it is waited
// for, up to this long without a word of its own.
const MAX_QUEUED_MS = 5 * 60 * 1000;
// The desktop reads at most 64 KiB from the relay in one message and closes the
// link on a larger one, taking every other request on it down too. A request
// that would seal past this is refused here instead of being sent. The budget
@@ -250,6 +257,12 @@ export class RemoteLink {
while (connection.socket.bufferedAmount > BUFFER_HIGH && live()) await new Promise((resolve) => setTimeout(resolve, 20));
if (!live()) return;
}
this.arm(entry);
}
private arm(entry: Pending) {
clearTimeout(entry.timer);
entry.progressAt = Date.now();
entry.timer = setTimeout(() => this.timedOut(entry), this.options.requestTimeoutMs);
}
@@ -263,6 +276,10 @@ export class RemoteLink {
try { stale.socket.close(CLOSE_RESYNC, "Resync"); } catch { /* already closing */ }
return;
}
if (Date.now() - (entry.progressAt ?? 0) < MAX_QUEUED_MS) {
entry.timer = setTimeout(() => this.timedOut(entry), this.options.requestTimeoutMs);
return;
}
this.pending.delete(entry.id);
entry.reject(new Error(t("远程 Studio 暂无响应,请检查电脑是否在线后重试。")));
}
@@ -367,7 +384,10 @@ export class RemoteLink {
pending.contentType = typeof message.contentType === "string" ? message.contentType : pending.contentType;
pending.etag = typeof message.etag === "string" ? message.etag : pending.etag;
pending.chunks.set(message.index, message.body);
if (message.done !== true) return;
if (message.done !== true) {
this.arm(pending);
return;
}
clearTimeout(pending.timer);
this.pending.delete(id);
const headers = new Headers();
@@ -33,6 +33,16 @@ export const FEEDBACK_WITHDRAWABLE: readonly FeedbackStatus[] = ["received", "ne
export const canWithdraw = (item: Pick<FeedbackItem, "status" | "statusUnavailable">): boolean =>
!item.statusUnavailable && FEEDBACK_WITHDRAWABLE.includes(item.status);
// One process's memory as a report carries it: a type from a fixed list and two
// sizes in kilobytes. Nothing in it names a path, a process id or a user.
export type FeedbackProcessType = "browser" | "renderer" | "gpu" | "utility" | "kernel";
export interface FeedbackProcessMemory {
process: FeedbackProcessType;
workingSetKB: number;
privateKB: number;
}
export interface FeedbackEnvInfo {
version: string;
commit: string;
@@ -43,6 +53,8 @@ export interface FeedbackEnvInfo {
locale: string;
channel: string;
providerKind: string;
// The kernel's own row; the window's shell adds the rest when a report is sent.
memory?: FeedbackProcessMemory[];
}
// uploadBytes bounds one raw file the page may hand over; the kernel shrinks
@@ -77,6 +89,8 @@ export interface FeedbackRequest {
// The UI language tag, so the issue can be triaged in the reporter's language.
locale?: string;
images: FeedbackImage[];
// The shell's process rows; the kernel keeps the fixed types and adds its own.
memory?: FeedbackProcessMemory[];
// Answer to a feedback.challenge_required refusal.
turnstileToken?: string;
}
+12
View File
@@ -2,6 +2,8 @@
// the app learns which shell it is running in. Electron exposes a preload
// bridge; a browser tab has none, and answers for itself.
import type { FeedbackProcessMemory } from "./feedback";
export type Shell = "electron" | "browser";
export interface HostInfo {
@@ -38,6 +40,9 @@ export interface HostPort {
captureWindow(): Promise<Uint8Array | null>;
/** How the shell draws this launch; null where it has no say, as in a tab. */
graphics(): Promise<GraphicsInfo | null>;
/** The shell's own processes as a type and two sizes each, for the memory a
* report may carry; empty where there is no shell to ask, as in a tab. */
processMemory(): Promise<FeedbackProcessMemory[]>;
openExternal(url: string): void | Promise<void>;
setUnreadBadge(unread: boolean): Promise<void>;
/** Where dropped files live. Empty where the shell cannot say — a browser
@@ -162,6 +167,7 @@ interface ElectronBridge {
closeWindow(): Promise<void>;
captureWindow?(): Promise<Uint8Array | null>;
graphics?(): Promise<GraphicsInfo | null>;
processMemory?(): Promise<unknown>;
openExternal(url: string): Promise<void>;
setUnreadBadge?(unread: boolean): Promise<void>;
pathForFile(file: File): string;
@@ -226,6 +232,9 @@ class ElectronHost implements HostPort {
graphics() {
return this.api.graphics?.().catch(() => null) ?? Promise.resolve(null);
}
processMemory() {
return this.api.processMemory?.().then((rows: unknown) => (Array.isArray(rows) ? (rows as FeedbackProcessMemory[]) : [])).catch(() => []) ?? Promise.resolve([]);
}
openExternal(url: string) {
return this.api.openExternal(url);
}
@@ -328,6 +337,9 @@ class BrowserHost implements HostPort {
graphics() {
return Promise.resolve(null);
}
processMemory() {
return Promise.resolve([]);
}
openExternal(url: string) {
window.open(url, "_blank", "noopener,noreferrer");
}
@@ -7,6 +7,7 @@ const LIMITS = { bodyBytes: 8192, nameChars: 40, contactChars: 120, images: 3, i
const ENV = {
version: "v2.24.0", commit: "7ee4bbb", surface: "studio", os: "windows", osVersion: "10.0.19045",
arch: "amd64", locale: "en-US", channel: "stable", providerKind: "deepseek",
memory: [{ process: "kernel" as const, workingSetKB: 196608, privateKB: 150000 }],
};
const DAY = 86_400_000;
@@ -1540,6 +1540,15 @@ A sub-agent row, by the list's own width:
- The row actions (`+`, `…`, refresh) are revealed on hover or focus, 22px, outlined, and share one hover, press, expanded and busy state, whichever row they sit on. Remote host actions are always drawn and outline only on those states.
- Every row's last action ends on the same edge: the machine row keeps a 15px right margin like a project row, so a machine's `+` sits over a project's `…`.
## Session rows
- The title takes every pixel the row's visible content leaves it. The age sits against the row's right padding and the earlier-versions badge beside it; nothing is held back on the right for a control that is not showing.
- The hover buttons (archive) take no width until the row is hovered or focused, then 24px plus 6px; on a device without hover they are always drawn.
- Taking the room only while shown means the title shortens when the buttons appear, never before. Pinning stays an overlay at the left, so the pin never moves the title.
- An unread dot is an overlay 11px from the right edge: an unread row keeps 22px after its age (or after its badge, 26px) for it, and the dot hides while the buttons are shown.
- A legacy `margin-right: 44px` on the age made room for a delete button the rail has long since hidden; the rail's rule sets it to zero.
- `perf/sessionrows.mjs` measures this in a real browser: with no button showing, a row with an age leaves at most 2px (8px with a badge) of its right side unused, and the buttons take no width at rest.
## Session rename dialog
- Double-clicking a sidebar session title or a tab starts inline editing.
@@ -1,8 +1,8 @@
.rail .sessquick {
flex: none; display: flex; align-items: center; width: 24px; margin-inline-end: 6px;
flex: none; display: flex; align-items: center; width: 0; margin-inline-end: 0; overflow: hidden;
opacity: 0; pointer-events: none; transition: opacity 100ms ease;
}
.rail .sessrow:is(:hover, :focus-within) > .sessquick,
.rail .sessrow:is(:hover, :focus-within) > .sessquick { width: 24px; margin-inline-end: 6px; opacity: 1; pointer-events: auto; }
.rail .sessrow:is(:hover, :focus-within) > .sesspin-action { opacity: 1; pointer-events: auto; }
.rail .sessquick > button,
.rail .sesspin-action {
@@ -30,9 +30,9 @@
.rail .sessrow:has(> .sessedit) > :is(.sesspin-action, .sesspin-marker, .sessquick) { visibility: hidden; pointer-events: none; }
.rail .sessrow:has(.sesspin-action):is(:hover, :focus-within)::before,
.rail .sessrow:has(.sesspin-action):is(:hover, :focus-within) > .pip { visibility: hidden; }
.rail .sessrow:has(.sessquick) > .unread-dot { inset-inline-end: 36px; }
.rail .sessrow:has(.sessquick):is(:hover, :focus-within) > .unread-dot { visibility: hidden; }
@media (hover: none) {
.rail .sessquick,
.rail .sessquick { width: 24px; margin-inline-end: 6px; opacity: 1; pointer-events: auto; }
.rail .sesspin-action { opacity: 1; pointer-events: auto; }
}
.rail .sesslegacy {
@@ -50,7 +50,9 @@
.rail .sessrow[data-run="running"] > .pip { display: block; }
.rail .rmtws-node .sessdel { opacity: 0; }
.rail .rmtws-node .sessrow:is(:hover, :focus-within) .sessdel { opacity: 1; }
.rail .sessage { margin-left: auto; font-size: var(--fs-micro); }
.rail .sessage { margin-left: auto; margin-right: 0; font-size: var(--fs-micro); }
.rail .sessage + .sesscopies { margin-inline-start: 6px; }
.rail .sessrow[data-unread]:not(:has(> .sesscopies)) > .sessage { margin-inline-end: 22px; }
.rail .sessmore { width: calc(100% - 12px); margin: 2px 0 5px 3px; padding-left: 9px; text-align: left; }
.rail .sessrow:hover { background: color-mix(in srgb, var(--text) 4%, transparent); }
.rail .sessrow[aria-selected="true"] { background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--text); font-weight: var(--w-reg); box-shadow: none; }
@@ -673,6 +673,31 @@ describe("capturing the window", () => {
expect(heldDraft().shots).toEqual([]);
});
it("touches no state at all when the shell answers after the window global is gone", async () => {
vi.spyOn(host(), "capturesWindow").mockReturnValue(true);
let finish: (b: Uint8Array) => void = () => {};
const taking = vi.spyOn(host(), "captureWindow").mockImplementation(() => new Promise((r) => { finish = r; }));
const unhandled = vi.fn();
const proc = (globalThis as unknown as { process: { on(e: string, f: () => void): void; off(e: string, f: () => void): void } }).process;
proc.on("unhandledRejection", unhandled);
setup();
await ready();
await userEvent.click(button());
await waitFor(() => expect(taking).toHaveBeenCalled());
cleanup();
const later = globalThis.setTimeout.bind(globalThis);
const own = Object.getOwnPropertyDescriptor(globalThis, "window")!;
Object.defineProperty(globalThis, "window", { configurable: true, get() { throw new ReferenceError("window is not defined"); } });
try {
finish(new Uint8Array([137, 80, 78, 71]));
await new Promise((r) => later(r, 30));
} finally {
Object.defineProperty(globalThis, "window", own);
proc.off("unhandledRejection", unhandled);
}
expect(unhandled).not.toHaveBeenCalled();
});
it("finishes quietly when the shell answers after the page is torn down", async () => {
vi.spyOn(host(), "capturesWindow").mockReturnValue(true);
let finish: (b: Uint8Array) => void = () => {};
+17 -3
View File
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ClipboardEvent } from "react";
import { t } from "../i18n";
import { systemLabel } from "./osname";
import { memoryLines } from "./memoryrows";
import { tx } from "../i18n/rich";
import { FEEDBACK_CATEGORIES, FEEDBACK_CODE, FEEDBACK_REPO_ISSUES, type FeedbackCategory, type FeedbackEnv, type FeedbackReceipt } from "../port/feedback";
import { FEEDBACK_CATEGORIES, FEEDBACK_CODE, FEEDBACK_REPO_ISSUES, type FeedbackCategory, type FeedbackEnv, type FeedbackProcessMemory, type FeedbackReceipt } from "../port/feedback";
import { host } from "../port/host";
import type { AgentPort } from "../port/port";
import { CopyButton } from "./CopyButton";
@@ -40,6 +41,7 @@ interface Props {
export function FeedbackForm({ port, onMine, onClose, onFile, onConceal }: Props) {
const [env, setEnv] = useState<FeedbackEnv | null>(null);
const [shellMemory, setShellMemory] = useState<FeedbackProcessMemory[]>([]);
const [envFailure, setEnvFailure] = useState<FeedbackFailure | null>(null);
const [category, setCategory] = useState<FeedbackCategory>(() => heldDraft().category);
const [body, setBody] = useState(() => heldDraft().body);
@@ -82,6 +84,11 @@ export function FeedbackForm({ port, onMine, onClose, onFile, onConceal }: Props
}, [port]);
useEffect(load, [load]);
useEffect(() => {
let alive = true;
void host().processMemory().then((rows) => alive && setShellMemory(rows));
return () => { alive = false; };
}, []);
useEffect(() => {
if (env && document.activeElement?.closest('[role="tablist"]')) field.current?.focus();
@@ -130,8 +137,10 @@ export function FeedbackForm({ port, onMine, onClose, onFile, onConceal }: Props
onConceal(true);
await new Promise<void>((r) => requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(r, 60))));
const bytes = await host().captureWindow();
onConceal(false);
// The send panel is mounted for as long as the dialog is, so a form that is gone
// has nothing left to unveil, and setting state then is a write into a torn-down page.
if (!mounted.current) return;
onConceal(false);
setCapture(bytes ? "idle" : "failed");
if (bytes) await add([new File([bytes as BlobPart], "window.png", { type: "image/png" })]);
if (mounted.current) focusFrame.current = requestAnimationFrame(() => captureButton.current?.focus());
@@ -180,6 +189,7 @@ export function FeedbackForm({ port, onMine, onClose, onFile, onConceal }: Props
contact: contact.trim() || undefined,
locale: document.documentElement.lang,
images: payload(shots),
...(shellMemory.length > 0 ? { memory: shellMemory } : {}),
});
shotEpoch.current++;
dropDraft();
@@ -407,8 +417,12 @@ export function FeedbackForm({ port, onMine, onClose, onFile, onConceal }: Props
<dd>{env.env.channel}</dd>
<dt>{t("模型服务")}</dt>
<dd>{env.env.providerKind}</dd>
<dt>{t("内存")}</dt>
<dd>
{memoryLines([...shellMemory, ...(env.env.memory ?? [])]).map((line) => <div key={line}>{line}</div>)}
</dd>
</dl>
<p className="fbk-hint">{t("只读。不包含密钥、地址或任何文件内容。")}</p>
<p className="fbk-hint">{t("只读。不包含密钥、地址或任何文件内容。内存一项只有进程类型和上面这些数字,用来判断是哪一部分占用高。")}</p>
{host().revealsLogs() && (
<div className="fbk-logs">
<button
+3 -1
View File
@@ -12,6 +12,7 @@ import { chartOfTool } from "./chart/spec";
import { GuardianCard } from "./cards/GuardianCard";
import { ApprovalCard, type PlanAction } from "./cards/ApprovalCard";
import { AskCard } from "./cards/AskCard";
import { revealNested } from "./cards/nestreveal";
import { SurfaceCard, surfaceTool } from "./surface/SurfaceCard";
import { ElicitCard } from "./cards/ElicitCard";
import { SayCard, type ReplyActions } from "./cards/SayCard";
@@ -326,7 +327,7 @@ export function Transcript({ items, port, entering, onEntered, brought, revision
const chunk = inner.querySelectorAll<HTMLElement>(".chunk")[block];
if (chunk) root.scrollTop = topOf(chunk) + into * chunk.offsetHeight - clear;
const settle = (tries: number, last = NaN) => {
if (mine !== landing.current) return;
if (mine !== landing.current || !inner.isConnected) return;
const found = inner.querySelector<HTMLElement>(selector);
if (found) {
const el = landingBox(found);
@@ -435,6 +436,7 @@ export function Transcript({ items, port, entering, onEntered, brought, revision
if (hidden || !focus || landed.current === focus.n) return;
landed.current = focus.n;
const where = callBlock.get(focus.call);
revealNested(focus.call);
if (where) land(where.block, where.into, `[data-call="${CSS.escape(focus.call)}"]`);
}, [hidden, focus, callBlock, land]);
+2 -1
View File
@@ -15,11 +15,12 @@ describe("the anchor a run-graph node lands on", () => {
expect(html).toContain('data-call="probe-0"');
});
it("marks a delegate's children too, which is where a fan-out's nodes are", () => {
it("marks a delegate's children too once its card is open, which is where a fan-out's nodes are", () => {
const html = renderToStaticMarkup(
<ToolCard
tool={{ ...call("probe-0"), profile: { name: "fleet", count: 2 } }}
running={false}
pinned
children={[call("probe-0/fleet-1"), call("probe-0/fleet-2")]}
/>,
);
@@ -19,6 +19,7 @@ import { Term, ToolOutput, BoundNote } from "./ToolOutput";
import { ExtensionView } from "./ExtensionView";
import { toolChangedFile, toolErrorDetail, toolFailed, toolFailureLabel, toolOutputEchoed, toolRefusalReason } from "./outcome";
import { StudioIcon } from "../StudioIcon";
import { registerNest } from "./nestreveal";
import { ImagePreview } from "../ImagePreview";
// The spec pops a symbol as it settles — colour arriving is the finish signal.
@@ -129,6 +130,18 @@ export function ToolCard({
if (bad) setOpen(null);
}, [bad]);
const open = touched ?? start;
// A delegate's child calls are the bulk of a long session, so a finished,
// closed card does not mount them; once opened they stay, so what the reader
// did inside (show all) survives closing it again.
const opened = useRef(false);
useEffect(() => {
if (open) opened.current = true;
}, [open]);
const nestMounted = open || running || opened.current;
useEffect(() => {
if (nestMounted || children.length === 0) return;
return registerNest(children.map((c) => c.id).filter((id): id is string => !!id), () => setOpen(true));
}, [nestMounted, children]);
const changes = changeCounts(tool);
const activityArgs = activity ? prettyArgs(tool.args) : "";
// A call being watched in the activity group opens to its input even with no
@@ -257,14 +270,18 @@ export function ToolCard({
.join(" · ")}
</span>
</div>
<div className="nest-bd">
{children.map((c) => (
<NestedCall key={c.id} tool={c} />
))}
</div>
{/* What the delegate handed back closes its own panel: outside it,
the sentence reads as the main run's conclusion. */}
{tool.output && <div className="nest-ret">{tool.output}</div>}
{nestMounted && (
<>
<div className="nest-bd">
{children.map((c) => (
<NestedCall key={c.id} tool={c} />
))}
</div>
{/* What the delegate handed back closes its own panel: outside it,
the sentence reads as the main run's conclusion. */}
{tool.output && <div className="nest-ret">{tool.output}</div>}
</>
)}
</div>
)}
</div>
@@ -284,7 +301,7 @@ export function ToolCard({
</div>
<div className="c">
{hasBody ? (
<details className="tool-disclosure" open={open} onToggle={(event) => event.currentTarget.open !== open && setOpen(event.currentTarget.open)}>
<details className="tool-disclosure" data-lazy={children.length > 0 && !nestMounted ? "" : undefined} open={open} onToggle={(event) => event.currentTarget.open !== open && setOpen(event.currentTarget.open)}>
<summary className="hl" data-swap={settling.swap ? "" : undefined}>{heading}</summary>
{body}
</details>
@@ -0,0 +1,100 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import "../testkit";
import { Transcript } from "../Transcript";
import { revealNested } from "./nestreveal";
import type { Item } from "../../state/session";
afterEach(cleanup);
const noop = async () => undefined as never;
const same = { entering: [] as string[], onEntered: () => {}, waiting: {}, scroll: { current: null }, onExtInvoke: () => {}, checkpoints: new Map(), onOpenProject: () => {}, onKeepHere: () => {}, takeovers: {} };
const view = (items: Item[], extra: Record<string, unknown> = {}) => (
<Transcript items={items} {...same} revision={items.length} hidden={false} focus={null} onApprove={noop} onFullAccess={noop} onPlan={noop} onAnswer={noop} onForget={noop}
onExtSubmit={noop} onPrepareRewind={noop} onCommitRewind={noop} onPrepareFileRevert={noop} onCommitFileRevert={noop} needsProject={false} {...extra} />
);
const longOut = "alpha line\n".repeat(120) + "needle-in-the-child\n";
const child = (id: string) => ({ id, name: "bash", args: "{}", output: id === "k2" ? longOut : "ok", readOnly: false, parentId: "t1" }) as never;
const agent = (running: boolean): Item => ({
t: "tool", id: "t1", running, children: [child("k1"), child("k2")],
tool: { id: "t1", name: "task", args: JSON.stringify({ description: "审查" }), output: "report", readOnly: false } as never,
});
const card = () => document.querySelector<HTMLDetailsElement>('[data-call="t1"] details.tool-disclosure')!;
const kids = () => document.querySelectorAll('[data-call="k1"], [data-call="k2"]').length;
const open = () => { act(() => { card().open = true; card().dispatchEvent(new Event("toggle")); }); };
describe("the child calls of a sub-agent card", () => {
it("are not mounted while a finished card is closed, and the header still says what is inside", () => {
render(view([agent(false)]));
expect(card().open).toBe(false);
expect(kids()).toBe(0);
expect(document.querySelector(".nest-hd .who")?.textContent).toContain("2");
expect(document.querySelector(".nest-ret")).toBeNull();
});
it("appear, whole, when the card is opened", () => {
render(view([agent(false)]));
open();
expect(kids()).toBe(2);
expect(document.querySelector(".nest-ret")?.textContent).toBe("report");
expect(screen.getAllByRole("button", { name: /显示全部/ })).toHaveLength(1);
});
it("stay mounted, with what the reader did in them, when the card is closed again", () => {
render(view([agent(false)]));
open();
fireEvent.click(screen.getByRole("button", { name: /显示全部/ }));
expect(screen.getByRole("button", { name: /收起/ })).toBeTruthy();
act(() => { card().open = false; card().dispatchEvent(new Event("toggle")); });
expect(kids()).toBe(2);
open();
expect(screen.getByRole("button", { name: /收起/ })).toBeTruthy();
});
it("are mounted for a sub-agent that is still running, so its work stays live", () => {
render(view([agent(true)]));
expect(kids()).toBe(2);
});
it("are brought up by a jump to one of them, which opens the card that holds it", () => {
render(view([agent(false)]));
expect(kids()).toBe(0);
act(() => revealNested("k2"));
act(() => { card().dispatchEvent(new Event("toggle")); });
expect(card().open).toBe(true);
expect(document.querySelector('[data-call="k2"]')).not.toBeNull();
});
it("are brought up by a find hit inside one of them", () => {
render(view([agent(false)], { find: { id: "t1", nth: 0, n: 1 }, query: "needle-in-the-child" }));
expect(card().open).toBe(true);
act(() => { card().dispatchEvent(new Event("toggle")); });
expect(document.querySelector('[data-call="k2"]')).not.toBeNull();
});
it("do not reappear as new motion when the transcript is drawn again", () => {
const { rerender } = render(view([agent(false)]));
open();
rerender(view([agent(false)]));
expect(card().open).toBe(true);
expect(kids()).toBe(2);
});
});
describe("a find landing that is still settling when the transcript goes away", () => {
it("does nothing further once its row has left the document", () => {
const queued: FrameRequestCallback[] = [];
const raf = vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { queued.push(cb); return queued.length; });
const style = vi.spyOn(window, "getComputedStyle");
const { unmount } = render(view([agent(false)], { find: { id: "t1", nth: 0, n: 1 }, query: "needle-in-the-child" }));
act(() => { card().dispatchEvent(new Event("toggle")); });
unmount();
style.mockClear();
raf.mockClear();
const pending = queued.splice(0);
for (const cb of pending) cb(0);
expect(style).not.toHaveBeenCalled();
expect(queued).toHaveLength(0);
});
});
@@ -0,0 +1,15 @@
const nests = new Map<string, () => void>();
// A finished delegate's child calls are not mounted until its card is open. A
// jump to one of them (the run graph) asks here for the card that holds it to
// open, since there is no node to scroll to before that.
export function registerNest(ids: string[], open: () => void): () => void {
for (const id of ids) nests.set(id, open);
return () => {
for (const id of ids) if (nests.get(id) === open) nests.delete(id);
};
}
export function revealNested(id: string): void {
nests.get(id)?.();
}
@@ -0,0 +1,85 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { Feedback } from "./Feedback";
import { dropDraft } from "./feedbackdraft";
import { MockPort } from "../port/mock";
import { host } from "../port/host";
import type { AgentPort } from "../port/port";
import type { FeedbackProcessMemory } from "../port/feedback";
beforeEach(dropDraft);
afterEach(() => {
cleanup();
vi.restoreAllMocks();
sessionStorage.clear();
});
const SHELL: FeedbackProcessMemory[] = [
{ process: "renderer", workingSetKB: 3992908, privateKB: 3900000 },
{ process: "browser", workingSetKB: 120000, privateKB: 90000 },
{ process: "gpu", workingSetKB: 76000, privateKB: 60000 },
];
const KERNEL: FeedbackProcessMemory = { process: "kernel", workingSetKB: 196608, privateKB: 150000 };
function setup(shell: FeedbackProcessMemory[]) {
vi.spyOn(host(), "processMemory").mockResolvedValue(shell);
const port = new MockPort() as unknown as AgentPort;
const base = port.feedbackEnv.bind(port);
port.feedbackEnv = vi.fn(async (locale?: string) => {
const got = await base(locale);
return { ...got, env: { ...got.env, memory: [KERNEL] } };
});
port.sendFeedback = vi.fn(port.sendFeedback.bind(port));
port.openExternal = vi.fn(async () => {});
render(<Feedback port={port} tab="send" onClose={vi.fn()} onError={vi.fn()} />);
return port;
}
const envBlock = async () => within((await screen.findByText("随反馈一起发送的环境信息")).closest("section") as HTMLElement);
async function sendOne(port: AgentPort) {
await screen.findByText("随反馈一起发送的环境信息");
await userEvent.clear(screen.getByRole("textbox", { name: /昵称/ }));
await userEvent.type(screen.getByRole("textbox", { name: /昵称/ }), "ada");
await userEvent.type(screen.getByRole("textbox", { name: /发生了什么/ }), "占用很高");
await userEvent.click(screen.getByRole("button", { name: /发送反馈/ }));
await vi.waitFor(() => expect(port.sendFeedback).toHaveBeenCalled());
}
describe("the memory a report carries", () => {
it("is shown to the person with its numbers before anything is sent, the interface first and the kernel last", async () => {
setup(SHELL);
expect(await (await envBlock()).findByText(/界面进程:3\.8 GB/)).toBeTruthy();
const lines = [...document.querySelectorAll(".fbk-env dd div")].map((n) => n.textContent ?? "");
expect(lines.map((l) => l.split(":")[0])).toEqual(["界面进程", "主进程", "图形进程", "内核"]);
expect(lines[3]).toContain("192.0 MB");
expect(screen.getByText(/内存一项只有进程类型和上面这些数字/)).toBeTruthy();
});
it("goes with the report as the shell's rows alone, which the kernel completes with its own", async () => {
const port = setup(SHELL);
await (await envBlock()).findByText(/界面进程/);
await sendOne(port);
const req = (port.sendFeedback as unknown as { mock: { calls: [{ memory?: FeedbackProcessMemory[] }][] } }).mock.calls[0][0];
expect(req.memory).toEqual(SHELL);
expect(JSON.stringify(req.memory)).not.toMatch(/kernel/);
});
it("is only the kernel's row in a tab, where there is no shell to ask, and sends no memory of its own", async () => {
const port = setup([]);
expect(await (await envBlock()).findByText(/内核:192\.0 MB/)).toBeTruthy();
expect(document.querySelectorAll(".fbk-env dd div")).toHaveLength(1);
await sendOne(port);
const req = (port.sendFeedback as unknown as { mock: { calls: [Record<string, unknown>][] } }).mock.calls[0][0];
expect("memory" in req).toBe(false);
});
it("shows no path, process id or command line anywhere in the block", async () => {
setup(SHELL);
await (await envBlock()).findByText(/界面进程/);
expect(document.querySelector(".fbk-env")?.textContent ?? "").not.toMatch(/\\|\/Users|pid|--/i);
});
});
+8 -2
View File
@@ -51,10 +51,15 @@ export function useFindLanding(
* could: a row can be taller than the view, so its top is not enough. Rows
* above it may still mount and move it, so it holds for a few frames. */
function reveal(row: HTMLElement, root: HTMLElement, needle: string, nth: number, mine: () => boolean, tries = 8): boolean {
if (!needle || !mine()) return false;
if (!needle || !mine() || !row.isConnected) return false;
landingBox(row);
const range = currentRange(row, needle, nth);
if (!range) return false;
if (!range) {
const lazy = [...row.querySelectorAll<HTMLDetailsElement>("details[data-lazy]:not([open])")];
for (const fold of lazy) fold.open = true;
if (lazy.length > 0 && tries > 0) requestAnimationFrame(() => reveal(row, root, needle, nth, mine, tries - 1));
return false;
}
for (let fold = range.startContainer.parentElement?.closest("details:not([open])"); fold && row.contains(fold);
fold = fold.parentElement?.closest("details:not([open])")) {
(fold as HTMLDetailsElement).open = true;
@@ -104,6 +109,7 @@ export function useFindPaint(
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
if (!flow.current?.isConnected) return;
paintFind(flow.current, query, current === null ? null : { id: current, nth });
});
};
@@ -0,0 +1,21 @@
import { t } from "../i18n";
import { bytes } from "../i18n/format";
import type { FeedbackProcessMemory, FeedbackProcessType } from "../port/feedback";
const NAME: Record<FeedbackProcessType, string> = {
browser: "主进程",
renderer: "界面进程",
gpu: "图形进程",
utility: "辅助进程",
kernel: "内核",
};
const ORDER: FeedbackProcessType[] = ["renderer", "browser", "gpu", "utility", "kernel"];
// The rows a report will carry, the largest working set of each type first in
// the order a person would look for them, each said as a number they can read.
export function memoryLines(rows: FeedbackProcessMemory[]): string[] {
return [...rows]
.sort((a, b) => ORDER.indexOf(a.process) - ORDER.indexOf(b.process) || b.workingSetKB - a.workingSetKB)
.map((r) => t("{name}:{set}(私有 {own})", { name: t(NAME[r.process]), set: bytes(r.workingSetKB * 1024), own: bytes(r.privateKB * 1024) }));
}
@@ -74,4 +74,40 @@ describe("paced streaming text", () => {
expect(view.container.textContent).toBe("a long burst that arrived while the pane was behind another");
expect(frames.size).toBe(0);
});
// Regenerate, rewind and history restore replace the text with something
// shorter. The cursor has to follow it back, or every later growth below the
// old length is short-circuited and the reveal silently stops animating.
it("paces growth again after the text is replaced with something shorter", () => {
const frames = new Map<number, FrameRequestCallback>();
let id = 0;
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
frames.set(++id, cb);
return id;
});
vi.stubGlobal("cancelAnimationFrame", (n: number) => frames.delete(n));
const drain = () => {
act(() => {
while (frames.size) {
const [next, cb] = frames.entries().next().value as [number, FrameRequestCallback];
frames.delete(next);
cb(performance.now());
}
});
};
const view = render(<Probe text="abcdefghij" />);
drain();
expect(view.container.textContent).toBe("abcdefghij");
view.rerender(<Probe text="ab" />);
expect(view.container.textContent).toBe("ab");
drain();
view.rerender(<Probe text="abcd" />);
const requested = frames.size;
drain();
expect(view.container.textContent).toBe("abcd");
expect(requested).toBeGreaterThan(0);
});
});
+7 -6
View File
@@ -25,16 +25,17 @@ export function useRevealed(text: string, streaming?: boolean): string {
setShown(want.current);
return;
}
// Replaced rather than appended (session switch, history restore): the
// cursor has to come back with the text, or every later growth below the
// old length is short-circuited below and the reveal never animates again.
if (at.current > want.current) {
at.current = want.current;
setShown(want.current);
}
if (at.current >= want.current) return;
let raf = 0;
const step = () => {
const target = want.current;
// Replaced rather than appended (session switch, history restore).
if (target < at.current) {
at.current = target;
setShown(target);
return;
}
if (at.current >= target) return;
at.current = Math.min(target, at.current + Math.max(1, Math.ceil((target - at.current) / DRAIN)));
setShown(at.current);
+48 -5
View File
@@ -100,6 +100,7 @@ func scanGitConfigExcludes(path string, read func(string) []string) string {
body := strings.Join(lines, "\n")
sc := bufio.NewScanner(strings.NewReader(body))
inCore := false
found := "" // git keeps the last value set in a file
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
@@ -114,10 +115,46 @@ func scanGitConfigExcludes(path string, read func(string) []string) string {
continue
}
if k, v, ok := strings.Cut(line, "="); ok && strings.EqualFold(strings.TrimSpace(k), "excludesfile") {
return strings.Trim(strings.TrimSpace(v), `"`)
found = gitConfigValue(v)
}
}
return ""
return found
}
// gitConfigValue decodes a git config value the way git reads one line of it:
// an unquoted '#' or ';' starts a comment, double quotes are removed, and
// unquoted whitespace around the value is dropped. \\ and \" are unescaped;
// any other backslash is kept as written, so a Windows path such as
// C:\Users\me\.gitignore still reads as that path.
func gitConfigValue(raw string) string {
var b strings.Builder
inQuote := false
pending := "" // unquoted whitespace, kept only if more of the value follows
write := func(s string) {
if b.Len() > 0 || inQuote {
b.WriteString(pending)
}
pending = ""
b.WriteString(s)
}
for i := 0; i < len(raw); i++ {
c := raw[i]
switch {
case c == '\\' && i+1 < len(raw) && (raw[i+1] == '\\' || raw[i+1] == '"'):
i++
write(raw[i : i+1])
case c == '"':
write("")
inQuote = !inQuote
case !inQuote && (c == '#' || c == ';'):
return b.String()
case !inQuote && (c == ' ' || c == '\t'):
pending += string(c)
default:
write(raw[i : i+1])
}
}
return b.String()
}
func expandHome(p string) string {
@@ -135,12 +172,18 @@ func statFile(p string) bool {
}
// gitConfigExcludesFile reads core.excludesFile from the global git config files
// directly (no git binary needed). Include directives are not followed.
// directly (no git binary needed). Include directives are not followed. Like
// git, it reads the files in order and lets a later one override an earlier
// one, so ~/.gitconfig wins over $XDG_CONFIG_HOME/git/config.
func gitConfigExcludesFile(read func(string) []string) string {
found := ""
for _, cfg := range gitConfigPaths() {
if p := scanGitConfigExcludes(cfg, read); p != "" {
return expandHome(p)
found = p
}
}
return ""
if found == "" {
return ""
}
return expandHome(found)
}
+59
View File
@@ -59,6 +59,65 @@ func TestFindRepoRoot(t *testing.T) {
}
}
func TestScanGitConfigExcludesReadsValueLikeGit(t *testing.T) {
dir := testenv.TempDir(t)
path := filepath.Join(dir, ".gitconfig")
cases := []struct {
name, body, want string
}{
{"last value wins", "[core]\n\texcludesFile = /first\n\texcludesFile = /second\n", "/second"},
{"semicolon comment", "[core]\n\texcludesFile = /ignore ; global ignore\n", "/ignore"},
{"hash comment", "[core]\n\texcludesfile = /ignore# global ignore\n", "/ignore"},
{"quoted with spaces and a hash", "[core]\n\texcludesFile = \"/my ignore#1\" ; c\n", "/my ignore#1"},
{"escaped backslashes", "[core]\n\texcludesFile = C:\\\\Users\\\\me\\\\.gitignore\n", `C:\Users\me\.gitignore`},
{"raw windows path", "[core]\n\texcludesFile = C:\\Users\\me\\.gitignore\n", `C:\Users\me\.gitignore`},
{"other section", "[core]\n\texcludesFile = /core\n[user]\n\texcludesFile = /user\n", "/core"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := os.WriteFile(path, []byte(tc.body), 0o644); err != nil {
t.Fatal(err)
}
if got := scanGitConfigExcludes(path, readUTF8Lines); got != tc.want {
t.Fatalf("scanGitConfigExcludes = %q, want %q", got, tc.want)
}
})
}
}
// Git reads $XDG_CONFIG_HOME/git/config before ~/.gitconfig and the later
// value wins, so core.excludesFile in ~/.gitconfig is the one git uses.
func TestGlobalExcludesFileHomeConfigOverridesXDG(t *testing.T) {
home := testenv.TempDir(t)
xdg := filepath.Join(home, "xdg")
if err := os.MkdirAll(filepath.Join(xdg, "git"), 0o755); err != nil {
t.Fatal(err)
}
fromXDG := filepath.Join(home, "xdg-ignore")
fromHome := filepath.Join(home, "home-ignore")
for _, p := range []string{fromXDG, fromHome} {
if err := os.WriteFile(p, []byte("*.log\n"), 0o644); err != nil {
t.Fatal(err)
}
}
writeConfig := func(path, excludes string) {
body := "[core]\n\texcludesFile = " + strings.ReplaceAll(excludes, `\`, `\\`) + "\n"
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
writeConfig(filepath.Join(xdg, "git", "config"), fromXDG)
writeConfig(filepath.Join(home, ".gitconfig"), fromHome)
t.Setenv("GIT_CONFIG_GLOBAL", "")
t.Setenv("XDG_CONFIG_HOME", xdg)
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
if got := globalExcludesFile(readUTF8Lines); got != fromHome {
t.Fatalf("globalExcludesFile = %q, want %q (from ~/.gitconfig)", got, fromHome)
}
}
// "\!name" and "\#name" ignore a file literally named "!name" or "#name". In a
// nested .gitignore that already worked; at the root the escape was dropped,
// turning the first into a negation and the second into a comment.
+54 -15
View File
@@ -43,7 +43,7 @@ type coalescer struct {
// called under mu. A single drainer forwards FIFO, so a sink that
// synchronously re-enters Emit enqueues and returns instead of deadlocking.
mu sync.Mutex
kind Kind
key deltaKey
buf strings.Builder
pending bool
timer *time.Timer
@@ -52,27 +52,65 @@ type coalescer struct {
draining bool
}
// isStreamDelta reports whether e is a pure streaming delta: merging is only
// safe when no other field carries meaning. The zero-probe comparison keeps
// this true by construction as Event grows fields.
func isStreamDelta(e Event) bool {
if (e.Kind != Text && e.Kind != Reasoning && e.Kind != CompactionProgress) || e.Text == "" {
return false
}
// deltaKey is the identity a buffered burst merges under. Source belongs here
// rather than to the payload: it names who emitted the delta, and two sources
// must not merge. A key change flushes, so text, reasoning and one tool's
// progress never merge into another's.
type deltaKey struct {
kind Kind
source string
toolID string
}
// streamDelta reports whether e is a pure streaming delta, with its merge key
// and payload. Progress carrying more than ID+Output — a sub-agent's status
// channel sets Name, ParentID and a rate — still fails the zero-probe below.
func streamDelta(e Event) (deltaKey, string, bool) {
key := deltaKey{kind: e.Kind, source: e.Source}
probe := e
probe.Text = ""
return reflect.DeepEqual(probe, Event{Kind: e.Kind})
probe.Source = ""
var payload string
switch e.Kind {
case Text, Reasoning, CompactionProgress:
payload = e.Text
probe.Text = ""
case ToolProgress:
key.toolID = e.Tool.ID
payload = e.Tool.Output
probe.Tool.ID, probe.Tool.Output = "", ""
default:
return deltaKey{}, "", false
}
if payload == "" || (e.Kind == ToolProgress && key.toolID == "") {
return deltaKey{}, "", false
}
if !reflect.DeepEqual(probe, Event{Kind: e.Kind}) {
return deltaKey{}, "", false
}
return key, payload, true
}
// event rebuilds the merged event from the key and the accumulated payload.
func (k deltaKey) event(payload string) Event {
e := Event{Kind: k.kind, Source: k.source}
if k.kind == ToolProgress {
e.Tool = Tool{ID: k.toolID, Output: payload}
} else {
e.Text = payload
}
return e
}
func (c *coalescer) Emit(e Event) {
key, payload, delta := streamDelta(e)
c.mu.Lock()
if !isStreamDelta(e) {
if !delta {
c.enqueueFlushLocked()
c.queue = append(c.queue, e)
c.drainAndUnlock()
return
}
if c.pending && c.kind != e.Kind {
if c.pending && c.key != key {
c.enqueueFlushLocked()
}
if !c.pending && time.Since(c.lastForward) >= c.window {
@@ -83,14 +121,14 @@ func (c *coalescer) Emit(e Event) {
}
if !c.pending {
c.pending = true
c.kind = e.Kind
c.key = key
if c.timer == nil {
c.timer = time.AfterFunc(c.window, c.flush)
} else {
c.timer.Reset(c.window)
}
}
c.buf.WriteString(e.Text)
c.buf.WriteString(payload)
if c.buf.Len() >= coalesceMaxBytes {
c.enqueueFlushLocked()
}
@@ -109,9 +147,10 @@ func (c *coalescer) enqueueFlushLocked() {
return
}
c.timer.Stop()
c.queue = append(c.queue, Event{Kind: c.kind, Text: c.buf.String()})
c.queue = append(c.queue, c.key.event(c.buf.String()))
c.buf.Reset()
c.pending = false
c.key = deltaKey{}
c.lastForward = time.Now()
}
+146
View File
@@ -256,3 +256,149 @@ func TestCoalesceMergesCompactionProgress(t *testing.T) {
t.Fatalf("the finished fold must arrive after its own text: %+v", got[2])
}
}
// A shell emits one ToolProgress per output line, so a build log is hundreds of
// frames a second unless they merge. Before this they each crossed the sink and
// filled a subscriber's queue, which is what made the hub shed answer text.
func TestCoalesceMergesToolProgress(t *testing.T) {
inner := &coalesceRecordSink{}
c := Coalesce(inner, time.Hour)
c.Emit(Event{Kind: ToolProgress, Tool: Tool{ID: "call-1", Output: "line 1\n"}}) // leading edge
for i := 2; i <= 100; i++ {
c.Emit(Event{Kind: ToolProgress, Tool: Tool{ID: "call-1", Output: "line\n"}})
}
c.Emit(Event{Kind: ToolResult, Tool: Tool{ID: "call-1", Output: "done"}})
got := inner.snapshot()
if len(got) != 3 {
t.Fatalf("got %d events, want 3 (leading chunk, merged burst, result): %+v", len(got), got)
}
if got[0].Kind != ToolProgress || got[0].Tool.ID != "call-1" || got[0].Tool.Output != "line 1\n" {
t.Fatalf("leading progress chunk = %+v", got[0])
}
if got[1].Kind != ToolProgress || got[1].Tool.ID != "call-1" {
t.Fatalf("merged burst = %+v", got[1])
}
if want := strings.Repeat("line\n", 99); got[1].Tool.Output != want {
t.Fatalf("merged payload = %q, want %q", got[1].Tool.Output, want)
}
}
// Two calls reporting at once must not be concatenated into one another's card.
func TestCoalesceSeparatesToolProgressByCallID(t *testing.T) {
inner := &coalesceRecordSink{}
c := Coalesce(inner, time.Hour)
c.Emit(Event{Kind: ToolProgress, Tool: Tool{ID: "a", Output: "a1"}}) // leading edge
c.Emit(Event{Kind: ToolProgress, Tool: Tool{ID: "a", Output: "a2"}})
c.Emit(Event{Kind: ToolProgress, Tool: Tool{ID: "b", Output: "b1"}})
c.Emit(Event{Kind: ToolResult, Tool: Tool{ID: "b", Output: "done"}})
got := inner.snapshot()
if len(got) != 4 {
t.Fatalf("got %d events, want 4 (leading, a's tail flushed by the switch, b, b's result): %+v", len(got), got)
}
if got[1].Tool.ID != "a" || got[1].Tool.Output != "a2" {
t.Fatalf("a's buffered tail = %+v", got[1])
}
if got[2].Tool.ID != "b" || got[2].Tool.Output != "b1" {
t.Fatalf("b's chunk = %+v", got[2])
}
}
// A sub-agent's status channel carries Name/ParentID/rate, not just ID+Output.
// Folding those would lose the fields, so they must cross untouched.
func TestCoalescePassesThroughRichToolProgress(t *testing.T) {
inner := &coalesceRecordSink{}
c := Coalesce(inner, time.Hour)
for range 5 {
c.Emit(Event{Kind: ToolProgress, Tool: Tool{ID: "sub", Name: SubagentProgressStatusName, Output: "running", RateTokensPerSec: 40}})
}
got := inner.snapshot()
if len(got) != 5 {
t.Fatalf("got %d events, want 5 (rich progress must not merge): %+v", len(got), got)
}
for _, e := range got {
if e.Tool.Name != SubagentProgressStatusName || e.Tool.RateTokensPerSec != 40 {
t.Fatalf("status metadata lost: %+v", e.Tool)
}
}
}
// Progress without a call id cannot be attributed, so it must not be buffered
// under an empty key where every unrelated chunk would land.
func TestCoalesceBypassesToolProgressWithoutID(t *testing.T) {
inner := &coalesceRecordSink{}
c := Coalesce(inner, time.Hour)
c.Emit(Event{Kind: ToolProgress, Tool: Tool{Output: "orphan"}})
c.Emit(Event{Kind: ToolProgress, Tool: Tool{Output: "orphan"}})
if got := inner.snapshot(); len(got) != 2 {
t.Fatalf("got %d events, want 2: %+v", len(got), got)
}
}
// Every progress chunk an executor emits carries Source, and text from a
// planner carries a different one. Merging must survive the first and never
// cross the second.
func TestCoalesceMergesDeltasCarryingSource(t *testing.T) {
inner := &coalesceRecordSink{}
c := Coalesce(inner, time.Hour)
c.Emit(Event{Kind: ToolProgress, Source: UsageSourceExecutor, Tool: Tool{ID: "call-1", Output: "line 1\n"}})
for range 99 {
c.Emit(Event{Kind: ToolProgress, Source: UsageSourceExecutor, Tool: Tool{ID: "call-1", Output: "line\n"}})
}
c.Emit(Event{Kind: ToolResult, Tool: Tool{ID: "call-1", Output: "done"}})
got := inner.snapshot()
if len(got) != 3 {
t.Fatalf("got %d events, want 3 (leading, merged burst, result): %+v", len(got), got)
}
if got[1].Source != UsageSourceExecutor {
t.Fatalf("merged frame lost its source: %q", got[1].Source)
}
if want := strings.Repeat("line\n", 99); got[1].Tool.Output != want {
t.Fatalf("merged payload = %q, want %q", got[1].Tool.Output, want)
}
}
// The same for answer text: a delta from a named source must still merge.
func TestCoalesceMergesTextCarryingSource(t *testing.T) {
inner := &coalesceRecordSink{}
c := Coalesce(inner, time.Hour)
c.Emit(Event{Kind: Text, Source: UsageSourceExecutor, Text: "a"})
for _, s := range []string{"b", "c", "d"} {
c.Emit(Event{Kind: Text, Source: UsageSourceExecutor, Text: s})
}
c.Emit(Event{Kind: TurnDone})
got := inner.snapshot()
if len(got) != 3 {
t.Fatalf("got %d events, want 3 (leading a, merged bcd, TurnDone): %+v", len(got), got)
}
if got[0].Text != "a" || got[0].Source != UsageSourceExecutor {
t.Fatalf("leading text = %+v", got[0])
}
if got[1].Text != "bcd" || got[1].Source != UsageSourceExecutor {
t.Fatalf("merged text = %+v", got[1])
}
}
// Different sources must not merge into one another: a planner's text and an
// executor's are attributable separately.
func TestCoalesceSeparatesBySource(t *testing.T) {
inner := &coalesceRecordSink{}
c := Coalesce(inner, time.Hour)
c.Emit(Event{Kind: Text, Source: UsageSourceExecutor, Text: "e1"})
c.Emit(Event{Kind: Text, Source: UsageSourcePlanner, Text: "p1"})
c.Emit(Event{Kind: TurnDone})
got := inner.snapshot()
if len(got) != 3 {
t.Fatalf("got %d events, want 3 (leading e1, planner p1, TurnDone): %+v", len(got), got)
}
if got[0].Source != UsageSourceExecutor || got[0].Text != "e1" {
t.Fatalf("executor text = %+v", got[0])
}
if got[1].Source != UsageSourcePlanner || got[1].Text != "p1" {
t.Fatalf("planner text = %+v", got[1])
}
}
+4 -1
View File
@@ -105,6 +105,9 @@ type feedbackSubmitBody struct {
Locale string `json:"locale"`
TurnstileToken string `json:"turnstileToken,omitempty"`
Images []feedbackImageBody `json:"images"`
// Memory is what the window's shell says about its own processes; the
// feedback package keeps only the fixed types and fills the kernel row itself.
Memory []feedback.ProcessMemory `json:"memory,omitempty"`
}
func (s *Server) feedbackSubmit(w http.ResponseWriter, r *http.Request) {
@@ -122,7 +125,7 @@ func (s *Server) feedbackSubmit(w http.ResponseWriter, r *http.Request) {
d := feedback.Draft{
IdempotencyKey: req.IdempotencyKey, Category: req.Category, Body: req.Body,
DisplayName: req.DisplayName, Contact: req.Contact, TurnstileToken: req.TurnstileToken,
Env: feedback.EnvContext{Surface: feedback.SurfaceStudio, Locale: req.Locale},
Env: feedback.EnvContext{Surface: feedback.SurfaceStudio, Locale: req.Locale, Shell: req.Memory},
}
images, ok := decodeFeedbackImages(w, req.Images)
if !ok {
+45
View File
@@ -501,3 +501,48 @@ func TestFeedbackWithdrawWithoutAServiceOrAReceiptIsRefused(t *testing.T) {
t.Fatalf("an unknown receipt: %d %s", resp.StatusCode, out)
}
}
func TestFeedbackSubmitCarriesTheShellsMemoryRowsAndOnlyThose(t *testing.T) {
stub := &feedbackStub{}
srv := feedbackServer(t, stub, true)
resp, out := feedbackPost(t, srv.URL+"/feedback", map[string]any{
"idempotencyKey": "k-mem", "category": "bug", "body": "slow", "displayName": "kim", "locale": "en-US",
"memory": []map[string]any{
{"process": "renderer", "workingSetKB": 3992908, "privateKB": 3900000},
{"process": `C:\Users\someone\Reasonix Studio.exe`, "workingSetKB": 1, "privateKB": 1},
{"process": "kernel", "workingSetKB": 7, "privateKB": 7},
},
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("a service that ignores memory must still answer: %d %s", resp.StatusCode, out)
}
rows := stub.posts[0]["env"].(map[string]any)["memory"].([]any)
var seen []string
for _, r := range rows {
seen = append(seen, r.(map[string]any)["process"].(string))
}
if strings.Join(seen, ",") != "renderer,kernel" {
t.Fatalf("the service was sent %v, want the renderer row and the kernel's own", seen)
}
if k := rows[1].(map[string]any); k["workingSetKB"].(float64) == 7 {
t.Fatalf("the kernel row came from the page: %v", k)
}
}
func TestFeedbackEnvAnswersWithTheKernelRowOnly(t *testing.T) {
srv := feedbackServer(t, &feedbackStub{}, true)
resp, err := http.Get(srv.URL + "/feedback/env")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var got struct {
Env feedback.Env `json:"env"`
}
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatal(err)
}
if len(got.Env.Memory) != 1 || got.Env.Memory[0].Process != "kernel" {
t.Fatalf("memory = %+v", got.Env.Memory)
}
}
+1
View File
@@ -25,6 +25,7 @@ func CollectEnv(ctx EnvContext) Env {
Locale: clamp(localeOf(ctx.Locale), 20),
Channel: channelOf(provider.ClientVersion()),
ProviderKind: providerKind(ctx.ProviderKind),
Memory: memoryRows(ctx.Shell),
}
}
+45
View File
@@ -0,0 +1,45 @@
package feedback
import "runtime"
// ProcessMemory is one row of the memory a report carries: a process type from
// a fixed list and two sizes. It names no path, process id, command line or
// user, so a number can be sent without reading it.
type ProcessMemory struct {
Process string `json:"process"`
WorkingSetKB int64 `json:"workingSetKB"`
PrivateKB int64 `json:"privateKB"`
}
const (
maxMemoryRows = 12
maxMemoryKB = 1 << 31
)
// shellProcess lists the types a shell may name. "kernel" is not among them:
// that row is read here, from this process, and nothing a caller sends can
// stand in for it.
var shellProcess = map[string]bool{"browser": true, "renderer": true, "gpu": true, "utility": true}
// memoryRows is what the window's shell said about its processes, cut to the
// types it may name and the sizes to 0..2^31, followed by the kernel's own row:
// the bytes it has taken from the system and the Go heap in use.
func memoryRows(shell []ProcessMemory) []ProcessMemory {
out := make([]ProcessMemory, 0, min(len(shell)+1, maxMemoryRows))
for _, r := range shell {
if len(out) == maxMemoryRows-1 {
break
}
if !shellProcess[r.Process] {
continue
}
out = append(out, ProcessMemory{Process: r.Process, WorkingSetKB: clampKB(r.WorkingSetKB), PrivateKB: clampKB(r.PrivateKB)})
}
var m runtime.MemStats
runtime.ReadMemStats(&m)
return append(out, ProcessMemory{Process: "kernel", WorkingSetKB: clampKB(int64(m.Sys >> 10)), PrivateKB: clampKB(int64(m.HeapInuse >> 10))})
}
func clampKB(v int64) int64 {
return max(0, min(v, maxMemoryKB))
}
@@ -0,0 +1,75 @@
package feedback
import (
"encoding/json"
"strings"
"testing"
)
func TestMemoryRowsKeepOnlyFixedTypesAndNumbers(t *testing.T) {
env := CollectEnv(EnvContext{Surface: SurfaceStudio, Shell: []ProcessMemory{
{Process: "browser", WorkingSetKB: 120000, PrivateKB: 90000},
{Process: "renderer", WorkingSetKB: 3992908, PrivateKB: 3900000},
{Process: "gpu", WorkingSetKB: 76000, PrivateKB: 60000},
{Process: `C:\Users\someone\AppData\Reasonix Studio.exe`, WorkingSetKB: 1, PrivateKB: 1},
{Process: "renderer --user-data-dir=/home/someone", WorkingSetKB: 1, PrivateKB: 1},
{Process: "kernel", WorkingSetKB: 999, PrivateKB: 999},
{Process: "utility", WorkingSetKB: -5, PrivateKB: 1 << 40},
}})
var seen []string
for _, r := range env.Memory {
seen = append(seen, r.Process)
}
if got := strings.Join(seen, ","); got != "browser,renderer,gpu,utility,kernel" {
t.Fatalf("rows = %q, want the four shell types the shell is allowed to name, then the kernel's own", got)
}
last := env.Memory[len(env.Memory)-1]
if last.Process != "kernel" || last.WorkingSetKB <= 0 {
t.Fatalf("the kernel row is filled from the runtime, not from the shell: %+v", last)
}
utility := env.Memory[3]
if utility.WorkingSetKB != 0 || utility.PrivateKB != maxMemoryKB {
t.Fatalf("numbers are clamped to 0..2^31: %+v", utility)
}
}
func TestMemoryRowsAreBounded(t *testing.T) {
var shell []ProcessMemory
for range 40 {
shell = append(shell, ProcessMemory{Process: "utility", WorkingSetKB: 1, PrivateKB: 1})
}
if n := len(CollectEnv(EnvContext{Shell: shell}).Memory); n != maxMemoryRows {
t.Fatalf("%d rows, want at most %d including the kernel's", n, maxMemoryRows)
}
}
func TestEnvJSONNamesNoPathOrProcessDetail(t *testing.T) {
env := CollectEnv(EnvContext{Surface: SurfaceStudio, Shell: []ProcessMemory{{Process: "renderer", WorkingSetKB: 5, PrivateKB: 4}}})
raw, err := json.Marshal(env)
if err != nil {
t.Fatal(err)
}
var shape struct {
Memory []map[string]any `json:"memory"`
}
if err := json.Unmarshal(raw, &shape); err != nil {
t.Fatal(err)
}
for _, row := range shape.Memory {
if len(row) != 3 || row["process"] == nil || row["workingSetKB"] == nil || row["privateKB"] == nil {
t.Fatalf("a row must be exactly process, workingSetKB and privateKB: %v", row)
}
}
for _, bad := range []string{`\\`, "/Users", "pid", "cmdline", "name"} {
if strings.Contains(strings.ToLower(string(raw[strings.Index(string(raw), `"memory"`):])), bad) {
t.Fatalf("the memory block names %q: %s", bad, raw)
}
}
}
func TestNoShellRowsStillReportsTheKernelOnly(t *testing.T) {
env := CollectEnv(EnvContext{Surface: SurfaceTUI})
if len(env.Memory) != 1 || env.Memory[0].Process != "kernel" {
t.Fatalf("a frontend with no shell reports the kernel alone: %+v", env.Memory)
}
}
+5
View File
@@ -82,10 +82,15 @@ type Env struct {
Locale string `json:"locale"`
Channel string `json:"channel"`
ProviderKind string `json:"providerKind"`
// Memory is one row per process type the report may name.
Memory []ProcessMemory `json:"memory,omitempty"`
}
// EnvContext is what only the caller knows about itself.
type EnvContext struct {
// Shell is what the window's shell reported about its own processes. Only
// the fixed process types are kept and the numbers are clamped.
Shell []ProcessMemory
Surface Surface
Locale string
ProviderKind string
@@ -0,0 +1,184 @@
package agent
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
"reasonix/internal/state/sessionstore"
)
func sidecarMessages() []provider.Message {
return []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, Content: strings.Repeat("work line\n", 800)},
{Role: provider.RoleUser, Content: "continue"},
{Role: provider.RoleAssistant, Content: strings.Repeat("more work\n", 800)},
{Role: provider.RoleUser, Content: "tail"},
{Role: provider.RoleAssistant, Content: "ok"},
}
}
func sidecarAgent(t *testing.T, prov provider.Provider, sess *sessionstore.Session, path string, load bool) *Agent {
t.Helper()
a := New(prov, tool.NewRegistry(), sess, Options{
ContextWindow: 20_000, CompactRatio: 0.5, RecentKeep: 2,
WorkspaceID: "ws", ModelRef: "p/m",
}, event.Discard)
a.BindSessionPath(path, load)
return a
}
// bindLiveAuthority puts sess on the production fail-closed path and hands it a
// live permit for path, exactly as a controller does after a successful lease.
func bindLiveAuthority(t *testing.T, sess *sessionstore.Session, path string) {
t.Helper()
sess.RequireWriteAuthority()
lease, err := sessionstore.TryAcquireSessionLease(path)
if err != nil {
t.Fatalf("TryAcquireSessionLease: %v", err)
}
t.Cleanup(lease.Release)
auth, err := lease.IssueWriteAuthority(sessionstore.NextSessionWriteGeneration())
if err != nil {
t.Fatalf("IssueWriteAuthority: %v", err)
}
sess.BindWriteAuthority(auth)
}
// The transcript save fails closed when this controller holds no live write
// authority for the path. The context sidecar write had no such check, so a
// controller that may not write the session still installed a fold there — and,
// carrying a projection loaded before the owner's fold, it summarised the same
// span a second time and replaced the digest the owner published.
func TestCompactionSidecarIsOutsideTheSessionWriteAuthority(t *testing.T) {
path := filepath.Join(testenv.TempDir(t), "session.jsonl")
// The stale writer binds before anything is folded, so its in-memory
// projection is empty while the owner's later fold lands on disk.
staleSess := &sessionstore.Session{Messages: sidecarMessages()}
staleSess.RequireWriteAuthority() // production write path: fail closed
staleProv := &fakeProvider{reply: "digest written without authority"}
stale := sidecarAgent(t, staleProv, staleSess, path, true)
ownerProv := &fakeProvider{reply: "digest written by the owner"}
owner := sidecarAgent(t, ownerProv, &sessionstore.Session{Messages: sidecarMessages()}, path, true)
if _, err := owner.CompactNow(context.Background(), CompactRequest{}); err != nil {
t.Fatalf("owner CompactNow: %v", err)
}
owned, ok, err := sessionstore.LoadCompactionState(path)
if err != nil || !ok {
t.Fatalf("load owned sidecar: ok=%v err=%v", ok, err)
}
if owned.Projection.SummaryHash == "" || owned.Projection.CoveredCount == 0 {
t.Fatalf("owner fold not published: %+v", owned.Projection)
}
ownerVersion := owner.window().currentProjectionVersion()
// This controller may not write the session at all.
authErr := staleSess.Save(path)
if authErr == nil {
t.Fatal("setup: the stale session was allowed to write the transcript")
}
t.Logf("transcript save for the same session: %v", authErr)
_, compactErr := stale.CompactNow(context.Background(), CompactRequest{})
if compactErr == nil {
t.Fatalf("a controller with no write authority for this session installed and published a fold; the transcript save for the same session was refused with %v", authErr)
}
t.Logf("stale writer refused the fold: %v", compactErr)
after, ok, err := sessionstore.LoadCompactionState(path)
if err != nil || !ok {
t.Fatalf("reload sidecar: ok=%v err=%v", ok, err)
}
if after.Projection.SummaryHash != owned.Projection.SummaryHash ||
after.Projection.ProjectionVersion != owned.Projection.ProjectionVersion ||
after.Generation != owned.Generation {
t.Fatalf("a controller with no write authority for this session replaced its installed fold: summary %q -> %q, projection version %d -> %d, generation %d -> %d, two paid summaries for one span (coverage %d/%d)",
owned.Projection.SummaryHash, after.Projection.SummaryHash, ownerVersion, after.Projection.ProjectionVersion,
owned.Generation, after.Generation, owned.Projection.CoveredCount, after.Projection.CoveredCount)
}
}
// The gate is an additional one: a controller that does hold the permit still
// installs and publishes a fold exactly as before, with one summary and one
// digest on disk.
func TestCompactionFoldPublishesUnderLiveWriteAuthority(t *testing.T) {
path := filepath.Join(testenv.TempDir(t), "session.jsonl")
sess := &sessionstore.Session{Messages: sidecarMessages()}
bindLiveAuthority(t, sess, path)
prov := &countingProvider{reply: "digest written under a live permit"}
a := sidecarAgent(t, prov, sess, path, true)
if _, err := a.CompactNow(context.Background(), CompactRequest{}); err != nil {
t.Fatalf("CompactNow under a live authority: %v", err)
}
if len(prov.got) != 1 {
t.Fatalf("summaries = %d, want exactly one", len(prov.got))
}
disk, ok, err := sessionstore.LoadCompactionState(path)
if err != nil || !ok {
t.Fatalf("load sidecar: ok=%v err=%v", ok, err)
}
if disk.Projection.SummaryHash == "" || disk.Projection.CoveredCount == 0 {
t.Fatalf("authorized fold not published: %+v", disk.Projection)
}
if disk.Projection.ProjectionVersion != 1 || disk.Generation != 1 {
t.Fatalf("published fold version = %d generation = %d, want 1/1", disk.Projection.ProjectionVersion, disk.Generation)
}
if disk.LastReceipt == nil || disk.LastReceipt.Status != "applied" || disk.LastReceipt.SummaryHash != disk.Projection.SummaryHash {
t.Fatalf("published receipt does not match the installed digest: %+v", disk.LastReceipt)
}
if got := a.window().currentProjectionVersion(); got != disk.Projection.ProjectionVersion {
t.Fatalf("memory projection version = %d, disk = %d", got, disk.Projection.ProjectionVersion)
}
}
// A refused publish is not applied, so the retry is a normal retry: it folds
// the span once under the restored permit and publishes one digest. The refusal
// itself must not leave a half-installed projection that the retry folds again.
func TestRefusedSidecarPublishFoldsOnceWhenAuthorityReturns(t *testing.T) {
path := filepath.Join(testenv.TempDir(t), "session.jsonl")
sess := &sessionstore.Session{Messages: sidecarMessages()}
sess.RequireWriteAuthority() // production write path, no permit yet
prov := &countingProvider{reply: "digest once the permit is back"}
a := sidecarAgent(t, prov, sess, path, true)
if _, err := a.CompactNow(context.Background(), CompactRequest{}); !errors.Is(err, sessionstore.ErrSessionWriteAuthorityMissing) {
t.Fatalf("CompactNow without a permit = %v, want %v", err, sessionstore.ErrSessionWriteAuthorityMissing)
}
if _, ok, err := sessionstore.LoadCompactionState(path); err != nil || ok {
t.Fatalf("a refused publish left a sidecar on disk: ok=%v err=%v", ok, err)
}
if got := a.window().currentProjectionVersion(); got != 0 {
t.Fatalf("a refused publish installed a projection in memory: version %d", got)
}
bindLiveAuthority(t, sess, path)
before := len(prov.got)
if _, err := a.CompactNow(context.Background(), CompactRequest{}); err != nil {
t.Fatalf("CompactNow with the permit restored: %v", err)
}
if got := len(prov.got) - before; got != 1 {
t.Fatalf("the retry made %d summary calls, want exactly one", got)
}
disk, ok, err := sessionstore.LoadCompactionState(path)
if err != nil || !ok {
t.Fatalf("load sidecar after the retry: ok=%v err=%v", ok, err)
}
if disk.Projection.ProjectionVersion != 1 || disk.Generation != 1 {
t.Fatalf("retry published version %d generation %d, want a single fold at 1/1", disk.Projection.ProjectionVersion, disk.Generation)
}
if disk.LastReceipt == nil || disk.LastReceipt.Status != "applied" ||
disk.LastReceipt.SummaryHash != disk.Projection.SummaryHash || disk.Projection.SummaryHash == "" {
t.Fatalf("retry receipt = %+v, want one applied fold for the published digest %q", disk.LastReceipt, disk.Projection.SummaryHash)
}
}
+4 -1
View File
@@ -452,7 +452,10 @@ func (a *contextWindow) persistCompactionStateLocked() error {
if a.sess.path == "" {
return nil
}
return sessionstore.SaveCompactionState(a.sess.path, a.sess.win.compactionState)
// The sidecar is the durable record of a fold, so it is published under the
// session's write authority: a controller whose transcript save would be
// refused must not install a fold either.
return a.sess.conversation.SaveCompactionState(a.sess.path, a.sess.win.compactionState)
}
// promptCacheKey builds a stable lineage key for session + model identity.
@@ -65,10 +65,10 @@ func TestRestoreArchivedIndexFailureLeavesNoDoubleIdentity(t *testing.T) {
}
}
func TestRestoreArchivedArchiveRemovalFailureLeavesNoDoubleIdentity(t *testing.T) {
func TestRestoreArchivedStagingRemovalFailureLeavesNoDoubleIdentity(t *testing.T) {
s, dir, archivePath := restoreFixture(t)
withFault(t, func(op, path string) error {
if op == "remove" && path == archivePath {
if op == "remove" && strings.HasPrefix(filepath.Base(path), restoreStagingPrefix) {
return &os.PathError{Op: "remove", Path: path, Err: os.ErrPermission}
}
return nil
+12 -28
View File
@@ -450,6 +450,12 @@ func (s Store) RestoreArchived(archivePath string) (SaveResult, error) {
memoryStoreMutationMu.Lock()
defer memoryStoreMutationMu.Unlock()
// A restore killed after it staged its entry leaves the entry hidden. The
// sweep resolves it by id before this call stages anything: an active id
// means the dead attempt published, an absent one means it never did.
if err := s.sweepRestoreStaging(); err != nil {
return SaveResult{}, err
}
archivePath = cleanMemoryPath(strings.TrimSpace(archivePath))
archived, base, ok, err := s.findArchivedByPath(archivePath)
if err != nil {
@@ -503,36 +509,14 @@ func (s Store) RestoreArchived(archivePath string) (SaveResult, error) {
if err != nil {
return SaveResult{}, err
}
if err := writeMemoryCreate(path, []byte(render(restored, restored.Name)), 0o644); err != nil {
if os.IsExist(err) {
return SaveResult{}, fmt.Errorf("memory name %q is already active", restored.Name)
}
// The archive entry is hidden before the active file exists, so a crash
// between the two can never leave the id listed as active and archived.
stagedPath, err := stageArchivedEntry(base, archivePath, archived.ID)
if err != nil {
return SaveResult{}, err
}
// A failed recovery must not leave one id active and archived at once. Both
// steps below run after the active file exists, so each undoes it; if the
// index write fails too, the retry below rewrites that line.
drop := func(cause error) (SaveResult, error) {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return SaveResult{}, fmt.Errorf("%w (and the restored %q could not be withdrawn: %w)", cause, restored.Name, err)
}
lines, _, err := indexLinesExceptIn(base, restored.Name)
if err != nil {
return SaveResult{}, fmt.Errorf("%w (and the index could not be cleaned: %w)", cause, err)
}
if err := flushIndexIn(base, lines); err != nil {
return SaveResult{}, fmt.Errorf("%w (and the index could not be cleaned: %w)", cause, err)
}
return SaveResult{}, cause
}
if err := reindexIn(base, restored.Name, restored); err != nil {
return drop(err)
}
if err := fault("remove", archivePath); err != nil {
return drop(err)
}
if err := os.Remove(archivePath); err != nil && !os.IsNotExist(err) {
return drop(err)
if err := publishRestored(base, archivePath, path, stagedPath, restored); err != nil {
return SaveResult{}, err
}
return SaveResult{Path: path, Memory: restored}, nil
}
@@ -0,0 +1,176 @@
package memory
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
const (
restoreStagingPrefix = ".restoring-"
restoreStampLayout = "20060102-150405.000"
)
// restoreStagingName is the hidden name an in-flight restore moves its archive
// entry to. It has no .md suffix, so no reader, index, list, or migration ever
// treats it as a fact or an archive entry.
func restoreStagingName(id string, when time.Time) string {
return restoreStagingPrefix + id + "-" + when.UTC().Format(restoreStampLayout)
}
// parseRestoreStagingName recovers the id and archive time a staging name
// carries. The stamp has a fixed width, so the id's own dashes are unambiguous.
func parseRestoreStagingName(name string) (string, time.Time, bool) {
if !strings.HasPrefix(name, restoreStagingPrefix) || strings.HasSuffix(name, ".md") {
return "", time.Time{}, false
}
rest := strings.TrimPrefix(name, restoreStagingPrefix)
cut := len(rest) - len(restoreStampLayout) - 1
if cut <= 0 || rest[cut] != '-' {
return "", time.Time{}, false
}
when, err := time.ParseInLocation(restoreStampLayout, rest[cut+1:], time.UTC)
if err != nil {
return "", time.Time{}, false
}
return rest[:cut], when, true
}
// sweepRestoreStaging reconciles hidden copies an interrupted restore left in
// .archive. Every restore runs it first, so a retry decides the fate of a dead
// attempt before it stages anything of its own.
func (s Store) sweepRestoreStaging() error {
for _, base := range s.dirs() {
if strings.TrimSpace(base) == "" {
continue
}
entries, err := readMemoryDirectory(filepath.Join(base, ".archive"))
if err != nil {
return err
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
id, when, ok := parseRestoreStagingName(entry.Name())
if !ok {
continue
}
if err := s.settleRestoreStaging(base, entry.Name(), id, when); err != nil {
return err
}
}
}
return nil
}
// settleRestoreStaging resolves one hidden copy by whether its id is active:
// an active id means the dead restore had published, so only the redundant
// copy goes; an absent id means it never published, so the entry goes back.
func (s Store) settleRestoreStaging(base, staging, id string, when time.Time) error {
_, _, active, err := s.findActive(id)
if err != nil {
return err
}
root, err := os.OpenRoot(base)
if err != nil {
return err
}
defer root.Close()
held := filepath.Join(".archive", staging)
if active {
if err := root.Remove(held); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
archived, ok := loadMemory(filepath.Join(base, ".archive", staging))
if !ok {
return fmt.Errorf("interrupted restore left an unreadable archive copy at %q", staging)
}
dest, err := archivePath(root, slug(archived.Name), when)
if err != nil {
return err
}
return renameMemoryFile(root, held, dest)
}
// stageArchivedEntry moves the archive entry to its hidden staging name before
// the active file is published. A failed rename returns the error with the
// archive entry untouched and nothing published.
func stageArchivedEntry(base, archivePath, id string) (string, error) {
if id == "" || id != filepath.Base(id) || strings.ContainsAny(id, `/\`) {
return "", fmt.Errorf("archived memory has no id that can be staged")
}
when := archiveTimeFromName(filepath.Base(archivePath))
if when.IsZero() {
when = time.Now().UTC()
}
staging := restoreStagingName(id, when)
root, err := os.OpenRoot(base)
if err != nil {
return "", err
}
defer root.Close()
held := filepath.Join(".archive", staging)
if err := renameMemoryFile(root, filepath.Join(".archive", filepath.Base(archivePath)), held); err != nil {
return "", err
}
return filepath.Join(base, held), nil
}
// putBackStagedArchive returns a staged entry to the exact archive name it was
// taken from, so a restore that fails before the commit changes nothing.
func putBackStagedArchive(base, stagingPath, archivePath string) error {
root, err := os.OpenRoot(base)
if err != nil {
return err
}
defer root.Close()
return renameMemoryFile(root,
filepath.Join(".archive", filepath.Base(stagingPath)),
filepath.Join(".archive", filepath.Base(archivePath)))
}
// publishRestored publishes the active fact, reindexes it, then drops the
// staging copy. Any failure withdraws the active fact and puts the archive
// entry back, so a refused restore leaves the store as it found it.
func publishRestored(base, archivePath, path, stagingPath string, m Memory) error {
if err := writeMemoryCreate(path, []byte(render(m, m.Name)), 0o644); err != nil {
if back := putBackStagedArchive(base, stagingPath, archivePath); back != nil {
return fmt.Errorf("%w (and the archived copy could not be put back: %w)", err, back)
}
if os.IsExist(err) {
return fmt.Errorf("memory name %q is already active", m.Name)
}
return err
}
undo := func(cause error) error {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("%w (and the restored %q could not be withdrawn: %w)", cause, m.Name, err)
}
if err := putBackStagedArchive(base, stagingPath, archivePath); err != nil {
return fmt.Errorf("%w (and the archived copy could not be put back: %w)", cause, err)
}
lines, _, err := indexLinesExceptIn(base, m.Name)
if err != nil {
return fmt.Errorf("%w (and the index could not be cleaned: %w)", cause, err)
}
if err := flushIndexIn(base, lines); err != nil {
return fmt.Errorf("%w (and the index could not be cleaned: %w)", cause, err)
}
return cause
}
if err := reindexIn(base, m.Name, m); err != nil {
return undo(err)
}
if err := fault("remove", stagingPath); err != nil {
return undo(err)
}
if err := os.Remove(stagingPath); err != nil && !os.IsNotExist(err) {
return undo(err)
}
return nil
}
@@ -0,0 +1,327 @@
package memory
// An archive restore interrupted after it published must converge on the next
// restore instead of leaving one id active and archived at once. The staging
// name carries the id, so no content comparison decides a leftover's fate.
import (
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"reasonix/internal/base/testenv"
)
const (
restoreKillDirEnv = "RX_RESTORE_KILL_DIR"
restoreKillArchiveEnv = "RX_RESTORE_KILL_ARCHIVE"
)
// stagingEntries returns the hidden restore copies in the store's .archive.
func stagingEntries(t *testing.T, dir string) []string {
t.Helper()
entries, err := os.ReadDir(filepath.Join(dir, ".archive"))
if os.IsNotExist(err) {
return nil
}
if err != nil {
t.Fatal(err)
}
var out []string
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), restoreStagingPrefix) {
out = append(out, entry.Name())
}
}
return out
}
func onlyStagingEntry(t *testing.T, dir string) string {
t.Helper()
held := stagingEntries(t, dir)
if len(held) != 1 {
t.Fatalf("staging entries in %s = %v, want exactly one", dir, held)
}
return held[0]
}
func assertNoStagingListed(t *testing.T, s Store) {
t.Helper()
archived, err := s.ListArchived()
if err != nil {
t.Fatal(err)
}
for _, entry := range archived {
if strings.HasPrefix(filepath.Base(entry.Path), restoreStagingPrefix) {
t.Fatalf("ListArchived reports a hidden staging copy: %+v", entry)
}
}
}
// stageArchivedEntryForTest emulates a process killed after staging an archive
// entry but before it published the active file.
func stageArchivedEntryForTest(t *testing.T, archivePath, id string) string {
t.Helper()
base := filepath.Base(archivePath)
stamp := base[:len(restoreStampLayout)]
staging := filepath.Join(filepath.Dir(archivePath), restoreStagingPrefix+id+"-"+stamp)
if err := os.Rename(archivePath, staging); err != nil {
t.Fatal(err)
}
return staging
}
func TestRestoreArchivedAfterAKillSweepsTheStagingCopy(t *testing.T) {
if os.Getenv(restoreKillDirEnv) != "" {
restoreKillChild()
return
}
dir := testenv.TempDir(t)
s := Store{Dir: dir}
alpha, err := s.SaveWithOptions(Memory{Name: "alpha", Description: "restored then killed", Type: TypeProject, Body: "alpha body"}, SaveOptions{})
if err != nil {
t.Fatal(err)
}
alphaArchive, err := s.Archive("alpha")
if err != nil {
t.Fatal(err)
}
if _, err := s.Save(Memory{Name: "beta", Description: "restored next", Type: TypeProject, Body: "beta body"}); err != nil {
t.Fatal(err)
}
betaArchive, err := s.Archive("beta")
if err != nil {
t.Fatal(err)
}
cmd := exec.Command(os.Args[0], "-test.run=^TestRestoreArchivedAfterAKillSweepsTheStagingCopy$")
cmd.Env = append(os.Environ(),
restoreKillDirEnv+"="+dir,
restoreKillArchiveEnv+"="+alphaArchive,
)
out, runErr := cmd.CombinedOutput()
var exitErr *exec.ExitError
if !errors.As(runErr, &exitErr) || exitErr.ExitCode() != 137 {
t.Fatalf("the child was not killed at the staging removal (run error %v, output %q)", runErr, out)
}
// The kill landed after publication: the active file and its index line
// exist and the archive entry is hidden under its staging name.
held := onlyStagingEntry(t, dir)
if !strings.Contains(held, alpha.Memory.ID) {
t.Fatalf("staging entry %q does not carry the restored id %q", held, alpha.Memory.ID)
}
fresh := Store{Dir: dir}
if _, ok, err := fresh.Read("alpha"); err != nil || !ok {
t.Fatalf("the killed restore left no readable active fact: ok=%v err=%v", ok, err)
}
assertNoStagingListed(t, fresh)
// A restore of a DIFFERENT entry reconciles the leftover: the id is active,
// so the hidden duplicate goes and the live fact is untouched.
if _, err := fresh.RestoreArchived(betaArchive); err != nil {
t.Fatalf("restore after the kill: %v", err)
}
if left := stagingEntries(t, dir); len(left) != 0 {
t.Fatalf("the sweep left staging copies behind: %v", left)
}
active, ok, err := fresh.Read("alpha")
if err != nil || !ok {
t.Fatalf("the sweep deleted the live fact: ok=%v err=%v", ok, err)
}
if active.Body != "alpha body" {
t.Fatalf("the live fact changed: %+v", active)
}
assertNoStagingListed(t, fresh)
body := mustReadString(t, filepath.Join(dir, indexFile))
if n := strings.Count(body, "(alpha.md)"); n != 1 {
t.Fatalf("MEMORY.md names the live fact %d times:\n%s", n, body)
}
}
func restoreKillChild() {
dir := os.Getenv(restoreKillDirEnv)
archive := os.Getenv(restoreKillArchiveEnv)
if dir == "" || archive == "" {
os.Exit(2)
}
s := Store{Dir: dir}
memoryFault = func(op, path string) error {
if op == "remove" && strings.HasPrefix(filepath.Base(path), restoreStagingPrefix) {
// Termination, not an error: no return, no defer, no undo.
os.Exit(137)
}
return nil
}
if _, err := s.RestoreArchived(archive); err != nil {
println("restore child: returned before the staging removal:", err.Error())
os.Exit(11)
}
println("restore child: completed without reaching the staging removal")
os.Exit(12)
}
func TestRestoreArchivedSweepPutsBackAnEntryThatNeverPublished(t *testing.T) {
dir := testenv.TempDir(t)
s := Store{Dir: dir}
alpha, err := s.SaveWithOptions(Memory{Name: "alpha", Description: "never published", Type: TypeProject, Body: "alpha body"}, SaveOptions{})
if err != nil {
t.Fatal(err)
}
alphaArchive, err := s.Archive("alpha")
if err != nil {
t.Fatal(err)
}
if _, err := s.Save(Memory{Name: "beta", Description: "next restore", Type: TypeProject, Body: "beta body"}); err != nil {
t.Fatal(err)
}
betaArchive, err := s.Archive("beta")
if err != nil {
t.Fatal(err)
}
stageArchivedEntryForTest(t, alphaArchive, alpha.Memory.ID)
fresh := Store{Dir: dir}
if _, active := mustStore(t, fresh).Read("alpha"); active {
t.Fatal("fixture staged an entry whose id is still active")
}
assertNoStagingListed(t, fresh)
if _, err := fresh.RestoreArchived(betaArchive); err != nil {
t.Fatalf("restore after the interrupted staging: %v", err)
}
if left := stagingEntries(t, dir); len(left) != 0 {
t.Fatalf("the sweep did not put the entry back: %v", left)
}
if _, err := os.Stat(alphaArchive); err != nil {
t.Fatalf("the archive entry did not come back at %s: %v", alphaArchive, err)
}
archived, err := fresh.ListArchived()
if err != nil {
t.Fatal(err)
}
var found *ArchivedMemory
for i := range archived {
if archived[i].ID == alpha.Memory.ID {
found = &archived[i]
}
}
if found == nil {
t.Fatalf("the recovered archive entry is not listable: %+v", archived)
}
if found.Body != "alpha body" {
t.Fatalf("the recovered archive entry lost its body: %+v", found)
}
if _, err := fresh.RestoreArchived(alphaArchive); err != nil {
t.Fatalf("the recovered entry is not restorable: %v", err)
}
restored, ok, err := fresh.Read("alpha")
if err != nil || !ok || restored.Body != "alpha body" {
t.Fatalf("the recovered entry did not restore: ok=%v err=%v memory=%+v", ok, err, restored)
}
}
func TestRestoreArchivedLeavesOneIndexLineAndNoStagingCopy(t *testing.T) {
dir := testenv.TempDir(t)
s := Store{Dir: dir}
if _, err := s.Save(Memory{Name: "alpha", Description: "restored once", Type: TypeProject, Body: "alpha body"}); err != nil {
t.Fatal(err)
}
archivePath, err := s.Archive("alpha")
if err != nil {
t.Fatal(err)
}
if _, err := s.RestoreArchived(archivePath); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(archivePath); !os.IsNotExist(err) {
t.Fatalf("the uninterrupted restore left the archive entry: %v", err)
}
if left := stagingEntries(t, dir); len(left) != 0 {
t.Fatalf("the uninterrupted restore left staging copies: %v", left)
}
body := mustReadString(t, filepath.Join(dir, indexFile))
if n := strings.Count(body, "(alpha.md)"); n != 1 {
t.Fatalf("MEMORY.md names the restored fact %d times:\n%s", n, body)
}
}
func TestRestoreArchivedStagingRenameFailurePublishesNothing(t *testing.T) {
s, dir, archivePath := restoreFixture(t)
withFault(t, func(op, path string) error {
if op == "rename" && path == filepath.Join(".archive", filepath.Base(archivePath)) {
return &os.PathError{Op: "rename", Path: path, Err: os.ErrPermission}
}
return nil
})
if _, err := s.RestoreArchived(archivePath); err == nil {
t.Fatal("recovery reported success although the archive entry could not be staged")
}
memoryFault = nil
if _, active := mustStore(t, s).Read("alpha"); active {
t.Fatal("a restore that could not stage published the active file anyway")
}
if _, err := os.Stat(archivePath); err != nil {
t.Fatalf("the archive entry left its place although staging failed: %v", err)
}
if left := stagingEntries(t, dir); len(left) != 0 {
t.Fatalf("a failed staging left a hidden copy: %v", left)
}
if _, err := s.RestoreArchived(archivePath); err != nil {
t.Fatalf("retry after a failed staging: %v", err)
}
if _, ok := mustStore(t, s).Read("alpha"); !ok {
t.Fatal("the retry did not restore the fact")
}
}
func TestRestoreArchivedSweepFailureDoesNotPublish(t *testing.T) {
s, _, alphaArchive := restoreFixture(t)
archived, err := s.ListArchived()
if err != nil || len(archived) != 1 {
t.Fatalf("fixture archive = %+v, err = %v", archived, err)
}
if _, err := s.Save(Memory{Name: "beta", Description: "second", Type: TypeProject, Body: "beta body"}); err != nil {
t.Fatal(err)
}
if err != nil {
t.Fatal(err)
}
betaArchive, err := s.Archive("beta")
if err != nil {
t.Fatal(err)
}
staging := stageArchivedEntryForTest(t, alphaArchive, archived[0].ID)
withFault(t, func(op, path string) error {
if op == "rename" && strings.HasPrefix(filepath.Base(path), restoreStagingPrefix) {
return &os.PathError{Op: "rename", Path: path, Err: os.ErrPermission}
}
return nil
})
if _, err := s.RestoreArchived(betaArchive); err == nil {
t.Fatal("recovery reported success although the sweep could not run")
}
memoryFault = nil
if _, active := mustStore(t, s).Read("beta"); active {
t.Fatal("a restore whose sweep failed published the active file anyway")
}
if _, err := os.Stat(betaArchive); err != nil {
t.Fatalf("the sweep failure disturbed the entry being restored: %v", err)
}
if _, err := os.Stat(staging); err != nil {
t.Fatalf("the sweep failure deleted the hidden copy: %v", err)
}
if _, err := s.RestoreArchived(betaArchive); err != nil {
t.Fatalf("retry after a failed sweep: %v", err)
}
if _, ok := mustStore(t, s).Read("beta"); !ok {
t.Fatal("the retry did not restore the fact")
}
if _, err := os.Stat(alphaArchive); err != nil {
t.Fatalf("the retry did not put the recovered entry back: %v", err)
}
}
@@ -0,0 +1,74 @@
package memory
import (
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/base/testenv"
)
// The sweep's active branch must DELETE the leftover, never rename it back: a
// fact that is already published must not regain an archive entry, or
// ListArchived would report a live fact as archived again.
func TestSweepOfAnActiveRestoreDoesNotRecreateTheArchiveEntry(t *testing.T) {
dir := testenv.TempDir(t)
s := Store{Dir: dir}
alpha, err := s.SaveWithOptions(Memory{Name: "alpha", Description: "published then left behind", Type: TypeProject, Body: "alpha body"}, SaveOptions{})
if err != nil {
t.Fatal(err)
}
alphaArchive, err := s.Archive("alpha")
if err != nil {
t.Fatal(err)
}
// Keep a copy of the archived bytes, then let the real restore publish alpha.
bytes, err := os.ReadFile(alphaArchive)
if err != nil {
t.Fatal(err)
}
// Same stamp prefix as the archive entry (the helper parses it), but not .md,
// so the copy is never read as a fact.
copied := filepath.Join(dir, ".archive", strings.TrimSuffix(filepath.Base(alphaArchive), ".md")+".bin")
if err := os.WriteFile(copied, bytes, 0o644); err != nil {
t.Fatal(err)
}
if _, err := s.RestoreArchived(alphaArchive); err != nil {
t.Fatalf("the fixture could not restore alpha: %v", err)
}
if _, active, err := s.Read("alpha"); err != nil || !active {
t.Fatalf("fixture: alpha is not active after its restore: active=%v err=%v", active, err)
}
// Now reproduce the post-publish kill: alpha is active AND a hidden staging
// copy carrying its id exists.
stageArchivedEntryForTest(t, copied, alpha.Memory.ID)
if _, err := s.Save(Memory{Name: "beta", Description: "next restore", Type: TypeProject, Body: "beta body"}); err != nil {
t.Fatal(err)
}
betaArchive, err := s.Archive("beta")
if err != nil {
t.Fatal(err)
}
if _, err := (Store{Dir: dir}).RestoreArchived(betaArchive); err != nil {
t.Fatalf("the restore that runs the sweep: %v", err)
}
if left := stagingEntries(t, dir); len(left) != 0 {
t.Fatalf("the sweep left staging copies behind: %v", left)
}
archived, err := Store{Dir: dir}.ListArchived()
if err != nil {
t.Fatal(err)
}
for _, entry := range archived {
if entry.ID == alpha.Memory.ID {
t.Fatalf("a live fact regained an archive entry: %+v", entry)
}
}
live, ok, err := (Store{Dir: dir}).Read("alpha")
if err != nil || !ok || live.Body != "alpha body" {
t.Fatalf("the live fact was disturbed by the sweep: ok=%v err=%v memory=%+v", ok, err, live)
}
}
@@ -103,6 +103,21 @@ func (st CompactionState) Foreign() bool {
return st.SchemaVersion > CompactionStateSchemaCurrent
}
// SaveCompactionState publishes the sidecar under this session's write
// authority — the permit a transcript save presents, exactly as save.go uses
// it. Without it a controller whose Save is refused would still install and
// publish a fold, replacing the owner's digest and paying twice for one span.
// Additional to the caller's CAS; a never-bound session keeps the legacy path.
func (s *Session) SaveCompactionState(sessionPath string, st CompactionState) error {
releaseAuth, err := s.requireWriteAuthorityForSave(sessionPath)
if err != nil {
return err
}
defer releaseAuth()
// Raw writers carry no writer identity; controller paths use this method.
return SaveCompactionState(sessionPath, st)
}
// SaveCompactionState writes the sidecar via strict atomic publish (temp +
// file fsync + rename + best-effort parent-dir fsync). Checkpoint sidecars are
// commit pointers: EXDEV/copy fallbacks that can tear an existing file are
@@ -0,0 +1,2 @@
- 新增:发送反馈时附上各进程的内存(只有进程类型和数字),表单里能先看到具体数字,方便判断是界面还是内核占得多。关联 #13899。
- Added: A report now carries each process's memory (only the process type and numbers), shown in the form first, to tell the interface from the kernel. Refs #13899.
@@ -0,0 +1,2 @@
- 改进:长会话里,已完成的子代理卡片收起时不再把它的子调用放进页面,展开时才加载;120 个子代理的页面节点从约 5 万降到约 5 千。关联 #13899。
- Improved: A finished sub-agent's card no longer keeps its child calls in the page while closed; they load on opening. 120 sub-agents drop from about 50,000 nodes to 5,000. Refs #13899.
@@ -0,0 +1,2 @@
- 修复:公网远程时,较大的历史或文件传输超过半分钟会被误报“请检查电脑是否在线”;现在电脑仍在回传就继续等。关联 #13909。
- Fixed: Over the public relay, a history or file taking over 30 seconds to arrive was reported as "check the computer is online"; it is now waited for. Refs #13909.
@@ -0,0 +1,2 @@
- 侧栏会话行的标题不再被过早截断:右侧不再为没显示的按钮留空位,归档按钮只在悬停或聚焦时占位。反馈 FB-J73V-ZH3V,议题 #13904。
- Session titles in the sidebar are no longer cut short: nothing is held back for buttons that are not showing, and the archive button takes room only while hovered or focused. Issue #13904.
+1
View File
@@ -252,6 +252,7 @@ var mirroredWireTypes = []wireMirror{
// The report form draws what the kernel will send and reads back what
// became of it; a field the page cannot read is a status it cannot show.
{"internal/platform/feedback/types.go", "Env", tsFeedbackFile, "FeedbackEnvInfo"},
{"internal/platform/feedback/env_memory.go", "ProcessMemory", tsFeedbackFile, "FeedbackProcessMemory"},
{"internal/platform/feedback/types.go", "Limits", tsFeedbackFile, "FeedbackLimits"},
{"internal/platform/feedback/types.go", "Receipt", tsFeedbackFile, "FeedbackReceipt"},
{"internal/platform/feedback/types.go", "Item", tsFeedbackFile, "FeedbackItem"},